feat(transcription): wire dropdown search input to editor @-text
For issue #380. The search input mirrors the @-text the user types until the user takes ownership by typing into the input itself. After that, the input owns its own state and editor typing no longer overrides it. Two empty states now exist: - "Namen eingeben…" when the search input is empty (AC-4) - "Keine Personen gefunden" when the search input has a query but the list is empty (existing behavior) The dropdown reads editorQuery through the shared $state proxy via a getter prop, matching the established pattern for model.items. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -20,17 +20,25 @@ type DropdownState = {
|
||||
|
||||
let {
|
||||
model,
|
||||
initialQuery = '',
|
||||
editorQuery = '',
|
||||
onSearch = () => {}
|
||||
}: {
|
||||
model: DropdownState;
|
||||
initialQuery?: string;
|
||||
/** Text typed after `@` in the host editor. Mirrors into the search input
|
||||
* until the user takes manual ownership by typing into the input itself. */
|
||||
editorQuery?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
} = $props();
|
||||
|
||||
// initialQuery is a one-shot prop — PersonMentionEditor mounts a fresh dropdown
|
||||
// with the typed text on each Tiptap onStart, so we deliberately snapshot here.
|
||||
let searchQuery = $state(untrack(() => initialQuery));
|
||||
let searchQuery = $state(untrack(() => editorQuery));
|
||||
let userHasEdited = $state(false);
|
||||
|
||||
// Mirror the editor's typed text until the user takes ownership.
|
||||
$effect(() => {
|
||||
if (!userHasEdited) {
|
||||
searchQuery = editorQuery;
|
||||
}
|
||||
});
|
||||
|
||||
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
||||
// reset when `items` changes (so it never points past the end of a new list).
|
||||
@@ -153,15 +161,24 @@ function selectItem(item: Person) {
|
||||
class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
||||
placeholder={m.person_mention_search_prompt()}
|
||||
bind:value={searchQuery}
|
||||
oninput={(e) => onSearch(e.currentTarget.value)}
|
||||
oninput={(e) => {
|
||||
userHasEdited = true;
|
||||
onSearch(e.currentTarget.value);
|
||||
}}
|
||||
onmousedown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if model.items.length === 0}
|
||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||
{m.person_mention_popup_empty()}
|
||||
</p>
|
||||
{#if searchQuery.trim() === ''}
|
||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||
{m.person_mention_search_prompt()}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||
{m.person_mention_popup_empty()}
|
||||
</p>
|
||||
{/if}
|
||||
<!--
|
||||
Empty-state escape hatch — without it the transcriber has to close
|
||||
the dropdown, navigate to /persons/new, come back, and re-type the
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import MentionDropdown from './MentionDropdown.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
@@ -32,10 +33,10 @@ function makeModel(items: Person[] = []): DropdownState {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('MentionDropdown — search input', () => {
|
||||
it('renders a search input pre-filled with the initialQuery prop', async () => {
|
||||
it('renders a search input pre-filled with the editorQuery prop', async () => {
|
||||
render(MentionDropdown, {
|
||||
model: makeModel(),
|
||||
initialQuery: 'WdG',
|
||||
editorQuery: 'WdG',
|
||||
onSearch: () => {}
|
||||
});
|
||||
|
||||
@@ -45,7 +46,7 @@ describe('MentionDropdown — search input', () => {
|
||||
it('exposes a data-test-search-input attribute for E2E selectors', async () => {
|
||||
render(MentionDropdown, {
|
||||
model: makeModel(),
|
||||
initialQuery: '',
|
||||
editorQuery: '',
|
||||
onSearch: () => {}
|
||||
});
|
||||
|
||||
@@ -54,11 +55,33 @@ describe('MentionDropdown — search input', () => {
|
||||
expect((input as HTMLInputElement).type).toBe('search');
|
||||
});
|
||||
|
||||
it('shows "enter a name" prompt when search is empty (not "no results")', async () => {
|
||||
render(MentionDropdown, {
|
||||
model: makeModel([]),
|
||||
editorQuery: '',
|
||||
onSearch: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(m.person_mention_search_prompt())).toBeVisible();
|
||||
await expect.element(page.getByText(m.person_mention_popup_empty())).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "no results" when search has a query but the list is empty', async () => {
|
||||
render(MentionDropdown, {
|
||||
model: makeModel([]),
|
||||
editorQuery: 'WdG',
|
||||
onSearch: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(m.person_mention_popup_empty())).toBeVisible();
|
||||
await expect.element(page.getByText(m.person_mention_search_prompt())).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onSearch with the current value whenever the user types', async () => {
|
||||
const onSearch = vi.fn();
|
||||
render(MentionDropdown, {
|
||||
model: makeModel(),
|
||||
initialQuery: '',
|
||||
editorQuery: '',
|
||||
onSearch
|
||||
});
|
||||
|
||||
|
||||
@@ -42,10 +42,12 @@ let dropdownState = $state<{
|
||||
items: Person[];
|
||||
command: (item: Person) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
editorQuery: string;
|
||||
}>({
|
||||
items: [],
|
||||
command: () => {},
|
||||
clientRect: null
|
||||
clientRect: null,
|
||||
editorQuery: ''
|
||||
});
|
||||
|
||||
type DropdownExports = {
|
||||
@@ -187,14 +189,27 @@ onMount(() => {
|
||||
displayName: renderProps.query
|
||||
});
|
||||
dropdownState.clientRect = renderProps.clientRect ?? null;
|
||||
dropdownState.editorQuery = renderProps.query;
|
||||
};
|
||||
|
||||
return {
|
||||
onStart(renderProps) {
|
||||
updateState(renderProps as unknown as LooseRenderProps);
|
||||
const loose = renderProps as unknown as LooseRenderProps;
|
||||
updateState(loose);
|
||||
// MentionDropdown reads `editorQuery` off the shared state
|
||||
// proxy via its `editorQuery` prop binding below — this is
|
||||
// the same pattern as `model.items`. We do not pass it as a
|
||||
// separate prop because Svelte 5's mount() does not expose
|
||||
// settable prop accessors, so we route through the proxy.
|
||||
const mounted = mount(MentionDropdown, {
|
||||
target: document.body,
|
||||
props: { model: dropdownState }
|
||||
props: {
|
||||
model: dropdownState,
|
||||
get editorQuery() {
|
||||
return dropdownState.editorQuery;
|
||||
},
|
||||
onSearch: () => {}
|
||||
}
|
||||
});
|
||||
component = mounted as object;
|
||||
exports = mounted as unknown as DropdownExports;
|
||||
|
||||
@@ -161,6 +161,21 @@ describe('PersonMentionEditor — typeahead', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
|
||||
|
||||
describe('PersonMentionEditor — AC-1: search input prefill', () => {
|
||||
it('prefills the dropdown search input with the text typed after @', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@WdG');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||
|
||||
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||
|
||||
Reference in New Issue
Block a user