From 3206753938fdb284815f1e2b372a963565d5313b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 2 Sep 2026 13:04:23 +0100 Subject: [PATCH 01/40] Harden board dialog keyboard safety --- .../components/board/BoardSettingsModal.vue | 29 +++-- .../src/components/board/ColumnEditModal.vue | 33 +++-- .../src/components/ui/TdDialog.vue | 48 ++----- .../composables/useDialogFocusManagement.ts | 87 +++++++++++++ .../src/composables/useKeyboardShortcuts.ts | 18 ++- .../composables/useRealtimeSafeDialogDraft.ts | 39 ++++++ .../src/locales/en/boardDetail.ts | 1 - .../src/locales/es/boardDetail.ts | 1 - .../src/locales/it/boardDetail.ts | 1 - .../components/BoardSettingsModal.spec.ts | 25 ++++ .../tests/components/ColumnEditModal.spec.ts | 38 ++++++ .../src/tests/components/ui/TdDialog.spec.ts | 25 ++++ .../composables/useKeyboardShortcuts.spec.ts | 39 ++++++ .../views/BoardView.keyboardRouting.spec.ts | 21 ++++ .../views/paper/PaperBoardDialogShell.spec.ts | 118 ++++++++++++++++++ .../views/paper/PaperBoardManagement.spec.ts | 82 ++++++++++++ .../boardMutationCapabilityParity.spec.ts | 4 +- frontend/taskdeck-web/src/views/BoardView.vue | 5 + .../src/views/paper/PaperBoardColumn.vue | 21 +++- .../src/views/paper/PaperBoardView.vue | 13 +- .../paper/board/PaperBoardDialogShell.vue | 60 ++++----- .../paper/board/PaperBoardSettingsDialog.vue | 30 +++-- .../paper/board/PaperColumnSettingsDialog.vue | 33 +++-- 23 files changed, 650 insertions(+), 121 deletions(-) create mode 100644 frontend/taskdeck-web/src/composables/useDialogFocusManagement.ts create mode 100644 frontend/taskdeck-web/src/composables/useRealtimeSafeDialogDraft.ts create mode 100644 frontend/taskdeck-web/src/tests/views/paper/PaperBoardDialogShell.spec.ts diff --git a/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue b/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue index de8fd1a31..f7c47545c 100644 --- a/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue +++ b/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue @@ -1,10 +1,11 @@ diff --git a/frontend/taskdeck-web/src/composables/useDialogFocusManagement.ts b/frontend/taskdeck-web/src/composables/useDialogFocusManagement.ts new file mode 100644 index 000000000..6d77e7d3f --- /dev/null +++ b/frontend/taskdeck-web/src/composables/useDialogFocusManagement.ts @@ -0,0 +1,87 @@ +import { nextTick, onUnmounted, watch, type Ref } from 'vue' + +const FOCUSABLE_SELECTOR = + 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' + +export interface DialogFocusManagementOptions { + isOpen: () => boolean + dialogRef: Ref + initialFocus?: (dialog: HTMLElement) => HTMLElement | null +} + +/** + * Shared modal focus lifecycle for Taskdeck dialogs. + * + * Captures and restores the opener, moves focus into the dialog after render, + * and keeps forward/backward Tab movement inside the active dialog. Escape is + * deliberately not handled here: each surface registers with the shared + * escape stack according to its own close contract. + */ +export function useDialogFocusManagement(options: DialogFocusManagementOptions) { + let previouslyFocusedElement: HTMLElement | null = null + + function restoreFocus() { + if (previouslyFocusedElement?.isConnected) { + previouslyFocusedElement.focus() + } + previouslyFocusedElement = null + } + + function focusDialog() { + const dialog = options.dialogRef.value + if (!dialog) return + const initialTarget = options.initialFocus?.(dialog) ?? dialog + initialTarget.focus() + } + + function trapFocus(event: KeyboardEvent) { + if (event.key !== 'Tab' || !options.dialogRef.value) return + + const focusableElements = Array.from( + options.dialogRef.value.querySelectorAll(FOCUSABLE_SELECTOR), + ) + + if (focusableElements.length === 0) { + event.preventDefault() + return + } + + const first = focusableElements[0]! + const last = focusableElements[focusableElements.length - 1]! + const activeIndex = focusableElements.indexOf(document.activeElement as HTMLElement) + + if (event.shiftKey && activeIndex <= 0) { + event.preventDefault() + last.focus() + } else if ( + !event.shiftKey && + (activeIndex === -1 || activeIndex === focusableElements.length - 1) + ) { + event.preventDefault() + first.focus() + } + } + + watch( + options.isOpen, + async (isOpen, wasOpen) => { + if (isOpen) { + if (!wasOpen) { + previouslyFocusedElement = + document.activeElement instanceof HTMLElement ? document.activeElement : null + } + await nextTick() + if (options.isOpen()) focusDialog() + } else if (wasOpen) { + restoreFocus() + } + }, + { immediate: true }, + ) + + // A parent commonly removes a dialog with v-if while its `isOpen` prop is + // still true, so unmount must restore independently of the watcher. + onUnmounted(restoreFocus) + + return { trapFocus } +} diff --git a/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts b/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts index f541efe5b..59354768d 100644 --- a/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts +++ b/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts @@ -28,11 +28,21 @@ export interface ShortcutConfig { */ export function useKeyboardShortcuts(shortcuts: ShortcutConfig[]) { const handleKeyDown = (event: KeyboardEvent) => { - // Ignore if typing in input/textarea (except Escape key) - const target = event.target as HTMLElement - const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' + // Text-entry controls own every key except Escape. Other interactive + // controls own native activation keys, while board-navigation keys remain + // available from card/collapse buttons for the roving-focus model. + const target = event.target instanceof Element ? event.target : null + const isTextEntry = Boolean(target?.closest( + 'input, textarea, select, [contenteditable]:not([contenteditable="false"])', + )) + const isActivationControl = Boolean(target?.closest( + 'button, a[href], [role="button"], [role="menuitem"], [role="option"], [role="tab"]', + )) - if (isTyping && event.key !== 'Escape') { + if ( + (isTextEntry && event.key !== 'Escape') || + (isActivationControl && (event.key === 'Enter' || event.key === ' ')) + ) { return } diff --git a/frontend/taskdeck-web/src/composables/useRealtimeSafeDialogDraft.ts b/frontend/taskdeck-web/src/composables/useRealtimeSafeDialogDraft.ts new file mode 100644 index 000000000..f44aca1bd --- /dev/null +++ b/frontend/taskdeck-web/src/composables/useRealtimeSafeDialogDraft.ts @@ -0,0 +1,39 @@ +import { watch } from 'vue' + +export interface RealtimeSafeDialogDraftOptions { + isOpen: () => boolean + source: () => T + sourceKey: (source: T) => string + seed: (source: T) => void + isDirty: () => boolean +} + +/** + * Re-seeds a dialog from live state without clobbering an in-progress draft. + * + * Opening the dialog and switching to a different entity always use the latest + * source. A same-entity reference replacement (for example a realtime board + * refresh) is accepted only while the current draft still matches its last + * seed snapshot. Each dialog owns that field-specific snapshot comparison. + */ +export function useRealtimeSafeDialogDraft(options: RealtimeSafeDialogDraftOptions) { + let seededKey: string | null = null + + watch( + () => [options.source(), options.isOpen()] as const, + ([source, isOpen], previous) => { + const wasOpen = previous?.[1] ?? false + if (!isOpen) { + seededKey = null + return + } + + const sourceKey = options.sourceKey(source) + if (!wasOpen || seededKey !== sourceKey || !options.isDirty()) { + options.seed(source) + seededKey = sourceKey + } + }, + { immediate: true }, + ) +} diff --git a/frontend/taskdeck-web/src/locales/en/boardDetail.ts b/frontend/taskdeck-web/src/locales/en/boardDetail.ts index 7ca05657f..35452b07c 100644 --- a/frontend/taskdeck-web/src/locales/en/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/en/boardDetail.ts @@ -92,7 +92,6 @@ export default { archiveConfirmCancel: 'Keep it here', restore: 'Restore board', saveError: 'Could not save the board. Please try again.', - archiveError: 'Could not archive the board. Please try again.', restoreError: 'Could not restore the board. Please try again.', }, } diff --git a/frontend/taskdeck-web/src/locales/es/boardDetail.ts b/frontend/taskdeck-web/src/locales/es/boardDetail.ts index bf92b529c..b41e3d6df 100644 --- a/frontend/taskdeck-web/src/locales/es/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/es/boardDetail.ts @@ -82,7 +82,6 @@ export default { archiveConfirmCancel: 'Déjalo aquí', restore: 'Restaurar el tablero', saveError: 'No se pudo guardar el tablero. Inténtalo de nuevo.', - archiveError: 'No se pudo archivar el tablero. Inténtalo de nuevo.', restoreError: 'No se pudo restaurar el tablero. Inténtalo de nuevo.', }, } diff --git a/frontend/taskdeck-web/src/locales/it/boardDetail.ts b/frontend/taskdeck-web/src/locales/it/boardDetail.ts index 513b90618..02c048e46 100644 --- a/frontend/taskdeck-web/src/locales/it/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/it/boardDetail.ts @@ -82,7 +82,6 @@ export default { archiveConfirmCancel: 'Lasciala qui', restore: 'Ripristina la bacheca', saveError: 'Non è stato possibile salvare la bacheca. Riprova.', - archiveError: 'Non è stato possibile archiviare la bacheca. Riprova.', restoreError: 'Non è stato possibile ripristinare la bacheca. Riprova.', }, } diff --git a/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts index b5d743fdd..76b9af65f 100644 --- a/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts @@ -81,6 +81,31 @@ describe('BoardSettingsModal', () => { expect(wrapper.text()).toContain('Active') }) + it('preserves in-progress edits when realtime replaces the board object', async () => { + const wrapper = mount(BoardSettingsModal, { + props: { + board, + isOpen: true, + }, + }) + + await wrapper.get('#board-name').setValue('My draft board') + await wrapper.get('#board-description').setValue('My draft description') + await wrapper.setProps({ + board: { + ...board, + name: 'Server refresh', + description: 'Server description', + updatedAt: new Date().toISOString(), + }, + }) + + expect((wrapper.get('#board-name').element as HTMLInputElement).value).toBe('My draft board') + expect((wrapper.get('#board-description').element as HTMLTextAreaElement).value).toBe( + 'My draft description', + ) + }) + it('should emit close event when close button is clicked', async () => { const wrapper = mount(BoardSettingsModal, { props: { diff --git a/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts b/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts index 66e5e5c90..765ceddd0 100644 --- a/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts @@ -76,6 +76,44 @@ describe('ColumnEditModal', () => { expect(wipCheckbox.element.checked).toBe(false) }) + it('preserves an in-progress edit when realtime replaces the column object', async () => { + const wrapper = mount(ColumnEditModal, { + props: { + column, + isOpen: true, + boardId: 'board-1', + }, + }) + + await wrapper.get('#column-name').setValue('My draft name') + await wrapper.setProps({ + column: { ...column, name: 'Server refresh', updatedAt: new Date().toISOString() }, + }) + + expect((wrapper.get('#column-name').element as HTMLInputElement).value).toBe('My draft name') + }) + + it('seeds the latest values when a cancelled dialog is reopened', async () => { + const wrapper = mount(ColumnEditModal, { + props: { + column, + isOpen: true, + boardId: 'board-1', + }, + }) + + await wrapper.get('#column-name').setValue('Cancelled draft') + await wrapper.setProps({ isOpen: false }) + await wrapper.setProps({ + column: { ...column, name: 'Latest server name' }, + isOpen: true, + }) + + expect((wrapper.get('#column-name').element as HTMLInputElement).value).toBe( + 'Latest server name', + ) + }) + it('should show WIP limit input when column has WIP limit', () => { const columnWithWip = { ...column, diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts index 8b93f54ab..6585ee824 100644 --- a/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts @@ -78,6 +78,31 @@ describe('TdDialog', () => { wrapper.unmount() }) + it('traps forward and backward Tab movement inside the dialog', async () => { + const wrapper = mount(TdDialog, { + props: { open: true }, + slots: { + default: '', + footer: '', + }, + attachTo: document.body, + }) + await nextTick() + const first = document.querySelector('[data-testid="first"]') as HTMLElement + const last = document.querySelector('[data-testid="last"]') as HTMLElement + + last.focus() + last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) + expect(document.activeElement).toBe(first) + + first.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }), + ) + expect(document.activeElement).toBe(last) + + wrapper.unmount() + }) + it('registers escape handler when opened', async () => { const wrapper = mount(TdDialog, { props: { open: false }, diff --git a/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts b/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts index 591a3bb59..e9e14b341 100644 --- a/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts @@ -116,6 +116,45 @@ describe('useKeyboardShortcuts', () => { wrapper.unmount() }) + it('leaves Enter on an interactive control to the control', () => { + const action = vi.fn() + const wrapper = mountWithShortcuts([ + { key: 'Enter', description: 'Open selected card', action }, + ]) + const button = document.createElement('button') + document.body.appendChild(button) + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }) + const preventDefault = vi.spyOn(event, 'preventDefault') + + button.dispatchEvent(event) + + expect(action).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + + button.remove() + wrapper.unmount() + }) + + it('keeps board navigation shortcuts available from a button', () => { + const action = vi.fn() + const wrapper = mountWithShortcuts([ + { key: 'ArrowRight', description: 'Next column', action }, + ]) + const button = document.createElement('button') + document.body.appendChild(button) + + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + + expect(action).toHaveBeenCalledTimes(1) + + button.remove() + wrapper.unmount() + }) + it('should allow Escape key even when typing in an input', () => { const action = vi.fn() const wrapper = mountWithShortcuts([ diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts index 0128095bd..47e8f012d 100644 --- a/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts @@ -121,6 +121,12 @@ const BoardCanvasStub = { template: '
', } +const PaperBoardViewStub = { + props: ['selectedColumnId'], + template: + '
', +} + function mountView() { const wrapper = mount(BoardView, { attachTo: document.body, @@ -133,6 +139,7 @@ function mountView() { KeyboardShortcutsHelp: { template: '
' }, FilterPanel: { template: '
' }, CaptureModal: { template: '
' }, + PaperBoardView: PaperBoardViewStub, }, }, }) @@ -196,4 +203,18 @@ describe('BoardView keyboard routing', () => { // BoardView re-bound it the selection would slide back to card-1 here. expect(selectedCardId()).toBe('card-2') }) + + it('passes the live keyboard lane selection to the Paper board', async () => { + usePaperThemeStore().enable() + mountView() + await waitForUi() + + const paperBoard = () => document.body.querySelector('[data-testid="paper-board"]') + expect(paperBoard()?.getAttribute('data-selected-column-id')).toBe('column-1') + + pressKey('ArrowRight') + await waitForUi() + + expect(paperBoard()?.getAttribute('data-selected-column-id')).toBe('column-2') + }) }) diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardDialogShell.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardDialogShell.spec.ts new file mode 100644 index 000000000..fb889115a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardDialogShell.spec.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import PaperBoardDialogShell from '../../../views/paper/board/PaperBoardDialogShell.vue' + +const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport') + +function installVisualViewport(height: number, offsetTop: number) { + const events = new EventTarget() + let currentHeight = height + let currentOffsetTop = offsetTop + + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { + get height() { + return currentHeight + }, + get offsetTop() { + return currentOffsetTop + }, + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + }, + }) + + return (next: { height: number; offsetTop: number }) => { + currentHeight = next.height + currentOffsetTop = next.offsetTop + events.dispatchEvent(new Event('resize')) + events.dispatchEvent(new Event('scroll')) + } +} + +function mountShell() { + return mount(PaperBoardDialogShell, { + attachTo: document.body, + props: { + isOpen: true, + eyebrow: 'Board', + title: 'Board settings', + closeLabel: 'Close settings', + testid: 'paper-dialog-shell', + }, + slots: { + default: '', + footer: '', + }, + }) +} + +afterEach(() => { + document.body.innerHTML = '' + if (originalVisualViewport) { + Object.defineProperty(window, 'visualViewport', originalVisualViewport) + } else { + Reflect.deleteProperty(window, 'visualViewport') + } +}) + +describe('PaperBoardDialogShell', () => { + it('traps forward and backward Tab movement inside the dialog', async () => { + const wrapper = mountShell() + await nextTick() + + const close = wrapper.get('[data-action="close-dialog"]').element as HTMLElement + const save = wrapper.get('[data-testid="dialog-save"]').element as HTMLElement + + save.focus() + await wrapper.get('[data-testid="dialog-save"]').trigger('keydown', { key: 'Tab' }) + expect(document.activeElement).toBe(close) + + await wrapper.get('[data-action="close-dialog"]').trigger('keydown', { + key: 'Tab', + shiftKey: true, + }) + expect(document.activeElement).toBe(save) + + wrapper.unmount() + }) + + it('follows the contracted visual viewport while open', async () => { + const resizeViewport = installVisualViewport(760, 0) + const wrapper = mountShell() + const backdrop = wrapper.get('[data-testid="paper-dialog-shell"]').element as HTMLElement + + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-height'), + ).toBe('760px') + + resizeViewport({ height: 420, offsetTop: 120 }) + await nextTick() + + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-height'), + ).toBe('420px') + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-offset-top'), + ).toBe('120px') + + wrapper.unmount() + }) + + it('keeps the dynamic-viewport CSS fallback when VisualViewport is unavailable', () => { + Object.defineProperty(window, 'visualViewport', { configurable: true, value: undefined }) + const wrapper = mountShell() + const backdrop = wrapper.get('[data-testid="paper-dialog-shell"]').element as HTMLElement + + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-height'), + ).toBe('') + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-offset-top'), + ).toBe('') + + wrapper.unmount() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts index 53ad3e37d..d7ebb831e 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts @@ -288,6 +288,27 @@ describe('PaperBoardView — column settings', () => { ).toBe('Today') }) + it('keeps a column draft when realtime replaces the live column object', async () => { + const wrapper = mountView() + + await wrapper.findAll('[data-testid="paper-column-edit"]')[1]!.trigger('click') + await wrapper.get('[data-testid="paper-column-dialog-name"]').setValue('My draft column') + + mockBoardStore.currentBoard = { + ...board, + columns: columns.map((column) => + column.id === 'col-today' + ? { ...column, name: 'Server refresh', updatedAt: new Date().toISOString() } + : column, + ), + } + await nextTick() + + expect( + (wrapper.get('[data-testid="paper-column-dialog-name"]').element as HTMLInputElement).value, + ).toBe('My draft column') + }) + it('renames a column through boardStore.updateColumn and closes', async () => { const wrapper = mountView() @@ -414,6 +435,28 @@ describe('PaperBoardView — column reorder', () => { expect(right[2]?.attributes('disabled')).toBeDefined() expect(right[0]?.attributes('disabled')).toBeUndefined() }) + + it('allows only one column reorder while the first request is in flight', async () => { + let resolveReorder!: () => void + mockBoardStore.reorderColumns.mockReturnValueOnce( + new Promise((resolve) => { + resolveReorder = () => resolve(columns) + }), + ) + const wrapper = mountView() + const moveRight = wrapper.findAll('[data-testid="paper-column-move-right"]')[0]! + + void moveRight.trigger('click') + await nextTick() + await moveRight.trigger('click') + + expect(mockBoardStore.reorderColumns).toHaveBeenCalledTimes(1) + expect(moveRight.attributes('disabled')).toBeDefined() + + resolveReorder() + await flushPromises() + expect(moveRight.attributes('disabled')).toBeUndefined() + }) }) describe('PaperBoardView — add a column to a populated board', () => { @@ -513,6 +556,34 @@ describe('PaperBoardView — board settings', () => { expect(wrapper.get('[data-testid="paper-board-dialog-state"]').text()).toBe('Active') }) + it('keeps board drafts when realtime replaces the live board object', async () => { + const wrapper = mountView() + + await wrapper.get('[data-testid="paper-board-settings"]').trigger('click') + await wrapper.get('[data-testid="paper-board-dialog-name"]').setValue('My draft board') + await wrapper + .get('[data-testid="paper-board-dialog-description"]') + .setValue('My draft description') + + mockBoardStore.currentBoard = { + ...board, + name: 'Server refresh', + description: 'Server description', + updatedAt: new Date().toISOString(), + } + await nextTick() + + expect( + (wrapper.get('[data-testid="paper-board-dialog-name"]').element as HTMLInputElement).value, + ).toBe('My draft board') + expect( + ( + wrapper.get('[data-testid="paper-board-dialog-description"]') + .element as HTMLTextAreaElement + ).value, + ).toBe('My draft description') + }) + it('renames the board through boardStore.updateBoard and closes', async () => { const wrapper = mountView() @@ -646,6 +717,17 @@ describe('PaperBoardView — dialogs and the board shortcuts', () => { }) }) +describe('PaperBoardView — visible keyboard column selection', () => { + it('marks the lane targeted by the board keyboard model', () => { + const wrapper = mountView({ selectedColumnId: 'col-today' }) + const selected = wrapper.get('[data-column-id="col-today"]') + + expect(selected.classes()).toContain('paper-board-column--selected') + expect(selected.attributes('aria-current')).toBe('true') + expect(wrapper.get('[data-column-id="col-backlog"]').attributes('aria-current')).toBeUndefined() + }) +}) + describe('PaperBoardView — the capture lane still exists', () => { it('routes "+ capture" to the column-scoped Inbox composer', async () => { const wrapper = mountView() diff --git a/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts index a2896cab7..b0f17be52 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts @@ -68,7 +68,9 @@ const NON_BOARD_SURFACE_MUTATIONS: Record = { * Action-group factories in `store/board/*` whose writes a board surface must * be able to drive. Structural, so a NEW action added to one of these groups * joins the required set automatically — a `duplicateColumn` would, and no name - * heuristic could be trusted to guess that verb in advance. + * heuristic could be trusted to guess that verb in advance. A brand-new action + * group is NOT discovered automatically: it must be added here, or only actions + * whose names match `MUTATION_NAME` below will be covered. */ const BOARD_SURFACE_GROUPS = ['boardCrud', 'columns', 'cards', 'comments'] diff --git a/frontend/taskdeck-web/src/views/BoardView.vue b/frontend/taskdeck-web/src/views/BoardView.vue index 3cfea25f4..2fee77b27 100644 --- a/frontend/taskdeck-web/src/views/BoardView.vue +++ b/frontend/taskdeck-web/src/views/BoardView.vue @@ -146,6 +146,10 @@ const { (columnId) => !paperOn.value || !paperCollapsedColumnIds.value.has(columnId), ) +const selectedColumnId = computed( + () => sortedColumns.value[selectedColumnIndex.value]?.id ?? null, +) + function handlePaperCollapsedColumnsChange(columnIds: string[]) { paperCollapsedColumnIds.value = new Set(columnIds) @@ -422,6 +426,7 @@ useKeyboardShortcuts([
@@ -233,7 +241,7 @@ function onCardDragOver(card: Card, e: DragEvent) { class="paper-board-column__ctl" :aria-label="t('boardDetail.column.moveRight')" :title="t('boardDetail.column.moveRight')" - :disabled="!canMoveRight" + :disabled="reorderBusy || !canMoveRight" data-testid="paper-column-move-right" @click="onMoveRight" > @@ -335,6 +343,11 @@ function onCardDragOver(card: Card, e: DragEvent) { transition: border-color var(--d-quick) var(--ease-paper); } +.paper-board-column--selected { + border-color: var(--ink-deep); + box-shadow: 0 0 0 1px var(--ink-deep), var(--shadow-press); +} + .paper-board-column--drag-over { border-color: var(--ember); box-shadow: 0 0 0 1px var(--ember), var(--shadow-press); diff --git a/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue b/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue index dc526ef18..3af12356d 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue @@ -37,8 +37,10 @@ const props = withDefaults( /** Card visual variant — propagated to every column. */ cardVariant?: PaperBoardCardVariant selectedCardId?: string | null + /** Lane currently targeted by BoardView's keyboard model. */ + selectedColumnId?: string | null }>(), - { cardVariant: 'index', selectedCardId: null }, + { cardVariant: 'index', selectedCardId: null, selectedColumnId: null }, ) const emit = defineEmits<{ @@ -511,12 +513,16 @@ async function createCardInColumn(column: Column, title: string) { } } +const columnReorderBusy = ref(false) + /** * Keyboard/pointer column reorder, alongside the existing drag handle. Drag is * the only reorder Legacy offers; it is unusable without a pointer, so Paper * adds explicit controls over the same `reorderColumns` action. */ async function moveColumn(column: Column, direction: 'left' | 'right') { + if (columnReorderBusy.value) return + const columns = sortedColumns.value const index = columns.findIndex((c) => c.id === column.id) const targetIndex = direction === 'left' ? index - 1 : index + 1 @@ -527,6 +533,7 @@ async function moveColumn(column: Column, direction: 'left' | 'right') { if (!removed) return reordered.splice(targetIndex, 0, removed) + columnReorderBusy.value = true try { await boardStore.reorderColumns( boardId.value, @@ -534,6 +541,8 @@ async function moveColumn(column: Column, direction: 'left' | 'right') { ) } catch (error) { logError('Failed to reorder columns (paper):', error) + } finally { + columnReorderBusy.value = false } } @@ -840,8 +849,10 @@ async function addStarterColumns() { :style="columnWidthStyle" :is-drag-over="dragOverColumnId === column.id" :selected-card-id="activeSelectedCardId" + :selected="column.id === props.selectedColumnId" :can-move-left="idx > 0" :can-move-right="idx < sortedColumns.length - 1" + :reorder-busy="columnReorderBusy" :composer-open="composerColumnId === column.id" :composer-busy="composerBusy" :composer-error="composerError" diff --git a/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue b/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue index 6247eeb29..afb421b82 100644 --- a/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue +++ b/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue @@ -1,7 +1,9 @@