diff --git a/frontend/viewer/src/lib/components/hotkey/hotkey.svelte b/frontend/viewer/src/lib/components/hotkey/hotkey.svelte new file mode 100644 index 0000000000..d03a94a40a --- /dev/null +++ b/frontend/viewer/src/lib/components/hotkey/hotkey.svelte @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/viewer/src/lib/components/ui/dialog-shared/dialog-shared-root.svelte b/frontend/viewer/src/lib/components/ui/dialog-shared/dialog-shared-root.svelte index 6c62b32cbe..da9ef123e7 100644 --- a/frontend/viewer/src/lib/components/ui/dialog-shared/dialog-shared-root.svelte +++ b/frontend/viewer/src/lib/components/ui/dialog-shared/dialog-shared-root.svelte @@ -19,6 +19,11 @@ export function useDialogSharedRoot(): DialogSharedRootStateProps { return dialogSharedRootContext.get(); } + + /** Module-level dialog count for callers outside dialog context (e.g. global hotkeys). */ + export function getOpenDialogCount(): number { + return openDialogs; + } {#if isActive && features.write}
- + + + {#snippet child({props})} + + {/snippet} + + + {#if shortForm} + {$t`New`} + {:else} + {pt($t`New Entry`, $t`New Word`, viewService.currentView)} + {/if} + {shortcutHint} + +
{/if} diff --git a/frontend/viewer/src/project/browse/BrowseView.svelte b/frontend/viewer/src/project/browse/BrowseView.svelte index afbe91a021..d06d2377d1 100644 --- a/frontend/viewer/src/project/browse/BrowseView.svelte +++ b/frontend/viewer/src/project/browse/BrowseView.svelte @@ -1,4 +1,5 @@ + void newEntry()} /> {#snippet children(isOpen: boolean)} diff --git a/frontend/viewer/src/project/browse/SearchFilter.svelte b/frontend/viewer/src/project/browse/SearchFilter.svelte index 8ec7aca785..e3d5e439cf 100644 --- a/frontend/viewer/src/project/browse/SearchFilter.svelte +++ b/frontend/viewer/src/project/browse/SearchFilter.svelte @@ -23,6 +23,7 @@ import ResponsivePopup from '$lib/components/responsive-popup/responsive-popup.svelte'; import {IsMobile} from '$lib/hooks/is-mobile.svelte'; import {Button} from '$lib/components/ui/button'; + import Hotkey from '$lib/components/hotkey/hotkey.svelte'; const stats = useProjectStats(); const viewService = useViewService(); @@ -42,6 +43,7 @@ publication?: IPublication; } = $props(); + let inputRef = $state(null); let missingField = $state(null); let selectedField = $state(null); let selectedWs = $state(wsService.vernacularNoAudio.map(ws => ws.wsId)); @@ -50,6 +52,11 @@ let includeSubDomains = $state(false); let userFilterActive = $state(false); + function focusSearch() { + inputRef?.focus(); + inputRef?.select(); + } + const LITE_MORPHEME_TYPES = new Set([ MorphTypeKind.Root, MorphTypeKind.BoundRoot, MorphTypeKind.Stem, MorphTypeKind.BoundStem, @@ -134,6 +141,8 @@ let filtersExpanded = $state(false); + + {#snippet placeholder()} {#if stats.current?.totalEntryCount !== undefined} @@ -148,7 +157,7 @@
- + {#snippet after()} { @@ -202,9 +208,16 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { ]); } + #write = true; + + /** Test-only: toggle the write feature exposed by {@link supportedFeatures}. */ + setWrite(write: boolean) { + this.#write = write; + } + supportedFeatures() { return Promise.resolve({ - write: true, + write: this.#write, audio: true, customViews: true, } satisfies IMiniLcmFeatures); diff --git a/frontend/viewer/src/project/project-context.svelte.ts b/frontend/viewer/src/project/project-context.svelte.ts index fe551a409d..e90941b86e 100644 --- a/frontend/viewer/src/project/project-context.svelte.ts +++ b/frontend/viewer/src/project/project-context.svelte.ts @@ -98,6 +98,11 @@ export class ProjectContext { public get features(): IMiniLcmFeatures { return this.#features.current; } + + /** Re-fetch {@link features} from the API (e.g. after a test toggles demo write). */ + public refetchFeatures(): Promise { + return this.#features.refetch(); + } public get historyService(): IHistoryServiceJsInvokable | undefined { return this.#historyService; } diff --git a/frontend/viewer/tests/ui/browse-hotkeys.test.ts b/frontend/viewer/tests/ui/browse-hotkeys.test.ts new file mode 100644 index 0000000000..74ecb292b6 --- /dev/null +++ b/frontend/viewer/tests/ui/browse-hotkeys.test.ts @@ -0,0 +1,92 @@ +import {expect, test, type Locator, type Page} from '@playwright/test'; + +import {DemoProjectPage} from './demo-project.page'; + +/** Demo project uses dictionary terminology ("New Word"); entry view would say "New Entry". */ +function newEntryDialog(page: Page): Locator { + return page.getByRole('dialog').filter({has: page.getByRole('heading', {name: /New (Entry|Word)/})}); +} + +test.describe('Browse hotkeys', () => { + let projectPage: DemoProjectPage; + + test.beforeEach(async ({page}) => { + projectPage = new DemoProjectPage(page); + await projectPage.goto(); + }); + + test.describe('New entry (Ctrl/Cmd+E)', () => { + test('Ctrl/Cmd+E opens the new entry dialog', async ({page}) => { + await page.keyboard.press('ControlOrMeta+e'); + await expect(newEntryDialog(page)).toBeVisible(); + }); + + test('Ctrl/Cmd+E works while the search input is focused', async ({page}) => { + await projectPage.entriesList.searchInput.click(); + await expect(projectPage.entriesList.searchInput).toBeFocused(); + + await page.keyboard.press('ControlOrMeta+e'); + await expect(newEntryDialog(page)).toBeVisible(); + }); + + test('plain E does not open the new entry dialog', async ({page}) => { + await page.keyboard.press('e'); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); + + test('Ctrl/Cmd+E does not reset an already-open new entry dialog', async ({page}) => { + await page.keyboard.press('ControlOrMeta+e'); + + const dialog = newEntryDialog(page); + await expect(dialog).toBeVisible(); + + const lexemeInput = dialog.locator('[style*="grid-area: lexemeForm"] input').first(); + await expect(lexemeInput).toBeVisible(); + await lexemeInput.fill('hotkey-preserve'); + await expect(lexemeInput).toHaveValue('hotkey-preserve'); + + await page.keyboard.press('ControlOrMeta+e'); + + await expect(page.getByRole('dialog')).toHaveCount(1); + await expect(lexemeInput).toHaveValue('hotkey-preserve'); + }); + + test('Ctrl/Cmd+E does nothing when the project is read-only', async ({page}) => { + await page.evaluate(async () => { + await window.__PLAYWRIGHT_UTILS__.setWrite(false); + }); + await expect(page.getByRole('button', {name: /New (Entry|Word)/})).toHaveCount(0); + + await page.keyboard.press('ControlOrMeta+e'); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); + }); + + test.describe('Search focus (Ctrl/Cmd+F)', () => { + test('Ctrl/Cmd+F focuses the Filter search input and selects existing text', async ({page}) => { + const filter = 'hotkey-select'; + await projectPage.entriesList.searchInput.fill(filter); + await projectPage.entriesList.searchInput.blur(); + await expect(projectPage.entriesList.searchInput).not.toBeFocused(); + + await page.keyboard.press('ControlOrMeta+f'); + + const searchInput = projectPage.entriesList.searchInput; + await expect(searchInput).toBeFocused(); + await expect(searchInput).toHaveValue(filter); + await expect.poll(async () => searchInput.evaluate((el: HTMLInputElement) => ({ + start: el.selectionStart, + end: el.selectionEnd, + length: el.value.length, + }))).toEqual({start: 0, end: filter.length, length: filter.length}); + }); + + test('Ctrl/Cmd+F focuses search after selecting an entry', async ({page}) => { + await projectPage.entriesList.selectEntryByIndex(0); + await expect(projectPage.entriesList.searchInput).not.toBeFocused(); + + await page.keyboard.press('ControlOrMeta+f'); + await expect(projectPage.entriesList.searchInput).toBeFocused(); + }); + }); +}); diff --git a/frontend/viewer/tests/ui/test.d.ts b/frontend/viewer/tests/ui/test.d.ts index fb511c51c7..8f812d8d4c 100644 --- a/frontend/viewer/tests/ui/test.d.ts +++ b/frontend/viewer/tests/ui/test.d.ts @@ -5,6 +5,10 @@ export { }; // for some reason this is required in order to make global changes declare global { interface Window { // eslint-disable-next-line @typescript-eslint/naming-convention - __PLAYWRIGHT_UTILS__: {demoApi: IMiniLcmJsInvokable} + __PLAYWRIGHT_UTILS__: { + demoApi: IMiniLcmJsInvokable; + /** Toggle demo write feature and refetch project features. */ + setWrite: (write: boolean) => Promise; + }; } }