From f87a0af1adf7932e9424917dd17ce5344f33af8d Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Fri, 23 Oct 2026 10:54:39 -0700 Subject: [PATCH] feat(lab): MobilePowerSearch edits list values through MobileTokenizer The value step for a string_list or entity_list field rendered Core Tokenizer, a desktop chip input with a popover menu, inside the sheet. It now renders the Lab MobileTokenizer: the trigger shows the staged tokens in the step, and tapping it opens a tall searchable sheet over the step, titled with the field's name. Search source, free-text creation, debounce, menu size, disabled state, value mapping and staging are unchanged; the step's footer still commits the value. MobileTokenizer gains two optional props that mirror Core Tokenizer: `isLabelHidden`, forwarded to Field so the step's heading and operator rows are not repeated (the trigger and the sheet keep the label as their accessible name), and `renderItem`, which renders a result row's content beside its checkbox so an entity field's custom rows carry over. The MobilePowerSearch test helpers now read the flow's active step as the first open dialog that is not hidden: since each step owns its dialog, the outgoing step stays open, inert and hidden through a fade jsdom never fires, and the old first-open-dialog read landed on it. --- .../MobilePowerSearch.doc.mjs | 2 +- .../MobilePowerSearch.test.tsx | 100 ++++++++++++++++-- .../MobilePowerSearch/MobilePowerSearch.tsx | 1 + .../MobilePowerSearchValueEditor.tsx | 39 ++++--- .../MobileTokenizer/MobileTokenizer.doc.mjs | 2 + .../MobileTokenizer/MobileTokenizer.test.tsx | 27 +++++ .../src/MobileTokenizer/MobileTokenizer.tsx | 32 ++++-- 7 files changed, 172 insertions(+), 31 deletions(-) diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs index 9fda6757a4537..3cc29a5eb3c52 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.doc.mjs @@ -153,7 +153,7 @@ export const docs = { 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.', + '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.', }, ], }, diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx index 0d307782568a6..b48107d2e1f65 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx @@ -356,6 +356,25 @@ 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. @@ -1470,15 +1489,29 @@ describe('MobilePowerSearch value types', () => { ); }); - it('string_list: tokenizer creates free-text values', async () => { + it('string_list: the mobile tokenizer creates free-text values in its own sheet, staged on the trigger and committed by the footer', async () => { const {onChange} = setupValues(); openAddFlow(); tapRow('ID'); - const input = within(sheet()).getByRole('combobox', {name: 'Values'}); - fireEvent.change(input, {target: {value: 'ABC-1'}}); + 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 screen.findByRole('option', {name: /ABC-1/, hidden: true}), + await within(inner).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'); addFilter(); expect(onChange).toHaveBeenCalledWith( [ @@ -1620,15 +1653,31 @@ describe('MobilePowerSearch value types', () => { ); }); - it('entity_list: tokenizer picks entities from the search source', async () => { + 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 () => { const {onChange} = setupValues(); openAddFlow(); tapRow('Assignee'); - const input = within(sheet()).getByRole('combobox', {name: 'Entities'}); - fireEvent.change(input, {target: {value: 'Ada'}}); + const trigger = openTokenizer('Assignee'); + const inner = tokenizerSheet(); + fireEvent.change(within(inner).getByLabelText('Search Assignee'), { + target: {value: 'Ada'}, + }); + // 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 screen.findByRole('option', {name: /Ada Lovelace/, hidden: true}), + await within(inner).findByRole( + 'checkbox', + {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'); addFilter(); expect(onChange).toHaveBeenCalledWith( [ @@ -1646,6 +1695,41 @@ 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(); diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx index 77890e81ade44..c7f1ac4ceb6d8 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx @@ -1647,6 +1647,7 @@ export function MobilePowerSearch({ operatorValue={draftOperator.value} filterValue={draft.value} onChange={handleDraftValueChange} + label={draftField.label} isDisabled={isEditorDisabled} maxMenuItems={maxOperatorMenuItems} timezoneID={timezoneID} diff --git a/packages/lab/src/MobilePowerSearch/MobilePowerSearchValueEditor.tsx b/packages/lab/src/MobilePowerSearch/MobilePowerSearchValueEditor.tsx index 5069f83a33931..6b8694c9ad6b8 100644 --- a/packages/lab/src/MobilePowerSearch/MobilePowerSearchValueEditor.tsx +++ b/packages/lab/src/MobilePowerSearch/MobilePowerSearchValueEditor.tsx @@ -4,21 +4,26 @@ /** * @file MobilePowerSearchValueEditor.tsx - * @input OperatorValue, staged FilterValue, onChange, timezoneID; public - * @astryxdesign/core inputs only (DateInput, DateRangeInput, DateTimeInput, - * Icon, List, NumberInput, TextInput, TimeInput, Tokenizer, Typeahead, - * VisuallyHidden) + * @input OperatorValue, staged FilterValue, onChange, label, timezoneID; + * public @astryxdesign/core inputs (DateInput, DateRangeInput, + * DateTimeInput, Icon, List, NumberInput, TextInput, TimeInput, Typeahead, + * VisuallyHidden) and the Lab MobileTokenizer * @output Sheet-native value editor for MobilePowerSearch: enum, enum_list and - * date_relative as full-row List choices with trailing checks; every other - * non-nested value type as the matching public input + * date_relative as full-row List choices with trailing checks; string_list + * and entity_list as a MobileTokenizer whose own sheet opens over the step; + * every other non-nested value type as the matching public input * @position Lab sub-component consumed by MobilePowerSearch.tsx * * Core's PowerSearchValueEditor is private, so this file rebuilds each * editor from public inputs. Closed vocabularies (enum, enum_list, * date_relative) are rendered as rows instead of a Selector or Tokenizer, * because a dropdown layer inside a sheet stacks a second overlay on a surface - * the user is already inside. Every editor stages its value; the sheet footer - * confirms it. + * the user is already inside. Open lists (string_list, entity_list) use the + * touch MobileTokenizer instead of Core's chip input with a popover menu: its + * trigger shows the staged tokens in the step, and tapping it opens a tall + * searchable sheet titled with the field's name. The step's heading and + * operator rows already name the field, so that tokenizer's label is hidden. + * Every editor stages its value; the sheet footer confirms it. */ import React, {useCallback, useMemo, type FormEvent} from 'react'; @@ -40,12 +45,12 @@ import type { } from '@astryxdesign/core/PowerSearch'; import {TextInput} from '@astryxdesign/core/TextInput'; import {TimeInput} from '@astryxdesign/core/TimeInput'; -import {Tokenizer} from '@astryxdesign/core/Tokenizer'; import {Typeahead} from '@astryxdesign/core/Typeahead'; import type {SearchableItem, SearchSource} from '@astryxdesign/core/Typeahead'; import type {ISODateString, ISOTimeString} from '@astryxdesign/core/utils'; import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden'; import {spacingVars} from '@astryxdesign/core/theme/tokens.stylex'; +import {MobileTokenizer} from '../MobileTokenizer'; import { isoDateTimeToUnixSeconds, isoDateToUnixSeconds, @@ -64,6 +69,11 @@ export interface MobilePowerSearchValueEditorProps { filterValue: FilterValue | undefined; /** Stage a value without leaving the sheet. */ onChange: (value: FilterValue) => void; + /** + * The field's name. Titles the sheet a list value's tokenizer opens, so it + * reads as the field being edited. + */ + label?: string; isDisabled?: boolean; /** Max suggestions in string and entity value typeaheads. */ maxMenuItems?: number; @@ -200,6 +210,7 @@ export function MobilePowerSearchValueEditor({ operatorValue, filterValue, onChange, + label, isDisabled = false, maxMenuItems, timezoneID, @@ -292,8 +303,8 @@ export function MobilePowerSearchValueEditor({ operatorValue.isArbitraryStringAllowed || !operatorValue.searchSource; return (
-
); @@ -528,8 +538,8 @@ export function MobilePowerSearchValueEditor({ : []; return (
-
); diff --git a/packages/lab/src/MobileTokenizer/MobileTokenizer.doc.mjs b/packages/lab/src/MobileTokenizer/MobileTokenizer.doc.mjs index 9b5124a610bf9..e8f0b70d49159 100644 --- a/packages/lab/src/MobileTokenizer/MobileTokenizer.doc.mjs +++ b/packages/lab/src/MobileTokenizer/MobileTokenizer.doc.mjs @@ -8,9 +8,11 @@ export const docs = { keywords: ['tokenizer', 'mobile', 'touch', 'bottom sheet', 'tags', 'chips'], props: [ {name: 'label', type: 'string', description: 'Field label and sheet heading.', required: true}, + {name: 'isLabelHidden', type: 'boolean', description: 'Hide the visible label; the trigger and the sheet keep it as their accessible name.', default: 'false'}, {name: 'searchSource', type: 'SearchSource', description: 'Core Typeahead search source (search + bootstrap).', required: true}, {name: 'value', type: 'T[]', description: 'Selected items (Core SearchableItem).', required: true}, {name: 'onChange', type: '(items: T[], change: MobileTokenizerChange) => void', description: 'Mirrors Core Tokenizer: single-item add/create/remove, or reorder.', required: true}, + {name: 'renderItem', type: '(item: T) => ReactNode', description: 'Custom content for a result row, shown beside its checkbox. Default: the item label.'}, {name: 'placeholder', type: 'string', description: 'Trigger text when nothing is selected.'}, {name: 'hasCreate', type: 'boolean', description: 'Offer Create "" for unmatched free text.', default: 'false'}, {name: 'maxEntries', type: 'number', description: 'Cap selections; add rows disable at the cap.'}, diff --git a/packages/lab/src/MobileTokenizer/MobileTokenizer.test.tsx b/packages/lab/src/MobileTokenizer/MobileTokenizer.test.tsx index aad635abca0d7..0a531a39a48bb 100644 --- a/packages/lab/src/MobileTokenizer/MobileTokenizer.test.tsx +++ b/packages/lab/src/MobileTokenizer/MobileTokenizer.test.tsx @@ -340,6 +340,33 @@ describe('MobileTokenizer (Lab, single-sheet flow)', () => { ); }); + it('hides the visible label on request while the trigger and the sheet keep it as their name, and renders rows through renderItem', async () => { + render( + {}} + renderItem={item => {item.label}!} + />, + ); + const trigger = screen.getByRole('button', {name: 'Tags'}); + // The Field's label is clipped out of the layout (sr-only), as Core + // Tokenizer's is, while the trigger keeps it as its name. + const label = screen.getByText('Tags', {selector: 'label'}); + expect(getComputedStyle(label).position).toBe('absolute'); + expect(getComputedStyle(label).clip).toMatch( + /^rect\(0(px)?, 0(px)?, 0(px)?, 0(px)?\)$/, + ); + expect(trigger).toHaveAccessibleName('Tags'); + fireEvent.click(trigger); + expect(await screen.findByRole('dialog')).toHaveAccessibleName('Tags'); + const row = await screen.findByRole('checkbox', {name: 'Design'}); + expect(within(row).getByTestId('custom-row')).toHaveTextContent('Design!'); + }); + it('keeps a disabled trigger focusable without opening the sheet (AST-085 FR1, FR2)', () => { render( extends Omit< 'onChange' > { label: string; + /** + * Hide the visible field label; the trigger and the sheet keep `label` as + * their accessible name. Mirrors Core Tokenizer. + */ + isLabelHidden?: boolean; searchSource: SearchSource; value: T[]; onChange: (items: T[], change: MobileTokenizerChange) => void; + /** + * Render function for a result row's content, shown beside the row's + * checkbox. Default: the item's label. Mirrors Core Tokenizer. + */ + renderItem?: (item: T) => ReactNode; placeholder?: string; description?: string; isOptional?: boolean; @@ -250,9 +261,11 @@ const styles = stylex.create({ export function MobileTokenizer({ label, + isLabelHidden, searchSource, value, onChange, + renderItem, placeholder, description, isOptional, @@ -407,6 +420,7 @@ export function MobileTokenizer({ return ( ({ rowDisabled && styles.rowDisabled, )}> - - {item.label} - + {renderItem ? ( + renderItem(item) + ) : ( + + {item.label} + + )}