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:
1 parent
fcfd21428d
commit
baf775c8de
4 files changed
+137
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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="" />
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.16.0'
|
||||
export const APP_VERSION = '0.16.1'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user