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:
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user