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",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.16.0",
|
"version": "0.16.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -80,6 +80,32 @@ function openProject(p: StoredProject) {
|
|||||||
emit('update:modelValue', false)
|
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) {
|
async function removeProject(e: MouseEvent, p: StoredProject) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
@@ -150,6 +176,7 @@ watch(
|
|||||||
@click="openProject(p)"
|
@click="openProject(p)"
|
||||||
@keydown.enter.prevent="openProject(p)"
|
@keydown.enter.prevent="openProject(p)"
|
||||||
@keydown.space.prevent="openProject(p)"
|
@keydown.space.prevent="openProject(p)"
|
||||||
|
@keydown="onRecentKeyDown($event, i)"
|
||||||
>
|
>
|
||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img v-if="p.thumbnail" :src="p.thumbnail" alt="" />
|
<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 AssetSidebar from '@/components/AssetSidebar.vue'
|
||||||
import CanvasStage from '@/components/CanvasStage.vue'
|
import CanvasStage from '@/components/CanvasStage.vue'
|
||||||
import ExportDialog from '@/components/ExportDialog.vue'
|
import ExportDialog from '@/components/ExportDialog.vue'
|
||||||
|
import WelcomeDialog from '@/components/WelcomeDialog.vue'
|
||||||
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
||||||
import HelpDialog from '@/components/HelpDialog.vue'
|
import HelpDialog from '@/components/HelpDialog.vue'
|
||||||
import { createAudioRuntime } from '@/composables/useAudioRuntime'
|
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', () => {
|
suite('clips — library & scope', () => {
|
||||||
test('addClip creates empty clip in library', () => {
|
test('addClip creates empty clip in library', () => {
|
||||||
const s = makeFreshStore()
|
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'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user