Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
],
},
Expand Down
100 changes: 92 additions & 8 deletions packages/lab/src/MobilePowerSearch/MobilePowerSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('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.
Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -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(
[
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/lab/src/MobilePowerSearch/MobilePowerSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1647,6 +1647,7 @@ export function MobilePowerSearch({
operatorValue={draftOperator.value}
filterValue={draft.value}
onChange={handleDraftValueChange}
label={draftField.label}
isDisabled={isEditorDisabled}
maxMenuItems={maxOperatorMenuItems}
timezoneID={timezoneID}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -200,6 +210,7 @@ export function MobilePowerSearchValueEditor({
operatorValue,
filterValue,
onChange,
label,
isDisabled = false,
maxMenuItems,
timezoneID,
Expand Down Expand Up @@ -292,8 +303,8 @@ export function MobilePowerSearchValueEditor({
operatorValue.isArbitraryStringAllowed || !operatorValue.searchSource;
return (
<div {...stylex.props(styles.input)}>
<Tokenizer
label="Values"
<MobileTokenizer
label={label ?? 'Values'}
isLabelHidden
searchSource={operatorValue.searchSource ?? EMPTY_SOURCE}
value={current}
Expand All @@ -308,7 +319,6 @@ export function MobilePowerSearchValueEditor({
hasCreate={hasCreate}
maxMenuItems={maxMenuItems}
isDisabled={isDisabled}
width="100%"
/>
</div>
);
Expand Down Expand Up @@ -528,8 +538,8 @@ export function MobilePowerSearchValueEditor({
: [];
return (
<div {...stylex.props(styles.input)}>
<Tokenizer
label="Entities"
<MobileTokenizer
label={label ?? 'Entities'}
isLabelHidden
searchSource={operatorValue.searchSource ?? EMPTY_SOURCE}
value={current}
Expand All @@ -552,7 +562,6 @@ export function MobilePowerSearchValueEditor({
debounceMs={operatorValue.searchSource ? 150 : 0}
maxMenuItems={maxMenuItems}
isDisabled={isDisabled}
width="100%"
/>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/lab/src/MobileTokenizer/MobileTokenizer.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>', 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<T>) => 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 "<query>" for unmatched free text.', default: 'false'},
{name: 'maxEntries', type: 'number', description: 'Cap selections; add rows disable at the cap.'},
Expand Down
27 changes: 27 additions & 0 deletions packages/lab/src/MobileTokenizer/MobileTokenizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MobileTokenizer
label="Tags"
isLabelHidden
searchSource={source}
value={[]}
debounceMs={0}
onChange={() => {}}
renderItem={item => <em data-testid="custom-row">{item.label}!</em>}
/>,
);
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(
<MobileTokenizer
Expand Down
32 changes: 25 additions & 7 deletions packages/lab/src/MobileTokenizer/MobileTokenizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
* @position Lab (canary) stack layer 1: validates the design before the
* Core promotion (Tokenizer presentation="bottom-sheet").
*
* API mirrors Core Tokenizer (label/searchSource/value/onChange/change,
* hasCreate, maxEntries) so graduation is an import swap. Differences from
* API mirrors Core Tokenizer (label/isLabelHidden/searchSource/value/onChange/
* change, renderItem, hasCreate, maxEntries) so graduation is an import swap. Differences from
* Core are the Lab deltas: the filter is a bordered TextInput (Core's
* PanelSearchInput is not exported), strings are literals (no i18n keys),
* and there is no theming/spec contract yet.
Expand All @@ -26,6 +26,7 @@ import {
useMemo,
useRef,
useState,
type ReactNode,
type SVGProps,
} from 'react';
import * as stylex from '@stylexjs/stylex';
Expand Down Expand Up @@ -60,9 +61,19 @@ export interface MobileTokenizerProps<T extends SearchableItem> 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<T>;
value: T[];
onChange: (items: T[], change: MobileTokenizerChange<T>) => 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;
Expand Down Expand Up @@ -250,9 +261,11 @@ const styles = stylex.create({

export function MobileTokenizer<T extends SearchableItem>({
label,
isLabelHidden,
searchSource,
value,
onChange,
renderItem,
placeholder,
description,
isOptional,
Expand Down Expand Up @@ -407,6 +420,7 @@ export function MobileTokenizer<T extends SearchableItem>({
return (
<Field
label={label}
isLabelHidden={isLabelHidden}
description={description}
isOptional={isOptional}
isDisabled={isDisabled}
Expand Down Expand Up @@ -570,11 +584,15 @@ export function MobileTokenizer<T extends SearchableItem>({
rowDisabled && styles.rowDisabled,
)}>
<span {...stylex.props(styles.rowLabel)}>
<Text
type="body"
color={rowDisabled ? 'inherit' : undefined}>
{item.label}
</Text>
{renderItem ? (
renderItem(item)
) : (
<Text
type="body"
color={rowDisabled ? 'inherit' : undefined}>
{item.label}
</Text>
)}
</span>
<CheckboxIndicator
state={isSelected ? 'checked' : 'unchecked'}
Expand Down
Loading