diff --git a/apps/storybook/stories/MobilePowerSearch.stories.tsx b/apps/storybook/stories/MobilePowerSearch.stories.tsx index 79bcd1bcd4b15..c09ee959deba0 100644 --- a/apps/storybook/stories/MobilePowerSearch.stories.tsx +++ b/apps/storybook/stories/MobilePowerSearch.stories.tsx @@ -222,7 +222,7 @@ const fullConfig: PowerSearchConfig = { }, { key: 'unread', - label: 'Unread only', + label: 'Only show unread', defaultOperator: 'yes', operators: [{key: 'yes', label: '', value: {type: 'empty'}}], }, diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs index 8f10b49143da4..ae7d229dcf4c3 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs @@ -77,7 +77,7 @@ export const docs = { name: 'timezoneID', type: 'string', description: - '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.', + 'IANA timezone for date values. Used by tokens, Filters rows, and the date editors alike, so a boundary date reads the same everywhere.', }, { 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. 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.', + '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 with its value in bold and a direct trailing remove action, an Add filter icon button at the top right, and sticky Clear all and Done actions. One-tap boolean filters use the same trailing remove action as every other removable filter. When there are no filters, the centered Add filter action stands alone; the duplicate header add and footer Done controls are hidden. Clear all asks for confirmation, removes editable filters without closing the sheet, and preserves read-only filters. Opening the rest of a row edits that filter with Cancel and Apply. Add filter opens directly when no filter exists and leaves search unfocused until the user chooses it; 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. A value editor that is the sheet’s only section sits directly on the sheet surface; multiple sections use separate muted containers. The previous step remains open beneath the current standalone BottomSheet, so Escape, a scrim press, or a downward swipe dismisses the current sheet and reveals that retained step; the Add filter header stays action-free. the stack keeps one scrim and blocks covered sheets. Single-value choices use persistent radio indicators and multi-value choices use persistent checkbox indicators. Searchable string and entity lists and date calendars remain inline in the value sheet; date-only and date-range selections honor timezoneID, while date-time operators keep their date-and-time input. Announcements made while a sheet is open are delivered inside that dialog. Filters remain keyed by identity across controlled reorder, stale filters stay visible and removable, and controlled removals re-home focus. The scroll viewport reaches the sheet edge while padded inner content aligns with the fixed header. 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: - '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.', + 'Pass timezoneID whenever filters carry dates, so tokens, rows, and editors agree on the calendar day.', }, { guidance: false, @@ -141,19 +141,19 @@ export const docs = { name: 'Filters sheet', required: true, description: - 'Tall sheet with a heading, Add filter, a divided list of chevron rows, and sticky Clear all plus Done.', + 'Tall sheet with a heading, an Add filter icon button, editable rows with direct trailing remove actions, and sticky Clear all plus Done.', }, { name: 'Add filter sheet', required: true, description: - 'Back, a field list grouped by field group (searchable past eight fields), or the content search with inline suggestions.', + 'A full-width Done footer when entered directly; otherwise the action-free header is followed by a field list grouped under General or each configured field group (searchable past eight fields), or the content search with inline suggestions.', }, { name: 'Value sheet', required: true, description: - 'Field or field-and-operator title, operator radios when needed, the value editor, and Cancel plus Add filter or Apply and Remove. A list value (string_list, entity_list) edits through the Lab MobileTokenizer: its trigger shows the staged tokens in the step, and tapping it opens a second, searchable sheet titled with the field name.', + 'Field or field-and-operator title, operator radios when needed, the value editor, and Cancel plus Add filter or Apply. String and entity lists search and select inline in the current sheet; date-only and date-range values use an inline Calendar.', }, ], }, diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx index f6c14e5970f31..de9f11d3059ad 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx @@ -193,6 +193,12 @@ const authorFilter: PowerSearchFilter = { value: {type: 'string', value: 'Ada'}, }; +const unassignedFilter: PowerSearchFilter = { + field: 'unassigned', + operator: 'isTrue', + value: {type: 'empty'}, +}; + const people: SearchableItem[] = [ {id: 'p1', label: 'Ada Lovelace'}, {id: 'p2', label: 'Grace Hopper'}, @@ -399,25 +405,6 @@ function sheet(): HTMLElement { return dialog; } -/** - * The sheet a list value's MobileTokenizer opens over the value step: the - * last open dialog in the document, which is the topmost. - */ -function tokenizerSheet(): HTMLElement { - const dialogs = document.querySelectorAll('dialog[open]'); - if (dialogs.length < 2) { - throw new Error('no tokenizer sheet over the value step'); - } - return dialogs[dialogs.length - 1]; -} - -/** Opens the MobileTokenizer named `name` from the value step. */ -function openTokenizer(name: string): HTMLElement { - const trigger = within(sheet()).getByRole('button', {name}); - fireEvent.click(trigger); - return trigger; -} - /** * Completes every pending sheet motion. jsdom resolves no transition timing, * so the panels wait for a native transitionend; this fires it. @@ -436,6 +423,7 @@ function tapRow(name: RegExp | string): void { // The replace handoff keeps the outgoing sheet presented while the incoming // one enters; the last match is always on the sheet that just arrived. fireEvent.click(matches[matches.length - 1]); + settleSheets(); } function openAddFlow(): void { @@ -478,7 +466,9 @@ function expectBleedRowsOnBodyInset(list: HTMLElement): void { expect(row.getAttribute('style') ?? '').not.toContain('--_item-inset'); } // The published padding is the body's inline padding (spacing-4). - const body = list.closest('[class*="body"]') as HTMLElement | null; + const body = list.closest( + '[class*="body"], [class*="scrollContent"]', + ) as HTMLElement | null; expect(body).not.toBeNull(); const computed = getComputedStyle(body!); expect(computed.getPropertyValue('--container-padding-inline-start')).toBe( @@ -615,7 +605,7 @@ describe('MobilePowerSearch trigger', () => { it('announces a result count change through the page channel while closed, and nothing for the count present on mount, under StrictMode', async () => { const pageRegion = () => - document.querySelector('body > [aria-live="polite"]'); + document.querySelector('[data-astryx-live-region="polite"]'); const {rerender} = render( { // ============================================================================= describe('MobilePowerSearch Filters sheet', () => { - it('shows selected filters as chevron rows with bold values', () => { + it('shows selected filters as editable rows with direct remove actions and bold values', () => { setup({filters: [openFilter]}); openSheet(); const list = within(sheet()).getByRole('list'); @@ -709,11 +699,52 @@ describe('MobilePowerSearch Filters sheet', () => { expect(within(row).getByText('Open')).toHaveStyle({ fontWeight: 'var(--font-weight-bold)', }); - expect(within(list).queryByRole('button', {name: /^Remove/})).toBeNull(); - expect(list.querySelectorAll('svg')).toHaveLength(1); + expect( + within(list).getByRole('button', {name: 'Remove Status filter'}), + ).toBeTruthy(); + }); + + it('uses a direct remove button for an applied one-tap filter instead of a checked indicator', () => { + const {onChange} = setup({filters: [unassignedFilter]}); + openSheet(); + const remove = within(sheet()).getByRole('button', { + name: 'Remove Unassigned filter', + }); + expect(remove.querySelector('svg')).toBeTruthy(); + expect(within(sheet()).queryByText('Unassigned, selected')).toBeNull(); + fireEvent.click(remove); + expect(onChange).toHaveBeenCalledWith([], 'remove', 0); }); - it('places Add filter in the header and Clear all plus Done in the footer', () => { + it('shows only the centered Add filter action when no filters are applied', () => { + function EmptyStateHarness() { + const [filters, setFilters] = useState>([ + openFilter, + ]); + return ( + setFilters(next)} + /> + ); + } + render(); + openSheet(); + fireEvent.click( + within(sheet()).getByRole('button', {name: 'Remove Status filter'}), + ); + const addActions = within(sheet()).getAllByRole('button', { + name: 'Add filter', + }); + expect(addActions).toHaveLength(1); + expect(within(sheet()).queryByRole('button', {name: 'Done'})).toBeNull(); + expect( + within(sheet()).queryByRole('button', {name: 'Clear all'}), + ).toBeNull(); + }); + + it('places the Add filter icon in the header and full-width Clear all plus Done in the fixed footer', () => { setup({filters: [openFilter]}); openSheet(); const heading = within(sheet()).getByRole('heading', {name: 'Filters'}); @@ -730,8 +761,12 @@ describe('MobilePowerSearch Filters sheet', () => { expect( list.compareDocumentPosition(clearAll) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); + expect(add).toHaveAttribute('data-variant', 'secondary'); expect(done).toHaveAttribute('data-variant', 'primary'); - expect(getComputedStyle(clearAll.parentElement!).position).toBe('sticky'); + expect(clearAll).toHaveAttribute('data-variant', 'secondary'); + expect(getComputedStyle(clearAll).flexGrow).toBe('1'); + expect(getComputedStyle(done).flexGrow).toBe('1'); + expect(getComputedStyle(clearAll.parentElement!).position).toBe('relative'); }); it('runs filter rows edge to edge on the body inset (List edge="bleed")', () => { @@ -749,7 +784,7 @@ describe('MobilePowerSearch Filters sheet', () => { expect(trigger()).toHaveFocus(); }); - it('clears removable filters, closes, and restores focus', () => { + it('clears removable filters, keeps Filters open, and announces the outcome there', async () => { function Harness() { const [filters, setFilters] = useState>([ openFilter, @@ -765,59 +800,50 @@ describe('MobilePowerSearch Filters sheet', () => { render(); openSheet(); fireEvent.click(within(sheet()).getByRole('button', {name: 'Clear all'})); - expect(isSheetOpen()).toBe(false); - settleSheets(); - expect(trigger()).toHaveFocus(); + const confirmation = screen.getByRole('alertdialog', { + name: 'Clear all filters?', + }); + fireEvent.click( + within(confirmation).getByRole('button', {name: 'Clear filters'}), + ); + expect(isSheetOpen()).toBe(true); + expect(within(sheet()).getByText('No filters applied')).toBeTruthy(); expect(within(field()).queryByText('Open')).toBeNull(); - }); - - 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([], '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. + await waitFor(() => + expect(sheetRegion().textContent).toContain('Editable filters removed'), + ); 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(); + it('returns to Filters unchanged when the clear confirmation scrim is pressed', () => { + const {onChange} = setup({filters: [openFilter]}); 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'), - ); + const confirmation = screen.getByRole('alertdialog', { + name: 'Clear all filters?', + }); + fireEvent.click(confirmation); + expect( + screen.queryByRole('alertdialog', {name: 'Clear all filters?'}), + ).toBeNull(); + expect(onChange).not.toHaveBeenCalled(); + expect(isSheetOpen()).toBe(true); + expect(within(sheet()).getByText('Status is')).toBeTruthy(); }); - it('shows Clear all when every filter is read-only, since Clear all removes those too', () => { + it('clears every removable filter but keeps read-only ones', () => { const pinned: PowerSearchFilter = {...openFilter, isReadOnly: true}; - const {onChange} = setup({filters: [pinned]}); + const {onChange} = setup({filters: [authorFilter, pinned]}); openSheet(); fireEvent.click(within(sheet()).getByRole('button', {name: 'Clear all'})); - expect(onChange).toHaveBeenCalledWith([], 'remove', 0); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.click( + within( + screen.getByRole('alertdialog', {name: 'Clear all filters?'}), + ).getByRole('button', {name: 'Clear filters'}), + ); + expect(onChange).toHaveBeenCalledWith([pinned], 'remove', 0); + expect(isSheetOpen()).toBe(true); }); it('respects hasClear', () => { @@ -857,6 +883,14 @@ describe('MobilePowerSearch Filters sheet', () => { within(sheet()).getByRole('button', {name: 'Author is Ada'}), ).toBeTruthy(); }); + + it('hides Clear all when every filter is read-only', () => { + setup({filters: [{...openFilter, isReadOnly: true}]}); + openSheet(); + expect( + within(sheet()).queryByRole('button', {name: 'Clear all'}), + ).toBeNull(); + }); }); // ============================================================================= @@ -907,19 +941,23 @@ describe('MobilePowerSearch Add filter', () => { ).toBeNull(); }); - it('closes to the trigger when Back is pressed from direct entry', () => { + it('closes to the trigger when footer Done is pressed from direct entry', () => { setup(); openSheet(); - fireEvent.click(within(sheet()).getByRole('button', {name: 'Back'})); + expect(within(sheet()).queryByRole('button', {name: 'Back'})).toBeNull(); + expect(within(sheet()).queryByRole('button', {name: 'Close'})).toBeNull(); + expect(within(sheet()).queryByRole('button', {name: 'Cancel'})).toBeNull(); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Done'})); expect(isSheetOpen()).toBe(false); settleSheets(); expect(trigger()).toHaveFocus(); }); - it('returns to Filters and focuses Add filter when Back is pressed from management', () => { + it('returns to Filters and focuses Add filter when Add filter is dismissed from management', () => { setup({filters: [openFilter]}); openAddFlow(); - fireEvent.click(within(sheet()).getByRole('button', {name: 'Back'})); + expect(within(sheet()).queryByRole('button', {name: 'Back'})).toBeNull(); + fireEvent.keyDown(sheet(), {key: 'Escape'}); expect( within(sheet()).getByRole('heading', {name: 'Filters'}), ).toBeTruthy(); @@ -933,7 +971,14 @@ describe('MobilePowerSearch Add filter', () => { openAddFlow(); const status = within(sheet()).getByRole('button', {name: 'Status'}); expect(status.textContent).toBe('Status'); + expect( + within(sheet()).getByRole('heading', {name: 'General', level: 4}), + ).toBeTruthy(); + expect(within(sheet()).getByRole('list', {name: 'General'})).toBeTruthy(); expect(within(sheet()).getByRole('button', {name: 'Author'})).toBeTruthy(); + expect( + within(sheet()).getByRole('heading', {name: 'Metadata', level: 4}), + ).toBeTruthy(); expect(within(sheet()).getByRole('list', {name: 'Metadata'})).toBeTruthy(); expect(status.closest('li')?.querySelectorAll('svg')).toHaveLength(1); }); @@ -958,6 +1003,16 @@ describe('MobilePowerSearch Add filter', () => { const search = within(sheet()).getByRole('textbox', { name: 'Search filters', }); + expect(search).not.toHaveFocus(); + search.focus(); + const scrollBody = Array.from( + sheet().querySelectorAll('div'), + ).find(element => getComputedStyle(element).overflowY === 'auto')!; + fireEvent.scroll(scrollBody); + expect(search).not.toHaveFocus(); + search.focus(); + fireEvent.touchMove(scrollBody); + expect(search).not.toHaveFocus(); fireEvent.change(search, {target: {value: 'Field'}}); expect( within(sheet()).getAllByRole('button', {name: /^Field/}), @@ -966,9 +1021,7 @@ describe('MobilePowerSearch Add filter', () => { expect(within(sheet()).getByRole('button', {name: 'Field 7'})).toBeTruthy(); expect(within(sheet()).queryByRole('button', {name: 'Field 3'})).toBeNull(); fireEvent.change(search, {target: {value: 'nope'}}); - expect( - within(sheet()).getByText('No filters match your search'), - ).toBeTruthy(); + expect(within(sheet()).getByText('No filters found')).toBeTruthy(); }); it('leaves out nested-only fields and warns the developer', () => { @@ -1033,14 +1086,16 @@ describe('MobilePowerSearch Add filter', () => { // ============================================================================= describe('MobilePowerSearch value sheet', () => { - it('runs field rows and choice rows edge to edge on the body inset', () => { + it('keeps field rows edge to edge and contains choice rows in their own section', () => { setup(); openAddFlow(); for (const list of within(sheet()).getAllByRole('list')) { expectBleedRowsOnBodyInset(list); } tapRow('Status'); - expectBleedRowsOnBodyInset(within(sheet()).getByRole('list')); + expect(within(sheet()).getByRole('list').className).not.toContain( + 'reachesContainerPadding', + ); }); it('stages an enum selection and commits it with exact add metadata', () => { @@ -1093,24 +1148,28 @@ describe('MobilePowerSearch value sheet', () => { within(sheet()).getByRole('heading', {name: 'Filters'}), ).toBeTruthy(); expect( - within(sheet()).getByRole('button', {name: 'Add filter'}), - ).toHaveFocus(); + within(sheet()) + .getAllByRole('button', {name: 'Add filter'}) + .some(button => button === document.activeElement), + ).toBe(true); }); - it('confirms an empty-value operator through Add filter', () => { + it('adds a single empty-value field immediately without opening an empty editor', () => { const {onChange} = setup(); openAddFlow(); + const fieldPickerPlus = within(sheet()) + .getByText('Unassigned') + .closest('li'); + expect(fieldPickerPlus?.querySelectorAll('svg')).toHaveLength(1); tapRow('Unassigned'); - expect(onChange).not.toHaveBeenCalled(); - expect( - within(sheet()).getByRole('heading', {name: 'Unassigned is true'}), - ).toBeTruthy(); - addFilter(); expect(onChange).toHaveBeenCalledWith( [{field: 'unassigned', operator: 'isTrue', value: {type: 'empty'}}], 'add', 0, ); + expect( + within(sheet()).getByRole('heading', {name: 'Filters'}), + ).toBeTruthy(); }); it('shows the operator step as radios only when a field has several', () => { @@ -1121,9 +1180,13 @@ describe('MobilePowerSearch value sheet', () => { expect( within(sheet()).getByRole('radiogroup', {name: 'Operator'}), ).toBeTruthy(); + const selectedOperator = within(sheet()).getByRole('radio', { + name: 'Status is', + }); + expect(selectedOperator).toBeChecked(); expect( - within(sheet()).getByRole('radio', {name: 'Status is'}), - ).toBeChecked(); + getComputedStyle(selectedOperator.closest('.astryx-item')!).flexDirection, + ).toBe('row-reverse'); fireEvent.click( within(sheet()).getByRole('radio', {name: 'Status is not'}), ); @@ -1237,14 +1300,14 @@ describe('MobilePowerSearch value sheet', () => { ); }); - it('opens the edit sheet with Cancel, Apply and Remove from a selected row', () => { + it('opens the edit sheet with only Cancel and Apply from a selected row', () => { setup({filters: [openFilter]}); openEditFlow('Status is Open'); expect(within(sheet()).getByRole('heading', {name: 'Status'})).toBeTruthy(); expect(within(sheet()).queryByRole('button', {name: 'Back'})).toBeNull(); expect(within(sheet()).getByRole('button', {name: 'Cancel'})).toBeTruthy(); expect(within(sheet()).getByRole('button', {name: 'Apply'})).toBeTruthy(); - expect(within(sheet()).getByRole('button', {name: 'Remove'})).toBeTruthy(); + expect(within(sheet()).queryByRole('button', {name: /^Remove/})).toBeNull(); expect( within(sheet()).getByRole('button', {name: 'Open, selected'}), ).toBeTruthy(); @@ -1289,10 +1352,12 @@ describe('MobilePowerSearch value sheet', () => { expect(onChange).not.toHaveBeenCalled(); }); - it('removes a filter from its edit sheet with exact remove metadata', () => { + it('removes a filter directly from the Filters sheet with exact remove metadata', () => { const {onChange} = setup({filters: [authorFilter, openFilter]}); - openEditFlow('Status is Open'); - fireEvent.click(within(sheet()).getByRole('button', {name: 'Remove'})); + openSheet(); + fireEvent.click( + within(sheet()).getByRole('button', {name: 'Remove Status filter'}), + ); expect(onChange).toHaveBeenCalledWith([authorFilter], 'remove', 1); expect( within(sheet()).getByRole('heading', {name: 'Filters'}), @@ -1327,11 +1392,15 @@ describe('MobilePowerSearch value sheet', () => { ); } render(); - openEditFlow('Status is Open'); - fireEvent.click(within(sheet()).getByRole('button', {name: 'Remove'})); + openSheet(); + fireEvent.click( + within(sheet()).getByRole('button', {name: 'Remove Status filter'}), + ); expect( - within(sheet()).getByRole('button', {name: 'Author is Grace'}), - ).toHaveFocus(); + within(sheet()) + .getAllByRole('button', {name: 'Remove Author filter'}) + .some(button => button === document.activeElement), + ).toBe(true); }); it('focuses Add filter after the last editable filter is removed', () => { @@ -1348,11 +1417,13 @@ describe('MobilePowerSearch value sheet', () => { ); } render(); - openEditFlow('Status is Open'); - fireEvent.click(within(sheet()).getByRole('button', {name: 'Remove'})); - expect(within(sheet()).getByText('No filters selected')).toBeTruthy(); + openSheet(); + fireEvent.click( + within(sheet()).getByRole('button', {name: 'Remove Status filter'}), + ); + expect(within(sheet()).getByText('No filters applied')).toBeTruthy(); expect( - within(sheet()).getByRole('button', {name: 'Add filter'}), + within(sheet()).getAllByRole('button', {name: 'Add filter'})[0], ).toHaveFocus(); }); @@ -1524,11 +1595,11 @@ describe('MobilePowerSearch value sheet', () => { // Content search // ============================================================================= -describe('MobilePowerSearch step sheets (stacked, replacing)', () => { - // Three sibling BottomSheets, one per step, controlled by one `step` value: - // a same-update close and open is the stack's replace handoff. These read - // only what a person or assistive technology can observe: which dialogs - // are open, which is modal, and which is inert and hidden. +describe('MobilePowerSearch step sheets (retained stack)', () => { + // Dismissing the top step closes only that sheet, so the prior step returns + // through the stack's reveal motion instead of reopening above the current + // one. These assertions read only what a person or assistive technology can + // observe. function stepDialogs(): HTMLElement[] { return Array.from(document.querySelectorAll('dialog[open]')); } @@ -1543,31 +1614,35 @@ describe('MobilePowerSearch step sheets (stacked, replacing)', () => { expect(sheet()).toHaveAttribute('aria-modal', 'true'); }); - it('Filters → Add filter is a replace handoff: the entering step is on top, the leaving step stays presented, inert and hidden, then leaves', () => { + it('pushes Add filter above Filters without a Back button and dismisses it to reveal Filters', () => { setup({filters: [openFilter]}); openSheet(); const filters = sheet(); tapRow('Add filter'); - const open = stepDialogs(); - expect(open.map(d => d.getAttribute('aria-label'))).toEqual([ + expect(stepDialogs().map(d => d.getAttribute('aria-label'))).toEqual([ 'Filters', 'Add filter', ]); - // The one being replaced: still presented, out of the tab order and the - // accessibility tree; the entering step is the interactive one. expect(filters).toHaveAttribute('inert'); expect(filters).toHaveAttribute('aria-hidden', 'true'); expect(sheet()).toHaveAttribute('aria-label', 'Add filter'); expect(sheet()).not.toHaveAttribute('inert'); expect(sheet()).not.toHaveAttribute('aria-hidden'); - // The entering step's motion completes first, then the retained step's - // fade (the stack sequences them): only the new step remains. - settleSheets(); + settleSheets(); expect(stepDialogs().map(d => d.getAttribute('aria-label'))).toEqual([ + 'Filters', 'Add filter', ]); - expect(isSheetOpen()).toBe(true); + + expect(within(sheet()).queryByRole('button', {name: 'Back'})).toBeNull(); + fireEvent.keyDown(sheet(), {key: 'Escape'}); + settleSheets(); + expect(stepDialogs().map(d => d.getAttribute('aria-label'))).toEqual([ + 'Filters', + ]); + expect(sheet()).not.toHaveAttribute('inert'); + expect(sheet()).not.toHaveAttribute('aria-hidden'); }); it('a step on its own is one open modal dialog, interactive and exposed', () => { @@ -1592,11 +1667,12 @@ describe('MobilePowerSearch step sheets (stacked, replacing)', () => { 'aria-label', expect.stringContaining('Status'), ); - // Back from the value step, Back from Add filter, Done from Filters. + // Cancel from the value step, dismiss Add filter, Done from Filters. fireEvent.click(within(sheet()).getByRole('button', {name: 'Cancel'})); settleSheets(); expect(sheet()).toHaveAttribute('aria-label', 'Add filter'); - fireEvent.click(within(sheet()).getByRole('button', {name: 'Back'})); + expect(within(sheet()).queryByRole('button', {name: 'Back'})).toBeNull(); + fireEvent.keyDown(sheet(), {key: 'Escape'}); settleSheets(); expect(sheet()).toHaveAttribute('aria-label', 'Filters'); fireEvent.click(within(sheet()).getByRole('button', {name: 'Done'})); @@ -1616,7 +1692,19 @@ describe('MobilePowerSearch step sheets (stacked, replacing)', () => { expect(trigger()).toHaveFocus(); }); - it('a scrim press on the Filters step (purpose info) closes the flow; on the value step (purpose form) it does not', () => { + it('Escape on a nested value step reveals its retained parent', () => { + setup({filters: [openFilter]}); + openSheet(); + tapRow('Add filter'); + tapRow(/^Status/); + settleSheets(); + fireEvent.keyDown(sheet(), {key: 'Escape'}); + settleSheets(); + expect(isSheetOpen()).toBe(true); + expect(sheet()).toHaveAttribute('aria-label', 'Add filter'); + }); + + it('a scrim press on the Filters step closes the flow; a nested value scrim reveals its parent', () => { setup({filters: [openFilter]}); openSheet(); settleSheets(); @@ -1625,23 +1713,24 @@ describe('MobilePowerSearch step sheets (stacked, replacing)', () => { expect(isSheetOpen()).toBe(false); settleSheets(); - openEditFlow(/^Status/); + openSheet(); + tapRow('Add filter'); + tapRow(/^Status/); settleSheets(); expect(sheet()).toHaveAttribute( 'aria-label', expect.stringContaining('Status'), ); fireEvent.click(sheet()); + settleSheets(); expect(isSheetOpen()).toBe(true); - expect(sheet()).toHaveAttribute( - 'aria-label', - expect.stringContaining('Status'), - ); + expect(sheet()).toHaveAttribute('aria-label', 'Add filter'); }); - it('a downward swipe on the Filters step dismisses the flow', () => { + it('a downward swipe on Add filter reveals Filters instead of closing the flow', () => { setup({filters: [openFilter]}); openSheet(); + tapRow('Add filter'); settleSheets(); const handle = sheet().querySelector( '.astryx-bottom-sheet > div[aria-hidden="true"]', @@ -1668,18 +1757,18 @@ describe('MobilePowerSearch step sheets (stacked, replacing)', () => { clientY: 120, pointerType: 'touch', }); - expect(isSheetOpen()).toBe(false); + settleSheets(); + expect(isSheetOpen()).toBe(true); + expect(sheet()).toHaveAttribute('aria-label', 'Filters'); }); - it('a stale dismissal from the leaving sheet cannot end the flow: a scrim press on the replaced Filters sheet is ignored once the step has moved on', () => { + it('a scrim press on the covered Filters sheet is ignored while Add filter is on top', () => { setup({filters: [openFilter]}); openSheet(); const filters = sheet(); tapRow('Add filter'); - // The leaving Filters dialog is still presented (fading, inert); a click - // on its own element is the scrim path, which BottomSheet does not gate - // on its open state — the flow's per-step guard must ignore it because - // the flow is on Add filter now. + // The covered Filters dialog stays presented but inert. A click on its own + // dialog must not dismiss the active Add filter sheet or end the flow. fireEvent.click(filters); expect(isSheetOpen()).toBe(true); expect(sheet()).toHaveAttribute('aria-label', 'Add filter'); @@ -1687,7 +1776,7 @@ describe('MobilePowerSearch step sheets (stacked, replacing)', () => { }); describe('MobilePowerSearch content search', () => { - it('opens empty content-search configurations directly to Add filter with focus in the search', () => { + it('opens empty content-search configurations directly to Add filter without focusing the search', () => { setup({config: contentSearchConfig, placeholder: 'Search content…'}); openSheet(); expect( @@ -1695,7 +1784,7 @@ describe('MobilePowerSearch content search', () => { ).toBeTruthy(); const input = within(sheet()).getByRole('searchbox', {name: 'Search'}); expect(input).toHaveAttribute('placeholder', 'Search content…'); - expect(input).toHaveFocus(); + expect(input).not.toHaveFocus(); }); it('lists suggestions inline below the search, reachable with ArrowDown', () => { @@ -1911,29 +2000,26 @@ describe('MobilePowerSearch value types', () => { ); }); - it('string_list: the mobile tokenizer creates free-text values in its own sheet, staged on the trigger and committed by the footer', async () => { + it('string_list: creates and selects an ID inline, then commits it from the value footer', async () => { const {onChange} = setupValues(); openAddFlow(); tapRow('ID'); - const trigger = openTokenizer('ID'); - const inner = tokenizerSheet(); - fireEvent.change(within(inner).getByLabelText('Search ID'), { - target: {value: 'ABC-1'}, - }); - // The value editor debounces the search by 150ms; on a busy CI box that - // and the sheet's render can outlast the 1s default, so wait longer. - fireEvent.click( - await within(inner).findByRole( - 'button', - {name: 'Add ABC-1'}, - {timeout: 15000}, - ), + const input = within(sheet()).getByRole('textbox', {name: 'Search ID'}); + fireEvent.change(input, {target: {value: 'ABC-1'}}); + const result = await within(sheet()).findByRole( + 'button', + {name: 'Add ABC-1'}, + {timeout: 15000}, ); - // The pick shows on the trigger in the value step at once; the step's - // footer stays disabled by nothing but the staged value. - expect(within(trigger).getByText('ABC-1')).toBeInTheDocument(); - fireEvent.click(within(inner).getByRole('button', {name: 'Done'})); - expect(trigger).toHaveAttribute('aria-expanded', 'false'); + const resultList = result.closest('[role="list"]') as HTMLElement; + expect( + getComputedStyle(resultList.parentElement!.parentElement!) + .backgroundColor, + ).not.toBe('var(--color-background-muted)'); + fireEvent.click(result); + expect( + within(sheet()).getByRole('button', {name: 'Add ABC-1, selected'}), + ).toBeTruthy(); addFilter(); expect(onChange).toHaveBeenCalledWith( [ @@ -2010,7 +2096,7 @@ describe('MobilePowerSearch value types', () => { expect(within(sheet()).getByRole('button', {name: 'Apply'})).toBeEnabled(); }); - it('date_absolute (date only): the date input shows the stored day', () => { + it('date_absolute (date only): renders the stored day in an inline calendar', () => { const dateFilter: PowerSearchFilter = { field: 'created', operator: 'after', @@ -2019,16 +2105,20 @@ describe('MobilePowerSearch value types', () => { }; setupValues({filters: [dateFilter], timezoneID: 'UTC'}); openEditFlow(/^Created is after/); - const input = within(sheet()).getByRole('combobox', {name: 'Date'}); - expect(input).toHaveValue('March 10, 2024'); + expect(within(sheet()).getByRole('grid')).toBeTruthy(); + expect( + sheet() + .querySelector('button[data-date="2024-03-10"]') + ?.closest('[role="gridcell"]'), + ).toHaveAttribute('aria-selected', 'true'); }); - it('date_absolute with isDateOnly: false gets the date-only editor, as the Core editor does (DEC-3)', () => { + it('date_absolute with isDateOnly: false keeps the large date-and-time editor', () => { setupValues(); openAddFlow(); tapRow('Updated'); - expect(within(sheet()).queryByLabelText('Date and time')).toBeNull(); - expect(within(sheet()).getByRole('combobox', {name: 'Date'})).toBeTruthy(); + expect(within(sheet()).getByLabelText('Date and time')).toBeTruthy(); + expect(within(sheet()).queryByRole('grid')).toBeNull(); }); it('date_relative: rows stage a single relative value', () => { @@ -2056,7 +2146,7 @@ describe('MobilePowerSearch value types', () => { ); }); - it('date_range: the range input is rendered with the stored range', () => { + it('date_range: renders the stored range in an inline calendar', () => { const rangeFilter: PowerSearchFilter = { field: 'window', operator: 'between', @@ -2070,37 +2160,37 @@ describe('MobilePowerSearch value types', () => { }; setupValues({filters: [rangeFilter], timezoneID: 'UTC'}); openEditFlow(/^Window is between/); - const range = within(sheet()).getByLabelText('Date range'); - expect(range.textContent ?? (range as HTMLInputElement).value).toMatch( - /Mar 1, 2024/, - ); + expect(within(sheet()).getByRole('grid')).toBeTruthy(); + expect( + sheet() + .querySelector('button[data-date="2024-03-01"]') + ?.closest('[role="gridcell"]'), + ).toHaveAttribute('aria-selected', 'true'); + expect( + sheet() + .querySelector('button[data-date="2024-03-09"]') + ?.closest('[role="gridcell"]'), + ).toHaveAttribute('aria-selected', 'true'); }); - it('entity_list: the mobile tokenizer picks entities from the search source in its own sheet, staged on the trigger and committed by the footer', async () => { + it('entity_list: searches and selects an entity inline, then commits it from the value footer', async () => { const {onChange} = setupValues(); openAddFlow(); tapRow('Assignee'); - const trigger = openTokenizer('Assignee'); - const inner = tokenizerSheet(); - fireEvent.change(within(inner).getByLabelText('Search Assignee'), { - target: {value: 'Ada'}, + const input = within(sheet()).getByRole('textbox', { + name: 'Search Assignee', }); - // The value editor debounces the search by 150ms; on a busy CI box that - // and the sheet's render can outlast the 1s default, so wait longer. + fireEvent.change(input, {target: {value: 'Ada'}}); fireEvent.click( - await within(inner).findByRole( - 'checkbox', + await within(sheet()).findByRole( + 'button', {name: 'Ada Lovelace'}, {timeout: 15000}, ), ); - expect(within(trigger).getByText('Ada Lovelace')).toBeInTheDocument(); - // The footer's Done shows once the search is cleared. - fireEvent.change(within(inner).getByLabelText('Search Assignee'), { - target: {value: ''}, - }); - fireEvent.click(within(inner).getByRole('button', {name: 'Done'})); - expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect( + within(sheet()).getByRole('button', {name: 'Ada Lovelace, selected'}), + ).toBeTruthy(); addFilter(); expect(onChange).toHaveBeenCalledWith( [ @@ -2118,41 +2208,6 @@ describe('MobilePowerSearch value types', () => { ); }); - it("entity_list: the tokenizer's sheet is titled with the field's name, and the hidden label still names the trigger", () => { - setupValues(); - openAddFlow(); - tapRow('Assignee'); - // The step already shows the field heading and the operator rows, so the - // tokenizer paints no label of its own... - const step = sheet(); - expect(within(step).queryByText('Entities')).toBeNull(); - const trigger = within(step).getByRole('button', {name: 'Assignee'}); - // ...while the trigger is still named after the field. - expect(trigger).toHaveAccessibleName('Assignee'); - fireEvent.click(trigger); - expect(tokenizerSheet()).toHaveAccessibleName('Assignee'); - }); - - it("entity_list: Escape in the tokenizer's sheet closes only that sheet; the value step and the flow stay open", () => { - setupValues(); - openAddFlow(); - tapRow('Assignee'); - const trigger = openTokenizer('Assignee'); - const inner = tokenizerSheet(); - expect(inner).toHaveAccessibleName('Assignee'); - expect(trigger).toHaveAttribute('aria-expanded', 'true'); - fireEvent.keyDown(inner, {key: 'Escape'}); - expect(trigger).toHaveAttribute('aria-expanded', 'false'); - // The inner sheet closes its dialog after its exit motion... - settleSheets(); - expect(inner).not.toHaveAttribute('open'); - // ...and the flow behind it is untouched. - expect(isSheetOpen()).toBe(true); - expect( - within(sheet()).getByRole('button', {name: 'Assignee'}), - ).toBeInTheDocument(); - }); - it('custom: the configured editor stages a JSON string', () => { const {onChange} = setupValues(); openAddFlow(); @@ -2204,80 +2259,32 @@ describe('MobilePowerSearch timezone', () => { value: {type: 'date_absolute', unixSeconds: BOUNDARY_SECONDS}, }; - // 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.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) => { + 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( + sheet() + .querySelector( + `button[data-date="${timezoneID === 'Asia/Tokyo' ? '2024-03-10' : '2024-03-09'}"]`, + ) + ?.closest('[role="gridcell"]'), + ).toHaveAttribute('aria-selected', 'true'); + }, + ); it('converts a picked day to midnight in the configured zone', () => { expect(isoDateToUnixSeconds('2024-03-09', 'Pacific/Honolulu')).toBe( @@ -2372,9 +2379,11 @@ describe('MobilePowerSearch Core parity', () => { expect( within(sheet()).queryByRole('button', {name: 'Clear all'}), ).toBeNull(); - expect( - within(sheet()).getByRole('button', {name: 'Add filter'}), - ).toHaveFocus(); + const addFilterButtons = within(sheet()).getAllByRole('button', { + name: 'Add filter', + }); + expect(addFilterButtons).toHaveLength(1); + expect(addFilterButtons[0]).toHaveFocus(); }); it("moves focus from a dropped field's row in Add filter to a remaining row", () => { @@ -2441,7 +2450,7 @@ describe('MobilePowerSearch Core parity', () => { }); 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', () => { + it('lists it by its raw keys, exposes the unavailable state, and removes it directly from Filters', () => { function Removable() { const [filters, setFilters] = useState< ReadonlyArray @@ -2467,13 +2476,15 @@ describe('MobilePowerSearch Core parity', () => { 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'})); + fireEvent.click(within(sheet()).getByRole('button', {name: 'Cancel'})); + fireEvent.click( + within(sheet()).getByRole('button', {name: 'Remove legacy filter'}), + ); 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'}), + within(sheet()).getByRole('button', {name: 'Remove Status filter'}), ).toHaveFocus(); }); @@ -2492,7 +2503,7 @@ describe('MobilePowerSearch Core parity', () => { }); 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 () => { + it('moves focus to Cancel 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(); @@ -2514,7 +2525,7 @@ describe('MobilePowerSearch Core parity', () => { within(sheet()).getByText('This filter is no longer available'), ).toBeTruthy(); expect( - within(sheet()).getByRole('button', {name: 'Remove'}), + within(sheet()).getByRole('button', {name: 'Cancel'}), ).toHaveFocus(); await waitFor(() => expect(sheetRegion().textContent).toBe( @@ -2573,7 +2584,7 @@ describe('MobilePowerSearch Core parity', () => { }); describe('announcements inside the open sheet', () => { - it('announces "Removed {label}" in the Filters sheet after Remove, as the Core token does', async () => { + it('announces "Removed {label}" in Filters after direct row removal', async () => { function Removable() { const [filters, setFilters] = useState< ReadonlyArray @@ -2587,15 +2598,17 @@ describe('MobilePowerSearch Core parity', () => { ); } render(); - openEditFlow('Author is Ada'); - fireEvent.click(within(sheet()).getByRole('button', {name: 'Remove'})); + openSheet(); + fireEvent.click( + within(sheet()).getByRole('button', {name: 'Remove Author filter'}), + ); 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 () => { + it('speaks "No filters found" 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) => ({ @@ -2612,17 +2625,17 @@ describe('MobilePowerSearch Core parity', () => { name: 'Search filters', }); fireEvent.change(search, {target: {value: 'nope'}}); - expect( - within(sheet()).getByText('No filters match your search'), - ).toBeTruthy(); + expect(within(sheet()).getByText('No filters found')).toBeTruthy(); await waitFor(() => - expect(sheetRegion().textContent).toBe('No filters match your search'), + expect(sheetRegion().textContent).toBe( + 'No filters found Try a different 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', { + within(sheet()).queryByText('No filters found', { ignore: '[data-astryx-modal-live-region] *, [data-astryx-modal-live-region]', }), @@ -2644,7 +2657,8 @@ describe('MobilePowerSearch Core parity', () => { // 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()).queryByRole('button', {name: 'Back'})).toBeNull(); + fireEvent.keyDown(sheet(), {key: 'Escape'}); expect( within(sheet()).getByRole('heading', {name: 'Filters'}), ).toBeTruthy(); @@ -2721,9 +2735,7 @@ describe('MobilePowerSearch Core parity', () => { expect( within(sheet()).getByRole('heading', {name: 'Add filter'}), ).toBeTruthy(); - expect( - within(sheet()).queryByText('No filters match your search'), - ).toBeNull(); + expect(within(sheet()).queryByText('No filters found')).toBeNull(); }); it("keeps the content searchbox's aria-controls pointing at an element while nothing lists", () => { diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx index b7f23c61b5dd8..6e6d154cecb69 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx @@ -12,11 +12,11 @@ * 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 - * Clear all / Done, and a field → operator → value drill-down whose edit - * sheet holds Cancel / Apply / Remove; the steps are sibling BottomSheets - * that replace one another on a same-update switch (one scrim, the - * entering step above the leaving one) + * tokens, a Filters sheet with editable rows and direct remove actions, Add + * filter and sticky Clear all / Done, and a field → operator → value + * drill-down whose edit sheet holds Cancel / Apply; forward navigation stacks each step + * above the one that opened it, and dismissing the current sheet reveals the + * retained previous step * @position Lab (canary) stack layer 1: validates the touch design before any * Core promotion. The caller chooses it explicitly * (`isTouch ? MobilePowerSearch : PowerSearch`); there is no pointer @@ -34,32 +34,35 @@ * - 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; + * exposed while a native modal dialog is open. Visual EmptyStates mount only + * while their step is open; empty-result and unavailable messages are spoken + * from their rendered element, once per occasion; Clear all announces after + * its confirmation closes inside the still-open Filters sheet; * - no theming contract: no `power-search` theme target or selectors; * - `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); + * the Filters rows share and date values formatted in `timezoneID`; * - fields whose every operator is `nested` are left out with a development * 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; + * - date-only and date-range values use inline Calendars in `timezoneID`, + * `isDateOnly: false` uses a large date-and-time input, and `date_relative` + * remains a row list; + * - Clear all asks for confirmation, removes editable filters while preserving + * read-only ones, and keeps the Filters sheet open for continued editing; + * - every removable Filters row, including one-tap boolean filters, uses the + * same trailing remove button instead of a selected-state indicator; when + * no filters exist, the centered Add filter is the only visible action; + * - Add filter leaves its search input unfocused until the user chooses it, so + * opening the sheet does not summon the software keyboard; + * - a value editor that is the sheet's only section sits directly on the sheet + * surface; muted section containers distinguish only multi-section editors; * - 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; + * raw keys and remains directly removable from the Filters row; rows and + * tokens are keyed by filter identity, so controlled reorder and removal + * keep focus stable or move it to the nearest remaining action; + * - the scroll viewport reaches the sheet edge so its scrollbar does too; + * padded inner content keeps list text aligned with the fixed header; * - 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. @@ -84,11 +87,14 @@ import React, { useRef, useState, type ReactNode, + type SVGProps, } from 'react'; import * as stylex from '@stylexjs/stylex'; import type {BaseProps} from '@astryxdesign/core'; import {BottomSheet} from '@astryxdesign/core/BottomSheet'; import {Button} from '@astryxdesign/core/Button'; +import {Dialog} from '@astryxdesign/core/Dialog'; +import {EmptyState} from '@astryxdesign/core/EmptyState'; import { Field, inputStatusBorderStyles, @@ -100,6 +106,8 @@ import { } from '@astryxdesign/core/Field'; import {Heading} from '@astryxdesign/core/Heading'; import {Icon, renderIconSlot, type IconType} from '@astryxdesign/core/Icon'; +import {IconButton} from '@astryxdesign/core/IconButton'; +import {Layout, LayoutContent, LayoutFooter} from '@astryxdesign/core/Layout'; import {containerPaddingInlineVarStyles} from '@astryxdesign/core/Layout/padding.stylex'; import {List, ListItem} from '@astryxdesign/core/List'; import {resolveOperatorLabel} from '@astryxdesign/core/PowerSearch'; @@ -118,6 +126,7 @@ import {formatFilterValue} from '@astryxdesign/core/PowerSearch/utils'; import type {InternalConfig} from '@astryxdesign/core/PowerSearch/utils'; import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList'; import {useSize} from '@astryxdesign/core/SizeContext'; +import {Stack} from '@astryxdesign/core/Stack'; import {Text} from '@astryxdesign/core/Text'; import {TextInput} from '@astryxdesign/core/TextInput'; import {Token} from '@astryxdesign/core/Token'; @@ -132,6 +141,7 @@ import {useLocale, useTranslator} from '@astryxdesign/core/i18n'; import { colorVars, fontWeightVars, + radiusVars, sizeVars, spacingVars, typeScaleVars, @@ -144,7 +154,6 @@ import { createFilterIdentities, fieldMatchesQuery, filterSignature, - formatDateDay, isSupportedOperator, nearestEditableIndex, resolveFilterIdentityIndex, @@ -232,6 +241,79 @@ export interface MobilePowerSearchProps extends Omit< // Styles // ============================================================================= +function PlusIcon(props: SVGProps) { + return ( + + + + + ); +} + +function ClearFiltersConfirmation({ + isOpen, + onOpenChange, + onConfirm, +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onConfirm: () => void; +}) { + const titleId = useId(); + const descriptionId = useId(); + return ( + + + + Clear all filters? + + + This removes all editable filters from this search. + + + } + footer={ + + + + ); +} + const sizeStyles = stylex.create({ sm: {minHeight: sizeVars['--size-element-sm']}, md: {minHeight: sizeVars['--size-element-md']}, @@ -338,6 +420,10 @@ const styles = stylex.create({ minHeight: '100%', isolation: 'isolate', }, + sheetWithFixedFooter: { + height: '100%', + overflow: 'hidden', + }, header: { position: 'sticky', insetBlockStart: 0, @@ -376,6 +462,20 @@ const styles = stylex.create({ paddingInline: spacingVars['--spacing-4'], paddingBlockEnd: spacingVars['--spacing-4'], }, + scrollViewport: { + flexGrow: 1, + minHeight: 0, + overflowY: 'auto', + overscrollBehaviorY: 'contain', + }, + scrollContent: { + minHeight: '100%', + display: 'flex', + flexDirection: 'column', + gap: spacingVars['--spacing-4'], + paddingInline: spacingVars['--spacing-4'], + paddingBlockEnd: spacingVars['--spacing-4'], + }, footer: { position: 'sticky', insetBlockEnd: 0, @@ -388,19 +488,41 @@ const styles = stylex.create({ paddingInline: spacingVars['--spacing-4'], backgroundColor: colorVars['--color-background-surface'], }, - footerActions: { - display: 'flex', - alignItems: 'center', - gap: spacingVars['--spacing-2'], - marginInlineStart: 'auto', - }, footerSoleAction: { flexGrow: 1, }, + footerEqualAction: { + flexGrow: 1, + flexBasis: 0, + minWidth: 0, + minHeight: 44, + }, + fixedFooter: { + position: 'relative', + insetBlockEnd: 'auto', + flexShrink: 0, + }, empty: { paddingBlock: spacingVars['--spacing-6'], textAlign: 'center', }, + emptyState: { + flexGrow: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + controlSection: { + padding: spacingVars['--spacing-3'], + borderRadius: radiusVars['--radius-container'], + backgroundColor: colorVars['--color-background-muted'], + }, + trailingControlRow: { + flexDirection: 'row-reverse', + }, + confirmationAction: { + width: '100%', + }, rowValue: { fontWeight: fontWeightVars['--font-weight-bold'], }, @@ -546,13 +668,7 @@ function SheetEmptyMessage({ // 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`. - */ +/** Formats the value text shared by trigger tokens and Filters rows. */ function formatValueText( config: InternalConfig, operator: PowerSearchOperator, @@ -562,12 +678,6 @@ function formatValueText( 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, @@ -649,9 +759,9 @@ function MobilePowerSearchToken({ /** * Lab prototype of the touch PowerSearch flow. The outer field keeps active * filters as display-only tokens and acts as one sheet trigger. The Filters - * sheet lists selected filters as chevron rows with Add filter in its header - * and Clear all plus Done in its sticky footer; opening a row edits that - * filter in a sheet with Cancel, Apply and Remove. Add filter opens directly + * sheet lists selected filters as editable rows with direct remove actions, + * Add filter in its header, and Clear all plus Done in its sticky footer; + * opening a row edits that filter in a sheet with Cancel and Apply. Add filter * when no filter exists and, when the config names a content-search field, * carries that search with inline suggestions. */ @@ -717,14 +827,10 @@ export function MobilePowerSearch({ // 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}); + // Clear all closes only its confirmation dialog; announce the result after + // that alertdialog has gone so the message lands in the still-open Filters + // sheet, coalesced with a result-count update from the same commit. + const pendingClearAnnouncementRef = useRef(false); const triggerLabel = labelFromProps ?? 'Manage filters'; const fieldPlaceholder = placeholderFromProps ?? 'Filters…'; @@ -754,6 +860,7 @@ export function MobilePowerSearch({ const endLaneRef = useRef(null); const managerAddButtonRef = useRef(null); const contentSearchRef = useRef(null); + const fieldSearchRef = useRef(null); const contentSuggestionListRef = useRef(null); const managerRowsRef = useRef(new Map()); const fieldRowsRef = useRef(new Map()); @@ -765,18 +872,12 @@ export function MobilePowerSearch({ 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. + // controlled update that unmounts the focused control can put focus on the + // nearest remaining row, else the sheet's first control or panel. 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')) @@ -796,8 +897,12 @@ export function MobilePowerSearch({ }, [], ); + // Direct row removal returns focus to the next row's trailing remove action, + // avoiding the clipped full-row focus ring that appears as a black line. + const pendingRemovalFocusRef = useRef(false); const [step, setStep] = useState(null); + const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false); 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, @@ -805,8 +910,13 @@ export function MobilePowerSearch({ const [wasDisabled, setWasDisabled] = useState(isDisabled); if (isDisabled !== wasDisabled) { setWasDisabled(isDisabled); - if (isDisabled && step != null) { - setStep(null); + if (isDisabled) { + if (step != null) { + setStep(null); + } + if (isClearConfirmationOpen) { + setIsClearConfirmationOpen(false); + } } } // The draft outlives a step change on purpose: the replace handoff keeps @@ -817,10 +927,9 @@ 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. + // Whether a Filters row is a button: the field is interactive and the filter + // is not read-only. Known filters open the value editor; stale filters open + // an unavailable state, while their trailing × remains the removal path. const isRowButton = useCallback( (filter: PowerSearchFilter): boolean => !filter.isReadOnly && isInteractive, [isInteractive], @@ -844,6 +953,15 @@ export function MobilePowerSearch({ // ------------------------------------------------------------------ focus -- + const blurFieldPickerSearch = useCallback(() => { + const activeElement = document.activeElement; + const contentSearch = contentSearchRef.current?.querySelector('input'); + const fieldSearch = fieldSearchRef.current?.querySelector('input'); + if (activeElement === contentSearch || activeElement === fieldSearch) { + (activeElement as HTMLInputElement).blur(); + } + }, []); + const focusPendingSheetTarget = useCallback(() => { const target = pendingSheetFocusRef.current; if (target == null) { @@ -857,7 +975,7 @@ export function MobilePowerSearch({ // 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. + // when one shows, else the first field row, else the first button. const firstFieldsControl = () => { const panel = fieldsSheetRef.current; return ( @@ -882,10 +1000,9 @@ export function MobilePowerSearch({ )?.focus({preventScroll: true}); return; } - // 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. + // Follow the row across controlled reorder or clone. If it disappeared, + // use the nearest remaining editable row, else Add filter. A removal puts + // focus on the next row's × action rather than the invisible row button. const index = resolveFilterIdentityIndex( latestFiltersRef.current, @@ -898,12 +1015,12 @@ export function MobilePowerSearch({ target.preferredIndex, isRowButton, ); - const rowButton = - index == null - ? null - : managerRowsRef.current - .get(index) - ?.querySelector('button'); + const row = index == null ? null : managerRowsRef.current.get(index); + const rowButton = pendingRemovalFocusRef.current + ? (row?.querySelector('[aria-label^="Remove "]') ?? + row?.querySelector('button')) + : row?.querySelector('button'); + pendingRemovalFocusRef.current = false; (rowButton ?? managerAddButtonRef.current)?.focus({preventScroll: true}); }, [isRowButton]); @@ -1054,9 +1171,7 @@ export function MobilePowerSearch({ fieldPickerOriginRef.current = 'trigger'; setFieldQuery(''); setContentQuery(''); - pendingSheetFocusRef.current = hasContentSearch - ? {type: 'content-search'} - : null; + pendingSheetFocusRef.current = null; setStep('fields'); return; } @@ -1088,11 +1203,9 @@ export function MobilePowerSearch({ fieldPickerOriginRef.current = 'manage'; setFieldQuery(''); setContentQuery(''); - pendingSheetFocusRef.current = hasContentSearch - ? {type: 'content-search'} - : null; + pendingSheetFocusRef.current = null; setStep('fields'); - }, [hasContentSearch, isInteractive]); + }, [isInteractive]); const queueManagerAddFocus = useCallback(() => { pendingSheetFocusRef.current = {type: 'manager-add'}; @@ -1185,9 +1298,26 @@ export function MobilePowerSearch({ if (operator == null) { return; } + if (operator.value.type === 'empty' && operators.length === 1) { + const nextFilter: PowerSearchFilter = { + field: field.key, + operator: operator.key, + value: {type: 'empty'}, + }; + pendingSheetFocusRef.current = { + type: 'manager-filter', + sourceFilter: nextFilter, + sourceSignature: filterSignature(nextFilter), + preferredIndex: filters.length, + }; + pendingMutationFocusRef.current = true; + onChange([...filters, nextFilter], 'add', filters.length); + setStep('manage'); + return; + } openCreateEditor(field, operator); }, - [config, isInteractive, openCreateEditor], + [config, filters, isInteractive, onChange, openCreateEditor], ); const handleContentSuggestionSelect = useCallback( @@ -1307,32 +1437,43 @@ export function MobilePowerSearch({ preferredIndex: nextIndex, }; } + pendingRemovalFocusRef.current = true; 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', { + const message = t('@astryx.tokenizer.tokenRemoved', { label: describeFilter(filter), }); + // A direct Filters-row removal stays in the same sheet, so announce + // immediately there. A custom editor that removes while returning to + // Filters defers until the step handoff has completed. + if (stepRef.current === 'manage') { + announce(message); + } else { + pendingAnnouncementRef.current = message; + } }, - [describeFilter, filters, isInteractive, isRowButton, onChange, t], + [ + announce, + 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. + // Clear all is a management action: confirm it, remove only filters the user + // can edit, preserve read-only filters, and keep the sheet open. const handleClearAll = useCallback(() => { - if (filters.length === 0) { + const kept = filters.filter(filter => filter.isReadOnly); + const firstRemovedIndex = filters.findIndex(filter => !filter.isReadOnly); + if (firstRemovedIndex < 0) { return; } - 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]); + pendingClearAnnouncementRef.current = true; + onChange(kept, 'remove', firstRemovedIndex); + }, [filters, onChange]); const handleOperatorSelect = useCallback((operator: PowerSearchOperator) => { setDraft(current => { @@ -1433,31 +1574,30 @@ export function MobilePowerSearch({ [handleStepOpenChange], ); const handleFieldsOpenChange = useCallback( - (isOpen: boolean) => handleStepOpenChange('fields', isOpen), - [handleStepOpenChange], + (isOpen: boolean) => { + if (isOpen || stepRef.current !== 'fields') { + return; + } + if (fieldPickerOriginRef.current === 'manage') { + queueManagerAddFocus(); + setStep('manage'); + } else { + setStep(null); + } + }, + [queueManagerAddFocus], ); const handleValueOpenChange = useCallback( - (isOpen: boolean) => handleStepOpenChange('value', isOpen), - [handleStepOpenChange], + (isOpen: boolean) => { + if (!isOpen && stepRef.current === 'value') { + returnFromValue(); + } + }, + [returnFromValue], ); 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) @@ -1465,7 +1605,7 @@ export function MobilePowerSearch({ onFocus?.(event); } }, - [announceOnPage, onFocus, step], + [onFocus], ); const handleBlurWithin = useCallback( (event: React.FocusEvent) => { @@ -1513,18 +1653,24 @@ export function MobilePowerSearch({ // acquires no resource, so there is no cleanup. const announcedResultCountRef = useRef(resultCountText); useEffect(() => { - if (resultCountText === announcedResultCountRef.current) { + if (isClearConfirmationOpen || !pendingClearAnnouncementRef.current) { return; } + pendingClearAnnouncementRef.current = false; announcedResultCountRef.current = resultCountText; - if (resultCountText == null) { + announce( + resultCountText == null + ? 'Editable filters removed' + : `Editable filters removed. ${resultCountText}`, + ); + }, [announce, isClearConfirmationOpen, resultCountText]); + + useEffect(() => { + if (resultCountText === announcedResultCountRef.current) { 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; + announcedResultCountRef.current = resultCountText; + if (resultCountText == null) { return; } announce(resultCountText); @@ -1558,8 +1704,8 @@ export function MobilePowerSearch({ // 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. + // no longer has by its raw keys, as its token shows on the trigger, and its + // trailing × remains available even when the editor becomes unavailable. const [filterIdentities] = useState(createFilterIdentities); const filterIds = filterIdentities.next(filters); const filterRows = filters.map((filter, index) => { @@ -1618,7 +1764,8 @@ export function MobilePowerSearch({ ); }); - const isClearAllShown = hasClear && isInteractive && filters.length > 0; + const hasRemovableFilter = filters.some(filter => !filter.isReadOnly); + const isClearAllShown = hasClear && isInteractive && hasRemovableFilter; const EditorOverride = draftOperator != null @@ -1651,19 +1798,28 @@ export function MobilePowerSearch({ styles.body, containerPaddingInlineVarStyles[4], ); + const scrollViewportProps = stylex.props(styles.scrollViewport); + const scrollContentProps = stylex.props( + styles.scrollContent, + containerPaddingInlineVarStyles[4], + ); const isEditorDisabled = isDisabled || isReadOnly; + const isFieldPickerDirectEntry = fieldPickerOriginRef.current === 'trigger'; + const isManageSheetOpen = + step === 'manage' || + (step === 'fields' && !isFieldPickerDirectEntry) || + (step === 'value' && (draft?.mode === 'edit' || !isFieldPickerDirectEntry)); + const isFieldsSheetOpen = + step === 'fields' || (step === 'value' && draft?.mode === 'create'); const isSaveDisabled = isEditorDisabled || draft?.operator == null || draft.value == null; const isDraftRenderable = draft != null && draftField != null && draftOperator != null; // 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); + // may be on one of the body's controls. Focus moves to Cancel, the remaining + // way back to the Filters row where direct removal lives. const cancelButtonRef = useRef(null); const wasDraftRenderableRef = useRef(isDraftRenderable); useLayoutEffect(() => { @@ -1678,7 +1834,7 @@ export function MobilePowerSearch({ if (dialog != null && active != null && dialog.contains(active)) { return; } - (removeButtonRef.current ?? cancelButtonRef.current)?.focus(); + cancelButtonRef.current?.focus(); }, [draft, isDraftRenderable, step]); return ( @@ -1772,7 +1928,7 @@ export function MobilePowerSearch({ + {...stylex.props(styles.sheet, styles.sheetWithFixedFooter)}> {step === 'manage' && ( )} @@ -1790,86 +1946,125 @@ export function MobilePowerSearch({
Filters
- {isInteractive && ( + {isInteractive && filterRows.length > 0 && (