From c835c370b443fa180d68a3c63c48e480da0429d9 Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Tue, 27 Oct 2026 17:55:16 -0700 Subject: [PATCH] fix(lab): MobilePowerSearch speaks a sheet's empty-result and unavailable messages only while that step is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closed step's body stays mounted, so its SheetEmptyMessage could change occasion with no one in the sheet: leaving Add filter after a query that listed suggestions spoke "No results found" (the results are emptied once the step is not 'fields'), and a config update that dropped the last edited field or operator after the value step closed wrote "This filter is no longer available" into the closed dialog, where the words surfaced in the next step that opened, while reopening the same stale filter spoke nothing (same occasion). The message now takes isShown — its step being the open one — speaks only then, and forgets its occasion while hidden, so the same one is spoken again when the step reopens on it. --- .../MobilePowerSearch.test.tsx | 83 +++++++++++++++++++ .../MobilePowerSearch/MobilePowerSearch.tsx | 17 +++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx index eef63e03d1406..f6c14e5970f31 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx @@ -2632,6 +2632,89 @@ describe('MobilePowerSearch Core parity', () => { ).toHaveLength(9); }); + it('does not announce "No results found" when Add filter is left after a query that listed suggestions', async () => { + setup({config: contentSearchConfig, filters: [openFilter]}); + openSheet(); + tapRow('Add filter'); + const input = within(sheet()).getByRole('searchbox', {name: 'Search'}); + fireEvent.change(input, {target: {value: 'status'}}); + expect( + within(sheet()).getAllByRole('button', {name: /Status/}).length, + ).toBeGreaterThan(0); + // Back to Filters: the Add filter sheet's body stays mounted, its + // results list emptied by the step change — no empty-result message is + // spoken, not on the page and not in the Filters dialog. + fireEvent.click(within(sheet()).getByRole('button', {name: 'Back'})); + expect( + within(sheet()).getByRole('heading', {name: 'Filters'}), + ).toBeTruthy(); + await new Promise(resolve => setTimeout(resolve, 40)); + expect(pageRegionText()).toBe(''); + expect( + sheet().querySelector( + ':scope > [data-astryx-modal-live-region="polite"]', + )?.textContent ?? '', + ).not.toContain('No results found'); + // Escape out of the flow after another query: nothing on the page. + tapRow('Add filter'); + fireEvent.change( + within(sheet()).getByRole('searchbox', {name: 'Search'}), + {target: {value: 'status'}}, + ); + fireEvent.keyDown(sheet(), {key: 'Escape'}); + settleSheets(); + await new Promise(resolve => setTimeout(resolve, 40)); + expect(pageRegionText()).toBe(''); + }); + + it('announces the unavailable state only while its step is open, and again when the same stale filter reopens', async () => { + const stale: PowerSearchConfig = { + ...config, + fields: config.fields.map(f => + f.key === 'status' + ? {...f, operators: f.operators.filter(o => o.key !== 'is')} + : f, + ), + }; + const {rerender} = setup({filters: [openFilter, authorFilter]}); + openEditFlow('Status is Open'); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Cancel'})); + expect( + within(sheet()).getByRole('heading', {name: 'Filters'}), + ).toBeTruthy(); + // The value step is closed but its body stays mounted with the draft; a + // config update that drops the edited operator must not be spoken — not + // now, and not held for the next dialog that opens. + rerender( + , + ); + await new Promise(resolve => setTimeout(resolve, 40)); + expect(pageRegionText()).toBe(''); + // Opening another filter's step: words held for the closed step would + // surface in the value step's dialog here. + tapRow('Author is Ada'); + await new Promise(resolve => setTimeout(resolve, 40)); + for (const region of document.querySelectorAll('[aria-live]')) { + expect(region.textContent ?? '').not.toContain( + 'This filter is no longer available', + ); + } + fireEvent.click(within(sheet()).getByRole('button', {name: 'Cancel'})); + // Reopening the stale filter — its row now reads the field's label and + // the raw operator key — speaks it, in its own dialog, as a new + // occasion even though the filter is the same. + tapRow('Status is'); + await waitFor(() => + expect(sheetRegion().textContent).toBe( + 'This filter is no longer available', + ), + ); + }); + it('shows no "no match" message for a config with no field', () => { setup({config: {name: 'Empty', fields: []}}); openSheet(); diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx index adabac695976d..b7f23c61b5dd8 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx @@ -503,15 +503,27 @@ function SheetEmptyMessage({ id, message, occasion, + isShown, }: { id?: string; message: string; occasion: string; + /** + * Whether the message's step is the open one. A closed sheet keeps its body + * mounted, so the message can exist — and its occasion change — while no + * one is in the sheet: nothing is spoken then, and the occasion is + * forgotten so the same one is spoken again when the step reopens on it. + */ + isShown: boolean; }) { const ref = useRef(null); const announce = useAnnounce(); const spokenOccasionRef = useRef(null); useEffect(() => { + if (!isShown) { + spokenOccasionRef.current = null; + return; + } if (spokenOccasionRef.current === occasion) { return; } @@ -520,7 +532,7 @@ function SheetEmptyMessage({ if (text) { announce(text); } - }, [announce, occasion]); + }, [announce, isShown, occasion]); return (
@@ -1994,6 +2006,7 @@ export function MobilePowerSearch({ id={contentResultsId} message="No results found" occasion={contentQuery} + isShown={step === 'fields'} /> ) : ( // An empty query with no bootstrap suggestion: the results @@ -2008,6 +2021,7 @@ export function MobilePowerSearch({ ) ) : ( @@ -2144,6 +2158,7 @@ export function MobilePowerSearch({ ) )}