From 3259f5484c014743933ec6d079a01b085a397d1b Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Tue, 27 Oct 2026 12:40:35 -0700 Subject: [PATCH] feat(lab): MobilePowerSearch takes the Core sheet flow's behaviors Ports what the Core PowerSearch sheet presentation gained into Lab's MobilePowerSearch so the touch flow's UX pass starts from the best version: announcements delivered inside the open sheet by useAnnounce, empty-result and unavailable messages spoken from their rendered element, one focus re-home rule over the button rows, rows and tokens keyed by filter identity, Apply and Cancel after a controlled update removed the edited filter or the opening field, the unavailable state with focus on Remove, stale filters listed by their raw keys with the step titled the same way, Removed and Clear all announcements, the field becoming disabled closing the flow, and endContent keeping its own taps. Semantic alignments with Core (DEC-3): Clear all removes every filter, read-only ones included, as one remove at the last index; date editors read and write the UTC day; isDateOnly: false gets the date-only editor; dates read as the UTC day on tokens and rows. --- .../MobilePowerSearch.doc.mjs | 8 +- .../MobilePowerSearch.test.tsx | 639 ++++++++++++-- .../MobilePowerSearch/MobilePowerSearch.tsx | 780 +++++++++++++----- .../MobilePowerSearchValueEditor.tsx | 50 +- .../mobilePowerSearchUtils.ts | 69 +- 5 files changed, 1224 insertions(+), 322 deletions(-) diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs index edd4fca1f6782..8f10b49143da4 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs @@ -70,14 +70,14 @@ export const docs = { name: 'hasClear', type: 'boolean', description: - 'Shows Clear all in the Filters sheet footer. Read-only filters are never cleared.', + 'Shows Clear all in the Filters sheet footer. Clear all removes every filter, read-only ones included, as the Core PowerSearch clear control does, and reports one `remove` change at the last index.', default: 'true', }, { name: 'timezoneID', type: 'string', description: - 'IANA timezone for date values. Used by tokens, Filters rows, and the date editors alike, so a boundary date reads the same everywhere.', + 'IANA timezone, accepted for API parity with Core PowerSearch. Dates follow Core: tokens and Filters rows show a date_absolute as the Core token does (its day in the browser zone, no time of day) and the date editors read and write the UTC day, so this prop does not change a date value today.', }, { name: 'maxSearchResults', @@ -107,7 +107,7 @@ export const docs = { ], usage: { description: - 'Lab prototype for trying the touch PowerSearch flow. Choose it explicitly on touch layouts (for example `const Search = isTouch ? MobilePowerSearch : PowerSearch`); it never switches on pointer type by itself. The whole field is one Manage filters trigger showing display-only tokens. Tapping it opens a tall Filters sheet listing each filter as a chevron row with its value in bold, Add filter at the top right, and sticky Clear all and Done actions. Opening a row edits that filter in a sheet with Cancel, Apply, and Remove. Add filter opens directly when no filter exists; when the config names a content-search field it carries that search with inline suggestions. Creating a filter drills field → operator (radios, only when a field has more than one) → value. Each step is its own BottomSheet; moving between steps is a BottomSheet replace handoff (the entering step rises above the leaving one under the one scrim), so the steps need no BottomSheetSwitcher. Single and multi-value choices are full-width rows with trailing checks; other value types use the matching Astryx input. Fields whose only operators are nested are left out with a development warning.', + 'Lab prototype for trying the touch PowerSearch flow. Choose it explicitly on touch layouts (for example `const Search = isTouch ? MobilePowerSearch : PowerSearch`); it never switches on pointer type by itself. The whole field is one Manage filters trigger showing display-only tokens. Tapping it opens a tall Filters sheet listing each filter as a chevron row with its value in bold, Add filter at the top right, and sticky Clear all and Done actions. Opening a row edits that filter in a sheet with Cancel, Apply, and Remove. Add filter opens directly when no filter exists; when the config names a content-search field it carries that search with inline suggestions. Creating a filter drills field → operator (radios, only when a field has more than one) → value. Each step is its own BottomSheet; moving between steps is a BottomSheet replace handoff (the entering step rises above the leaving one under the one scrim), so the steps need no BottomSheetSwitcher. Single and multi-value choices are full-width rows with trailing checks; other value types use the matching Astryx input. Announcements made while a sheet is open — an added or removed filter, a result count, an empty-result message — are spoken inside the open sheet; Clear all removes every filter and is announced once the flow has closed. A filter whose field or operator the config no longer knows still lists by its raw keys, and its step offers Remove. Fields whose only operators are nested are left out with a development warning.', bestPractices: [ { guidance: true, @@ -117,7 +117,7 @@ export const docs = { { guidance: true, description: - 'Pass timezoneID whenever filters carry dates, so tokens, rows, and editors agree on the calendar day.', + 'A date_absolute value is the UTC midnight of the chosen day, as Core PowerSearch stores it, and tokens and rows show it as the Core token does, so a filter reads and means the same in both components.', }, { guidance: false, diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx index 2aba0934bb10c..eef63e03d1406 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx @@ -63,6 +63,18 @@ beforeEach(async () => { Element.prototype.setPointerCapture = vi.fn(); Element.prototype.releasePointerCapture = vi.fn(); } + // jsdom has no showModal(), so an open dialog never matches `:modal`; stand + // in for the sheets the browser presents modally, so `useAnnounce` routes + // into the open sheet's dialog as it does in a browser. + const matches = Element.prototype.matches; + vi.spyOn(Element.prototype, 'matches').mockImplementation(function ( + this: Element, + selector: string, + ) { + return selector === ':modal' + ? this instanceof HTMLDialogElement && this.open + : matches.call(this, selector); + }); vi.stubGlobal( 'matchMedia', vi.fn().mockReturnValue({ @@ -90,9 +102,10 @@ beforeEach(async () => { ); window.scrollTo = vi.fn(); // The shared page-body live regions outlive a test and clear on a 2s - // timer, and a previous test's announcement may still be a frame away; - // let it land, then start this test with the regions empty. - await new Promise(resolve => setTimeout(resolve, 0)); + // timer, and a previous test's announcement may still be a frame away — + // or, for words a closing modal dialog hands on to the page body, two; let + // them land, then start this test with the regions empty. + await new Promise(resolve => setTimeout(resolve, 20)); for (const region of document.querySelectorAll( 'body > [data-astryx-live-region]', )) { @@ -102,6 +115,7 @@ beforeEach(async () => { afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); // ============================================================================= @@ -330,6 +344,24 @@ function setup( return {onChange, ...result}; } +/** + * The polite live region `useAnnounce` keeps inside an open sheet's dialog, + * created on the first message delivered there; read inside `waitFor`. + */ +function sheetRegion(): HTMLElement { + const region = sheet().querySelector( + ':scope > [data-astryx-modal-live-region="polite"]', + ); + if (!region) { + throw new Error('no live region inside the open sheet yet'); + } + return region; +} + +const pageRegionText = () => + document.querySelector('body > [data-astryx-live-region="polite"]') + ?.textContent ?? ''; + function trigger(): HTMLElement { return document.querySelector('[aria-haspopup="dialog"]')!; } @@ -524,11 +556,14 @@ describe('MobilePowerSearch trigger', () => { expect(getComputedStyle(button).position).toBe('absolute'); }); - it('does not open when disabled', () => { + it('does not open when disabled, and the trigger stays focusable through aria-disabled rather than native disabled', () => { setup({isDisabled: true}); openSheet(); expect(isSheetOpen()).toBe(false); - expect(trigger()).toBeDisabled(); + expect(trigger()).not.toBeDisabled(); + expect(trigger()).toHaveAttribute('aria-disabled', 'true'); + trigger().focus(); + expect(trigger()).toHaveFocus(); }); it('keeps the trigger focusable when a disabled reason is given', () => { @@ -626,18 +661,9 @@ describe('MobilePowerSearch trigger', () => { expect(pageRegion()?.textContent).toBe('1 result'); }); - it('routes a result count change to the live region inside the open sheet, which stays mounted and empty until then', async () => { - const pageRegion = () => - document.querySelector('body > [aria-live="polite"]'); + it('routes a result count change to the live region useAnnounce keeps inside the open sheet, not the page body', async () => { const {rerender} = setup({resultCount: 12}); openSheet(); - const region = within(sheet()).getByTestId( - 'mobile-power-search-sheet-status', - ); - expect(region).toHaveAttribute('role', 'status'); - expect(region).toHaveAttribute('aria-live', 'polite'); - expect(region.textContent).toBe(''); - expect(sheet().contains(region)).toBe(true); rerender( { resultCount={3} />, ); - await waitFor(() => expect(region.textContent).toBe('3 results')); + await waitFor(() => expect(sheetRegion().textContent).toBe('3 results')); + expect(sheetRegion()).toHaveAttribute('aria-live', 'polite'); // The page-body channel, which a native modal hides, is left alone. - expect(pageRegion()?.textContent ?? '').toBe(''); + expect(pageRegionText()).toBe(''); }); it('forwards display-only props to token overrides', () => { @@ -744,13 +771,53 @@ describe('MobilePowerSearch Filters sheet', () => { expect(within(field()).queryByText('Open')).toBeNull(); }); - it('clears every removable filter but keeps read-only ones', () => { + it('Clear all removes every filter, read-only ones included, as the Core field does (DEC-3), and announces it once the flow has closed', async () => { const pinned: PowerSearchFilter = {...openFilter, isReadOnly: true}; const {onChange} = setup({filters: [authorFilter, pinned]}); openSheet(); fireEvent.click(within(sheet()).getByRole('button', {name: 'Clear all'})); - expect(onChange).toHaveBeenCalledWith([pinned], 'remove', 0); + expect(onChange).toHaveBeenCalledWith([], 'remove', 1); expect(isSheetOpen()).toBe(false); + // Nothing is spoken while the dialog is still exiting; the trigger's + // focus after the exit is the signal. + expect(pageRegionText()).toBe(''); + settleSheets(); + trigger().focus(); + await waitFor(() => expect(pageRegionText()).toBe('All filters removed')); + }); + + it('Clear all with a result count that changes in the same commit speaks both once the flow has closed', async () => { + function CountFollowsFilters() { + const [filters, setFilters] = useState([ + openFilter, + authorFilter, + ]); + return ( + setFilters([...next])} + resultCount={filters.length * 10} + /> + ); + } + render(); + openSheet(); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Clear all'})); + expect(isSheetOpen()).toBe(false); + settleSheets(); + trigger().focus(); + await waitFor(() => + expect(pageRegionText()).toBe('All filters removed. 0 results'), + ); + }); + + it('shows Clear all when every filter is read-only, since Clear all removes those too', () => { + const pinned: PowerSearchFilter = {...openFilter, isReadOnly: true}; + const {onChange} = setup({filters: [pinned]}); + openSheet(); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Clear all'})); + expect(onChange).toHaveBeenCalledWith([], 'remove', 0); }); it('respects hasClear', () => { @@ -1379,9 +1446,10 @@ describe('MobilePowerSearch value sheet', () => { expect(onChange).not.toHaveBeenCalled(); }); - it('disables edit actions when an open editor becomes disabled', () => { + it('closes the flow when the field becomes disabled while a step is open, and does not reopen it when re-enabled', () => { const {rerender} = setup({filters: [openFilter]}); openEditFlow('Status is Open'); + expect(isSheetOpen()).toBe(true); rerender( { isDisabled />, ); - expect(within(sheet()).getByRole('button', {name: 'Apply'})).toBeDisabled(); - expect( - within(sheet()).getByRole('button', {name: 'Remove'}), - ).toBeDisabled(); - expect(within(sheet()).getByRole('button', {name: 'Cancel'})).toBeEnabled(); - expect( - within(sheet()).getByRole('radio', {name: 'Status is'}), - ).toBeDisabled(); + expect(isSheetOpen()).toBe(false); + // The trigger is never natively disabled, so the closing flow can hand + // focus back to it instead of the page body. + settleSheets(); + expect(trigger()).toHaveFocus(); + expect(trigger()).toHaveAttribute('aria-disabled', 'true'); + expect(trigger()).not.toBeDisabled(); + rerender( + , + ); + expect(isSheetOpen()).toBe(false); }); it('commits the complete filter returned by a custom editor', () => { @@ -1690,15 +1765,10 @@ describe('MobilePowerSearch content search', () => { // The add is announced inside the sheet the flow is now on, not on the // page body, which assistive technology does not expose under a native // modal dialog. - const region = within(sheet()).getByTestId( - 'mobile-power-search-sheet-status', - ); await waitFor(() => - expect(region.textContent).toBe('Added "release notes"'), + expect(sheetRegion().textContent).toBe('Added "release notes"'), ); - expect( - document.querySelector('body > [aria-live="polite"]')?.textContent ?? '', - ).toBe(''); + expect(pageRegionText()).toBe(''); }); it('announces both the add and a result count that follows the filters in the same commit, joined in the sheet region', async () => { @@ -1726,15 +1796,12 @@ describe('MobilePowerSearch content search', () => { within(sheet()).getByRole('button', {name: '"release notes"'}), ); await waitFor(() => expect(isSheetOpen()).toBe(true)); - const region = within(sheet()).getByTestId( - 'mobile-power-search-sheet-status', - ); await waitFor(() => - expect(region.textContent).toBe('Added "release notes" 20 results'), + expect(sheetRegion().textContent).toBe( + 'Added "release notes" 20 results', + ), ); - expect( - document.querySelector('body > [aria-live="polite"]')?.textContent ?? '', - ).toBe(''); + expect(pageRegionText()).toBe(''); }); it('adds a value suggestion from Enter and announces it', () => { @@ -1956,11 +2023,12 @@ describe('MobilePowerSearch value types', () => { expect(input).toHaveValue('March 10, 2024'); }); - it('date_absolute (date and time): a date-time input is rendered', () => { + it('date_absolute with isDateOnly: false gets the date-only editor, as the Core editor does (DEC-3)', () => { setupValues(); openAddFlow(); tapRow('Updated'); - expect(within(sheet()).getByLabelText('Date and time')).toBeTruthy(); + expect(within(sheet()).queryByLabelText('Date and time')).toBeNull(); + expect(within(sheet()).getByRole('combobox', {name: 'Date'})).toBeTruthy(); }); it('date_relative: rows stage a single relative value', () => { @@ -2136,28 +2204,80 @@ describe('MobilePowerSearch timezone', () => { value: {type: 'date_absolute', unixSeconds: BOUNDARY_SECONDS}, }; - it.each([ - ['Asia/Tokyo', 'Mar 10, 2024', 'March 10, 2024'], - ['Pacific/Honolulu', 'Mar 9, 2024', 'March 9, 2024'], - ])( - 'formats the token, the row and the editor in %s', - (timezoneID, compact, long) => { - setup({config: valueTypesConfig, filters: [dateFilter], timezoneID}); - const tokenValue = within(field()).getByText(new RegExp(compact)); - expect(tokenValue).toHaveStyle({fontWeight: 'var(--font-weight-bold)'}); - openSheet(); - const row = within(sheet()).getByRole('button', { - name: new RegExp(`^Created is after ${compact}`), - }); - expect(within(row).getByText(new RegExp(compact))).toHaveStyle({ - fontWeight: 'var(--font-weight-bold)', - }); - fireEvent.click(row); - expect(within(sheet()).getByRole('combobox', {name: 'Date'})).toHaveValue( - long, - ); - }, - ); + // The browser's zone is pinned west of Greenwich, so the instant's day + // there (March 9) differs from its UTC day (March 10) and a regression to + // the UTC day on tokens or rows fails on a UTC runner too. + describe('in a browser zone west of Greenwich', () => { + const originalTZ = process.env.TZ; + beforeEach(() => { + process.env.TZ = 'America/Los_Angeles'; + }); + afterEach(() => { + if (originalTZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTZ; + } + }); + + it.each([['Asia/Tokyo'], ['Pacific/Honolulu'], [undefined]])( + 'formats the token and the row exactly as the Core token does — the day in the browser zone, no time — and the editor reads the UTC day, whatever timezoneID says (DEC-3, FR11, FR12): %s', + timezoneID => { + // Core's token: `formatDateAbsoluteCompact` in the browser's zone. + expect( + new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + calendar: 'gregory', + }).format(BOUNDARY_SECONDS * 1000), + ).toBe('Mar 9, 2024'); + setup({config: valueTypesConfig, filters: [dateFilter], timezoneID}); + const tokenValue = within(field()).getByText('Mar 9, 2024'); + expect(tokenValue).toHaveStyle({ + fontWeight: 'var(--font-weight-bold)', + }); + expect(tokenValue.textContent).not.toMatch(/\d:\d\d/); + openSheet(); + const row = within(sheet()).getByRole('button', { + name: 'Created is after Mar 9, 2024', + }); + expect(within(row).getByText('Mar 9, 2024')).toHaveStyle({ + fontWeight: 'var(--font-weight-bold)', + }); + fireEvent.click(row); + // 03:00 UTC on March 10: the editor reads the UTC day, in Tokyo and + // Honolulu alike. + expect( + within(sheet()).getByRole('combobox', {name: 'Date'}), + ).toHaveValue('March 10, 2024'); + }, + ); + }); + + it('a picked day is stored as its UTC midnight, as the Core editor stores it (DEC-3)', () => { + const {onChange} = setup({config: valueTypesConfig, filters: []}); + openAddFlow(); + tapRow('Created'); + const input = within(sheet()).getByRole('combobox', {name: 'Date'}); + fireEvent.change(input, {target: {value: 'March 9, 2024'}}); + fireEvent.blur(input); + addFilter(); + expect(onChange).toHaveBeenCalledWith( + [ + { + field: 'created', + operator: 'after', + value: { + type: 'date_absolute', + unixSeconds: Date.UTC(2024, 2, 9) / 1000, + }, + }, + ], + 'add', + 0, + ); + }); it('converts a picked day to midnight in the configured zone', () => { expect(isoDateToUnixSeconds('2024-03-09', 'Pacific/Honolulu')).toBe( @@ -2184,6 +2304,393 @@ describe('MobilePowerSearch timezone', () => { }); }); +// ============================================================================= +// Core parity +// ============================================================================= + +describe('MobilePowerSearch Core parity', () => { + const third: PowerSearchFilter = { + field: 'status', + operator: 'isNot', + value: {type: 'enum', value: 'closed'}, + }; + const legacyFilter: PowerSearchFilter = { + field: 'legacy', + operator: 'was', + value: {type: 'string', value: 'x'}, + }; + function Harness({ + filters, + cfg = config, + hasClear, + }: { + filters: ReadonlyArray; + cfg?: PowerSearchConfig; + hasClear?: boolean; + }) { + return ( + {}} + hasClear={hasClear} + /> + ); + } + + describe('a controlled update that removes the focused control re-homes focus over the button rows', () => { + it('moves focus from a removed Filters row to the row that followed it, counting only button rows', () => { + const pinned: PowerSearchFilter = {...openFilter, isReadOnly: true}; + const fourth: PowerSearchFilter = { + field: 'author', + operator: 'is', + value: {type: 'string', value: 'Grace'}, + }; + const {rerender} = render( + , + ); + openSheet(); + within(sheet()).getByRole('button', {name: 'Author is Ada'}).focus(); + rerender(); + // The row that followed it — not one row too far for the plain row. + expect( + within(sheet()).getByRole('button', {name: 'Status is not Closed'}), + ).toHaveFocus(); + // The last button row removed: the new last one takes focus. + within(sheet()).getByRole('button', {name: 'Author is Grace'}).focus(); + rerender(); + expect( + within(sheet()).getByRole('button', {name: 'Status is not Closed'}), + ).toHaveFocus(); + }); + + it('moves focus from Clear all, gone with the last filter, to Add filter', () => { + const {rerender} = render(); + openSheet(); + within(sheet()).getByRole('button', {name: 'Clear all'}).focus(); + rerender(); + expect( + within(sheet()).queryByRole('button', {name: 'Clear all'}), + ).toBeNull(); + expect( + within(sheet()).getByRole('button', {name: 'Add filter'}), + ).toHaveFocus(); + }); + + it("moves focus from a dropped field's row in Add filter to a remaining row", () => { + const {rerender} = render(); + openAddFlow(); + within(sheet()).getByRole('button', {name: 'Author'}).focus(); + rerender( + f.key !== 'author'), + }} + />, + ); + expect( + within(sheet()).queryByRole('button', {name: 'Author'}), + ).toBeNull(); + const focused = document.activeElement as HTMLElement; + expect(focused.closest('li')).not.toBeNull(); + expect(sheet().contains(focused)).toBe(true); + }); + }); + + describe('rows keyed by filter identity', () => { + it('keeps focus on the same filter when the parent reorders the filters, with the same objects and with clones', () => { + const {rerender} = render( + , + ); + openSheet(); + const authorRow = within(sheet()).getByRole('button', { + name: 'Author is Ada', + }); + authorRow.focus(); + rerender(); + expect(within(sheet()).getByRole('button', {name: 'Author is Ada'})).toBe( + authorRow, + ); + expect(authorRow).toHaveFocus(); + rerender( + , + ); + expect(within(sheet()).getByRole('button', {name: 'Author is Ada'})).toBe( + authorRow, + ); + expect(authorRow).toHaveFocus(); + }); + + it('keeps focus on the same filter when the parent removes a filter above it', () => { + const {rerender} = render( + , + ); + openSheet(); + const authorRow = within(sheet()).getByRole('button', { + name: 'Author is Ada', + }); + authorRow.focus(); + rerender(); + expect(authorRow).toHaveFocus(); + expect( + within(sheet()).getByRole('button', {name: 'Status is not Closed'}), + ).not.toHaveFocus(); + }); + }); + + describe('a filter the config no longer knows', () => { + it('lists it by its raw keys on the trigger and in the Filters sheet, titles its step the same way, and lets Remove take it out', () => { + function Removable() { + const [filters, setFilters] = useState< + ReadonlyArray + >([openFilter, legacyFilter]); + return ( + setFilters(next)} + /> + ); + } + render(); + expect(within(field()).getByText('legacy: was')).toBeTruthy(); + openSheet(); + const row = within(sheet()).getByRole('button', {name: 'legacy was'}); + fireEvent.click(row); + expect( + within(sheet()).getByRole('heading', {name: 'legacy was'}), + ).toBeTruthy(); + expect(sheet()).toHaveAttribute('aria-label', 'legacy was'); + expect( + within(sheet()).getByText('This filter is no longer available'), + ).toBeTruthy(); + expect(within(sheet()).queryByRole('button', {name: 'Apply'})).toBeNull(); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Remove'})); + expect( + within(sheet()).queryByRole('button', {name: 'legacy was'}), + ).toBeNull(); + // The nearest remaining row that is a button takes focus. + expect( + within(sheet()).getByRole('button', {name: 'Status is Open'}), + ).toHaveFocus(); + }); + + it('reads the field label next to the raw operator key when only the operator is gone', () => { + const staleOperator: PowerSearchFilter = { + field: 'author', + operator: 'was', + value: {type: 'string', value: 'Ada'}, + }; + setup({filters: [staleOperator]}); + openSheet(); + expect( + within(sheet()).getByRole('button', {name: 'Author was'}), + ).toBeTruthy(); + }); + }); + + describe('the unavailable state', () => { + it('moves focus to Remove when a controlled config update removes the edited operator, and announces it in the sheet', async () => { + const {rerender} = setup({filters: [openFilter]}); + openEditFlow('Status is Open'); + within(sheet()).getByRole('radio', {name: 'Status is not'}).focus(); + rerender( + + f.key === 'status' + ? {...f, operators: f.operators.filter(o => o.key !== 'is')} + : f, + ), + }} + filters={[openFilter]} + onChange={vi.fn()} + />, + ); + expect( + within(sheet()).getByText('This filter is no longer available'), + ).toBeTruthy(); + expect( + within(sheet()).getByRole('button', {name: 'Remove'}), + ).toHaveFocus(); + await waitFor(() => + expect(sheetRegion().textContent).toBe( + 'This filter is no longer available', + ), + ); + }); + }); + + describe('Apply and Cancel after a controlled update', () => { + it('Apply with the edited filter gone writes nothing and focuses the nearest remaining row, else Add filter', () => { + const onChange = vi.fn(); + const {rerender} = render( + , + ); + openEditFlow('Author is Ada'); + rerender( + , + ); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Apply'})); + expect(onChange).not.toHaveBeenCalled(); + expect( + within(sheet()).getByRole('button', {name: 'Status is not Closed'}), + ).toHaveFocus(); + }); + + it('Cancel from creation after the opening field was removed focuses the first control of Add filter', () => { + const {rerender} = render(); + openAddFlow(); + tapRow('Author'); + rerender( + f.key !== 'author'), + }} + />, + ); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Cancel'})); + expect( + within(sheet()).getByRole('heading', {name: 'Add filter'}), + ).toBeTruthy(); + const focused = document.activeElement as HTMLElement; + expect(focused.closest('li')).not.toBeNull(); + expect(sheet().contains(focused)).toBe(true); + }); + }); + + describe('announcements inside the open sheet', () => { + it('announces "Removed {label}" in the Filters sheet after Remove, as the Core token does', async () => { + function Removable() { + const [filters, setFilters] = useState< + ReadonlyArray + >([openFilter, authorFilter]); + return ( + setFilters(next)} + /> + ); + } + render(); + openEditFlow('Author is Ada'); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Remove'})); + await waitFor(() => + expect(sheetRegion().textContent).toBe('Removed Author is Ada'), + ); + expect(pageRegionText()).toBe(''); + }); + + it('speaks "No filters match your search" from its rendered element once per query, and not for an empty query', async () => { + const many: PowerSearchConfig = { + name: 'Many', + fields: Array.from({length: 9}, (_, i) => ({ + key: `f${i}`, + label: `Field ${i}`, + operators: [ + {key: 'is', label: 'is', value: {type: 'string' as const}}, + ], + })), + }; + setup({config: many}); + openSheet(); + const search = within(sheet()).getByRole('textbox', { + name: 'Search filters', + }); + fireEvent.change(search, {target: {value: 'nope'}}); + expect( + within(sheet()).getByText('No filters match your search'), + ).toBeTruthy(); + await waitFor(() => + expect(sheetRegion().textContent).toBe('No filters match your search'), + ); + fireEvent.change(search, {target: {value: ''}}); + // The message is gone from the body; only the live region still holds + // the words it spoke. + expect( + within(sheet()).queryByText('No filters match your search', { + ignore: + '[data-astryx-modal-live-region] *, [data-astryx-modal-live-region]', + }), + ).toBeNull(); + expect( + within(sheet()).getAllByRole('button', {name: /^Field/}), + ).toHaveLength(9); + }); + + it('shows no "no match" message for a config with no field', () => { + setup({config: {name: 'Empty', fields: []}}); + openSheet(); + expect( + within(sheet()).getByRole('heading', {name: 'Add filter'}), + ).toBeTruthy(); + expect( + within(sheet()).queryByText('No filters match your search'), + ).toBeNull(); + }); + + it("keeps the content searchbox's aria-controls pointing at an element while nothing lists", () => { + setup({config: contentSearchConfig}); + openSheet(); + const input = within(sheet()).getByRole('searchbox', {name: 'Search'}); + const controlled = () => + document.getElementById(input.getAttribute('aria-controls') as string); + expect(controlled()).not.toBeNull(); + fireEvent.change(input, {target: {value: 'zzzz-nothing-matches'}}); + expect(controlled()).not.toBeNull(); + expect(sheet().contains(controlled())).toBe(true); + }); + }); + + describe('the trigger', () => { + it('does not open the flow for a tap on the end lane, where endContent keeps its own taps', () => { + const onAction = vi.fn(); + setup({ + filters: [openFilter], + endContent: ( + + ), + }); + fireEvent.click(screen.getByRole('button', {name: 'Save view'})); + expect(onAction).toHaveBeenCalledTimes(1); + expect(isSheetOpen()).toBe(false); + // A tap on a token still opens it. + fireEvent.click(within(field()).getByText('Open')); + expect(isSheetOpen()).toBe(true); + }); + + it('fires onBlur when the flow opens and onFocus when it closes, once each', () => { + const onFocus = vi.fn(); + const onBlur = vi.fn(); + setup({filters: [openFilter], onFocus, onBlur}); + openSheet(); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Done'})); + settleSheets(); + expect(trigger()).toHaveFocus(); + expect(onFocus).toHaveBeenCalledTimes(2); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + }); +}); + // ============================================================================= // Helpers // ============================================================================= diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx index 330726ed1aed4..adabac695976d 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx @@ -7,8 +7,9 @@ * @input Core PowerSearch config, filters and onChange (the same props as * @astryxdesign/core PowerSearch); public @astryxdesign/core components only * (BottomSheet, Button, Field, Heading, Icon, List, - * RadioList, Text, TextInput, Token, Tooltip hooks, i18n, hooks, utils, - * Layout container-padding styles) + * RadioList, Text, TextInput, Token, Tooltip hooks, i18n, hooks — + * useAnnounce for every announcement — utils, Layout container-padding + * styles) * @output Exports MobilePowerSearch — Lab prototype of the touch PowerSearch * flow: the whole field is one "Manage filters" trigger with display-only * tokens, a Filters sheet with chevron rows, Add filter and sticky @@ -29,21 +30,36 @@ * - no `menuWidth` or `tokenOverflowBehavior`: there is no typeahead popover * and tokens wrap inside the trigger; * - strings are literals (no new i18n keys; existing Core keys are reused - * only for the result count and the add announcement); - * - announcements made while a sheet is open go to a persistent polite - * region inside each sheet, created empty and kept mounted (spec AST-086 - * FR3), because the shared page-body channel is not exposed while a - * native modal dialog is open (its Platform support section); the closed - * field still uses the shared channel; + * for the result count, the add and the removal announcements); + * - announcements made while a sheet is open are delivered inside that + * sheet's dialog by `useAnnounce`, called from a `SheetAnnouncer` rendered + * in each step (spec AST-086 FR14): the shared page-body channel is not + * exposed while a native modal dialog is open. Empty-result and + * unavailable messages are spoken from their rendered element, once per + * occasion; Clear all's outcome and a result count that changes during + * the flow's exit are spoken once focus is back on the trigger; * - no theming contract: no `power-search` theme target or selectors; - * - `components[type].Token` overrides receive Core's PowerSearchTokenProps, - * which carry no `timezoneID`; the built-in token renders Core `Token` - * directly so the configured timezone reaches tokens and row summaries - * through the same formatter; + * - `components[type].Token` overrides receive Core's PowerSearchTokenProps; + * the built-in token renders Core `Token` directly, with the value text + * the Filters rows share; `timezoneID` is accepted for API parity and + * changes no date value (dates follow Core's semantics, below); * - fields whose every operator is `nested` are left out with a development - * warning; `nested` has no sheet editor yet; - * - `date_absolute` honors `isDateOnly: false` with a DateTimeInput, and - * `date_relative` is a row list rather than a Selector; + * warning; `nested` has no sheet editor yet (Core's built-in nested editor + * is not exported); + * - dates follow Core PowerSearch's semantics (`component:PowerSearch` + * DEC-3, FR11, FR12): a `date_absolute` is the UTC midnight of the chosen + * day, which the date editor reads and writes as that UTC day; the token + * and the Filters row show it as the Core token does (the day in the + * browser's zone, no time); `isDateOnly: false` gets the same date-only + * editor, and `date_relative` is a row list rather than a Selector; + * - Clear all removes every filter, read-only ones included, reported as one + * `remove` at the last index, as the Core field's clear control does; + * - a filter whose field or operator the config no longer knows lists by its + * raw keys and its step is the unavailable state, whose Remove is the way + * out; rows and tokens are keyed by filter identity, so a controlled + * reorder or a removal above the focused row keeps focus where it was, and + * a removed focused control hands focus to the nearest remaining button + * row, else the sheet's first control; * - the sheets pad their own content (`padding={0}` on BottomSheet); each * sheet body publishes that padding as container padding so its * `edge="bleed"` lists run edge to edge with row text on the body's inset. @@ -63,6 +79,7 @@ import React, { useEffect, useId, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -105,11 +122,11 @@ import {Text} from '@astryxdesign/core/Text'; import {TextInput} from '@astryxdesign/core/TextInput'; import {Token} from '@astryxdesign/core/Token'; import {useTooltip} from '@astryxdesign/core/Tooltip'; -import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden'; import { useAnnounce, useDevWarning, useMergedRefs, + type AnnounceFn, } from '@astryxdesign/core/hooks'; import {useLocale, useTranslator} from '@astryxdesign/core/i18n'; import { @@ -124,8 +141,10 @@ import {isImeKeyEvent, isRenderable, rtlStyles} from '@astryxdesign/core/utils'; import {MobilePowerSearchValueEditor} from './MobilePowerSearchValueEditor'; import { createContentSearchSource, + createFilterIdentities, fieldMatchesQuery, filterSignature, + formatDateDay, isSupportedOperator, nearestEditableIndex, resolveFilterIdentityIndex, @@ -188,7 +207,12 @@ export interface MobilePowerSearchProps extends Omit< maxSearchResults?: number; /** Label for the edit sheet's save action. @default 'Apply' when editing, 'Add filter' when creating */ popoverSaveButtonLabel?: string; - /** Timezone ID for date formatting in tokens, row summaries, and date editors. */ + /** + * Accepted for API parity with Core PowerSearch. Dates follow Core's + * semantics: tokens and rows show a `date_absolute` as the Core token does + * (its day in the browser's zone) and the date editors read and write the + * UTC day, so this prop does not change a date value today. + */ timezoneID?: string; /** Content at the end of the field row. */ endContent?: ReactNode; @@ -440,18 +464,114 @@ function resolveDraftFilterIndex( const SEARCHABLE_FIELD_COUNT = 8; const DEFAULT_MAX_SEARCH_RESULTS = 10; const MAX_BROWSE_MENU_ITEMS = 1000; -// How long a message stays in the sheets' live region; the shared channel -// clears on the same schedule. -const SHEET_ANNOUNCEMENT_CLEAR_MS = 2000; + +// ============================================================================= +// In-sheet announcements +// ============================================================================= + +/** + * Rendered inside a step's BottomSheet so that `useAnnounce` resolves the + * sheet's dialog as the enclosing modal (spec AST-086 FR14): a message + * written through it is delivered inside that dialog, the only channel + * assistive technology exposes while the native modal is open. Publishes + * that dialog's `announce` to the flow above while mounted. Renders nothing. + */ +function SheetAnnouncer({ + onAnnounceChange, +}: { + onAnnounceChange: (announce: AnnounceFn | null) => void; +}) { + const announce = useAnnounce(); + useEffect(() => { + onAnnounceChange(announce); + return () => { + onAnnounceChange(null); + }; + }, [announce, onAnnounceChange]); + return null; +} + +/** + * A sheet's empty-result or unavailable message, rendered inside the step's + * dialog and spoken from its rendered text once per occasion — a query that + * matches nothing, a filter that became unavailable — through `useAnnounce`, + * which, called from inside the sheet, delivers into the sheet's dialog. The + * message is plain text, not an EmptyState, so nothing announces it twice. + * (Core's own-content hook is not reachable from Lab; this is its shape.) + */ +function SheetEmptyMessage({ + id, + message, + occasion, +}: { + id?: string; + message: string; + occasion: string; +}) { + const ref = useRef(null); + const announce = useAnnounce(); + const spokenOccasionRef = useRef(null); + useEffect(() => { + if (spokenOccasionRef.current === occasion) { + return; + } + spokenOccasionRef.current = occasion; + const text = ref.current?.textContent?.trim(); + if (text) { + announce(text); + } + }, [announce, occasion]); + return ( +
+ + {message} + +
+ ); +} // ============================================================================= // Token // ============================================================================= +/** + * Formats a filter's value for a token or a row with the formatter the + * Filters rows share. A `date_absolute` reads as the Core PowerSearch token + * shows it (`formatDateDay`: a day in the browser's zone, no time), so a Lab + * token or row and a Core token read the same; everything else goes through + * Core's `formatFilterValue`. + */ +function formatValueText( + config: InternalConfig, + operator: PowerSearchOperator, + filter: PowerSearchFilter, + maxLength: number, + t: ReturnType, + locale: string, + timezoneID: string | undefined, +): string { + if ( + operator.value.type === 'date_absolute' && + filter.value.type === 'date_absolute' + ) { + return formatDateDay(filter.value.unixSeconds, locale); + } + return formatFilterValue( + config, + operator.value, + filter.value, + maxLength, + t, + locale, + timezoneID, + ); +} + /** * Display-only token for the trigger. Core's PowerSearchToken has no * `timezoneID` prop, so the value is formatted here with the same formatter - * and timezone the Filters rows use. + * the Filters rows use. A filter whose field or operator the config no + * longer has still shows, by its raw keys, as the Core token does. */ function MobilePowerSearchToken({ config, @@ -465,8 +585,8 @@ function MobilePowerSearchToken({ }: { config: InternalConfig; filter: PowerSearchFilter; - field: PowerSearchField; - operator: PowerSearchOperator; + field: PowerSearchField | undefined; + operator: PowerSearchOperator | undefined; maxLength: number; size: PowerSearchSize; isDisabled: boolean; @@ -474,23 +594,28 @@ function MobilePowerSearchToken({ }) { const t = useTranslator(); const locale = useLocale(); - const operatorLabel = resolveOperatorLabel(operator, t); + const fieldLabel = field?.label ?? filter.field; + const operatorLabel = + operator == null ? filter.operator : resolveOperatorLabel(operator, t); const tokenLabel = operatorLabel - ? `${field.label}: ${operatorLabel}` - : field.label; + ? `${fieldLabel}: ${operatorLabel}` + : fieldLabel; const adjustedMaxLength = Math.max( - maxLength - field.label.length - operatorLabel.length, + maxLength - fieldLabel.length - operatorLabel.length, 10, ); - const value = formatFilterValue( - config, - operator.value, - filter.value, - adjustedMaxLength, - t, - locale, - timezoneID, - ); + const value = + operator == null + ? '' + : formatValueText( + config, + operator, + filter, + adjustedMaxLength, + t, + locale, + timezoneID, + ); return ( (null); - const pendingSheetMessagesRef = useRef([]); - const sheetAnnouncementClearRef = useRef | null>(null); + // `useAnnounce` delivers inside the open Astryx modal that encloses its + // caller (spec AST-086 FR14), and this component renders above the sheets, + // so with a sheet open it cannot call the hook itself: each step's body + // renders a `SheetAnnouncer` inside its BottomSheet, which publishes the + // dialog's own `announce` here while that step is the active one. With no + // sheet open the field's own `useAnnounce` routes a message by where the + // field is — a Dialog or modal Drawer that holds it, else the shared + // page-body channel. + const sheetAnnounceRef = useRef(null); + const handleAnnounceChange = useCallback((next: AnnounceFn | null) => { + sheetAnnounceRef.current = next; + }, []); const stepRef = useRef(null); const announce = useCallback( (message: string) => { - if (stepRef.current == null) { - announceOnPage(message); - return; - } - // Clear, then write on the next frame so the same words announce again - // (FR6: a new occasion), and clear shortly after so stale text does not - // linger in a sheet that opens later (FR3). Messages that arrive while - // the frame is pending are joined in order into that one write, the way - // the shared channel joins them (FR9), so an add and the result count - // it changes in the same commit are both heard. - pendingSheetMessagesRef.current.push(message); - if (sheetAnnouncementFrameRef.current != null) { - return; - } - if (sheetAnnouncementClearRef.current != null) { - clearTimeout(sheetAnnouncementClearRef.current); - sheetAnnouncementClearRef.current = null; - } - setSheetAnnouncement(''); - sheetAnnouncementFrameRef.current = requestAnimationFrame(() => { - sheetAnnouncementFrameRef.current = null; - const joined = pendingSheetMessagesRef.current.join(' '); - pendingSheetMessagesRef.current = []; - setSheetAnnouncement(joined); - sheetAnnouncementClearRef.current = setTimeout(() => { - sheetAnnouncementClearRef.current = null; - setSheetAnnouncement(''); - }, SHEET_ANNOUNCEMENT_CLEAR_MS); - }); + const inSheet = stepRef.current == null ? null : sheetAnnounceRef.current; + (inSheet ?? announceOnPage)(message); }, [announceOnPage], ); - // Owns the sheet announcer's frame and clear timer: released on teardown, - // with the refs and the pending queue reset, so an Effect teardown that is - // not an unmount (an hide) leaves no cancelled id behind that - // would block later in-sheet announcements. - useEffect( - () => () => { - if (sheetAnnouncementFrameRef.current != null) { - cancelAnimationFrame(sheetAnnouncementFrameRef.current); - sheetAnnouncementFrameRef.current = null; - } - if (sheetAnnouncementClearRef.current != null) { - clearTimeout(sheetAnnouncementClearRef.current); - sheetAnnouncementClearRef.current = null; - } - pendingSheetMessagesRef.current = []; - }, - [], - ); + // An announcement queued by a step change — a suggestion or a removal that + // changed the filters — is spoken in the commit that shows the next step, + // so it lands in that step's dialog rather than the one about to close. + const pendingAnnouncementRef = useRef(null); + // What to speak once the flow has fully closed and focus is back on the + // trigger: that Clear all removed every filter, and a result count that + // changed during the exit. The closing dialog stays modal through its exit, + // so neither its region nor the page-body channel is heard before then. + const afterCloseRef = useRef<{ + isCleared: boolean; + resultCount: string | null; + }>({isCleared: false, resultCount: null}); const triggerLabel = labelFromProps ?? 'Manage filters'; const fieldPlaceholder = placeholderFromProps ?? 'Filters…'; @@ -642,18 +736,67 @@ export function MobilePowerSearch({ const contentResultsId = useId(); const triggerRef = useRef(null); const wrapperRef = useRef(null); + // The field's end lane — the result count and `endContent` — sits outside + // the trigger button: a caller's action button there keeps its own tab + // stop and taps, so a tap on it must not open the flow. + const endLaneRef = useRef(null); const managerAddButtonRef = useRef(null); const contentSearchRef = useRef(null); const contentSuggestionListRef = useRef(null); const managerRowsRef = useRef(new Map()); const fieldRowsRef = useRef(new Map()); + const manageSheetRef = useRef(null); + const fieldsSheetRef = useRef(null); const fieldPickerOriginRef = useRef<'manage' | 'trigger'>('manage'); const latestFiltersRef = useRef(filters); const pendingSheetFocusRef = useRef(null); const pendingMutationFocusRef = useRef(false); + // Where focus was inside the Filters or Add filter sheet — the sheet and + // the index among its button rows — read on every focus change there, so a + // controlled update that unmounts the focused control (a filter's row, a + // dropped field's row, Clear all gone with the last filter) can put focus + // on the nearest remaining row by that index, else the sheet's first + // control, else the sheet panel. A removed element fires no blur, so the + // position is what the effect below reads. + const lastSheetFocusRef = useRef<{ + sheet: 'manage' | 'fields'; + rowIndex: number | null; + } | null>(null); + // One index space for the record and the recovery: rows that are buttons + // (a plain read-only row is passed over, as the Remove and Apply/Cancel + // rules pass it over), in document order. + const buttonRowsOf = (panel: HTMLElement): HTMLElement[] => + Array.from(panel.querySelectorAll('li')) + .map(row => row.querySelector('button')) + .filter((button): button is HTMLElement => button != null); + const noteSheetFocus = useCallback( + (sheet: 'manage' | 'fields') => (event: React.FocusEvent) => { + const row = (event.target as HTMLElement).closest('li'); + const button = row?.querySelector('button') ?? null; + const rowIndex = + button == null + ? -1 + : buttonRowsOf(event.currentTarget as HTMLElement).indexOf(button); + lastSheetFocusRef.current = { + sheet, + rowIndex: rowIndex === -1 ? null : rowIndex, + }; + }, + [], + ); const [step, setStep] = useState(null); stepRef.current = step; + // A field that becomes disabled cannot keep its flow open, and must not + // reopen it when re-enabled. Adjusted in the render that sees the change, + // not an effect, so no frame shows both. + const [wasDisabled, setWasDisabled] = useState(isDisabled); + if (isDisabled !== wasDisabled) { + setWasDisabled(isDisabled); + if (isDisabled && step != null) { + setStep(null); + } + } // The draft outlives a step change on purpose: the replace handoff keeps // the outgoing sheet presented while the incoming one enters, and clearing // the draft with the step would blank that sheet mid-transition. @@ -662,6 +805,14 @@ export function MobilePowerSearch({ const [contentQuery, setContentQuery] = useState(''); const isInteractive = !isDisabled && !isReadOnly; + // Whether a Filters row is a button: the field interactive and the filter + // not read-only. The row opens the value step — the editor for its + // operator's value type, or for a filter the config no longer knows the + // unavailable state, whose Remove is the way out. + const isRowButton = useCallback( + (filter: PowerSearchFilter): boolean => !filter.isReadOnly && isInteractive, + [isInteractive], + ); const contentResults = useMemo(() => { if (step !== 'fields' || !hasContentSearch || !isInteractive) { return []; @@ -691,25 +842,50 @@ export function MobilePowerSearch({ managerAddButtonRef.current?.focus({preventScroll: true}); return; } + // Add filter's fallback: a controlled update may have removed the field + // whose row opened the step, or the content-search field itself; the + // sheet's first focusable control then takes focus — the search field + // when one shows, else the first field row, else Back. + const firstFieldsControl = () => { + const panel = fieldsSheetRef.current; + return ( + panel?.querySelector('input') ?? + panel?.querySelector('li button') ?? + panel?.querySelector('button') ?? + null + ); + }; if (target.type === 'content-search') { - contentSearchRef.current - ?.querySelector('input') - ?.focus({preventScroll: true}); + ( + contentSearchRef.current?.querySelector('input') ?? + firstFieldsControl() + )?.focus({preventScroll: true}); return; } if (target.type === 'field') { - fieldRowsRef.current - .get(target.fieldKey) - ?.querySelector('button') - ?.focus({preventScroll: true}); + ( + fieldRowsRef.current + .get(target.fieldKey) + ?.querySelector('button') ?? firstFieldsControl() + )?.focus({preventScroll: true}); return; } - const index = resolveFilterIdentityIndex( - latestFiltersRef.current, - target.sourceFilter, - target.sourceSignature, - target.preferredIndex, - ); + // The row the step came from, followed across a controlled reorder or + // clone; when a controlled update removed it, or left two filters it + // could be, the nearest remaining row that is a button, as after a + // removal; else Add filter. + const index = + resolveFilterIdentityIndex( + latestFiltersRef.current, + target.sourceFilter, + target.sourceSignature, + target.preferredIndex, + ) ?? + nearestEditableIndex( + latestFiltersRef.current, + target.preferredIndex, + isRowButton, + ); const rowButton = index == null ? null @@ -717,7 +893,52 @@ export function MobilePowerSearch({ .get(index) ?.querySelector('button'); (rowButton ?? managerAddButtonRef.current)?.focus({preventScroll: true}); - }, []); + }, [isRowButton]); + + // A controlled update that unmounts the focused control of the Filters or + // Add filter sheet leaves focus on the page body inside the open modal. + // Read before paint on every commit of an open step: when focus is no + // longer inside the sheet's dialog, the nearest remaining row by the last + // focused index takes it, else the sheet's first control, else its panel. + // The pending-target effects below run after and win when a step change + // named a control. + useLayoutEffect(() => { + const last = lastSheetFocusRef.current; + if (step !== 'manage' && step !== 'fields') { + return; + } + if (last == null || last.sheet !== step) { + return; + } + const panel = + step === 'manage' ? manageSheetRef.current : fieldsSheetRef.current; + const dialog = panel?.closest('dialog'); + const active = document.activeElement; + if ( + panel == null || + dialog == null || + (active != null && active !== document.body && dialog.contains(active)) + ) { + return; + } + const rowButtons = buttonRowsOf(panel); + let target: HTMLElement | null = null; + if (last.rowIndex != null && rowButtons.length > 0) { + // The following button row, else the last. + target = rowButtons[Math.min(last.rowIndex, rowButtons.length - 1)]; + } + target ??= + panel.querySelector('input') ?? + rowButtons[0] ?? + panel.querySelector('button') ?? + null; + if (target != null) { + target.focus({preventScroll: true}); + } else { + const focusable = dialog.querySelector('[tabindex="-1"]'); + (focusable ?? dialog).focus({preventScroll: true}); + } + }); // Both effects run after the entering sheet's own panel focus (child // effects commit first), so the intended control wins. The filters effect @@ -737,6 +958,16 @@ export function MobilePowerSearch({ } }, [focusPendingSheetTarget, step]); + // An announcement queued by a step change is spoken in the commit that + // shows the next step, so it lands in that step's dialog. + useEffect(() => { + const message = pendingAnnouncementRef.current; + if (step != null && message != null) { + pendingAnnouncementRef.current = null; + announce(message); + } + }, [announce, step]); + // ----------------------------------------------------------------- fields -- const fields = useMemo( @@ -823,11 +1054,13 @@ export function MobilePowerSearch({ const handleWrapperClick = useCallback( (event: React.MouseEvent) => { - // Tokens are display-only; a tap anywhere on the field opens the sheet. - // The trigger button handles its own click. + // Tokens are display-only; a tap on them or the empty space around them + // opens the sheet. The trigger button handles its own click, and the + // end lane handles its own. if ( event.target instanceof Node && - triggerRef.current?.contains(event.target) + (triggerRef.current?.contains(event.target) || + endLaneRef.current?.contains(event.target)) ) { return; } @@ -869,7 +1102,21 @@ export function MobilePowerSearch({ currentFilter == null || currentFilter.isReadOnly ) { - queueManagerAddFocus(); + // A controlled update removed the edited filter, or left two it + // could be, or made it read-only: nothing is written, and focus + // returns to the nearest row that is a button around where it was, + // else Add filter. + pendingSheetFocusRef.current = + next.sourceFilter != null && + next.sourceSignature != null && + next.filterIndex != null + ? { + type: 'manager-filter', + sourceFilter: next.sourceFilter, + sourceSignature: next.sourceSignature, + preferredIndex: next.filterIndex, + } + : {type: 'manager-add'}; setStep('manage'); return; } @@ -960,13 +1207,17 @@ export function MobilePowerSearch({ }; pendingMutationFocusRef.current = true; onChange([...filters, nextFilter], 'add', filters.length); - announce(t('@astryx.tokenizer.tokenAdded', {label: item.label})); + // Spoken once the Filters sheet is the open dialog, so it lands there + // rather than in the Add filter dialog about to be replaced. + pendingAnnouncementRef.current = t('@astryx.tokenizer.tokenAdded', { + label: item.label, + }); setStep('manage'); return; } openCreateEditor(field, operator, 'content-search'); }, - [announce, config, filters, isInteractive, onChange, openCreateEditor, t], + [config, filters, isInteractive, onChange, openCreateEditor, t], ); const handleFilterEdit = useCallback( @@ -990,6 +1241,41 @@ export function MobilePowerSearch({ [filters, isInteractive], ); + // A filter as its Filters row reads it: the field's label when the field + // remains, the raw key for what the config no longer has, and the value as + // the token shows it. + const describeFilterParts = useCallback( + (filter: PowerSearchFilter): {label: string; value: string | null} => { + const field = config.getField(filter.field); + const operator = config.getOperator(filter.field, filter.operator); + const fieldLabel = field?.label ?? filter.field; + const operatorLabel = + operator == null ? filter.operator : resolveOperatorLabel(operator, t); + const label = [fieldLabel, operatorLabel].filter(Boolean).join(' '); + if (operator == null) { + return {label, value: null}; + } + const value = formatValueText( + config, + operator, + filter, + Number.MAX_SAFE_INTEGER, + t, + locale, + timezoneID, + ); + return {label, value: value || null}; + }, + [config, locale, t, timezoneID], + ); + const describeFilter = useCallback( + (filter: PowerSearchFilter): string => { + const parts = describeFilterParts(filter); + return parts.value ? `${parts.label} ${parts.value}` : parts.label; + }, + [describeFilterParts], + ); + const handleRemoveFilter = useCallback( (index: number) => { const filter = filters[index]; @@ -997,7 +1283,7 @@ export function MobilePowerSearch({ return; } const nextFilters = filters.filter((_, i) => i !== index); - const nextIndex = nearestEditableIndex(nextFilters, index); + const nextIndex = nearestEditableIndex(nextFilters, index, isRowButton); if (nextIndex == null) { pendingSheetFocusRef.current = {type: 'manager-add'}; } else { @@ -1011,19 +1297,28 @@ export function MobilePowerSearch({ } pendingMutationFocusRef.current = true; onChange(nextFilters, 'remove', index); + // The Core token's × announces the same phrase; spoken once the Filters + // sheet is the open dialog. + pendingAnnouncementRef.current = t('@astryx.tokenizer.tokenRemoved', { + label: describeFilter(filter), + }); }, - [filters, isInteractive, onChange], + [describeFilter, filters, isInteractive, isRowButton, onChange, t], ); + // Clear all keeps the Core PowerSearch semantics (DEC-3): every filter + // goes, read-only ones included, reported as a `remove` at the last index — + // the change the Core field's clear control commits. const handleClearAll = useCallback(() => { - // Read-only filters are the consumer's, not the user's: Clear all leaves - // them in place. - const kept = filters.filter(filter => filter.isReadOnly); - const firstRemovedIndex = filters.findIndex(filter => !filter.isReadOnly); - if (firstRemovedIndex < 0) { + if (filters.length === 0) { return; } - onChange(kept, 'remove', firstRemovedIndex); + onChange([], 'remove', filters.length - 1); + // Clear all closes the flow. The Filters dialog stays modal through its + // exit, hiding both its own region and the page-body channel, so the + // outcome is spoken once the dialog has gone and focus is back on the + // trigger. + afterCloseRef.current.isCleared = true; closeSheet(); }, [closeSheet, filters, onChange]); @@ -1136,6 +1431,21 @@ export function MobilePowerSearch({ const handleFocusWithin = useCallback( (event: React.FocusEvent) => { + // The flow returns focus to the trigger only after its exit completes: + // the dialogs have closed and the page-body channel is exposed again. + // One announcement — two in one tick would replace each other in the + // single polite region. + if (step == null) { + const {isCleared, resultCount} = afterCloseRef.current; + afterCloseRef.current = {isCleared: false, resultCount: null}; + if (isCleared && resultCount != null) { + announceOnPage(`All filters removed. ${resultCount}`); + } else if (isCleared) { + announceOnPage('All filters removed'); + } else if (resultCount != null) { + announceOnPage(resultCount); + } + } if ( !(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget) @@ -1143,7 +1453,7 @@ export function MobilePowerSearch({ onFocus?.(event); } }, - [onFocus], + [announceOnPage, onFocus, step], ); const handleBlurWithin = useCallback( (event: React.FocusEvent) => { @@ -1180,23 +1490,32 @@ export function MobilePowerSearch({ return resultCount; }, [resultCount, t]); - // This Effect synchronizes a polite live region with the result count: the - // shared page-body one while the field is closed, the sheets' own while a - // sheet is open. A count present on mount is not announced unprompted, and - // the guard compares against the last announced text rather than a mounted - // flag, so StrictMode's effect replay announces nothing either (FR6). It - // needs no cleanup of its own: on the open-sheet path `announce` starts a - // frame and a clear timer, which the sheet announcer's teardown Effect and - // the next write release. + // This Effect synchronizes a polite live region with the result count + // through `announce`, which routes by where the field is: into the open + // sheet's dialog while a step is open (its `SheetAnnouncer`), else by the + // field's own `useAnnounce` — a Dialog or modal Drawer that holds the + // field, or the shared page-body channel (spec AST-086 FR14). A count + // present on mount is not announced unprompted, and the guard compares + // against the last announced text rather than a mounted flag, so + // StrictMode's effect replay announces nothing either. The announcement + // acquires no resource, so there is no cleanup. const announcedResultCountRef = useRef(resultCountText); useEffect(() => { if (resultCountText === announcedResultCountRef.current) { return; } announcedResultCountRef.current = resultCountText; - if (resultCountText != null) { - announce(resultCountText); + if (resultCountText == null) { + return; } + // A change that lands while the flow is closing — Clear all's, whose + // commit also ends the flow — waits for the trigger focus with the other + // after-close messages, since the dialog is still modal through its exit. + if (stepRef.current == null && afterCloseRef.current.isCleared) { + afterCloseRef.current.resultCount = resultCountText; + return; + } + announce(resultCountText); }, [resultCountText, announce]); // --------------------------------------------------------------- tooltip -- @@ -1222,23 +1541,17 @@ export function MobilePowerSearch({ // ---------------------------------------------------------------- render -- - const filterRows = filters.flatMap((filter, index) => { - const field = config.getField(filter.field); - const operator = config.getOperator(filter.field, filter.operator); - if (!field || !operator) { - return []; - } - const operatorLabel = resolveOperatorLabel(operator, t); - const label = [field.label, operatorLabel].filter(Boolean).join(' '); - const value = formatFilterValue( - config, - operator.value, - filter.value, - Number.MAX_SAFE_INTEGER, - t, - locale, - timezoneID, - ); + // Rows and tokens are keyed by filter identity, not position: a parent's + // reorder or a removal above the focused row then moves elements instead + // of remounting them, and focus stays on the same filter. Only a filter + // that is itself removed loses its element, and the re-home effect above + // takes over. Every filter lists — one whose field or operator the config + // no longer has by its raw keys, as its token shows on the trigger; its row + // opens the unavailable state, whose Remove is the way out. + const [filterIdentities] = useState(createFilterIdentities); + const filterIds = filterIdentities.next(filters); + const filterRows = filters.map((filter, index) => { + const {label, value} = describeFilterParts(filter); const displayLabel = value ? ( <> {label} {value} @@ -1246,25 +1559,24 @@ export function MobilePowerSearch({ ) : ( label ); - return [ - { - displayLabel, - filter, - index, - key: `${index}-${filter.field}-${filter.operator}`, - }, - ]; + return { + displayLabel, + filter, + index, + isEditable: isRowButton(filter), + key: `filter-${filterIds[index]}`, + }; }); const tokens = filters.map((filter, index) => { const field = config.getField(filter.field); const operator = config.getOperator(filter.field, filter.operator); - if (!field || !operator) { - return null; - } - const key = `${index}-${filter.field}-${filter.operator}`; - const TokenOverride = componentOverrides?.[operator.value.type]?.Token; - if (TokenOverride) { + const key = `filter-${filterIds[index]}`; + const TokenOverride = + field != null && operator != null + ? componentOverrides?.[operator.value.type]?.Token + : undefined; + if (TokenOverride && field != null && operator != null) { return ( !filter.isReadOnly); - const isClearAllShown = hasClear && hasRemovableFilter && isInteractive; + const isClearAllShown = hasClear && isInteractive && filters.length > 0; const EditorOverride = draftOperator != null @@ -1304,14 +1615,22 @@ export function MobilePowerSearch({ const draftOperatorLabel = draftOperator == null ? '' : resolveOperatorLabel(draftOperator, t); + // The title — the dialog's name — reads the field and operator as the + // Filters row does: by their labels, or, for a filter whose field or + // operator the config no longer has, by the raw keys the row shows. const editorTitle = - draftField == null - ? draft?.mode === 'edit' - ? 'Edit filter' - : 'Add filter' - : draftOperators.length <= 1 && draftOperatorLabel !== '' - ? `${draftField.label} ${draftOperatorLabel}` - : draftField.label; + draft == null + ? 'Add filter' + : draftField == null || (draftOperator == null && draft.operator != null) + ? [ + draftField?.label ?? draft.field, + draftOperatorLabel !== '' ? draftOperatorLabel : draft.operator, + ] + .filter(Boolean) + .join(' ') + : draftOperators.length <= 1 && draftOperatorLabel !== '' + ? `${draftField.label} ${draftOperatorLabel}` + : draftField.label; const saveButtonLabel = popoverSaveButtonLabel ?? (draft?.mode === 'edit' ? 'Apply' : 'Add filter'); @@ -1326,22 +1645,29 @@ export function MobilePowerSearch({ isEditorDisabled || draft?.operator == null || draft.value == null; const isDraftRenderable = draft != null && draftField != null && draftOperator != null; - - // One persistent polite region per sheet, created empty and kept mounted - // while its text changes (FR3), because the shared page-body channel is - // not exposed while a native modal dialog is open; only the active sheet's - // dialog is exposed, so a message is heard once. A polite status region - // needs no name to announce, so it carries none rather than one assembled - // from the label and a literal. - const sheetStatus = ( - - {sheetAnnouncement} - - ); + // A controlled config update that removes the field or operator being + // edited replaces the step's body with the unavailable state while focus + // may be on one of the body's controls, which unmount without a blur. Focus + // then moves to the way out — Remove, or Cancel when there is no Remove — + // so it never falls to the page body inside the open sheet. The message + // itself is announced by its rendered element. + const removeButtonRef = useRef(null); + const cancelButtonRef = useRef(null); + const wasDraftRenderableRef = useRef(isDraftRenderable); + useLayoutEffect(() => { + const becameUnavailable = + wasDraftRenderableRef.current && !isDraftRenderable && draft != null; + wasDraftRenderableRef.current = isDraftRenderable; + if (!becameUnavailable || step !== 'value') { + return; + } + const active = document.activeElement; + const dialog = cancelButtonRef.current?.closest('dialog'); + if (dialog != null && active != null && dialog.contains(active)) { + return; + } + (removeButtonRef.current ?? cancelButtonRef.current)?.focus(); + }, [draft, isDraftRenderable, step]); return ( <> @@ -1402,7 +1728,10 @@ export function MobilePowerSearch({ ref={triggerRef} autoFocus={hasAutoFocus} onClick={openManager} - disabled={isDisabled && !showsDisabledMessage} + // Never natively `disabled`: the trigger stays focusable, so it + // can take focus back when a flow closes because the field + // became disabled, and so a disabled reason can be read from + // it; `openManager` refuses to open while disabled. aria-disabled={isDisabled ? true : undefined} aria-haspopup="dialog" aria-expanded={step != null} @@ -1416,7 +1745,7 @@ export function MobilePowerSearch({ {(endContent || isRenderable(resultCountText)) && ( -
+
{isRenderable(resultCountText) && ( {resultCountText} @@ -1433,11 +1762,17 @@ export function MobilePowerSearch({ -
- {sheetStatus} +
+ {step === 'manage' && ( + + )}
@@ -1464,7 +1799,7 @@ export function MobilePowerSearch({ ) : ( {filterRows.map(row => { - const canEdit = isInteractive && !row.filter.isReadOnly; + const canEdit = row.isEditable; return ( -
- {sheetStatus} +
+ {step === 'fields' && ( + + )}