0.16.1: welcome screen arrow-key navigation

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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 10:29:07 +02:00
1 parent fcfd21428d
commit baf775c8de
4 files changed
+137 -2

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "0.16.0",
"version": "0.16.1",
"type": "module",
"scripts": {
"dev": "vite",
+27
View File
@@ -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)"
>
<div class="thumb">
<img v-if="p.thumbnail" :src="p.thumbnail" alt="" />
+108
View File
@@ -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<Element>) =>
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<HTMLElement>
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<HTMLElement>
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()
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.16.0'
export const APP_VERSION = '0.16.1'
export const APP_NAME = 'RUWIO'