feat(web): the board works from the keyboard

With a card focused (Tab reaches them): arrows move between cards, also
across swimlanes' columns; Shift+←/→ moves the card to the next column
with the same rules as dragging it (allowed transitions, WIP warning,
reason when the status asks for one) and keeps the focus on it; p, e
and a open its priority, estimate or assignee; Enter opens it. The «?»
help lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:20:30 +02:00
parent dfb5f973f4
commit 172c53d21d
4 changed files with 134 additions and 3 deletions

View File

@@ -26,7 +26,7 @@
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | | 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | | ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | | 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | | 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |

View File

@@ -307,7 +307,7 @@ visualización de la plataforma:
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |

View File

@@ -13,6 +13,22 @@ export const SHORTCUTS: { keys: string; what: string; where: string }[] = [
{ keys: 'g s', what: 'Ir a los sprints', where: 'Dentro de un proyecto' }, { keys: 'g s', what: 'Ir a los sprints', where: 'Dentro de un proyecto' },
{ keys: 'g r', what: 'Ir a la hoja de ruta', where: 'Dentro de un proyecto' }, { keys: 'g r', what: 'Ir a la hoja de ruta', where: 'Dentro de un proyecto' },
{ keys: 'c', what: 'Crear un ticket', where: 'Tablero' }, { keys: 'c', what: 'Crear un ticket', where: 'Tablero' },
{
keys: '← ↑ → ↓',
what: 'Moverse entre tarjetas (Tab entra en el tablero)',
where: 'Tablero',
},
{
keys: 'Shift ← →',
what: 'Pasar la tarjeta a la columna de al lado',
where: 'Tablero',
},
{
keys: 'p · e · a',
what: 'Prioridad, estimación o persona de la tarjeta',
where: 'Tablero',
},
{ keys: 'Enter', what: 'Abrir la tarjeta', where: 'Tablero' },
{ keys: 'e', what: 'Editar el ticket', where: 'Ficha de un ticket' }, { keys: 'e', what: 'Editar el ticket', where: 'Ficha de un ticket' },
{ keys: 'Esc', what: 'Cerrar', where: 'Búsqueda y ayuda' }, { keys: 'Esc', what: 'Cerrar', where: 'Búsqueda y ayuda' },
]; ];

View File

@@ -310,6 +310,7 @@ const NOBODY = '__nobody__';
<ts-issue-card <ts-issue-card
cdkDrag cdkDrag
[cdkDragData]="issue" [cdkDragData]="issue"
[attr.data-issue]="issue.id"
[issue]="issue" [issue]="issue"
[workflow]="project.workflow()" [workflow]="project.workflow()"
[editable]="project.canContribute()" [editable]="project.canContribute()"
@@ -880,9 +881,29 @@ export class BoardPage {
); );
} }
/** `c`: new ticket (not while typing, nor with a drawer open). */ /**
* `c`: new ticket. On a focused card: arrows move between cards,
* Shift+←/→ moves the card a column, `p`/`e`/`a` open its priority,
* estimate or assignee, Enter opens it. Not while typing, nor with a
* drawer open.
*/
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void { protected onKey(event: KeyboardEvent): void {
const card = (event.target as HTMLElement | null)?.closest?.(
'ts-issue-card',
) as HTMLElement | null;
if (
card &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!(event.target instanceof HTMLSelectElement) &&
!document.querySelector('[role="dialog"]') &&
this.onCardKey(event, card)
) {
event.preventDefault();
return;
}
if ( if (
event.key !== 'c' || event.key !== 'c' ||
event.ctrlKey || event.ctrlKey ||
@@ -897,6 +918,93 @@ export class BoardPage {
this.openCreate(); this.openCreate();
} }
/** Handles a key on a focused card; false when it is not one of its keys. */
private onCardKey(event: KeyboardEvent, card: HTMLElement): boolean {
const issueId = card.dataset['issue'] ?? '';
const list = card.closest('.ts-column__list');
const row = list?.parentElement?.closest('.ts-board');
if (!list || !row) return false;
const cards = (el: Element) =>
Array.from(el.querySelectorAll<HTMLElement>(':scope > ts-issue-card'));
const lists = Array.from(row.querySelectorAll('.ts-column__list'));
const here = cards(list);
const index = here.indexOf(card);
const column = lists.indexOf(list);
const key = event.key;
if (key === 'ArrowDown' || key === 'ArrowUp') {
const next = here[index + (key === 'ArrowDown' ? 1 : -1)];
if (next) focusCard(next);
return true;
}
if ((key === 'ArrowLeft' || key === 'ArrowRight') && event.shiftKey) {
const target = lists[column + (key === 'ArrowRight' ? 1 : -1)];
if (target) this.moveByKey(issueId, target.id.split('|')[1]);
return true;
}
if (key === 'ArrowLeft' || key === 'ArrowRight') {
// The nearest column with cards that way, at the same height or the last.
const step = key === 'ArrowRight' ? 1 : -1;
for (let i = column + step; i >= 0 && i < lists.length; i += step) {
const there = cards(lists[i]);
if (there.length) {
focusCard(there[Math.min(index, there.length - 1)]);
break;
}
}
return true;
}
const field = { p: 'Prioridad', e: 'Estimación', a: 'Persona' }[key];
if (field && !event.shiftKey) {
const select = card.querySelector<HTMLSelectElement>(
`select[aria-label^="${field}"]`,
);
if (!select) return false;
select.focus();
try {
select.showPicker();
} catch {
// Older browsers: focused, the arrows change it.
}
return true;
}
return false;
}
/** Shift+←/→ on a card: the same as dropping it on that column. */
private moveByKey(issueId: string, to: string): void {
const issue = this.board
.columns()
.flatMap((c) => c.issues)
.find((i) => i.id === issueId);
if (!issue || !to || issue.status === to) return;
if (!this.project.workflow().allowedFrom(issue.status).includes(to)) {
this.toast.info(
`De «${this.statusLabel(issue.status)}» no se puede pasar a «${this.statusLabel(to)}»`,
);
return;
}
const target = this.board.columns().find((c) => c.status === to);
const limit = this.project.workflow().wipLimit(to);
if (target && overWip(target.total + 1, limit))
this.toast.info(
`«${this.statusLabel(to)}» pasa de su límite WIP (${target.total + 1}/${limit}): mejor terminar algo antes de empezar más`,
);
if (this.project.workflow().requiresReason(to)) {
this.pendingMove = { issueId, to };
this.reason().reset({ text: '' });
this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm());
return;
}
this.board.moveIssue(issueId, to);
// The card is drawn again in its new column: the focus follows it.
setTimeout(() => {
const moved = document.querySelector<HTMLElement>(
`ts-issue-card[data-issue="${issueId}"]`,
);
if (moved) focusCard(moved);
});
}
protected openCreate(): void { protected openCreate(): void {
const types = this.typeOptions(); const types = this.typeOptions();
if (!types.some((t) => t.value === this.newType())) if (!types.some((t) => t.value === this.newType()))
@@ -937,3 +1045,10 @@ export class BoardPage {
this.drawer.closeAll(); this.drawer.closeAll();
} }
} }
/** Puts the focus on a card's link (its title), into view. */
function focusCard(card: HTMLElement): void {
const link = card.querySelector<HTMLElement>('.ts-card__title');
link?.focus();
link?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}