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} + + )}