From b8935c7296b4858514c960e0c5287e9f44e9c651 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 24 Jul 2026 09:41:04 +0700 Subject: [PATCH 1/3] Add Ctrl/Cmd+E hotkey to open new entry from browse view. Co-authored-by: Cursor --- .../dialog-shared/dialog-shared-root.svelte | 5 ++ frontend/viewer/src/lib/utils/platform.ts | 15 ++++++ .../src/project/PrimaryNewEntryButton.svelte | 32 +++++++++--- .../src/project/browse/BrowseView.svelte | 14 +++++ .../viewer/tests/ui/new-entry-hotkey.test.ts | 52 +++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 frontend/viewer/src/lib/utils/platform.ts create mode 100644 frontend/viewer/tests/ui/new-entry-hotkey.test.ts 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..b9779bc029 100644 --- a/frontend/viewer/src/project/browse/BrowseView.svelte +++ b/frontend/viewer/src/project/browse/BrowseView.svelte @@ -1,5 +1,6 @@ + {#snippet children(isOpen: boolean)} diff --git a/frontend/viewer/tests/ui/new-entry-hotkey.test.ts b/frontend/viewer/tests/ui/new-entry-hotkey.test.ts new file mode 100644 index 0000000000..ea9da06e8b --- /dev/null +++ b/frontend/viewer/tests/ui/new-entry-hotkey.test.ts @@ -0,0 +1,52 @@ +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('New entry hotkey', () => { + let projectPage: DemoProjectPage; + + test.beforeEach(async ({page}) => { + projectPage = new DemoProjectPage(page); + await projectPage.goto(); + }); + + 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'); + }); +}); From 1ec969cdff0951d5b045f400a4174cd5f2953ff5 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 24 Jul 2026 11:31:04 +0700 Subject: [PATCH 2/3] Extract Hotkey component and add Ctrl/Cmd+F to focus browse search. Co-authored-by: Cursor --- .../src/lib/components/hotkey/hotkey.svelte | 36 ++++++++++ .../src/project/browse/BrowseView.svelte | 14 +--- .../src/project/browse/SearchFilter.svelte | 11 ++- .../viewer/tests/ui/browse-hotkeys.test.ts | 72 +++++++++++++++++++ .../viewer/tests/ui/new-entry-hotkey.test.ts | 52 -------------- 5 files changed, 120 insertions(+), 65 deletions(-) create mode 100644 frontend/viewer/src/lib/components/hotkey/hotkey.svelte create mode 100644 frontend/viewer/tests/ui/browse-hotkeys.test.ts delete mode 100644 frontend/viewer/tests/ui/new-entry-hotkey.test.ts 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/project/browse/BrowseView.svelte b/frontend/viewer/src/project/browse/BrowseView.svelte index b9779bc029..d06d2377d1 100644 --- a/frontend/viewer/src/project/browse/BrowseView.svelte +++ b/frontend/viewer/src/project/browse/BrowseView.svelte @@ -1,6 +1,6 @@ - + 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()} { + 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.describe('Search focus (Ctrl/Cmd+F)', () => { + test('Ctrl/Cmd+F focuses the Filter search input', async ({page}) => { + await projectPage.entriesList.searchInput.blur(); + await expect(projectPage.entriesList.searchInput).not.toBeFocused(); + + await page.keyboard.press('ControlOrMeta+f'); + await expect(projectPage.entriesList.searchInput).toBeFocused(); + }); + + 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/new-entry-hotkey.test.ts b/frontend/viewer/tests/ui/new-entry-hotkey.test.ts deleted file mode 100644 index ea9da06e8b..0000000000 --- a/frontend/viewer/tests/ui/new-entry-hotkey.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -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('New entry hotkey', () => { - let projectPage: DemoProjectPage; - - test.beforeEach(async ({page}) => { - projectPage = new DemoProjectPage(page); - await projectPage.goto(); - }); - - 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'); - }); -}); From 64f807e9f4b980a09d2ea25fc63065451cfa4ddd Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 24 Jul 2026 12:02:19 +0700 Subject: [PATCH 3/3] Cover read-only Ctrl+E and Ctrl+F text selection in browse hotkey tests. Co-authored-by: Cursor --- .../src/project/demo/in-memory-demo-api.ts | 17 +++++++++++-- .../src/project/project-context.svelte.ts | 5 ++++ .../viewer/tests/ui/browse-hotkeys.test.ts | 24 +++++++++++++++++-- frontend/viewer/tests/ui/test.d.ts | 6 ++++- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index 6da02731cf..f56ad56071 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -122,7 +122,13 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { window.lexbox.ServiceProvider.setService(DotnetService.FwLiteConfig, mockFwLiteConfig); window.lexbox.ServiceProvider.setService(DotnetService.UpdateService, mockUpdateService); window.lexbox.ServiceProvider.setService(DotnetService.JsEventListener, mockJsEventListener); - window.__PLAYWRIGHT_UTILS__ = { demoApi: inMemoryLexboxApi }; + window.__PLAYWRIGHT_UTILS__ = { + demoApi: inMemoryLexboxApi, + async setWrite(write: boolean) { + inMemoryLexboxApi.setWrite(write); + await projectContext.refetchFeatures(); + }, + }; window.lexbox.ServiceProvider.setService(DotnetService.CombinedProjectsService, { localProjects(): Promise { @@ -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 index ca7040e116..74ecb292b6 100644 --- a/frontend/viewer/tests/ui/browse-hotkeys.test.ts +++ b/frontend/viewer/tests/ui/browse-hotkeys.test.ts @@ -50,15 +50,35 @@ test.describe('Browse hotkeys', () => { 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', async ({page}) => { + 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'); - await expect(projectPage.entriesList.searchInput).toBeFocused(); + + 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}) => { 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; + }; } }