diff --git a/.changeset/tokenizer-touch-surface.md b/.changeset/tokenizer-touch-surface.md new file mode 100644 index 000000000000..8d20370996e0 --- /dev/null +++ b/.changeset/tokenizer-touch-surface.md @@ -0,0 +1,57 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Tokenizer fits the pointer: a scrolling chip row and a suggestion sheet +on a finger, the inline field on a mouse + +`Tokenizer` has always been a control for a mouse. You type _between_ the +chips, in an input that shares a line with them, and you remove the last one +with Backspace on an empty input. Both of those need a hardware keyboard. On a +phone, focusing that input raises the virtual keyboard over the bottom half of +the screen — which is where the suggestion popover opens — and every chip added +grows the field by a line and pushes the page under your thumb. + +The same component now renders a second surface where the primary pointer is a +finger (`pointer: coarse`): the chips sit on one sideways-scrolling line, so +the field is exactly one line tall however many there are and nothing below it +moves; an Add button at the trailing edge, outside the scroller so it is in the +same place with two chips or twenty, opens a pinned-tall sheet; and the sheet +puts its search field at the top where the keyboard cannot cover it, over +full-width rows a thumb can hit. Tapping a row adds that token and leaves the +sheet up for the next one, so building a set of five is five taps. The list is +populated before anything is typed — in a sheet the list is the content, and a +search box over an empty pane is a dead end. + +Nothing changes at the call site. It is one component with two surfaces, not +two components — same props, same values, no new import, no media query to +write. With a mouse the rendered output is the control that was always there. + +The switch is the pointer alone, deliberately with no width bound. `pointer` +means the PRIMARY device, so a touchscreen laptop reports `fine` and keeps the +typable field (its keyboard is right there), while a narrowed desktop window is +still a mouse. Adding a width test would only re-exclude tablets, which have +the same thumb and more room to use it. + +Every prop keeps its meaning, and the two that gain one say so in the docs: +`placeholder` also becomes the sheet search field's placeholder, so write it as +a search hint; `maxEntries` disables Add at the cap, and the token that reaches +it closes the sheet. `hasCreate`, `renderItem`, `renderToken`, `maxMenuItems`, +`debounceMs`, `htmlName`, `hasClear`, `status`, and the disabled-reason tooltip +all behave as they do on the pointer surface, because the selection logic is +now one shared hook rather than two copies — including the "Create X" sentinel, +which is the part that must not fork. + +The public surface barely moves: one new export, `TokenizerTouchSurface`, the +touch half with the pointer test skipped, so a Storybook story or a +handset-only app can render it directly, plus three `@astryx.tokenizer.*` +catalog keys for the Add button and the sheet's search field. The plus glyph is +drawn in the component rather than registered as an icon name — it is +structural, the way CheckboxIndicator draws its own tick, and registering a +name would put every theme on the hook for an icon. + +Costs 16.4 KB gzipped on top of Tokenizer's 58.6 KB — mostly BottomSheet and +List, which most apps already ship — for every consumer including desktop-only +ones, since the choice is made at runtime. + +@imdreamrunner diff --git a/.github/a11y-baseline.json b/.github/a11y-baseline.json index 644230e18778..0e13369664f5 100644 --- a/.github/a11y-baseline.json +++ b/.github/a11y-baseline.json @@ -1053,6 +1053,11 @@ "impact": "serious", "helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast?application=playwright" }, + { + "key": "Tokenizer::Touch: disabled, with a reason::color-contrast", + "impact": "serious", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast?application=playwright" + }, { "key": "Toolbar::Composition: Tab Navigation::landmark-unique", "impact": "moderate", diff --git a/apps/storybook/stories/Tokenizer.stories.tsx b/apps/storybook/stories/Tokenizer.stories.tsx index 3c7bdeb450ff..d185b830589e 100644 --- a/apps/storybook/stories/Tokenizer.stories.tsx +++ b/apps/storybook/stories/Tokenizer.stories.tsx @@ -1,10 +1,13 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. import {useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; import type {Meta, StoryObj} from '@storybook/react'; -import {Tokenizer} from '@astryxdesign/core/Tokenizer'; +import {Tokenizer, TokenizerTouchSurface} from '@astryxdesign/core/Tokenizer'; import type {SearchableItem, SearchSource} from '@astryxdesign/core/Typeahead'; +import {Banner} from '@astryxdesign/core/Banner'; import {Button} from '@astryxdesign/core/Button'; +import {useMediaQuery} from '@astryxdesign/core/hooks'; import {MagnifyingGlassIcon} from '@heroicons/react/24/outline'; // Sample data @@ -25,6 +28,41 @@ const userSource: SearchSource = { bootstrap: () => users.slice(0, 5), }; +// A longer list for the touch stories, where the sheet shows the whole source +// before anything is typed and a short one would not fill it. +const skills: SearchableItem[] = [ + {id: 'react', label: 'React'}, + {id: 'typescript', label: 'TypeScript'}, + {id: 'stylex', label: 'StyleX'}, + {id: 'node', label: 'Node'}, + {id: 'graphql', label: 'GraphQL'}, + {id: 'rust', label: 'Rust'}, + {id: 'go', label: 'Go'}, + {id: 'python', label: 'Python'}, + {id: 'swift', label: 'Swift'}, + {id: 'kotlin', label: 'Kotlin'}, + {id: 'figma', label: 'Figma'}, + {id: 'docker', label: 'Docker'}, +]; + +const skillSource: SearchSource = { + search: (query: string) => + skills.filter(s => s.label.toLowerCase().includes(query.toLowerCase())), + bootstrap: () => skills, +}; + +const touchStyles = stylex.create({ + // A handset's width, so the touch stories read at the size they were + // designed for even in a desktop browser. + phone: { + width: 390, + maxWidth: '100%', + display: 'flex', + flexDirection: 'column', + gap: 16, + }, +}); + const meta: Meta = { title: 'Core/Tokenizer', component: Tokenizer, @@ -428,7 +466,8 @@ export const StatusVariantComparison: Story = { const [a, setA] = useState([]); const [b, setB] = useState([]); return ( -
+
{ + const [value, setValue] = useState([ + skills[0], + skills[1], + ]); + // Report the surface actually on screen, rather than assuming a desktop. + const isTouch = useMediaQuery('(pointer: coarse)'); + return ( +
+ + setValue(items)} + placeholder="Search skills" + width="100%" + /> +
+ ); + }, +}; + +export const TouchDefault: Story = { + name: 'Touch: default', + parameters: { + docs: { + description: { + story: + 'Three chips and the Add button. Tap Add to open the suggestion ' + + 'sheet; tapping a row adds that token and leaves the sheet up for ' + + 'the next one.', + }, + }, + }, + render: () => { + const [value, setValue] = useState([ + skills[0], + skills[1], + skills[2], + ]); + return ( +
+ setValue(items)} + placeholder="Search skills" + width="100%" + /> +
+ ); + }, +}; + +export const TouchManyTokens: Story = { + name: 'Touch: more tokens than fit', + parameters: { + docs: { + description: { + story: + 'The chips scroll sideways within the field. The field stays ' + + 'exactly one line tall however many there are, so adding and ' + + 'removing never reflows the form below it, and Add stays put at ' + + 'the trailing edge rather than scrolling away with the chips.', + }, + }, + }, + render: () => { + const [value, setValue] = useState(skills.slice(0, 8)); + return ( +
+ setValue(items)} + placeholder="Search skills" + hasClear + width="100%" + /> +
+ ); + }, +}; + +export const TouchEmpty: Story = { + name: 'Touch: nothing selected', + parameters: { + docs: { + description: { + story: + 'With no tokens the placeholder holds the line. It doubles as the ' + + "sheet's search placeholder, so write it as a search hint.", + }, + }, + }, + render: () => { + const [value, setValue] = useState([]); + return ( +
+ setValue(items)} + placeholder="Search skills" + width="100%" + /> +
+ ); + }, +}; + +export const TouchCreatable: Story = { + name: 'Touch: free-text tags', + parameters: { + docs: { + description: { + story: + 'With `hasCreate`, typing something the source does not have puts ' + + 'a Create row at the end of the list. The keyboard\u2019s return ' + + 'key commits it too.', + }, + }, + }, + render: () => { + const [value, setValue] = useState([]); + return ( +
+ setValue(items)} + placeholder="Search or add a tag" + hasCreate + width="100%" + /> +
+ ); + }, +}; + +export const TouchBounded: Story = { + name: 'Touch: capped at maxEntries', + parameters: { + docs: { + description: { + story: + 'Add is disabled once the cap is reached, and the token that ' + + 'reaches it closes the sheet: there is nothing left to offer.', + }, + }, + }, + render: () => { + const [value, setValue] = useState([skills[0]]); + return ( +
+ setValue(items)} + placeholder="Search skills" + maxEntries={3} + width="100%" + /> +
+ ); + }, +}; + +export const TouchStatus: Story = { + name: 'Touch: validation status', + parameters: { + docs: { + description: { + story: + 'Status, description, and required treatment come from the same ' + + 'Field wrapper the pointer surface uses, so they look and behave ' + + 'identically on both.', + }, + }, + }, + render: () => { + const [value, setValue] = useState([]); + return ( +
+ setValue(items)} + placeholder="Search skills" + isRequired + status={{type: 'error', message: 'Pick at least one skill'}} + width="100%" + /> +
+ ); + }, +}; + +export const TouchDisabled: Story = { + name: 'Touch: disabled, with a reason', + parameters: { + docs: { + description: { + story: + 'With `disabledMessage` the Add button stays focusable under ' + + '`aria-disabled` so the reason is reachable by keyboard and by ' + + 'tap, while the sheet stays shut.', + }, + }, + }, + render: () => ( +
+ {}} + placeholder="Search skills" + isDisabled + disabledMessage="Ask an admin to unlock this field" + width="100%" + /> +
+ ), +}; diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index 6a8b94d5d2ff..cfa0d01d2ed1 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -619,6 +619,18 @@ "defaultMessage": "Clear all", "description": "Label on the \"×\" button that removes every token/chip from a Tokenizer input. Imperative verb + determiner \"all\"; short." }, + "@astryx.tokenizer.add": { + "defaultMessage": "Add", + "description": "Visible text on the button at the end of a Tokenizer's token row on a touch device; it opens a sheet of suggestions to pick from. Imperative verb; very short, it sits inside the field." + }, + "@astryx.tokenizer.addTokens": { + "defaultMessage": "Add {label}", + "description": "Accessible name of that Add button, and the heading of the sheet it opens. `{label}` is the field's own label, e.g. `Add Team members`. Must contain the button's visible text (\"Add\") as its first word." + }, + "@astryx.tokenizer.searchLabel": { + "defaultMessage": "Search", + "description": "Screen-reader-only label on the search field at the top of a Tokenizer's suggestion sheet on a touch device. Imperative verb; the visible hint is the placeholder beside it." + }, "@astryx.topNav.heading.openMenu": { "defaultMessage": "Open menu", "description": "Screen-reader-only label on the `⋯` overflow button in a TopNav section heading. Kept separate from `sideNav.heading.openMenu` so translations may diverge." diff --git a/packages/core/src/Tokenizer/Tokenizer.doc.mjs b/packages/core/src/Tokenizer/Tokenizer.doc.mjs index f9779566b819..dc4624660ae4 100644 --- a/packages/core/src/Tokenizer/Tokenizer.doc.mjs +++ b/packages/core/src/Tokenizer/Tokenizer.doc.mjs @@ -6,7 +6,7 @@ export const docs = { name: 'Tokenizer', displayName: 'Tokenizer', category: 'Data Input', - keywords: ["tokenizer","multiselect","multi-select","chips","tags","combobox","autocomplete","taginput","chipinput"], + keywords: ["tokenizer","multiselect","multi-select","chips","tags","combobox","autocomplete","taginput","chipinput","mobile","touch","bottomsheet"], props: [ { name: 'label', @@ -38,7 +38,7 @@ export const docs = { name: 'placeholder', type: 'string', description: - 'Input placeholder text. Only shown when no tokens are selected.', + 'Input placeholder text. Only shown when no tokens are selected. On a touch device it also becomes the placeholder of the sheet\'s search field, so write it as a search hint ("Search skills").', }, { name: 'maxEntries', @@ -215,7 +215,7 @@ export const docs = { }, usage: { description: - 'Tokenizer is a multi-select input that lets users search, select, and manage multiple items displayed as removable chips. Use it when users need to build a set of selections from a searchable data source, like adding team members, applying tags, or choosing filters.', + 'Tokenizer is a multi-select input that lets users search, select, and manage multiple items displayed as removable chips. Use it when users need to build a set of selections from a searchable data source, like adding team members, applying tags, or choosing filters. It renders two surfaces and picks between them at runtime: with a mouse, chips that wrap around an inline text input with a suggestion popover; where the primary pointer is a finger, chips on one sideways-scrolling line plus an Add button that opens a full-height sheet of suggestions. Nothing at the call site changes, and the value is the same either way.', bestPractices: [ {guidance: true, description: 'Write a placeholder that tells users what they can search for, such as "Search people..." or "Add tags...", so the input is not a blank mystery.'}, {guidance: true, description: 'Set maxEntries when the number of selections should be bounded, like limiting a review to 5 approvers.'}, @@ -225,12 +225,15 @@ export const docs = { {guidance: false, description: 'Avoid applying custom colors to individual tokens inside a Tokenizer; use the default token style for visual consistency across the set.'}, {guidance: false, description: 'Don\'t hide the label; every Tokenizer needs a visible label so users understand what they are selecting. Use isLabelHidden only when surrounding context makes the purpose obvious.'}, {guidance: false, description: 'Wrap a disabled Tokenizer in Tooltip to explain why it is disabled; disabled controls swallow the hover events the wrapper needs. Use the disabledMessage prop instead.'}, + {guidance: false, description: 'Don\'t branch on a media query at the call site to render a mobile alternative; Tokenizer already chooses its surface from the primary pointer, and a second switch outside it will disagree with the one inside.'}, + {guidance: false, description: 'Avoid renderToken output that only works in a wrapping row, such as a chip with a second line; on touch the chips sit on a single scrolling line whose height is fixed.'}, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text above the input describing what the user is selecting. Also used as the accessible name.'}, {name: 'Token chips', required: false, description: 'Removable chips representing each selected item. Each chip shows a label and a remove button.'}, - {name: 'Search input', required: true, description: 'The text input where users type to search the data source. Hides when maxEntries is reached.'}, - {name: 'Dropdown menu', required: false, description: 'The search results list that appears below the input as the user types.'}, + {name: 'Search input', required: true, description: 'The text input where users type to search the data source. Inline among the chips with a mouse, and at the top of the suggestion sheet on touch. Hides when maxEntries is reached.'}, + {name: 'Dropdown menu', required: false, description: 'The search results list that appears below the input as the user types. On touch it is the sheet\'s list of full-width rows instead, populated before anything is typed.'}, + {name: 'Add button', required: false, description: 'Touch only. Sits at the trailing edge of the token row, outside the scroller so it stays in place, and opens the suggestion sheet. Disabled once maxEntries is reached.'}, {name: 'End content', required: false, description: 'A trailing slot after the input for action buttons, counts, or other controls.'}, {name: 'Clear button', required: false, description: 'A button that removes all selected tokens at once. Shown when hasClear is true and tokens are present.'}, ], @@ -272,7 +275,8 @@ export const docsZh = { name: 'placeholder', type: 'string', description: - '\u8f93\u5165\u6846\u5360\u4f4d\u6587\u672c\u3002\u4ec5\u5728\u672a\u9009\u62e9\u4efb\u4f55\u6807\u8bb0\u65f6\u663e\u793a\u3002', + '\u8f93\u5165\u6846\u5360\u4f4d\u6587\u672c\u3002\u4ec5\u5728\u672a\u9009\u62e9\u4efb\u4f55\u6807\u8bb0\u65f6\u663e\u793a\u3002' + + '\u5728\u89e6\u63a7\u8bbe\u5907\u4e0a\uff0c\u5b83\u540c\u65f6\u4f5c\u4e3a\u5e95\u90e8\u5f39\u51fa\u9762\u677f\u4e2d\u641c\u7d22\u6846\u7684\u5360\u4f4d\u6587\u672c\uff0c\u56e0\u6b64\u8bf7\u5199\u6210\u641c\u7d22\u63d0\u793a\u3002', }, { name: 'maxEntries', @@ -429,7 +433,7 @@ export const docsZh = { }, usage: { description: - 'Tokenizer is a multi-select input that lets users search, select, and manage multiple items displayed as removable chips. Use it when users need to build a set of selections from a searchable data source, like adding team members, applying tags, or choosing filters.', + 'Tokenizer is a multi-select input that lets users search, select, and manage multiple items displayed as removable chips. Use it when users need to build a set of selections from a searchable data source, like adding team members, applying tags, or choosing filters. It renders two surfaces and picks between them at runtime: with a mouse, chips that wrap around an inline text input with a suggestion popover; where the primary pointer is a finger, chips on one sideways-scrolling line plus an Add button that opens a full-height sheet of suggestions. Nothing at the call site changes, and the value is the same either way.', bestPractices: [ {guidance: true, description: 'Write a placeholder that tells users what they can search for, such as "Search people..." or "Add tags...", so the input is not a blank mystery.'}, {guidance: true, description: 'Set maxEntries when the number of selections should be bounded, like limiting a review to 5 approvers.'}, @@ -439,12 +443,15 @@ export const docsZh = { {guidance: false, description: 'Avoid applying custom colors to individual tokens inside a Tokenizer; use the default token style for visual consistency across the set.'}, {guidance: false, description: 'Don\'t hide the label; every Tokenizer needs a visible label so users understand what they are selecting. Use isLabelHidden only when surrounding context makes the purpose obvious.'}, {guidance: false, description: 'Wrap a disabled Tokenizer in Tooltip to explain why it is disabled; disabled controls swallow the hover events the wrapper needs. Use the disabledMessage prop instead.'}, + {guidance: false, description: 'Don\'t branch on a media query at the call site to render a mobile alternative; Tokenizer already chooses its surface from the primary pointer, and a second switch outside it will disagree with the one inside.'}, + {guidance: false, description: 'Avoid renderToken output that only works in a wrapping row, such as a chip with a second line; on touch the chips sit on a single scrolling line whose height is fixed.'}, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text above the input describing what the user is selecting. Also used as the accessible name.'}, {name: 'Token chips', required: false, description: 'Removable chips representing each selected item. Each chip shows a label and a remove button.'}, - {name: 'Search input', required: true, description: 'The text input where users type to search the data source. Hides when maxEntries is reached.'}, - {name: 'Dropdown menu', required: false, description: 'The search results list that appears below the input as the user types.'}, + {name: 'Search input', required: true, description: 'The text input where users type to search the data source. Inline among the chips with a mouse, and at the top of the suggestion sheet on touch. Hides when maxEntries is reached.'}, + {name: 'Dropdown menu', required: false, description: 'The search results list that appears below the input as the user types. On touch it is the sheet\'s list of full-width rows instead, populated before anything is typed.'}, + {name: 'Add button', required: false, description: 'Touch only. Sits at the trailing edge of the token row, outside the scroller so it stays in place, and opens the suggestion sheet. Disabled once maxEntries is reached.'}, {name: 'End content', required: false, description: 'A trailing slot after the input for action buttons, counts, or other controls.'}, {name: 'Clear button', required: false, description: 'A button that removes all selected tokens at once. Shown when hasClear is true and tokens are present.'}, ], @@ -453,10 +460,10 @@ export const docsZh = { /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */ export const docsDense = { - description: 'Multi-select typeahead w/ token chips for selected items. Composes BaseTypeahead for search+Token for chips.', + description: 'Multi-select typeahead w/ token chips for selected items. Composes BaseTypeahead for search+Token for chips. Two surfaces, chosen at runtime from the primary pointer: inline input+popover on a mouse, h-scrolling chip row+Add button+tall suggestion sheet on touch.', usage: { description: - 'Multi-select input for searching and selecting multiple items as removable chips. Use for team members, tags, filters, or any set built from a searchable source.', + 'Multi-select input for searching and selecting multiple items as removable chips. Use for team members, tags, filters, or any set built from a searchable source. Picks its own surface from the pointer; same props, same value on both.', bestPractices: [ {guidance: true, description: 'Placeholder that communicates what to search, such as "Search people..." rather than blank.'}, {guidance: true, description: 'maxEntries when selections are bounded (e.g. 5 approvers max).'}, @@ -466,6 +473,8 @@ export const docsDense = { {guidance: false, description: 'Avoid custom token colors; default style for consistency.'}, {guidance: false, description: 'Don\'t hide the label unless context makes purpose obvious.'}, {guidance: false, description: 'Wrap a disabled Tokenizer in Tooltip to explain why it is disabled; disabled controls swallow the hover events the wrapper needs. Use the disabledMessage prop instead.'}, + {guidance: false, description: 'Don\'t media-query a mobile alternative at the call site; the component already switches on pointer: coarse.'}, + {guidance: false, description: 'Avoid renderToken output needing a wrapping row (e.g. two-line chips); touch puts chips on one fixed-height scrolling line.'}, ], }, propDescriptions: { @@ -474,7 +483,7 @@ export const docsDense = { value: 'Array of currently selected items.', onChange: "Fired on selection change. Change arg includes affected item+type ('add'|'create'|'remove'|'reorder').", hasCreate: 'Enable free-text token creation. Shows "Create" dropdown option for unmatched typed text.', - placeholder: 'Input placeholder. Only shown when no tokens selected.', + placeholder: 'Input placeholder. Only shown when no tokens selected. Touch: also the sheet search field\'s placeholder.', maxEntries: 'Max selections allowed. Input hidden at limit.', hasClear: 'Clear-all button for bulk removal.', renderToken: 'Custom token render. Default renders Token w/ label+onRemove.', diff --git a/packages/core/src/Tokenizer/Tokenizer.tsx b/packages/core/src/Tokenizer/Tokenizer.tsx index 2f825b8fe7ab..652972bfe0f2 100644 --- a/packages/core/src/Tokenizer/Tokenizer.tsx +++ b/packages/core/src/Tokenizer/Tokenizer.tsx @@ -4,13 +4,15 @@ /** * @file Tokenizer.tsx - * @input Uses React, BaseTypeahead, Field, Token, useAnnounce - * @output Exports Tokenizer multi-select typeahead component + * @input Uses React, BaseTypeahead, Field, Token, useTokenSelection, useMediaQuery + * @output Exports Tokenizer multi-select typeahead component and its two surfaces * @position Composed component; forwards DOM ref and exposes focus control via * handleRef * * SYNC: When modified, update: * - /packages/core/src/Tokenizer/index.ts + * - /packages/core/src/Tokenizer/TouchTokenizerField.tsx + * - /packages/core/src/Tokenizer/Tokenizer.doc.mjs * - /apps/storybook/stories/Tokenizer.stories.tsx * - /packages/cli/assets/templates/blocks/components/Tokenizer/ (showcase blocks) */ @@ -19,7 +21,6 @@ import React, { useCallback, useId, useImperativeHandle, - useMemo, useRef, useState, type ReactNode, @@ -43,8 +44,8 @@ import {Token} from '../Token'; import {renderIconSlot, type IconType} from '../Icon'; import {OverflowList} from '../OverflowList'; import {useLayer} from '../Layer/useLayer'; +import {useMediaQuery} from '../hooks/useMediaQuery'; import {useTooltip} from '../Tooltip'; -import {useAnnounce} from '../hooks/useAnnounce'; import { colorVars, spacingVars, @@ -55,6 +56,8 @@ import type {SearchableItem, SearchSource} from '../Typeahead/types'; import {mergeProps} from '../utils'; import {themeProps} from '../utils/themeProps'; import {useTranslator} from '../i18n'; +import {TouchTokenizerField} from './TouchTokenizerField'; +import {useTokenSelection} from './useTokenSelection'; // Re-export status types for convenience export type { @@ -329,49 +332,24 @@ const layerPlaceholderSizeStyles = stylex.create({ // Component // ============================================================================= -// Sentinel prefix for creatable items — used to distinguish -// "Create: X" suggestions from real search results. -const CREATABLE_ID_PREFIX = '__xds_create__'; - /** - * Multi-select input with token chips and typeahead search. - * - * Composes BaseTypeahead for search and Token for selected items. - * Tokens render inline before the text input. Selecting an item adds a token - * and clears the query. Backspace on empty input removes the last token. + * The pointer that decides which surface a `Tokenizer` renders. * - * @example - * ``` - * const [members, setMembers] = useState([]); - * { - * setMembers(items); - * if (change.type === 'add') { - * console.log('Added:', change.item.label); - * } - * }} - * placeholder="Search people..." - * /> - * setTags(items)} - * renderToken={(item, onRemove) => ( - * - * )} - * maxEntries={5} - * /> - * ``` + * `pointer: coarse` is the *primary* pointing device, which is what makes it + * the whole test. A touchscreen laptop reports `fine` (its trackpad) with + * `any-pointer: coarse` alongside, so it keeps the typable field — right, + * because its keyboard is there. A tablet reports `coarse` and gets the sheet, + * at any width. There is deliberately no width bound: it would only re-exclude + * the tablets, since a narrowed desktop window is still a mouse. */ -export function Tokenizer({ +const TOUCH_POINTER_QUERY = '(pointer: coarse)'; + +/** + * The pointer-driven field: tokens that wrap, with a text input among them and + * a suggestion menu in a popover. `Tokenizer` renders this whenever the primary + * pointer is not a finger. + */ +function PointerTokenizerField({ label, isLabelHidden = false, description, @@ -520,123 +498,15 @@ export function Tokenizer({ const isAtMax = maxEntries != null && value.length >= maxEntries; - // Filter out already-selected items from search results - const selectedIds = useMemo( - () => new Set(value.map(item => item.id)), - [value], - ); - - const filteredSource: SearchSource = useMemo( - () => ({ - search: async (query: string) => { - const results = await searchSource.search(query); - const filtered = results.filter(item => !selectedIds.has(item.id)); - - // Append a "Create: X" synthetic item when hasCreate is true, - // the user has typed something, and it doesn't exactly match an - // existing result. - if (hasCreate && query.trim()) { - const trimmed = query.trim(); - const alreadyExists = - selectedIds.has(trimmed) || - filtered.some( - item => item.label.toLowerCase() === trimmed.toLowerCase(), - ); - if (!alreadyExists) { - const creatableItem = { - id: `${CREATABLE_ID_PREFIX}${trimmed}`, - label: `Create "${trimmed}"`, - auxiliaryData: {__createdValue: trimmed}, - } as unknown as T; - filtered.push(creatableItem); - } - } - - return filtered; - }, - bootstrap: async () => { - const results = await searchSource.bootstrap(); - return results.filter(item => !selectedIds.has(item.id)); - }, - }), - [searchSource, selectedIds, hasCreate], - ); - - const emptySource: SearchSource = useMemo( - () => ({ - search: async () => [], - bootstrap: async () => [], - }), - [], - ); - - // Announce token add/remove politely via the persistent live region. - // Tokens previously appeared and disappeared silently — Backspace on an - // empty input removes the trailing token, and the per-token remove buttons - // gave no audible feedback either. - const announce = useAnnounce(); - - // Handle adding an item — detect creatable synthetic items - const handleAdd = useCallback( - (item: T | null) => { - if (!item) { - return; - } - if (isAtMax) { - return; - } - - // Detect "Create: X" synthetic items from the creatable source - if ( - hasCreate && - typeof item.id === 'string' && - item.id.startsWith(CREATABLE_ID_PREFIX) - ) { - const createdValue = item.id.slice(CREATABLE_ID_PREFIX.length); - if (selectedIds.has(createdValue)) { - return; - } - const base = {id: createdValue, label: createdValue}; - const realItem = base as T; - const newItems = [...value, realItem]; - onChange(newItems, {item: realItem, type: 'create'}); - announce(t('@astryx.tokenizer.tokenAdded', {label: createdValue})); - return; - } - - if (selectedIds.has(item.id)) { - return; - } - const newItems = [...value, item]; - onChange(newItems, {item, type: 'add'}); - announce(t('@astryx.tokenizer.tokenAdded', {label: item.label})); - }, - [value, onChange, isAtMax, selectedIds, hasCreate, announce, t], - ); - - // Handle removing an item. Single removal path: both Backspace on an empty - // input and the per-token remove buttons route through here, so the - // announcement covers both. - const handleRemove = useCallback( - (item: T) => { - const newItems = value.filter(v => v.id !== item.id); - onChange(newItems, {item, type: 'remove'}); - announce(t('@astryx.tokenizer.tokenRemoved', {label: item.label})); - inputRef.current?.focus(); - }, - [value, onChange, announce, t], - ); - - // Handle clearing all items - const handleClearAll = useCallback(() => { - if (value.length === 0) { - return; - } - // Report the last item as removed (convention) - const lastItem = value[value.length - 1]; - onChange([], {item: lastItem, type: 'remove'}); - inputRef.current?.focus(); - }, [value, onChange]); + const {filteredSource, emptySource, addItem, removeItem, clearAll} = + useTokenSelection({ + value, + onChange, + searchSource, + hasCreate, + maxEntries, + onAfterRemove: () => inputRef.current?.focus(), + }); // Handle backspace on empty input — remove last token const handleKeyDown = useCallback( @@ -648,10 +518,10 @@ export function Tokenizer({ ) { e.preventDefault(); const lastItem = value[value.length - 1]; - handleRemove(lastItem); + removeItem(lastItem); } }, - [value, handleRemove], + [value, removeItem], ); // Click wrapper to focus input @@ -683,7 +553,7 @@ export function Tokenizer({ // Render tokens const tokens = value.map(item => { - const onRemoveItem = () => handleRemove(item); + const onRemoveItem = () => removeItem(item); if (renderToken) { return ( @@ -770,7 +640,7 @@ export function Tokenizer({ ref={inputRef} searchSource={isAtMax ? emptySource : filteredSource} value={null} - onChange={handleAdd} + onChange={addItem} renderItem={renderItem} placeholder={value.length === 0 ? placeholder : ''} hasEntriesOnFocus={isAtMax ? false : hasEntriesOnFocus} @@ -814,7 +684,7 @@ export function Tokenizer({ label={t('@astryx.tokenizer.clearAll')} onClick={e => { e.stopPropagation(); - handleClearAll(); + clearAll(); }} /> )} @@ -923,4 +793,76 @@ export function Tokenizer({ ); } +PointerTokenizerField.displayName = 'PointerTokenizerField'; + +/** + * Multi-select input with token chips and search, in the shape the pointer + * calls for. + * + * With a mouse it is the control it has always been: tokens that wrap, a text + * input among them, and a suggestion menu in a popover. Selecting an item adds + * a token and clears the query; Backspace on an empty input removes the last + * one. + * + * Where the primary pointer is a finger it is a row of tokens that scrolls + * sideways and an Add button that opens a full-height sheet of suggestions. + * See {@link TouchTokenizerField} for why each of those parts is what it is. + * + * ## Why a runtime switch and not CSS + * + * The two surfaces are structurally different — an inline input with a popover + * versus a scrolling row with a sheet — so "render both, hide one" would double + * the DOM, double the tab stops, and run two searches. The condition is not + * layout either: it is *which interaction is possible*, since the desktop + * control's two core gestures (type between the tokens, Backspace to remove) + * both need a keyboard that a phone only shows by covering the field. + * + * ## Hydration + * + * `useMediaQuery` reports false during SSR, so server HTML is always the + * pointer field and the swap happens after hydration. Both surfaces render the + * same closed field — a bordered box of tokens under the same label — so what + * moves is small and what changes is what a tap does. + * + * @example + * ``` + * const [members, setMembers] = useState([]); + * { + * setMembers(items); + * if (change.type === 'add') { + * console.log('Added:', change.item.label); + * } + * }} + * placeholder="Search people..." + * /> + * setTags(items)} + * renderToken={(item, onRemove) => ( + * + * )} + * maxEntries={5} + * /> + * ``` + */ +export function Tokenizer(props: TokenizerProps) { + const isTouch = useMediaQuery(TOUCH_POINTER_QUERY); + + return isTouch ? ( + + ) : ( + + ); +} + Tokenizer.displayName = 'Tokenizer'; diff --git a/packages/core/src/Tokenizer/TokenizerTouch.test.tsx b/packages/core/src/Tokenizer/TokenizerTouch.test.tsx new file mode 100644 index 000000000000..cd40e9bcbc12 --- /dev/null +++ b/packages/core/src/Tokenizer/TokenizerTouch.test.tsx @@ -0,0 +1,503 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file TokenizerTouch.test.tsx + * @input Uses vitest, @testing-library/react, Tokenizer + * @output Behavior coverage for the touch surface and the surface switch + * @position Test file for /packages/core/src/Tokenizer/ + * + * Tokenizer.test.tsx covers the pointer surface; the shared test setup answers + * `(pointer: coarse)` with false, so every test in that file keeps hitting it. + * This file stubs `matchMedia` per test to reach the other one. + * + * The token row's one-line scrolling is CSS the browser resolves and jsdom + * does not lay out, so it is asserted on the style DEFINITION rather than a + * measurement — enough to fail loudly if someone deletes the property. + * + * SYNC: When TouchTokenizerField.tsx changes, update tests to match + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + beforeAll, +} from 'vitest'; +import {render, screen, waitFor, act} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {useState} from 'react'; +import {Tokenizer} from './Tokenizer'; +import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; +import type {SearchSource, SearchableItem} from '../Typeahead/types'; + +// --------------------------------------------------------------------------- +// jsdom scaffolding +// --------------------------------------------------------------------------- + +/** Matches the repo-wide setup polyfill, so hover-gated behavior still works. */ +const HOVER_CAPABLE = /\(\s*hover\s*:\s*hover\s*\)/; + +/** + * Answer media queries the way a given device would. + * + * Width queries are answered HONESTLY, so a width bound creeping into the + * surface switch fails a test rather than passing silently on a stub that + * ignores it. + */ +function stubMedia({ + pointer, + width, +}: { + pointer: 'coarse' | 'fine'; + width: number; +}): void { + vi.stubGlobal('matchMedia', (query: string) => { + const maxWidth = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query); + const minWidth = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query); + let matches: boolean; + if (/any-pointer:\s*coarse/.test(query)) { + matches = pointer === 'coarse'; + } else if (/pointer:\s*coarse/.test(query)) { + matches = pointer === 'coarse'; + } else if (/pointer:\s*fine/.test(query)) { + matches = pointer === 'fine'; + } else if (maxWidth) { + matches = width <= Number(maxWidth[1]); + } else if (minWidth) { + matches = width >= Number(minWidth[1]); + } else { + matches = HOVER_CAPABLE.test(query); + } + return { + matches, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }; + }); +} + +function setDevice(kind: 'phone' | 'desktop'): void { + stubMedia( + kind === 'phone' + ? {pointer: 'coarse', width: 393} + : {pointer: 'fine', width: 1280}, + ); +} + +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + globalThis.ResizeObserver = MockResizeObserver; +}); + +beforeEach(() => { + // jsdom implements neither open/close nor pointer capture. + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute('open', ''); + }); + HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute('open', ''); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute('open'); + }); + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + } + setDevice('phone'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + __resetLiveRegionsForTest(); +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +interface Skill extends SearchableItem { + id: string; + label: string; +} + +const SKILLS: Skill[] = [ + {id: 'react', label: 'React'}, + {id: 'typescript', label: 'TypeScript'}, + {id: 'stylex', label: 'StyleX'}, + {id: 'node', label: 'Node'}, + {id: 'graphql', label: 'GraphQL'}, +]; + +function makeSource(items: Skill[] = SKILLS): SearchSource { + return { + search: async (query: string) => + items.filter(i => i.label.toLowerCase().includes(query.toLowerCase())), + bootstrap: async () => items, + }; +} + +/** The field's Add button — the only control on the closed touch field. */ +function addButton(): HTMLElement { + return screen.getByRole('button', {name: /^Add /}); +} + +async function openSheet( + user: ReturnType, +): Promise { + return user.click(addButton()); +} + +function searchBox(): HTMLElement { + return screen.getByRole('textbox', {name: 'Search'}); +} + +const NO_TOKENS: Skill[] = []; + +interface HarnessProps { + initial?: Skill[]; + source?: SearchSource; + onChange?: (items: Skill[]) => void; + [key: string]: unknown; +} + +function Harness({ + initial = NO_TOKENS, + source, + onChange, + ...rest +}: HarnessProps) { + const [value, setValue] = useState(initial); + return ( + + label="Skills" + searchSource={source ?? makeSource()} + value={value} + onChange={items => { + setValue(items); + onChange?.(items); + }} + {...rest} + /> + ); +} + +// --------------------------------------------------------------------------- +// Which surface +// --------------------------------------------------------------------------- + +describe('surface switch', () => { + it('gives a mouse the typable field it has always had', () => { + setDevice('desktop'); + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /^Add /})).toBeNull(); + }); + + it('gives a finger the token row and an Add button', () => { + render(); + + expect(addButton()).toBeInTheDocument(); + // No inline input to type between the tokens: on this surface the search + // lives in the sheet. + expect(screen.queryByRole('combobox')).toBeNull(); + }); + + it('does not switch on width alone', () => { + stubMedia({pointer: 'fine', width: 380}); + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('switches on a tablet, which is wide and still a finger', () => { + stubMedia({pointer: 'coarse', width: 1194}); + render(); + + expect(addButton()).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// The closed field +// --------------------------------------------------------------------------- + +describe('the closed field', () => { + it('names the group with the field label', () => { + render(); + + expect(screen.getByRole('group', {name: 'Skills'})).toBeInTheDocument(); + }); + + it('shows a token per selection, each removable', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('TypeScript')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', {name: /Remove React/i})); + expect(onChange).toHaveBeenCalledWith([SKILLS[1]]); + }); + + it('keeps the tokens on one scrolling line, never wrapping', () => { + render(); + + const row = document.querySelector('[data-astryx-token-row]'); + expect(row).not.toBeNull(); + const declared = getComputedStyle(row as Element); + expect(declared.flexWrap).toBe('nowrap'); + expect(declared.overflowX).toBe('auto'); + }); + + it('shows the placeholder while nothing is selected', () => { + render(); + + expect(screen.getByText('Search skills')).toBeInTheDocument(); + }); + + it('carries the hidden inputs a form submission needs', () => { + render(); + + const hidden = document.querySelectorAll( + 'input[type="hidden"][name="skills"]', + ); + expect([...hidden].map(i => i.value)).toEqual(['react', 'typescript']); + }); + + it('clears every token at once when asked', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', {name: /clear all/i})); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('focuses the Add button through handleRef', () => { + const handleRef = {current: null} as React.RefObject<{ + focus(): void; + blur(): void; + } | null>; + render(); + + act(() => handleRef.current?.focus()); + expect(document.activeElement).toBe(addButton()); + }); +}); + +// --------------------------------------------------------------------------- +// The sheet +// --------------------------------------------------------------------------- + +describe('the suggestion sheet', () => { + it('opens on Add and offers the whole source before anything is typed', async () => { + const user = userEvent.setup(); + render(); + + await openSheet(user); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(searchBox()).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole('button', {name: 'GraphQL'})).toBeInTheDocument(), + ); + }); + + it('is pinned tall, so the keyboard cannot cover the search field', async () => { + const user = userEvent.setup(); + render(); + await openSheet(user); + + // 92dvh is BottomSheet's Tall budget, and Tall is its only + // keyboard-aware height. + const panel = document + .querySelector('dialog') + ?.querySelector('[style*="--_sheet-budget"]'); + expect(panel?.getAttribute('style')).toContain('92dvh'); + }); + + it('does not offer what is already selected', async () => { + const user = userEvent.setup(); + render(); + + await openSheet(user); + + await waitFor(() => + expect(screen.getByRole('button', {name: 'Node'})).toBeInTheDocument(), + ); + expect(screen.queryByRole('button', {name: 'React'})).toBeNull(); + }); + + it('adds the tapped suggestion and leaves the sheet up for the next one', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await openSheet(user); + + await user.click(await screen.findByRole('button', {name: 'React'})); + + expect(onChange).toHaveBeenCalledWith([SKILLS[0]]); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + // Gone from the list the moment it is picked, with no second search. + await waitFor(() => + expect(screen.queryByRole('button', {name: 'React'})).toBeNull(), + ); + }); + + it('searches the source as the user types', async () => { + const user = userEvent.setup(); + const source = makeSource(); + const search = vi.spyOn(source, 'search'); + render(); + await openSheet(user); + + await user.type(searchBox(), 'graph'); + + await waitFor(() => expect(search).toHaveBeenCalledWith('graph')); + await waitFor(() => + expect(screen.queryByRole('button', {name: 'React'})).toBeNull(), + ); + expect(screen.getByRole('button', {name: 'GraphQL'})).toBeInTheDocument(); + }); + + it('says so when a search finds nothing', async () => { + const user = userEvent.setup(); + render(); + await openSheet(user); + + await user.type(searchBox(), 'zzz'); + + expect(await screen.findByText('Nothing here')).toBeInTheDocument(); + }); + + it('commits free text as a new token when hasCreate is on', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await openSheet(user); + + await user.type(searchBox(), 'Zig'); + await user.click(await screen.findByRole('button', {name: /Create "Zig"/})); + + expect(onChange).toHaveBeenCalledWith([{id: 'Zig', label: 'Zig'}]); + }); + + it('takes Enter as the commit for free text', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await openSheet(user); + + await user.type(searchBox(), 'Zig'); + await screen.findByRole('button', {name: /Create "Zig"/}); + await user.keyboard('{Enter}'); + + expect(onChange).toHaveBeenCalledWith([{id: 'Zig', label: 'Zig'}]); + }); + + it('announces what a search turned up', async () => { + const user = userEvent.setup(); + render(); + await openSheet(user); + + await user.type(searchBox(), 'graph'); + + await waitFor(() => { + const polite = document.querySelector( + '[data-astryx-live-region="polite"]', + ); + expect(polite?.textContent).toMatch(/1 result/); + }); + }); + + // The sheet's header must cover the list scrolling under it AND stay under + // BottomSheet's grab handle. Both are z-order in one stacking context, so + // the header's layer is scoped by an isolated wrapper — which a browser + // showed the hard way: with a bare z-index the header hid the handle pill, + // and with none at all List's position: relative rows painted straight + // through the header's background. + it('layers the sticky header between the list and the grab handle', async () => { + const user = userEvent.setup(); + render(); + await openSheet(user); + + const header = (await screen.findByRole('heading', {name: 'Add Skills'})) + .parentElement as HTMLElement; + expect(getComputedStyle(header).position).toBe('sticky'); + expect(getComputedStyle(header).zIndex).toBe('1'); + expect(getComputedStyle(header.parentElement as Element).isolation).toBe( + 'isolate', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Limits and disabled state +// --------------------------------------------------------------------------- + +describe('limits', () => { + it('stops offering Add once maxEntries is reached', () => { + render(); + + expect(addButton()).toBeDisabled(); + }); + + it('closes the sheet on the token that reaches the limit', async () => { + const user = userEvent.setup(); + render(); + await openSheet(user); + + await user.click(await screen.findByRole('button', {name: 'Node'})); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(addButton()).toBeDisabled(); + }); + + it('opens nothing while disabled', async () => { + const user = userEvent.setup(); + render(); + + expect(addButton()).toBeDisabled(); + await user.click(addButton()); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('keeps the button focusable when there is a reason to show', async () => { + render(); + + const button = addButton(); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute('aria-disabled', 'true'); + + button.focus(); + expect(await screen.findByText('Ask an admin first')).toBeInTheDocument(); + }); + + it('blocks the sheet even though the button still takes focus', async () => { + const user = userEvent.setup(); + render(); + + await user.click(addButton()); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/packages/core/src/Tokenizer/TouchTokenizerField.tsx b/packages/core/src/Tokenizer/TouchTokenizerField.tsx new file mode 100644 index 000000000000..ed95067f1b4f --- /dev/null +++ b/packages/core/src/Tokenizer/TouchTokenizerField.tsx @@ -0,0 +1,723 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file TouchTokenizerField.tsx + * @input Uses React, Field, BottomSheet, List, TextInput, Token, Button, + * useTokenSelection + * @output Exports TouchTokenizerField — the touch surface behind Tokenizer + * @position Internal component; consumed by Tokenizer.tsx + * + * The touch half of `Tokenizer`, holding `Tokenizer`'s whole prop contract so + * the two are interchangeable. Everything field-shaped — the `Field` wrapper, + * the status treatment, the disabled-reason tooltip, `htmlName`'s hidden + * inputs — behaves exactly as it does on the pointer control. What changes is + * where you search and where the suggestions land. + * + * ## Why the pointer control cannot just be made bigger + * + * Its two core gestures both need a hardware keyboard. You type *between* the + * tokens, in an input that shares a line with them; and you remove the last + * one with Backspace on an empty input. On a phone, focusing that input raises + * the virtual keyboard over the bottom half of the screen, which is where the + * suggestion popover would open — and as each token is added the field grows a + * line and pushes the page under your thumb. + * + * ## Three ideas in the surface + * + * 1. The tokens scroll sideways instead of wrapping. A row of chips that wraps + * reflows the whole form every time one is added or removed; a row that + * scrolls stays exactly one line tall forever, so nothing below it moves. + * 2. Adding is a separate, fixed target. `Add` sits outside the scroller at the + * trailing edge, so it is in the same place with two tokens or twenty, and + * a tap on it can never be mistaken for the start of a sideways drag. + * 3. The suggestions are a sheet, not a popover, and the sheet is pinned tall. + * Its search field is at the top where the keyboard cannot cover it, the + * results are full-width rows a thumb can hit, and `tall` is the one height + * BottomSheet keeps clear of the keyboard. + * + * ## The two props with no work to do here + * + * `tokenOverflowBehavior` describes what a WRAPPING row does when it runs out + * of width; this row scrolls instead, so there is no overflow to summarise and + * no focus state to expand into. `hasEntriesOnFocus` governs whether a popover + * appears over the page before the user has typed — a sheet they just opened + * on purpose is not unbidden, so the list always populates. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/Tokenizer/Tokenizer.tsx + * - /packages/core/src/Tokenizer/useTokenSelection.ts + * - /packages/core/src/Tokenizer/Tokenizer.doc.mjs + * - /packages/core/src/Tokenizer/TokenizerTouch.test.tsx + */ + +import { + useCallback, + useEffect, + useId, + useImperativeHandle, + useMemo, + useRef, + useState, + type FocusEvent, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {BottomSheet} from '../BottomSheet'; +import {Button} from '../Button'; +import { + Field, + InputClearButton, + inputWrapperStyles, + inputStatusBorderStyles, + inputStatusHoverShadowStyles, + inputStatusFocusWithinStyles, +} from '../Field'; +import {Heading} from '../Heading'; +import {useAnnounce} from '../hooks/useAnnounce'; +import {renderIconSlot} from '../Icon'; +import {useTranslator} from '../i18n'; +import {List, ListItem} from '../List'; +import {useSize} from '../SizeContext/SizeContext'; +import {Spinner} from '../Spinner'; +import {Text} from '../Text'; +import {TextInput} from '../TextInput'; +import { + colorVars, + spacingVars, + sizeVars, + typeScaleVars, +} from '../theme/tokens.stylex'; +import {Token} from '../Token'; +import type {SearchableItem} from '../Typeahead/types'; +import {mergeProps} from '../utils'; +import {themeProps} from '../utils/themeProps'; +import type {TokenizerProps} from './Tokenizer'; +import {isCreatableItem, useTokenSelection} from './useTokenSelection'; + +/** + * The comfortable minimum tap target on both iOS and Android. Applied as a + * FLOOR under the size prop rather than replacing it: `size` still means what + * it means, it just cannot produce a field a thumb misses. + */ +const TOUCH_TARGET = '44px'; + +const styles = stylex.create({ + wrapper: { + // One line, always. The tokens scroll within it. + flexWrap: 'nowrap', + gap: spacingVars['--spacing-1'], + height: 'auto', + // Border concentricity, as on the pointer field: a token's radius-1 (4px) + // sits concentric inside the wrapper's radius-2 (8px) when the inset is + // 8 - 4 - 1 = 3px. + paddingBlock: `calc(${spacingVars['--spacing-1']} - 1px)`, + paddingInline: `calc(${spacingVars['--spacing-1']} - 1px)`, + }, + scroller: { + display: 'flex', + alignItems: 'center', + flexWrap: 'nowrap', + gap: spacingVars['--spacing-1'], + // Takes the row's spare width, and may be narrower than its content. + flex: '1 1 auto', + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', + // A scrollbar inside a 44px field would eat the tokens it is measuring. + // Touch surfaces overlay their scrollbars anyway; this only removes the + // classic one a hybrid device might draw. + scrollbarWidth: 'none', + // Deliberately no touch-action: the default lets this scroller take + // horizontal pans while the page keeps vertical ones. Claiming `pan-x` + // here would kill any drag steeper than about 45 degrees outright. + overscrollBehaviorInline: 'contain', + // Nudge the inline padding onto the scroller so the first and last token + // clear the wrapper's rounded corners as they pass under them. + scrollPaddingInline: spacingVars['--spacing-1'], + }, + startIcon: { + display: 'flex', + flexShrink: 0, + // Restores the default 8px inline-start inset: the wrapper's padding is + // cut to 3px for border concentricity with the chips. + marginInlineStart: `calc(${spacingVars['--spacing-2']} - ${spacingVars['--spacing-1']} + 1px)`, + }, + token: { + display: 'flex', + flexShrink: 0, + }, + placeholder: { + color: colorVars['--color-text-secondary'], + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + paddingInlineStart: `calc(${spacingVars['--spacing-2']} - ${spacingVars['--spacing-1']} + 1px)`, + // Below 16px iOS zooms the page when a control is tapped. Matches the + // floor every other input in the system uses on a coarse pointer. + fontSize: `max(1rem, ${typeScaleVars['--text-body-size']})`, + lineHeight: typeScaleVars['--text-body-leading'], + }, + endSection: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-2'], + flexShrink: 0, + }, + plus: { + width: '1em', + height: '1em', + flexShrink: 0, + }, + plusAccent: { + color: colorVars['--color-icon-accent'], + }, + addButton: { + flexShrink: 0, + minBlockSize: TOUCH_TARGET, + minInlineSize: TOUCH_TARGET, + }, + // ---- the sheet ---- + // A stacking context of its own, so the header below can out-rank the list + // WITHOUT out-ranking the sheet's grab handle. + // + // Both live in the sheet panel's stacking context otherwise: the handle is + // z-index 1 there, and List's rows are position: relative, so a sticky + // header needs a layer to cover the rows and must not have one to stay under + // the handle. Isolating scopes the header's z-index to this subtree and + // enters the panel's context as one unit at auto, below the handle. + sheetContent: { + isolation: 'isolate', + }, + sheetHeader: { + position: 'sticky', + insetBlockStart: 0, + // Scoped by sheetContent's isolation: it covers the rows scrolling under + // it, and cannot reach the grab handle. + zIndex: 1, + display: 'flex', + flexDirection: 'column', + gap: spacingVars['--spacing-3'], + // Opaque: the list scrolls underneath this. + backgroundColor: colorVars['--color-background-surface'], + paddingInline: spacingVars['--spacing-4'], + // The handle pill floats in the top ~14px of the sheet, over this header's + // top padding; spacing-4 keeps the heading's text clear of it without + // reserving a band of its own. + paddingBlockStart: spacingVars['--spacing-4'], + paddingBlockEnd: spacingVars['--spacing-2'], + }, + sheetBody: { + paddingInline: spacingVars['--spacing-4'], + paddingBlockEnd: spacingVars['--spacing-4'], + }, + sheetMessage: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: spacingVars['--spacing-2'], + paddingBlock: spacingVars['--spacing-8'], + }, +}); + +// The size scale with the thumb floor folded in. It has to be one declaration: +// StyleX compiles minBlockSize to min-height, so a separate floor would be the +// same property and simply lose the merge. +const sizeStyles = stylex.create({ + sm: {minHeight: `max(${TOUCH_TARGET}, ${sizeVars['--size-element-sm']})`}, + md: {minHeight: `max(${TOUCH_TARGET}, ${sizeVars['--size-element-md']})`}, + lg: {minHeight: `max(${TOUCH_TARGET}, ${sizeVars['--size-element-lg']})`}, +}); + +/** + * The plus that marks both "open the picker" and "this row adds". + * + * Drawn here rather than taken from the icon registry, which has no plus: it + * is structural, part of this control's anatomy the way CheckboxIndicator's + * tick is part of a checkbox, not content an app would swap. Two strokes, so + * it costs nothing next to registering a name every theme would then owe an + * icon for. + */ +function PlusGlyph({isAccent = false}: {isAccent?: boolean}) { + return ( + + ); +} + +/** + * The touch-driven field: a row of tokens that scrolls sideways, and an `Add` + * button that opens a pinned-tall sheet of suggestions. + */ +export function TouchTokenizerField({ + label, + isLabelHidden = false, + description, + isRequired = false, + isOptional = false, + status, + statusVariant = 'attached', + startIcon, + labelTooltip, + searchSource, + value, + onChange, + renderItem, + renderToken, + maxEntries, + placeholder, + maxMenuItems = 10, + emptySearchResultsText: emptySearchResultsTextFromProps, + isDisabled = false, + htmlName, + disabledMessage, + hasClear = false, + endContent, + hasAutoFocus, + size: sizeProp, + debounceMs = 150, + hasCreate = false, + onChangeQuery, + onFocus, + onBlur, + width, + xstyle, + className, + style, + 'data-testid': testId, + ref, + handleRef, +}: TokenizerProps) { + const t = useTranslator(); + const announce = useAnnounce(); + const size = useSize(sizeProp, 'md'); + const addButtonId = useId(); + const labelId = useId(); + const descriptionId = useId(); + const statusMessageId = useId(); + const addButtonRef = useRef(null); + const wrapperRef = useRef(null); + + const emptySearchResultsText = + emptySearchResultsTextFromProps ?? + t('@astryx.typeahead.emptySearchResults'); + + // The disabled reason rides on the Add button rather than the field wrapper. + // On the pointer surface the wrapper carries it because a disabled control + // swallows the hover the tooltip needs; here the Add button is the only + // focusable thing left when the field is disabled, and Button already knows + // this trick — given a tooltip it keeps itself focusable under aria-disabled + // and wires the description up itself. + const showsDisabledMessage = isDisabled && !!disabledMessage; + + useImperativeHandle(handleRef, () => ({ + focus() { + addButtonRef.current?.focus(); + }, + blur() { + addButtonRef.current?.blur(); + }, + })); + + const {isAtMax, decorateResults, addItem, removeItem, clearAll} = + useTokenSelection({ + value, + onChange, + searchSource, + hasCreate, + maxEntries, + onAfterRemove: () => addButtonRef.current?.focus(), + }); + + // hasAutoFocus puts the caret in the pointer field's input on mount. There + // is no input here, so it takes the control that opens one instead — and + // does NOT open the sheet, which would be a modal nobody asked for. The + // latch makes it a once-per-mount effect, as autoFocus is, without lying to + // the dependency array about what it reads. + const didAutoFocusRef = useRef(false); + useEffect(() => { + if (hasAutoFocus && !didAutoFocusRef.current) { + didAutoFocusRef.current = true; + addButtonRef.current?.focus(); + } + }, [hasAutoFocus]); + + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [query, setQuery] = useState(''); + const [rawResults, setRawResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const openSheet = useCallback(() => { + if (isDisabled || isAtMax) { + return; + } + setQuery(''); + setIsSheetOpen(true); + }, [isDisabled, isAtMax]); + + const handleSheetOpenChange = useCallback( + (open: boolean) => { + setIsSheetOpen(open); + if (!open) { + setQuery(''); + onChangeQuery?.(''); + } + }, + [onChangeQuery], + ); + + const handleQueryChange = useCallback( + (next: string) => { + setQuery(next); + onChangeQuery?.(next); + }, + [onChangeQuery], + ); + + // Search whatever the sheet is currently asking for. Unlike the pointer + // surface, an empty query BOOTSTRAPS rather than showing nothing: the list + // is the sheet's whole content, and an empty sheet with a search box in it + // is a dead end. `hasEntriesOnFocus` is therefore not consulted here — it + // governs whether an unbidden popover appears over the page, and a sheet + // the user has just opened on purpose is not unbidden. + // + // The raw source is searched, not the selection-filtered one: that source is + // rebuilt on every add, which would re-issue a request per token. Selected + // items and the "Create X" entry are applied to the results instead. + const searchGenRef = useRef(0); + useEffect(() => { + if (!isSheetOpen) { + return; + } + const gen = ++searchGenRef.current; + let timer: ReturnType | undefined; + + const run = async () => { + setIsLoading(true); + try { + const found = + query.length > 0 + ? await searchSource.search(query) + : await searchSource.bootstrap(); + if (searchGenRef.current !== gen) { + return; + } + setRawResults(found.slice(0, maxMenuItems)); + } catch { + if (searchGenRef.current === gen) { + setRawResults([]); + } + } finally { + if (searchGenRef.current === gen) { + setIsLoading(false); + } + } + }; + + // Only typing is debounced. The open is not: it would show an empty sheet + // for a debounce interval every time. + if (query.length === 0 || debounceMs <= 0) { + void run(); + } else { + timer = setTimeout(() => void run(), debounceMs); + } + + return () => { + if (timer) { + clearTimeout(timer); + } + searchSource.cancel?.(); + }; + }, [isSheetOpen, query, searchSource, debounceMs, maxMenuItems]); + + // Selected items drop out of the list the instant they are picked, without + // another round trip. + const suggestions = useMemo( + () => decorateResults(rawResults, query), + [decorateResults, rawResults, query], + ); + + // Announce what a search turned up, as the pointer surface's menu does. + const announcedRef = useRef(null); + useEffect(() => { + if (!isSheetOpen || isLoading || query.length === 0) { + announcedRef.current = null; + return; + } + const message = + suggestions.length === 0 + ? emptySearchResultsText + : t('@astryx.typeahead.resultCount', {count: suggestions.length}); + if (announcedRef.current !== message) { + announcedRef.current = message; + announce(message); + } + }, [ + isSheetOpen, + isLoading, + query, + suggestions.length, + emptySearchResultsText, + announce, + t, + ]); + + const handlePick = useCallback( + (item: T) => { + addItem(item); + setQuery(''); + onChangeQuery?.(''); + // The last allowed token closes the sheet: there is nothing left to + // offer, and leaving an empty list up reads as a failure. + if (maxEntries != null && value.length + 1 >= maxEntries) { + setIsSheetOpen(false); + } + }, + [addItem, maxEntries, value.length, onChangeQuery], + ); + + // Enter on a phone keyboard is the "done" key, so it commits free text the + // same way it does on the pointer surface. Without hasCreate there is + // nothing to commit and the top suggestion is NOT taken: on this surface + // suggestions are tapped, and nothing is highlighted to take. + const handleSearchEnter = useCallback(() => { + if (!hasCreate) { + return; + } + const creatable = suggestions.find(isCreatableItem); + if (creatable) { + handlePick(creatable); + } + }, [hasCreate, suggestions, handlePick]); + + // Focus reaching the field from outside, and leaving it entirely, is + // reported the same way the pointer surface reports it. + const handleFocusCapture = useCallback( + (e: FocusEvent) => { + if (!wrapperRef.current?.contains(e.relatedTarget)) { + onFocus?.(e); + } + }, + [onFocus], + ); + + const handleBlurCapture = useCallback( + (e: FocusEvent) => { + if (!wrapperRef.current?.contains(e.relatedTarget)) { + onBlur?.(e); + } + }, + [onBlur], + ); + + const ariaDescribedBy = + [ + description ? descriptionId : null, + status?.message ? statusMessageId : null, + ] + .filter(Boolean) + .join(' ') || undefined; + + const addAccessibleName = t('@astryx.tokenizer.addTokens', {label}); + const isAddBlocked = isDisabled || isAtMax; + + const tokens = value.map(item => { + const onRemoveItem = () => removeItem(item); + + if (renderToken) { + return ( + + {renderToken(item, onRemoveItem)} + + ); + } + + return ( + + ); + }); + + // A sm control is 28px tall — under the thumb floor, and this one is inside + // a sheet with room to spare. The field's own size is untouched. + const sheetControlSize = size === 'sm' ? 'md' : size; + + return ( + cannot name. isGroupLabel renders the label + // as a span and the group takes it via aria-labelledby. + inputID={addButtonId} + labelID={labelId} + isGroupLabel + descriptionID={description ? descriptionId : undefined} + isOptional={isOptional} + isRequired={isRequired} + isDisabled={isDisabled} + status={ + status + ? { + type: status.type, + message: status.message, + messageID: status.message ? statusMessageId : undefined, + } + : undefined + } + statusVariant={statusVariant} + labelTooltip={labelTooltip} + width={width} + xstyle={xstyle} + className={className} + style={style}> +
+ {startIcon && ( + + {renderIconSlot(startIcon, {size: 'sm', color: 'secondary'})} + + )} +
+ {value.length > 0 ? ( + tokens + ) : ( + {placeholder} + )} +
+ {(endContent || (hasClear && value.length > 0 && !isDisabled)) && ( +
+ {endContent} + {hasClear && value.length > 0 && !isDisabled && ( + + )} +
+ )} +
+ +
+
+ {addAccessibleName} + +
+
+ {suggestions.length > 0 ? ( + + {suggestions.map(item => ( + } + onClick={() => handlePick(item)} + /> + ))} + + ) : ( +
+ {isLoading ? ( + + ) : ( + {emptySearchResultsText} + )} +
+ )} +
+
+
+
+ ); +} + +TouchTokenizerField.displayName = 'TouchTokenizerField'; diff --git a/packages/core/src/Tokenizer/index.ts b/packages/core/src/Tokenizer/index.ts index f168f53c98d3..44b4615b971a 100644 --- a/packages/core/src/Tokenizer/index.ts +++ b/packages/core/src/Tokenizer/index.ts @@ -12,6 +12,12 @@ */ export {Tokenizer} from './Tokenizer'; +/** + * The touch surface with the pointer test skipped, so a story or a + * handset-only app can render it directly. `Tokenizer` is the one to use: it + * picks this surface itself wherever the primary pointer is a finger. + */ +export {TouchTokenizerField as TokenizerTouchSurface} from './TouchTokenizerField'; export type { TokenizerProps, TokenizerSize, diff --git a/packages/core/src/Tokenizer/useTokenSelection.ts b/packages/core/src/Tokenizer/useTokenSelection.ts new file mode 100644 index 000000000000..f763a37935d4 --- /dev/null +++ b/packages/core/src/Tokenizer/useTokenSelection.ts @@ -0,0 +1,249 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useTokenSelection.ts + * @input Uses React, useAnnounce, useTranslator, Typeahead types + * @output Exports useTokenSelection — the selection half of Tokenizer + * @position Internal hook; consumed by Tokenizer.tsx and TouchTokenizerField.tsx + * + * Everything about a Tokenizer that is not a surface: which items the search + * source may still offer, what adding and removing do to `value`, and what a + * screen reader hears about it. + * + * It lives here because `Tokenizer` renders two surfaces — a field with an + * inline typeahead for a mouse, a sheet of suggestions for a finger — and the + * rules for what a token IS must not fork between them. The "Create X" + * sentinel is the sharp edge: a synthetic item that has to be recognised on + * the way back in and turned into a real one, in exactly the same way on both. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/Tokenizer/Tokenizer.tsx + * - /packages/core/src/Tokenizer/TouchTokenizerField.tsx + */ + +import {useCallback, useMemo} from 'react'; +import {useAnnounce} from '../hooks/useAnnounce'; +import {useTranslator} from '../i18n'; +import type {SearchableItem, SearchSource} from '../Typeahead/types'; +import type {TokenizerChange} from './Tokenizer'; + +/** + * Sentinel prefix for creatable items — used to distinguish + * "Create: X" suggestions from real search results. + */ +const CREATABLE_ID_PREFIX = '__xds_create__'; + +/** + * Whether a suggestion is the synthetic "Create X" entry rather than something + * the search source returned. + */ +export function isCreatableItem(item: SearchableItem): boolean { + return typeof item.id === 'string' && item.id.startsWith(CREATABLE_ID_PREFIX); +} + +export interface UseTokenSelectionOptions { + /** Currently selected items. */ + value: T[]; + /** Reports a new selection, with the change that produced it. */ + onChange: (items: T[], change: TokenizerChange) => void; + /** The caller's search source, before selected items are filtered out. */ + searchSource: SearchSource; + /** Whether free text may be committed as a new token. */ + hasCreate: boolean; + /** Upper bound on selections, if any. */ + maxEntries?: number; + /** + * Called after a removal (single or clear-all), for the surface to put + * focus back where the user was working. + */ + onAfterRemove?: () => void; +} + +export interface UseTokenSelectionResult { + /** Whether `maxEntries` has been reached. */ + isAtMax: boolean; + /** The ids already selected — the set the source is filtered against. */ + selectedIds: ReadonlySet; + /** + * Turn a source's raw results into the ones worth offering: selected items + * dropped, plus the synthetic "Create X" entry when `hasCreate` is on and + * the query is not already an item. + * + * A surface that searches for itself calls this on what came back, rather + * than searching through {@link UseTokenSelectionResult.filteredSource} — a + * source rebuilt on every selection would re-issue a request per token + * added. + */ + decorateResults: (results: T[], query: string) => T[]; + /** + * The caller's source with {@link UseTokenSelectionResult.decorateResults} + * applied to everything it returns. + */ + filteredSource: SearchSource; + /** A source that returns nothing — what a surface shows at `maxEntries`. */ + emptySource: SearchSource; + /** Add an item (or recognise and materialise a "Create X" sentinel). */ + addItem: (item: T | null) => void; + /** Remove one item. */ + removeItem: (item: T) => void; + /** Remove every item. */ + clearAll: () => void; +} + +/** + * The selection engine behind both Tokenizer surfaces. + * + * @example + * ``` + * const {filteredSource, addItem, removeItem} = useTokenSelection({ + * value, + * onChange, + * searchSource, + * hasCreate, + * maxEntries, + * onAfterRemove: () => inputRef.current?.focus(), + * }); + * ``` + */ +export function useTokenSelection({ + value, + onChange, + searchSource, + hasCreate, + maxEntries, + onAfterRemove, +}: UseTokenSelectionOptions): UseTokenSelectionResult { + const t = useTranslator(); + + const isAtMax = maxEntries != null && value.length >= maxEntries; + + // Filter out already-selected items from search results + const selectedIds = useMemo( + () => new Set(value.map(item => item.id)), + [value], + ); + + const decorateResults = useCallback( + (results: T[], query: string): T[] => { + const filtered = results.filter(item => !selectedIds.has(item.id)); + + // Append a "Create: X" synthetic item when hasCreate is true, + // the user has typed something, and it doesn't exactly match an + // existing result. + if (hasCreate && query.trim()) { + const trimmed = query.trim(); + const alreadyExists = + selectedIds.has(trimmed) || + filtered.some( + item => item.label.toLowerCase() === trimmed.toLowerCase(), + ); + if (!alreadyExists) { + const creatableItem = { + id: `${CREATABLE_ID_PREFIX}${trimmed}`, + label: `Create "${trimmed}"`, + auxiliaryData: {__createdValue: trimmed}, + } as unknown as T; + filtered.push(creatableItem); + } + } + + return filtered; + }, + [selectedIds, hasCreate], + ); + + const filteredSource: SearchSource = useMemo( + () => ({ + search: async (query: string) => + decorateResults(await searchSource.search(query), query), + bootstrap: async () => + decorateResults(await searchSource.bootstrap(), ''), + }), + [searchSource, decorateResults], + ); + + const emptySource: SearchSource = useMemo( + () => ({ + search: async () => [], + bootstrap: async () => [], + }), + [], + ); + + // Announce token add/remove politely via the persistent live region. + // Tokens previously appeared and disappeared silently — Backspace on an + // empty input removes the trailing token, and the per-token remove buttons + // gave no audible feedback either. + const announce = useAnnounce(); + + // Handle adding an item — detect creatable synthetic items + const addItem = useCallback( + (item: T | null) => { + if (!item) { + return; + } + if (isAtMax) { + return; + } + + // Detect "Create: X" synthetic items from the creatable source + if (hasCreate && isCreatableItem(item)) { + const createdValue = item.id.slice(CREATABLE_ID_PREFIX.length); + if (selectedIds.has(createdValue)) { + return; + } + const base = {id: createdValue, label: createdValue}; + const realItem = base as T; + const newItems = [...value, realItem]; + onChange(newItems, {item: realItem, type: 'create'}); + announce(t('@astryx.tokenizer.tokenAdded', {label: createdValue})); + return; + } + + if (selectedIds.has(item.id)) { + return; + } + const newItems = [...value, item]; + onChange(newItems, {item, type: 'add'}); + announce(t('@astryx.tokenizer.tokenAdded', {label: item.label})); + }, + [value, onChange, isAtMax, selectedIds, hasCreate, announce, t], + ); + + // Handle removing an item. Single removal path: Backspace on an empty input, + // the per-token remove buttons, and the touch surface's chips all route + // through here, so the announcement covers every one of them. + const removeItem = useCallback( + (item: T) => { + const newItems = value.filter(v => v.id !== item.id); + onChange(newItems, {item, type: 'remove'}); + announce(t('@astryx.tokenizer.tokenRemoved', {label: item.label})); + onAfterRemove?.(); + }, + [value, onChange, announce, t, onAfterRemove], + ); + + // Handle clearing all items + const clearAll = useCallback(() => { + if (value.length === 0) { + return; + } + // Report the last item as removed (convention) + const lastItem = value[value.length - 1]; + onChange([], {item: lastItem, type: 'remove'}); + onAfterRemove?.(); + }, [value, onChange, onAfterRemove]); + + return { + isAtMax, + selectedIds, + decorateResults, + filteredSource, + emptySource, + addItem, + removeItem, + clearAll, + }; +}