diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index 48ee6db7..c1f42a68 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -33,7 +33,8 @@ let { editorQuery = '', onSearch = () => {}, ondismiss = () => {}, - focusOnMount = false + focusOnMount = false, + editingDisplayName = '' }: { model: DropdownState; /** Text typed after `@` in the host editor. Mirrors into the search input @@ -46,6 +47,10 @@ let { /** Re-edit (#628) opens with the search field focused; the fresh-@ path keeps * focus in the editor so typing flows to the contenteditable. */ focusOnMount?: boolean; + /** When set (re-edit, #628), the persistent live region announces the editing + * context for this mention. Routed through the SAME aria-live region as the + * result count — never a second live region (avoids the double-announce bug). */ + editingDisplayName?: string; } = $props(); let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH))); @@ -260,7 +265,11 @@ function selectItem(item: Person) { -->

{#if model.items.length === 0} - {isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()} + {#if editingDisplayName && !userHasEdited} + {m.person_mention_editing_announce({ displayName: editingDisplayName })} + {:else} + {isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()} + {/if} {:else if model.items.length === 1} {m.person_mention_results_count_singular()} {:else} diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte index 84df6b5d..274f97ce 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte @@ -82,7 +82,7 @@ type LooseRenderProps = { // and (#628) the pencil re-edit affordance — so at most one dropdown is ever // mounted (the AC-6 single-dropdown invariant). open() closes any prior // dropdown first; render() is a thin adapter over open()/update()/close(). -type OpenOptions = { focusOnMount?: boolean }; +type OpenOptions = { focusOnMount?: boolean; editingDisplayName?: string }; type MentionController = { open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void; @@ -183,6 +183,7 @@ function createMentionController(): MentionController { model: dropdownState, ondismiss: dismiss, focusOnMount: opts.focusOnMount ?? false, + editingDisplayName: opts.editingDisplayName ?? '', // MentionDropdown reads `editorQuery` off the shared state proxy via // this getter — Svelte 5's mount() does not expose settable prop // accessors, so we route through the proxy (same pattern as items). @@ -235,7 +236,10 @@ function commitRelink(pos: number): CommitFn { // the stored displayName. function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) { if (!editor || !editor.isEditable) return; - controller.open(getRect, displayName, commitRelink(pos), { focusOnMount: true }); + controller.open(getRect, displayName, commitRelink(pos), { + focusOnMount: true, + editingDisplayName: displayName + }); } onMount(() => { diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 479291b8..195da335 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -1309,3 +1309,27 @@ describe('PersonMentionEditor — #628 security', () => { }); }); }); + +// ─── #628 NFR a11y: editing context announced via the existing live region ─── + +describe('PersonMentionEditor — #628 editing announce', () => { + it('re-edit open announces the editing context through the single persistent live region', async () => { + mockFetchEmpty(); + renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); + await vi.waitFor(() => + expect(document.querySelector('[data-type="mention"] button')).not.toBeNull() + ); + (document.querySelector('[data-type="mention"] button') as HTMLElement).dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }) + ); + + await vi.waitFor(() => { + const liveRegions = document.querySelectorAll('[role="listbox"] [aria-live="polite"]'); + // Exactly ONE live region (no second announcer — avoids double-announce). + expect(liveRegions.length).toBe(1); + expect(liveRegions[0].textContent ?? '').toContain( + m.person_mention_editing_announce({ displayName: 'Aug' }) + ); + }); + }); +});