From baf775c8de208bd8f43092a42150cf5c4e38351e Mon Sep 17 00:00:00 2001 From: th Date: Fri, 24 Apr 2026 10:29:07 +0200 Subject: [PATCH] 0.16.1: welcome screen arrow-key navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up/Down arrow keys now move focus through the recents list in the welcome dialog; Home/End jump to the first/last entry. Tab / Enter / Space behaviour is untouched — arrows are additive. Covered by two UI tests (arrow roundtrip + Home/End) that poll for the async IDB load instead of a fixed delay. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/components/WelcomeDialog.vue | 27 ++++++++ src/devtools/tests.ts | 108 +++++++++++++++++++++++++++++++ src/version.ts | 2 +- 4 files changed, 137 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1a56bc3..de37f3a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "0.16.0", + "version": "0.16.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue index 4b68988..f6a05ba 100644 --- a/src/components/WelcomeDialog.vue +++ b/src/components/WelcomeDialog.vue @@ -80,6 +80,32 @@ function openProject(p: StoredProject) { emit('update:modelValue', false) } +/** Move focus through the recents list using the arrow keys. Keeps + * Tab working as usual — arrow nav is just a more natural shortcut + * for picking from a list. */ +function focusRecent(index: number) { + const list = recentRefs.value + if (!list.length) return + const clamped = Math.max(0, Math.min(index, list.length - 1)) + list[clamped]?.focus() +} + +function onRecentKeyDown(e: KeyboardEvent, index: number) { + if (e.key === 'ArrowDown') { + e.preventDefault() + focusRecent(index + 1) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + focusRecent(index - 1) + } else if (e.key === 'Home') { + e.preventDefault() + focusRecent(0) + } else if (e.key === 'End') { + e.preventDefault() + focusRecent(recentRefs.value.length - 1) + } +} + async function removeProject(e: MouseEvent, p: StoredProject) { e.stopPropagation() const ok = await confirmDialog({ @@ -150,6 +176,7 @@ watch( @click="openProject(p)" @keydown.enter.prevent="openProject(p)" @keydown.space.prevent="openProject(p)" + @keydown="onRecentKeyDown($event, i)" >
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 395d34c..1ecff7b 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -51,6 +51,7 @@ import Timeline from '@/components/Timeline.vue' import AssetSidebar from '@/components/AssetSidebar.vue' import CanvasStage from '@/components/CanvasStage.vue' import ExportDialog from '@/components/ExportDialog.vue' +import WelcomeDialog from '@/components/WelcomeDialog.vue' import FullscreenPreview from '@/components/FullscreenPreview.vue' import HelpDialog from '@/components/HelpDialog.vue' import { createAudioRuntime } from '@/composables/useAudioRuntime' @@ -2338,6 +2339,113 @@ export function registerAllTests(): void { }) }) + suite('ui — WelcomeDialog', () => { + // Modal teleports to body — snapshot/diff dialogs to isolate. + const snapshotDialogs = () => + new Set(Array.from(document.querySelectorAll('[role="dialog"]'))) + const dialogsAddedSince = (baseline: Set) => + Array.from(document.querySelectorAll('[role="dialog"]')).filter( + (el) => !baseline.has(el), + ) as HTMLElement[] + + /** Poll until the predicate matches or the budget elapses. The + * dialog's IDB load + Vue render cycle is fully async, so fixed + * sleeps were flaky on slower machines. */ + async function waitUntil(predicate: () => boolean, budgetMs = 1000, stepMs = 10) { + const start = performance.now() + while (!predicate()) { + if (performance.now() - start > budgetMs) return + await tick(stepMs) + } + } + + test('ArrowDown / ArrowUp move focus through the recents list', async () => { + // Seed two recents via the real IDB path so the dialog's own + // `listRecentProjects` call finds them. + const s = makeFreshStore() + const idA = `test-arrow-a-${Date.now()}` + const idB = `test-arrow-b-${Date.now()}` + await saveProjectToStore(s.project, { id: idA, name: 'A' }) + await tick(5) + await saveProjectToStore(s.project, { id: idB, name: 'B' }) + + const baseline = snapshotDialogs() + const handle = mountComponent(WelcomeDialog, { modelValue: true }) + // Wait for the dialog's async reload() to populate the list. + await waitUntil(() => { + const d = dialogsAddedSince(baseline)[0] + return !!d && d.querySelectorAll('.recent').length >= 2 + }) + const dialog = dialogsAddedSince(baseline)[0] + expect(dialog).toBeTruthy() + const items = dialog.querySelectorAll('.recent') as NodeListOf + expect(items.length >= 2).toBe(true) + + // The initial focus should already be on the first recent. + items[0].focus() + expect(document.activeElement).toBe(items[0]) + + // ArrowDown moves to the next item. + items[0].dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ) + await nextTick() + expect(document.activeElement).toBe(items[1]) + + // ArrowUp moves back. + items[1].dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }), + ) + await nextTick() + expect(document.activeElement).toBe(items[0]) + + handle.unmount() + await deleteStoredProject(idA) + await deleteStoredProject(idB) + }) + + test('Home and End jump to the first / last recent', async () => { + const s = makeFreshStore() + const ids = [ + `test-home-end-a-${Date.now()}`, + `test-home-end-b-${Date.now()}`, + `test-home-end-c-${Date.now()}`, + ] + for (const id of ids) { + await saveProjectToStore(s.project, { id, name: id }) + await tick(5) + } + + const baseline = snapshotDialogs() + const handle = mountComponent(WelcomeDialog, { modelValue: true }) + await waitUntil(() => { + const d = dialogsAddedSince(baseline)[0] + return !!d && d.querySelectorAll('.recent').length >= 3 + }) + const dialog = dialogsAddedSince(baseline)[0] + const items = dialog.querySelectorAll('.recent') as NodeListOf + expect(items.length >= 3).toBe(true) + + // Start somewhere in the middle, then End jumps to the last. + items[1].focus() + items[1].dispatchEvent( + new KeyboardEvent('keydown', { key: 'End', bubbles: true }), + ) + await nextTick() + expect(document.activeElement).toBe(items[items.length - 1]) + + // Home returns to the first. + items[items.length - 1].dispatchEvent( + new KeyboardEvent('keydown', { key: 'Home', bubbles: true }), + ) + await nextTick() + expect(document.activeElement).toBe(items[0]) + + handle.unmount() + for (const id of ids) await deleteStoredProject(id) + }) + }) + suite('clips — library & scope', () => { test('addClip creates empty clip in library', () => { const s = makeFreshStore() diff --git a/src/version.ts b/src/version.ts index d2ffe8c..2d51acd 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.16.0' +export const APP_VERSION = '0.16.1' export const APP_NAME = 'RUWIO'