feat(transcription): announce re-edit context via the existing live region (#628)
Passes editingDisplayName into MentionDropdown; the persistent aria-live region
announces person_mention_editing_announce({displayName}) on re-edit open and
falls back to the prompt/empty/count copy once the user edits or results arrive.
Routed through the SAME sr-only region as the result count — no second live
region (avoids the double-announce bug Leonie S-2 fixed). Fresh-@ passes an
empty editingDisplayName, so its announcements are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,8 @@ let {
|
|||||||
editorQuery = '',
|
editorQuery = '',
|
||||||
onSearch = () => {},
|
onSearch = () => {},
|
||||||
ondismiss = () => {},
|
ondismiss = () => {},
|
||||||
focusOnMount = false
|
focusOnMount = false,
|
||||||
|
editingDisplayName = ''
|
||||||
}: {
|
}: {
|
||||||
model: DropdownState;
|
model: DropdownState;
|
||||||
/** Text typed after `@` in the host editor. Mirrors into the search input
|
/** 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
|
/** Re-edit (#628) opens with the search field focused; the fresh-@ path keeps
|
||||||
* focus in the editor so typing flows to the contenteditable. */
|
* focus in the editor so typing flows to the contenteditable. */
|
||||||
focusOnMount?: boolean;
|
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();
|
} = $props();
|
||||||
|
|
||||||
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
||||||
@@ -260,7 +265,11 @@ function selectItem(item: Person) {
|
|||||||
-->
|
-->
|
||||||
<p class="sr-only" aria-live="polite">
|
<p class="sr-only" aria-live="polite">
|
||||||
{#if model.items.length === 0}
|
{#if model.items.length === 0}
|
||||||
|
{#if editingDisplayName && !userHasEdited}
|
||||||
|
{m.person_mention_editing_announce({ displayName: editingDisplayName })}
|
||||||
|
{:else}
|
||||||
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
||||||
|
{/if}
|
||||||
{:else if model.items.length === 1}
|
{:else if model.items.length === 1}
|
||||||
{m.person_mention_results_count_singular()}
|
{m.person_mention_results_count_singular()}
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ type LooseRenderProps = {
|
|||||||
// and (#628) the pencil re-edit affordance — so at most one dropdown is ever
|
// 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
|
// mounted (the AC-6 single-dropdown invariant). open() closes any prior
|
||||||
// dropdown first; render() is a thin adapter over open()/update()/close().
|
// dropdown first; render() is a thin adapter over open()/update()/close().
|
||||||
type OpenOptions = { focusOnMount?: boolean };
|
type OpenOptions = { focusOnMount?: boolean; editingDisplayName?: string };
|
||||||
|
|
||||||
type MentionController = {
|
type MentionController = {
|
||||||
open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void;
|
open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void;
|
||||||
@@ -183,6 +183,7 @@ function createMentionController(): MentionController {
|
|||||||
model: dropdownState,
|
model: dropdownState,
|
||||||
ondismiss: dismiss,
|
ondismiss: dismiss,
|
||||||
focusOnMount: opts.focusOnMount ?? false,
|
focusOnMount: opts.focusOnMount ?? false,
|
||||||
|
editingDisplayName: opts.editingDisplayName ?? '',
|
||||||
// MentionDropdown reads `editorQuery` off the shared state proxy via
|
// MentionDropdown reads `editorQuery` off the shared state proxy via
|
||||||
// this getter — Svelte 5's mount() does not expose settable prop
|
// this getter — Svelte 5's mount() does not expose settable prop
|
||||||
// accessors, so we route through the proxy (same pattern as items).
|
// accessors, so we route through the proxy (same pattern as items).
|
||||||
@@ -235,7 +236,10 @@ function commitRelink(pos: number): CommitFn {
|
|||||||
// the stored displayName.
|
// the stored displayName.
|
||||||
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
|
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
|
||||||
if (!editor || !editor.isEditable) return;
|
if (!editor || !editor.isEditable) return;
|
||||||
controller.open(getRect, displayName, commitRelink(pos), { focusOnMount: true });
|
controller.open(getRect, displayName, commitRelink(pos), {
|
||||||
|
focusOnMount: true,
|
||||||
|
editingDisplayName: displayName
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user