From dacdf9245c89d30c4811beef6528d55fb61e650c Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Fri, 21 Aug 2026 17:31:56 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20Tokenizer=20fits=20the=20poin?= =?UTF-8?q?ter=20=E2=80=94=20a=20chip=20row=20and=20a=20sheet=20on=20a=20f?= =?UTF-8?q?inger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second surface to `Tokenizer`, chosen at runtime from the primary pointer, so a phone gets a control shaped for a thumb instead of one shaped for a keyboard. The pointer control needs a keyboard for both of its core gestures. You type BETWEEN the chips, in an input sharing their line, 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 — where the suggestion popover opens — and each chip added grows the field by a line and pushes the page under your thumb. The touch surface is three ideas: - The chips scroll sideways instead of wrapping, so the field is exactly one line tall however many there are and nothing below it moves. - Add is a separate, fixed target OUTSIDE the scroller, at the trailing edge: same place with two chips or twenty, and a tap on it can never be mistaken for the start of a sideways drag. (The prototype had it inside the scroller, where it scrolls away once the chips overflow.) - Suggestions are a pinned-tall sheet: search at the top where the keyboard cannot cover it, full-width rows a thumb can hit, and `tall` is the one BottomSheet height that is keyboard-aware. The list populates before anything is typed — in a sheet the list IS the content, so `hasEntriesOnFocus`, which is about not putting a popover over the page unbidden, does not apply. Built from the mobile prototype at sandbox/pages/mobile-prototypes/?p=tokenizer. The switch is `pointer: coarse` alone, with no width bound — same test, and same reasoning, as the DateInput touch surface: `pointer` is the PRIMARY device, so a touchscreen laptop keeps the typable field and a narrowed desktop window is still a mouse, while a tablet gets the sheet at any width. The selection logic is now one hook, `useTokenSelection`, rather than two copies. That is what keeps the "Create X" sentinel — a synthetic item that has to be recognised on the way back in and turned into a real one — from forking between the surfaces. The pointer surface's rendered output is unchanged; its diff is the imports, the extraction, and a rename to `PointerTokenizerField`. Two things a browser caught that jsdom could not, both now covered by a test: - The sheet's sticky header sits between two layers in ONE stacking context. A bare `z-index: 1` put it over BottomSheet's grab handle and hid the pill; no z-index at all let List's `position: relative` rows paint straight through its background. It needs an isolated wrapper to scope the layer. - `minBlockSize` compiles to `min-height`, so a 44px floor declared next to the size scale is the same property and simply loses the merge. The floor is folded into the scale as `max(44px, )`. Costs 16.4 KB gzipped on top of Tokenizer's 58.6 KB (measured: bundle the component alone, minified, from dist before and after), for every consumer including desktop-only ones, since the choice is made at runtime. New public API is one export, `TokenizerTouchSurface` — the touch half with the pointer test skipped, so the Storybook stories are reviewable on a laptop — plus three `@astryx.tokenizer.*` catalog keys. No media-query constant is exported: the DateInput surface already publishes `TOUCH_POINTER_QUERY`, and two of those in the root barrel would collide. Verified on an iPhone 15 profile in Chromium: the row scrolls (scrollWidth 540 vs clientWidth 253) while the field stays 52px and does not move, the sheet opens at 92dvh with the pill visible over the header, the header covers rows scrolling under it, and tapping the search field through it still focuses it. --- .changeset/tokenizer-touch-surface.md | 57 ++ .../stories/TokenizerTouch.stories.tsx | 326 ++++++++ packages/core/locales/en.json | 12 + packages/core/src/Tokenizer/Tokenizer.doc.mjs | 33 +- packages/core/src/Tokenizer/Tokenizer.tsx | 274 +++---- .../src/Tokenizer/TokenizerTouch.test.tsx | 503 ++++++++++++ .../src/Tokenizer/TouchTokenizerField.tsx | 723 ++++++++++++++++++ packages/core/src/Tokenizer/index.ts | 6 + .../core/src/Tokenizer/useTokenSelection.ts | 249 ++++++ 9 files changed, 2005 insertions(+), 178 deletions(-) create mode 100644 .changeset/tokenizer-touch-surface.md create mode 100644 apps/storybook/stories/TokenizerTouch.stories.tsx create mode 100644 packages/core/src/Tokenizer/TokenizerTouch.test.tsx create mode 100644 packages/core/src/Tokenizer/TouchTokenizerField.tsx create mode 100644 packages/core/src/Tokenizer/useTokenSelection.ts 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/apps/storybook/stories/TokenizerTouch.stories.tsx b/apps/storybook/stories/TokenizerTouch.stories.tsx new file mode 100644 index 000000000000..14b39030eaa8 --- /dev/null +++ b/apps/storybook/stories/TokenizerTouch.stories.tsx @@ -0,0 +1,326 @@ +// 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, TokenizerTouchSurface} from '@astryxdesign/core/Tokenizer'; +import {useMediaQuery} from '@astryxdesign/core/hooks'; +import type {SearchableItem, SearchSource} from '@astryxdesign/core/Typeahead'; +import {Banner} from '@astryxdesign/core/Banner'; + +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 styles = stylex.create({ + // A handset's width, so the stories below 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/Touch surface', + component: Tokenizer, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + '`Tokenizer` fits the pointer it is used with. With a mouse it is ' + + 'the field documented under Core/Tokenizer: chips that wrap around ' + + 'an inline text input, with suggestions in a popover. Where the ' + + 'primary pointer is a finger (`pointer: coarse`) the same ' + + 'component renders a surface built for one — chips on a single ' + + 'sideways-scrolling line so the form never reflows, and an Add ' + + 'button that opens a pinned-tall sheet whose search field sits ' + + 'above the keyboard.\n\n' + + 'Same props, same values, no new import: two surfaces of one ' + + 'component.\n\n' + + '**Reviewing on a desktop browser:** the first story shows you the ' + + 'pointer surface, because that is the right answer for a mouse. ' + + 'Every other story renders `TokenizerTouchSurface`, which is the ' + + 'touch half with the pointer test skipped.', + }, + }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// ============================================================ +// RESPONSIVE — the component as you would actually use it +// ============================================================ + +export const Default: Story = { + parameters: { + docs: { + description: { + story: + 'On this desktop browser you are seeing the pointer surface. Open ' + + 'the same story on a phone, or in a device-emulated tab that ' + + 'reports a coarse pointer, and the same markup becomes a scrolling ' + + 'chip row with an Add button. Nothing at the call site changes.', + }, + }, + }, + render: () => { + 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%" + /> + + ); + }, +}; + +// ============================================================ +// THE TOUCH SURFACE, forced — reviewable with a mouse +// ============================================================ + +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, + }; +} From 175000807625ffe109481fafa8240d2c1139286a Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Sat, 22 Aug 2026 00:09:33 -0700 Subject: [PATCH 2/2] fix(storybook): file the touch stories under Tokenizer, not a component of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pr-a11y` failed, and the interesting part is why: the story title `Core/Tokenizer/Touch surface` is the only three-segment title in the repo, and the a11y tooling reads those two ends differently. The audit FILTERS by `title.split('/')[1]` — `Tokenizer`, so `--components Tokenizer` found the stories — but KEYS its report by `title.split('/').pop()`, so every violation was filed under a component named "Touch surface", which does not exist. The weekly a11y summary is built from the same report, so it would have grown a phantom component too. Folded the eight stories into `Tokenizer.stories.tsx` behind a section comment instead. Two files can share a title (`Vega/VegaChart` does), but one file per component is the repo's shape and leaves nothing to reason about. That leaves one real violation, now correctly keyed `Tokenizer::Touch: disabled, with a reason::color-contrast`, and it is the disabled treatment the whole system already has rather than anything this surface does. Measured, the three nodes are the field label at 2.52 and the two chips at 1.72 — the same nodes and the same ratios as the already-baselined `Tokenizer::Disabled` and `Tokenizer::Disabled With Message`. Both surfaces compose the same two dims: `inputWrapperStyles.disabled` puts `opacity: 0.5` on the field and `Token` adds its own, so a chip is at 0.25 effective on the pointer surface today (verified in the built Storybook: `core-tokenizer--disabled` reports the identical 0.25). Fixing it means changing what disabled looks like system-wide, which is not this PR, and WCAG 1.4.3 exempts inactive controls. So it joins the two entries beside it in the baseline. The disabled Add button lands at the same 0.25 as the chips around it, which is why axe does not flag it separately and why it should not be exempted from the field's dim — it would be the one bright thing in a disabled field. Verified: a11y gate passes (0 new, 3 baselined), RTL audit passes, the modal close-visibility guard passes, storybook typecheck and build pass, eslint clean. --- .github/a11y-baseline.json | 5 + apps/storybook/stories/Tokenizer.stories.tsx | 313 ++++++++++++++++- .../stories/TokenizerTouch.stories.tsx | 326 ------------------ 3 files changed, 316 insertions(+), 328 deletions(-) delete mode 100644 apps/storybook/stories/TokenizerTouch.stories.tsx 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/apps/storybook/stories/TokenizerTouch.stories.tsx b/apps/storybook/stories/TokenizerTouch.stories.tsx deleted file mode 100644 index 14b39030eaa8..000000000000 --- a/apps/storybook/stories/TokenizerTouch.stories.tsx +++ /dev/null @@ -1,326 +0,0 @@ -// 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, TokenizerTouchSurface} from '@astryxdesign/core/Tokenizer'; -import {useMediaQuery} from '@astryxdesign/core/hooks'; -import type {SearchableItem, SearchSource} from '@astryxdesign/core/Typeahead'; -import {Banner} from '@astryxdesign/core/Banner'; - -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 styles = stylex.create({ - // A handset's width, so the stories below 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/Touch surface', - component: Tokenizer, - tags: ['autodocs'], - parameters: { - docs: { - description: { - component: - '`Tokenizer` fits the pointer it is used with. With a mouse it is ' + - 'the field documented under Core/Tokenizer: chips that wrap around ' + - 'an inline text input, with suggestions in a popover. Where the ' + - 'primary pointer is a finger (`pointer: coarse`) the same ' + - 'component renders a surface built for one — chips on a single ' + - 'sideways-scrolling line so the form never reflows, and an Add ' + - 'button that opens a pinned-tall sheet whose search field sits ' + - 'above the keyboard.\n\n' + - 'Same props, same values, no new import: two surfaces of one ' + - 'component.\n\n' + - '**Reviewing on a desktop browser:** the first story shows you the ' + - 'pointer surface, because that is the right answer for a mouse. ' + - 'Every other story renders `TokenizerTouchSurface`, which is the ' + - 'touch half with the pointer test skipped.', - }, - }, - }, - decorators: [ - Story => ( -
- -
- ), - ], -}; - -export default meta; -type Story = StoryObj; - -// ============================================================ -// RESPONSIVE — the component as you would actually use it -// ============================================================ - -export const Default: Story = { - parameters: { - docs: { - description: { - story: - 'On this desktop browser you are seeing the pointer surface. Open ' + - 'the same story on a phone, or in a device-emulated tab that ' + - 'reports a coarse pointer, and the same markup becomes a scrolling ' + - 'chip row with an Add button. Nothing at the call site changes.', - }, - }, - }, - render: () => { - 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%" - /> - - ); - }, -}; - -// ============================================================ -// THE TOUCH SURFACE, forced — reviewable with a mouse -// ============================================================ - -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%" - /> - ), -};