Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions frontend/viewer/src/lib/components/hotkey/hotkey.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script module lang="ts">
/** Keyboard modifier for app shortcuts. Extend this union as more modifiers are supported. */
export type Modifier = 'primary';
</script>

<script lang="ts">
import {getOpenDialogCount} from '$lib/components/ui/dialog-shared/dialog-shared-root.svelte';
import {hasPrimaryModifier} from '$lib/utils/platform';

type Props = {
key: string;
modifier?: Modifier;
disabled?: boolean;
allowWhenDialogOpen?: boolean;
onHotkey: () => void;
};

let {key, modifier = 'primary', disabled = false, allowWhenDialogOpen = false, onHotkey}: Props = $props();

function matchesModifier(e: KeyboardEvent, mod: Modifier): boolean {
switch (mod) {
case 'primary':
return hasPrimaryModifier(e);
}
}

function handleKeydown(e: KeyboardEvent) {
if (e.key.toLowerCase() !== key.toLowerCase() || !matchesModifier(e, modifier)) return;
if (disabled) return;
if (!allowWhenDialogOpen && getOpenDialogCount() > 0) return;
e.preventDefault();
onHotkey();
}
</script>

<svelte:window onkeydown={handleKeydown} />
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
</script>

<script lang="ts">
Expand Down
15 changes: 15 additions & 0 deletions frontend/viewer/src/lib/utils/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** True on macOS / iOS (for Cmd vs Ctrl shortcut labels and matching). */
export function isApplePlatform(): boolean {
if (typeof navigator === 'undefined') return false;
const uaDataPlatform = (navigator as Navigator & {userAgentData?: {platform?: string}}).userAgentData
?.platform;
if (uaDataPlatform) return /mac|iphone|ipad|ipod/i.test(uaDataPlatform);
// Fallback for browsers without userAgentData (Firefox, older Safari).
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
}

/** Primary modifier for app shortcuts: Cmd on Apple, Ctrl elsewhere. */
export function hasPrimaryModifier(e: KeyboardEvent): boolean {
if (e.altKey || e.shiftKey) return false;
return isApplePlatform() ? e.metaKey : e.ctrlKey;
}
32 changes: 25 additions & 7 deletions frontend/viewer/src/project/PrimaryNewEntryButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { t } from 'svelte-i18n-lingui';
import {useFeatures} from '$lib/services/feature-service';
import {isApplePlatform} from '$lib/utils/platform';
import {pt} from '$lib/views/view-text';
import {useViewService} from '$lib/views/view-service.svelte';

Expand Down Expand Up @@ -42,16 +44,32 @@
instances[id] === true ||
// implicitly active (not explicitly inactive and no other instance is active)
instances[id] === undefined && !Object.values(instances).some(_active => _active));

const shortcutHint = isApplePlatform() ? '⌘E' : 'Ctrl+E';
</script>

{#if isActive && features.write}
<div class="relative z-1" in:receive={{ key: 'new-entry-button' }} out:send={{ key: 'new-entry-button' }}>
<Button variant="default" size="extended-fab" class="font-semibold" icon="i-mdi-plus-thick" {onclick}>
{#if shortForm}
<span>{$t`New`}</span>
{:else}
<span>{pt($t`New Entry`, $t`New Word`, viewService.currentView)}</span>
{/if}
</Button>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({props})}
<Button variant="default" size="extended-fab" class="font-semibold" icon="i-mdi-plus-thick" {...props} {onclick}>
{#if shortForm}
<span>{$t`New`}</span>
{:else}
<span>{pt($t`New Entry`, $t`New Word`, viewService.currentView)}</span>
{/if}
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content class="flex items-center gap-2">
{#if shortForm}
<span>{$t`New`}</span>
{:else}
<span>{pt($t`New Entry`, $t`New Word`, viewService.currentView)}</span>
{/if}
<kbd class="text-background/70 font-sans tracking-widest">{shortcutHint}</kbd>
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
4 changes: 4 additions & 0 deletions frontend/viewer/src/project/browse/BrowseView.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import Hotkey from '$lib/components/hotkey/hotkey.svelte';
import MasterDetailView from '$lib/components/master-detail/MasterDetailView.svelte';
import {IsMobile} from '$lib/hooks/is-mobile.svelte';
import EntryView from './EntryView.svelte';
Expand All @@ -7,6 +8,7 @@
import {t} from 'svelte-i18n-lingui';
import SidebarPrimaryAction from '../SidebarPrimaryAction.svelte';
import {useDialogsService} from '$lib/services/dialogs-service';
import {useFeatures} from '$lib/services/feature-service';
import PrimaryNewEntryButton from '../PrimaryNewEntryButton.svelte';
import {BrowseParam} from '$lib/utils/search-params';
import {pt} from '$lib/views/view-text';
Expand All @@ -23,6 +25,7 @@
const projectContext = useProjectContext();
const viewService = useViewService();
const dialogsService = useDialogsService();
const features = useFeatures();
const entryListViewMode = useProjectStorage().entryListViewMode;

let selectedId = $state('');
Expand Down Expand Up @@ -52,6 +55,7 @@
await entriesList?.tryToScrollToEntry(selectedId);
}
</script>
<Hotkey key="e" disabled={!features.write} onHotkey={() => void newEntry()} />
<SidebarPrimaryAction>
{#snippet children(isOpen: boolean)}
<PrimaryNewEntryButton active={!IsMobile.value && isOpen} onclick={newEntry}/>
Expand Down
11 changes: 10 additions & 1 deletion frontend/viewer/src/project/browse/SearchFilter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -42,6 +43,7 @@
publication?: IPublication;
} = $props();

let inputRef = $state<HTMLInputElement | null>(null);
let missingField = $state<MissingOption | null>(null);
let selectedField = $state<SelectedField | null>(null);
let selectedWs = $state<string[]>(wsService.vernacularNoAudio.map(ws => ws.wsId));
Expand All @@ -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,
Expand Down Expand Up @@ -134,6 +141,8 @@
let filtersExpanded = $state(false);
</script>

<Hotkey key="f" onHotkey={focusSearch} />

{#snippet placeholder()}
{#if stats.current?.totalEntryCount !== undefined}
<ViewT view={viewService.currentView} classic={$t`Filter # entries`} lite={$t`Filter # words`}>
Expand All @@ -148,7 +157,7 @@

<div class="flex items-center gap-0.5">
<Sidebar.Trigger icon="i-mdi-menu" class="aspect-square p-0" />
<ComposableInput bind:value={search} inputProps={{ 'aria-label': $t`Filter` }} {placeholder} autofocus class="px-1 items-center overflow-x-hidden h-12 md:h-10">
<ComposableInput bind:value={search} bind:inputRef inputProps={{ 'aria-label': $t`Filter` }} {placeholder} autofocus class="px-1 items-center overflow-x-hidden h-12 md:h-10">
{#snippet after()}
<ResponsivePopup
bind:open={filtersExpanded}
Expand Down
17 changes: 15 additions & 2 deletions frontend/viewer/src/project/demo/in-memory-demo-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IProjectModel[]> {
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions frontend/viewer/src/project/project-context.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IMiniLcmFeatures | undefined> {
return this.#features.refetch();
}
public get historyService(): IHistoryServiceJsInvokable | undefined {
return this.#historyService;
}
Expand Down
92 changes: 92 additions & 0 deletions frontend/viewer/tests/ui/browse-hotkeys.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment thread
hahn-kev marked this conversation as resolved.

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();
});
});
});
6 changes: 5 additions & 1 deletion frontend/viewer/tests/ui/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
};
}
}
Loading