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

@@ -310,6 +310,7 @@ const NOBODY = '__nobody__';
<ts-issue-card
cdkDrag
[cdkDragData]="issue"
[attr.data-issue]="issue.id"
[issue]="issue"
[workflow]="project.workflow()"
[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'])
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 (
event.key !== 'c' ||
event.ctrlKey ||
@@ -897,6 +918,93 @@ export class BoardPage {
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 {
const types = this.typeOptions();
if (!types.some((t) => t.value === this.newType()))
@@ -937,3 +1045,10 @@ export class BoardPage {
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' });
}