From 5861a7af146580e00503914af915f818fa18f147 Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Tue, 27 Oct 2026 11:53:03 -0700 Subject: [PATCH] fix(Tokenizer): hold back the touch sheet presentation until its UX pass finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implementation stays, unexposed. `TokenizerProps` offers no `experimental.presentation`; every public Tokenizer renders the inline combobox. Astryx's own packages reach the sheet-capable signature as `TokenizerWithPresentation` through the internal entry `@astryxdesign/core/internal/tokenizer-presentation` (architecture:react-component-runtime INV13), and the sheet tests run through it. The experimental prop and example leave `Tokenizer.doc.mjs` and the experimental stories leave Storybook; the pending [experimental] changeset is replaced by an internal [chore] one; the Tokenizer record carries the implementation gap on DEC-3, FR16–FR24, Public concepts and readiness. --- .../tokenizer-experimental-bottom-sheet.md | 7 - .../tokenizer-sheet-presentation-held-back.md | 7 + apps/storybook/stories/Tokenizer.stories.tsx | 140 ------------------ packages/core/package.json | 5 + packages/core/src/Tokenizer/Tokenizer.doc.mjs | 16 -- .../Tokenizer/Tokenizer.props-type.test.tsx | 30 ++-- packages/core/src/Tokenizer/Tokenizer.spec.md | 89 ++++++----- .../core/src/Tokenizer/Tokenizer.ssr.test.tsx | 5 +- packages/core/src/Tokenizer/Tokenizer.tsx | 81 +++++----- .../Tokenizer/TokenizerBottomSheet.test.tsx | 95 ++++++------ .../useAnnounceRenderedText.adopters.test.tsx | 5 +- .../internal/tokenizer-presentation.test.ts | 29 ++++ .../src/internal/tokenizer-presentation.ts | 25 ++++ scripts/sync-exports.js | 1 + 14 files changed, 240 insertions(+), 295 deletions(-) delete mode 100644 .changeset/tokenizer-experimental-bottom-sheet.md create mode 100644 .changeset/tokenizer-sheet-presentation-held-back.md create mode 100644 packages/core/src/internal/tokenizer-presentation.test.ts create mode 100644 packages/core/src/internal/tokenizer-presentation.ts diff --git a/.changeset/tokenizer-experimental-bottom-sheet.md b/.changeset/tokenizer-experimental-bottom-sheet.md deleted file mode 100644 index 21be220b8df22..0000000000000 --- a/.changeset/tokenizer-experimental-bottom-sheet.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@astryxdesign/core': patch ---- - -[experimental] Tokenizer gains a touch surface behind `experimental={{presentation: 'bottom-sheet' | 'adaptive'}}`. `bottom-sheet` replaces the inline combobox with a trigger that shows the selected tokens and opens one tall modal sheet: a search row on top, full-width checkbox rows that keep their place while the sheet is open and regroup selected-first on the next open, 50-row progressive rendering for long lists, a `Create "…"` row with a trailing Add action under `hasCreate`, and a footer with a confirmed Clear all and Done. `adaptive` uses the sheet on compact coarse-pointer screens and the popover otherwise, resolved the same way Selector resolves it. The sheet surface loads on demand, so a Tokenizer that does not opt in does not carry it. The trigger describes its selected tokens to assistive technology, announcements made while the sheet is open reach assistive technology through a live region inside the sheet, with `experimental` passed the component also accepts a `Ref` because `ref` reaches the trigger in the sheet presentation (the exported `TokenizerProps` interface is unchanged), and in development the field warns once about props that have no surface in the sheet presentation (`renderItem`, `renderToken`, `endContent`, `hasClear`, `hasEntriesOnFocus`, `menuWidth`, `tokenOverflowBehavior`, `hasAutoFocus`). New `@astryx.tokenizer.*` catalog keys for the sheet's strings; the sheet's parts expose no theming targets while the option is experimental. Anything inside Tokenizer's `experimental` prop may change or be removed in a patch release; omitting it keeps the stable behavior. - -@imdreamrunner diff --git a/.changeset/tokenizer-sheet-presentation-held-back.md b/.changeset/tokenizer-sheet-presentation-held-back.md new file mode 100644 index 0000000000000..734ed7f3cc9b5 --- /dev/null +++ b/.changeset/tokenizer-sheet-presentation-held-back.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Tokenizer's touch sheet presentation is held back until its user experience is finished. The implementation stays in the package, reachable only through an internal entry reserved for Astryx's own packages; `TokenizerProps` offers no `experimental.presentation`, every Tokenizer renders the inline combobox, and nothing a consumer sees changes. + +@imdreamrunner diff --git a/apps/storybook/stories/Tokenizer.stories.tsx b/apps/storybook/stories/Tokenizer.stories.tsx index d4f1e8c239c2a..d85226ab454e4 100644 --- a/apps/storybook/stories/Tokenizer.stories.tsx +++ b/apps/storybook/stories/Tokenizer.stories.tsx @@ -798,143 +798,3 @@ export const CaretPlacement: Story = { } }, }; -// A long, searchable dataset for the sheet presentation: enough entries to -// see the 50-row batches of progressive rendering. -const sheetTags: SearchableItem[] = Array.from({length: 1000}, (_, index) => { - const number = index + 1; - return { - id: `tag-${number}`, - label: - number === 1 - ? 'Design' - : number === 2 - ? 'Eng' - : `Tag ${String(number).padStart(2, '0')}`, - }; -}); - -const sheetTagSource: SearchSource = { - search: (query: string) => - sheetTags.filter(tag => - tag.label.toLowerCase().includes(query.toLowerCase()), - ), - bootstrap: () => sheetTags, -}; - -/** - * The experimental touch surface, forced on every device so it is reviewable - * on a laptop: `experimental.presentation="bottom-sheet"`. The trigger shows - * the selected tokens; tapping it opens one tall sheet with the search row on - * top, checkbox rows that keep their place while the sheet is open, a Create - * row for typed text, and a confirmed Clear all beside Done. - */ -export const ExperimentalBottomSheet: Story = { - render: args => { - const [value, setValue] = useState([ - sheetTags[0], - sheetTags[1], - ]); - return ( - setValue(items)} - hasCreate - maxMenuItems={1000} - debounceMs={0} - experimental={{presentation: 'bottom-sheet'}} - /> - ); - }, - args: { - label: 'Tags', - placeholder: 'Add tags', - }, - name: 'Experimental: bottom sheet', -}; - -/** - * `experimental.presentation="adaptive"`: the sheet on a compact - * coarse-pointer screen (narrow the viewport and emulate touch), otherwise the - * inline combobox — the same resolution Selector uses. - */ -export const ExperimentalAdaptive: Story = { - render: args => { - // Enough tokens to wrap the inline field's row on a phone, so the box - // the trigger keeps at the swap is the wrapped one (FR16, FR17). - const [value, setValue] = useState([ - sheetTags[0], - sheetTags[1], - sheetTags[2], - sheetTags[3], - sheetTags[4], - ]); - return ( - setValue(items)} - maxMenuItems={1000} - debounceMs={0} - experimental={{presentation: 'adaptive'}} - /> - ); - }, - args: { - label: 'Tags', - placeholder: 'Add tags', - }, - name: 'Experimental: adaptive', -}; - -/** - * Disabled and at-limit states of the sheet presentation: the disabled - * trigger stays focusable and does not open; at `maxEntries` the sheet's - * unchecked rows are inert while checked rows still toggle. - */ -export const ExperimentalBottomSheetStates: Story = { - render: args => { - const [value, setValue] = useState([ - sheetTags[0], - sheetTags[1], - ]); - return ( -
- {}} - isDisabled - experimental={{presentation: 'bottom-sheet'}} - /> - {}} - isDisabled - experimental={{presentation: 'bottom-sheet'}} - /> - setValue(items)} - maxEntries={2} - debounceMs={0} - experimental={{presentation: 'bottom-sheet'}} - /> -
- ); - }, - args: { - placeholder: 'Add tags', - }, - name: 'Experimental: bottom sheet states', -}; diff --git a/packages/core/package.json b/packages/core/package.json index 8bd491e6273cb..295c3d98433c4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -823,6 +823,11 @@ "source": "./src/internal/touch.ts", "types": "./dist/internal/touch.d.ts", "default": "./dist/internal/touch.js" + }, + "./internal/tokenizer-presentation": { + "source": "./src/internal/tokenizer-presentation.ts", + "types": "./dist/internal/tokenizer-presentation.d.ts", + "default": "./dist/internal/tokenizer-presentation.js" } }, "files": [ diff --git a/packages/core/src/Tokenizer/Tokenizer.doc.mjs b/packages/core/src/Tokenizer/Tokenizer.doc.mjs index b2201899bb0cf..b393882d803bb 100644 --- a/packages/core/src/Tokenizer/Tokenizer.doc.mjs +++ b/packages/core/src/Tokenizer/Tokenizer.doc.mjs @@ -289,13 +289,6 @@ export const docs = { type: 'CSSProperties', description: 'Inline styles for the visual control box.', }, - { - name: 'experimental', - type: "{presentation?: 'popover' | 'bottom-sheet' | 'adaptive'}", - stability: 'experimental', - description: - "Experimental options: anything in this object may change or be removed in a patch release, and omitting it keeps the stable behavior. presentation (default 'popover') chooses how the field presents its choices. popover is the inline combobox with its anchored suggestion menu. bottom-sheet replaces it with a trigger showing the selected tokens that opens one tall modal sheet: a search row on top, full-width checkbox rows that keep their place while the sheet is open, a Create row with hasCreate, and a footer with a confirmed Clear all and Done. adaptive uses the bottom sheet on compact touch screens and the popover otherwise, resolved the same way Selector resolves it. In the sheet presentation the trigger is the control (ref, id, aria-* and data-* reach it; with experimental passed, ref also accepts a Ref — an input-only ref still type-checks against that wider type, but with adaptive or bottom-sheet it receives the trigger button at runtime, so type the ref for both elements; the exported TokenizerProps interface is unchanged), and renderItem, renderToken, endContent, hasClear, hasEntriesOnFocus, menuWidth, tokenOverflowBehavior and hasAutoFocus have no surface to act on; the field warns once in development when they are passed. For example: .", - }, ], theming: { targets: [ @@ -635,13 +628,6 @@ export const docsZh = { type: 'CSSProperties', description: '可视控件框的内联样式。', }, - { - name: 'experimental', - type: "{presentation?: 'popover' | 'bottom-sheet' | 'adaptive'}", - stability: 'experimental', - description: - "实验性选项:此对象中的任何内容都可能在补丁版本中更改或移除;省略它即保持稳定行为。presentation(默认 'popover')决定字段如何呈现选项:popover 为内联组合框及其锚定建议菜单;bottom-sheet 改为一个显示已选标记的触发按钮,点按后打开一个高的模态底部面板——顶部为搜索行,下方为整行复选框行(面板打开期间位置保持不变),hasCreate 时有「创建」行,底部为需确认的「全部清除」和「完成」;adaptive 在紧凑触摸屏上使用底部面板,其他情况使用 popover,解析方式与 Selector 相同。在底部面板呈现中,触发按钮即为控件(ref、id、aria-* 和 data-* 都作用于它;传入 experimental 后,ref 也接受 Ref——仅为输入框类型的 ref 在这个更宽的类型下仍能通过类型检查,但在 adaptive 或 bottom-sheet 下运行时收到的是触发按钮,因此请为两种元素声明 ref 的类型;导出的 TokenizerProps 接口不变);renderItem、renderToken、endContent、hasClear、hasEntriesOnFocus、menuWidth、tokenOverflowBehavior 和 hasAutoFocus 没有作用对象,传入时开发模式下会警告一次。例如:。", - }, ], theming: { targets: [ @@ -839,7 +825,5 @@ export const docsDense = { 'Icon at input start, before tokens. Icon name, SVG component, or ReactNode.', endContent: 'Content at input row end. For buttons, counts, controls.', handleRef: 'Imperative handle for focus() and blur() control.', - experimental: - "Experimental; may change in a patch. presentation: 'popover' (default, inline combobox) | 'bottom-sheet' (trigger + one searchable modal sheet of checkbox rows) | 'adaptive' (sheet on compact touch, else popover).", }, }; diff --git a/packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx b/packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx index 5e7ca712b58c4..be6ccc66f34e3 100644 --- a/packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx +++ b/packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx @@ -4,8 +4,10 @@ * @input Uses vitest, TokenizerProps and Tokenizer * @output Type-level proof that the exported `TokenizerProps` stays an * interface callers can extend and `Omit` (its `ref` typed for the combobox - * input as released), and that only the experimental opt-in admits a `ref` - * that also accepts the sheet presentation's trigger button + * input as released), that the public component names no `experimental` + * presentation, and that only the internal `TokenizerWithPresentation` + * admits the presentation options and a `ref` that also accepts the sheet + * presentation's trigger button * @position Core type tests; checked by `tsc`, with `@ts-expect-error` * marking the shapes that must keep failing */ @@ -13,6 +15,7 @@ import {createRef, type ComponentProps} from 'react'; import {describe, it, expect} from 'vitest'; import type {SearchableItem} from '../Typeahead/types'; import {Tokenizer, type TokenizerProps} from './Tokenizer'; +import {TokenizerWithPresentation} from '../internal/tokenizer-presentation'; type Item = SearchableItem; const source = {search: () => [], bootstrap: () => []}; @@ -62,13 +65,22 @@ export function compiles() { onChange={() => {}} ref={inputRef} /> + {/* The public props name no presentation. */} {}} - ref={inputRef} + // @ts-expect-error -- `experimental` is not on TokenizerProps experimental={{presentation: 'adaptive'}} /> + {}} + // @ts-expect-error -- nor on the public component + experimental={{presentation: 'bottom-sheet'}} + /> {/* The stable shape: ref is the combobox input's. */} {}} ref={inputRef} /> - {/* The experimental opt-in admits the wider ref. */} - - {/* An input-typed ref is still fine with the opt-in. */} - - {/* @ts-expect-error -- without experimental, ref keeps the released input-only type */} {}} + // @ts-expect-error -- the public ref keeps the released input-only type ref={wideRef} /> @@ -108,7 +120,7 @@ export function compiles() { } describe('Tokenizer props type', () => { - it('keeps TokenizerProps an interface that extends and Omit-wraps (type-checked above)', () => { + it('keeps TokenizerProps an interface that extends and Omit-wraps, with no presentation option on the public component (type-checked above)', () => { expect(typeof compiles).toBe('function'); }); }); diff --git a/packages/core/src/Tokenizer/Tokenizer.spec.md b/packages/core/src/Tokenizer/Tokenizer.spec.md index 9a535ff763919..409cd3ba4ea1e 100644 --- a/packages/core/src/Tokenizer/Tokenizer.spec.md +++ b/packages/core/src/Tokenizer/Tokenizer.spec.md @@ -15,6 +15,8 @@ verified_by: packages/core/src/Tokenizer/Tokenizer.test.tsx, packages/core/src/Tokenizer/Tokenizer.ssr.test.tsx, packages/core/src/Tokenizer/TokenizerBottomSheet.test.tsx, + packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx, + packages/core/src/internal/tokenizer-presentation.test.ts, packages/core/src/Typeahead/Typeahead.test.tsx, packages/core/src/Layer/useLayer.test.tsx, scripts/check-knowledge.mjs, @@ -50,15 +52,15 @@ system_specs: # Tokenizer component contract -| Area | Contract | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | One optional `experimental` prop object whose `presentation` option selects the surface (DEC-3); the stable props, exports, and syntax are unchanged, and `TokenizerProps` stays an interface whose `ref` is `Ref`; only with `experimental` passed does the component accept a wider `ref`. The existing `hasEntriesOnFocus` mode has a defined post-selection lifecycle (DEC-2). | -| Behavior | A committed empty-query bootstrap choice keeps remaining eligible loaded choices open, filters committed IDs, and advances the active option; terminal states close safely. The `bottom-sheet` presentation replaces the inline combobox with a trigger that opens one searchable modal sheet of checkbox rows (FR16–FR24). | -| End-user impact | People selecting several loaded values can continue without reopening the menu, while disabled, loading, exhausted, and dismissed states remain non-interactive. On a compact touch screen, an opted-in field offers thumb-sized rows and a search field the keyboard does not cover. | -| Builder impact | None for existing callers. A caller opts into the touch surface with `experimental={{presentation: 'adaptive'}}` or `'bottom-sheet'`, accepting that the option may change or be removed in a patch release. | -| Compatibility/readiness | Omitting `experimental`, and the default `hasEntriesOnFocus={false}` path, keep today's inline combobox and direct single-select BaseTypeahead unchanged. DEC-2 is current and owner-approved with implementation and exact-head evidence pending; DEC-3 is current and implemented. | -| Review checks | Reject implementations that filter by label instead of ID, reorder eligible choices, lose focus or a valid active descendant, refetch unnecessarily, or let disabled, exhausted, dismissed, or stale-loading states remain interactive or reopen. Reject a sheet presentation that switches without `experimental`, moves an existing row on toggle, or clears every token without confirmation. | -| Governing rules | This canonical `component:Tokenizer` record: [FR13–FR24](#behavioral-and-layout-contract), [AR5–AR6](#accessibility-contract), [DEC-2](#dec-2--focus-bootstrap-supports-consecutive-committed-selections), and [DEC-3](#dec-3--the-touch-sheet-presentation-is-an-experimental-option). | +| Area | Contract | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | One optional `experimental` prop object whose `presentation` option selects the surface (DEC-3); the stable props, exports, and syntax are unchanged, and `TokenizerProps` stays an interface whose `ref` is `Ref`; only with `experimental` passed does the component accept a wider `ref`. The existing `hasEntriesOnFocus` mode has a defined post-selection lifecycle (DEC-2). | +| Behavior | A committed empty-query bootstrap choice keeps remaining eligible loaded choices open, filters committed IDs, and advances the active option; terminal states close safely. The `bottom-sheet` presentation replaces the inline combobox with a trigger that opens one searchable modal sheet of checkbox rows (FR16–FR24). | +| End-user impact | People selecting several loaded values can continue without reopening the menu, while disabled, loading, exhausted, and dismissed states remain non-interactive. On a compact touch screen, an opted-in field offers thumb-sized rows and a search field the keyboard does not cover. | +| Builder impact | None for existing callers. A caller opts into the touch surface with `experimental={{presentation: 'adaptive'}}` or `'bottom-sheet'`, accepting that the option may change or be removed in a patch release. | +| Compatibility/readiness | Omitting `experimental`, and the default `hasEntriesOnFocus={false}` path, keep today's inline combobox and direct single-select BaseTypeahead unchanged. DEC-2 is current and owner-approved with implementation and exact-head evidence pending; DEC-3 is current and implemented, with the implementation gap that the sheet presentation is not yet exposed: `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| Review checks | Reject implementations that filter by label instead of ID, reorder eligible choices, lose focus or a valid active descendant, refetch unnecessarily, or let disabled, exhausted, dismissed, or stale-loading states remain interactive or reopen. Reject a sheet presentation that switches without `experimental`, moves an existing row on toggle, or clears every token without confirmation. | +| Governing rules | This canonical `component:Tokenizer` record: [FR13–FR24](#behavioral-and-layout-contract), [AR5–AR6](#accessibility-contract), [DEC-2](#dec-2--focus-bootstrap-supports-consecutive-committed-selections), and [DEC-3](#dec-3--the-touch-sheet-presentation-is-an-experimental-option). | ## Intent @@ -151,8 +153,13 @@ One public concept is introduced, as an experimental surface: the optional `experimental` prop object and its `presentation` option, `'popover' | 'bottom-sheet' | 'adaptive'` (DEC-3). The option type is inlined on the prop; no experimental hook or type is exported. Declarations carry `@experimental` -and consumer docs carry `stability: 'experimental'` metadata. Existing -consumer props and usage remain in `Tokenizer.doc.mjs`. The existing +and consumer docs carry `stability: 'experimental'` metadata. Implementation +gap: the option is not yet exposed — `TokenizerProps` offers no `experimental` +while the sheet's user experience is being finished, consumer docs carry no +`experimental` entry, and the sheet-capable signature is reachable only +through `@astryxdesign/core/internal/tokenizer-presentation`, an internal entry +reserved for Astryx's own packages (`architecture:react-component-runtime` +INV13). Existing consumer props and usage remain in `Tokenizer.doc.mjs`. The existing `hasEntriesOnFocus` mode includes the component-owned post-selection behavior in FR13–FR15. @@ -189,32 +196,32 @@ pending. FR1–FR15 describe the `popover` presentation, the inline combobox; FR16–FR24 describe the `experimental.presentation` option and its sheet presentation, which has no inline combobox, suggestion menu, or end lane. -| ID | Invariant | Basis | Status | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------- | -| FR1 | Tokenizer MUST keep one native combobox input mounted across token addition, removal, clear, ordinary wrapping, and maximum-entry states. Reaching `maxEntries` may make the input zero-width, transparent, and out of flow, but MUST NOT replace its identity. | Current source and tests | Verified current behavior | -| FR2 | Tokenizer's suggestion menu MUST remain associated with the outer field/control surface. Existing `menuWidth` behavior remains an exact preferred pixel width plus the outer field's minimum width; it is not a maximum. | Current BaseTypeahead/Tokenizer source and docs | Verified current behavior; preserve | -| FR3 | `unfocusedInline` MAY replace direct token layout with OverflowList while blurred. `unfocusedLayer` MAY keep the real wrapper/input mounted in a context layer while an ordinary-flow placeholder represents the collapsed field. | Current source, tests, and stories | Verified current behavior | -| FR4 | Tokenizer MUST provide Core peers one package-internal descriptor with stable identities for its outer field/control surface and active opening control. Layer consumes the descriptor; public consumers cannot observe or configure it. | Approved package-internal contract; #5804 evidence; `spec:AST-002/DEC-1` | Current contract; implementation pending | -| FR5 | Add/search opens MUST identify the stable inner input as the opening control before the result row disappears. Selection, query clearing, result removal, refocus, or later input collapse MUST NOT substitute the transient result row or a pointer coordinate. | Current selection order plus approved descriptor contract | Current contract; implementation pending | -| FR6 | Edit opens MUST capture the actual activated token/control before state removes or replaces it. The Tokenizer-owned capture path MUST include custom `renderToken` output when activation originates from an identifiable descendant control; it MUST NOT require a new public ref contract. | Current owned wrapper plus approved capture contract | Current contract; implementation pending | -| FR7 | A non-null element is usable only while connected, rendered, and geometrically meaningful for the open being prepared. A disconnected, stale, hidden-layer, zero-width, or otherwise unusable opening control MUST fall back safely to the outer field at logical start before show. | Current max/overflow states plus approved safety rule | Current contract; implementation pending | -| FR8 | The descriptor MUST remain valid across focus movement, token add/remove/clear, wrapping, and `unfocusedLayer` reparenting. It MUST clear on unmount, perform no DOM work during SSR, and never retain an element beyond the open preparation that uses it. | Approved identity/lifetime contract | Current contract; implementation pending | -| FR9 | A consuming surface MUST resolve its preferred width before choosing horizontal placement. A surface at least as wide as the outer field MUST use outer-field logical-start alignment. Only a surface narrower than the outer field may use opening-control geometry. | Approved conditional-placement contract | Current contract; implementation pending | -| FR10 | For a narrower surface, Layer MUST compare the opening control with the outer field's physical start/end geometry, choose the nearer physical edge, map that result to logical start/end, and use logical start on a tie. Viewport collision runs last. | Approved Layer-consumer contract | Current contract; implementation pending | -| FR11 | Opening placement MUST be modality-neutral and latched for one open lifetime. Pointer, keyboard, touch, and programmatic activation of the same control MUST produce the same preferred edge. Pointer movement, later focus movement, resize, or target collapse MUST NOT recompute it. | `architecture:interaction-modality` plus approved stability contract | Current contract; implementation pending | -| FR12 | Placement state MUST commit while the surface is hidden, and Layer MUST show it on the next animation frame only after anchor, alignment, and width styles exist. A stale or invalid descriptor MUST never cause a visible wrong-position or first-frame flash. | Approved no-flash contract | Current contract; implementation pending | -| FR13 | Tokenizer MUST exclude every choice whose `SearchableItem.id` is already present in its committed `value` from displayed bootstrap and search cohorts, including results that settle after the controlled value changes. A caller-rejected proposed value remains eligible because it was not committed. Distinct IDs remain eligible even when their labels match. | Approved Tokenizer multi-selection contract | Current contract; implementation and verification pending | -| FR14 | With `hasEntriesOnFocus` enabled, committing a choice from the current settled empty-query bootstrap cohort MUST keep the input focused and the popup open when another eligible loaded choice remains. The active option advances to the next remaining choice in displayed order; when the committed choice was last, the nearest preceding choice becomes active. `aria-activedescendant` MUST reference that rendered option. | Approved rapid multi-select lifecycle | Current contract; implementation and verification pending | -| FR15 | The retained popup MUST close and clear its active descendant when no eligible loaded choice remains, `maxEntries` is reached, the control becomes disabled, focus leaves, or the person dismisses it. Disabled Tokenizer remains non-interactive. Pending or stale source work MUST NOT reopen a terminal popup or expose stale choices; loading has no selectable placeholder and its busy state clears when work is cancelled or settles. | Approved terminal-state safety boundary | Current contract; implementation and verification pending | -| FR16 | `experimental.presentation` selects the surface. `popover`, the value when `experimental` or the option is omitted, is the inline combobox with its anchored suggestion menu, unchanged. `bottom-sheet` is the trigger and modal sheet of FR17–FR24 on every device. `adaptive` resolves to `bottom-sheet` on compact coarse-pointer screens and to `popover` otherwise, through the one touch-presentation rule `spec:AST-094/FR13` owns, as the selection controls do. `adaptive` renders on the server, and hydrates, as the inline field, because the resolver answers `popover` where there is no `matchMedia` and in the hydrating render; on a compact coarse-pointer client the field swaps to the trigger in the first render after hydration; nothing is focused before interaction, so focus is undisturbed (`document.activeElement` stays the body), except with `hasAutoFocus`, whose server-rendered `autofocus` focuses the inline input on load: then the swap moves that focus to the trigger — the same element and visit for the caller, so no `onBlur`/`onFocus` pair is reported. The trigger keeps the field's box — the same width, top, label, and `size` block size — except the inline input's own row: when the tokens leave the input no room on their row, the inline field wraps the input onto a row of its own, and the trigger, which has no input, drops that row (one `md` row, 27px, on a 390 and a 414px phone with five tokens); with room for the input beside the tokens, nothing shifts. A resolution change while the sheet is open takes effect after it closes and focus has returned to the trigger — the presentation hold `architecture:layer-runtime/INV14` assigns to the layer runtime; INV14 gap: the runtime does not yet provide one (its record names INV14 as ownership not yet met), so until it does the field keeps its own hold, to be replaced by the runtime's when it exists. Whenever the presentation changes while its control holds focus — then, or while the sheet is closed — focus moves to the new presentation's control, the combobox input or the trigger, so it never lands on the page body (`spec:AST-085/FR4`). | DEC-3; touch-flow evidence from the Lab prototype | Verified current behavior | -| FR17 | In the sheet presentation the control surface is one button, named by the field label, with `aria-haspopup="dialog"` and `aria-expanded`. It shows the selected tokens without remove controls, or the placeholder when empty, wraps tokens onto further lines, and keeps the inline field's block size for its `size` step. Activating it opens one tall modal BottomSheet named by the field label; the suggestion popover, inline input, and end lane do not render. While disabled the trigger stays focusable with `aria-disabled="true"`, does not open, and shows `disabledMessage` as the inline field does (`spec:AST-085/FR1`); a field that becomes disabled while its sheet is open closes the sheet and returns focus to the trigger (`spec:AST-085/FR2`). | DEC-3; Lab touch-flow evidence; `spec:AST-085` | Verified current behavior | -| FR18 | The sheet is a single surface: a search row at the top using the panel search field the selection controls share, a scrolling list of rows, and, while the query is empty, a footer with Clear all and Done. There is no second sheet. On open, focus moves to the sheet's panel, not the search field, so the reading order starts at the top of the sheet and the on-screen keyboard rises only when the person reaches for the search (WCAG 2.2 SC 2.4.3 Focus Order and 3.2.1 On Focus through `spec:AST-020/FR1`). Done closes the sheet; swipe, scrim, and Escape dismissal follow BottomSheet `purpose="info"`; each of those closes clears the query and reports it through `onChangeQuery`, so the next open lists the bootstrap set, while a close the field forces (becoming disabled, FR17) keeps the query, since nothing the person did changed it; focus returns to the trigger after the sheet closes. | DEC-3; Lab touch-flow evidence; `component:BottomSheet` | Verified current behavior | -| FR19 | Each row is a `checkbox` named by the item label whose `aria-checked` mirrors membership in `value`. Activating an unchecked row commits an `add` change; a checked row commits a `remove` change; both apply immediately and are announced with the inline field's words through the sheet's own live region (AR6). Row order is stable while the sheet is open: the items selected when it opened, in `value` order, then selected items absent from the results, then the results in source order; a toggle never moves an existing row. The next open takes a fresh snapshot. The query filters the selected rows by case-insensitive label substring. A row renders an item without its `action` and warns once in development that the sheet does not render it, as the inline panel does (`spec:AST-058/FR9`). | DEC-3; Lab touch-flow evidence | Verified current behavior | -| FR20 | An empty query lists the source's bootstrap set, capped at `maxMenuItems` as the inline field caps its focus bootstrap (the prop keeps its meaning in the sheet), requested at once on open and on clearing the query, so an instant source shows no busy feedback and announces no busy start; a query shorter than `minQueryLength` is not searched: it lists only the selected rows it matches and, with `hasCreate`, the create row, with no busy feedback, no empty state, and no outcome announcement, as the inline field shows nothing for such a query; otherwise the source is searched `debounceMs` after the query change and the results are capped at `maxMenuItems`. `onChangeQuery` fires on every query change. From open, and from each query change, until that search settles, the list is pending: it shows named busy feedback above any rows still listed from the previous search, the list element carries the busy semantics (`aria-busy`), since the sheet has no combobox (`family:input-fields/FR7`, which places busy semantics on the list a presentation without a combobox updates), and no empty state shows; when the settled search removes the row that holds focus, focus moves to the search field (`spec:AST-085/FR4`). Once settled, the outcome is announced through the sheet's live region (AR6): the result count for a searched query, or the rendered text of the empty state when it shows (`spec:AST-056/FR3`). A source that rejects settles the search to no results, as the inline field does. With no rows and no create row, a searched query shows the `emptySearchText` empty state (its default message when omitted); an empty query shows no list, no message, and announces nothing — only the search field, above the footer — as the inline field opens no menu and reports nothing for a bootstrap set that is empty: nothing has failed, and typing is what finds items (a search-only source, `bootstrap: () => []`, is such a source). | DEC-3; Lab touch-flow evidence; `family:input-fields` | Verified current behavior | -| FR21 | Long lists render progressively: 50 rows at a time, the next batch when the list scrolls within 200px of its end, with the window reset to the first batch on open and on each query change. The rendered and total counts are exposed as `data-rendered-count` and `data-total-count` on the list, and the rows sit in a `list` whose `listitem` wrappers carry `aria-setsize` with the total and `aria-posinset` with the row's position — the `checkbox` role itself admits neither — so a person moving through the rows can learn that more exist before the list has scrolled; whether a screen reader speaks the position ("n of total") from the focused checkbox depends on the screen reader (`spec:AST-009/FR8`), and the accessibility tree proves the `list`, `listitem`, `aria-setsize`, and `aria-posinset` exposure; focus reaching the last rendered row renders the next batch (WCAG 2.2 SC 1.3.1 Info and Relationships and 4.1.2 Name, Role, Value through `spec:AST-020/FR1`). | DEC-3; Lab touch-flow evidence | Verified current behavior | -| FR22 | With `hasCreate`, a non-empty trimmed query that matches no selected id, no selected label, and no result label (case-insensitive) adds a create row above the list showing `Create "{text}"` with a trailing Add action named `Add {text}`. Activating it commits a `create` change whose item has the text as both id and label, clears the query, and moves focus to the sheet's search field, since the row it was on is gone. The create row is absent at `maxEntries`. | DEC-3; Lab touch-flow evidence; `hasCreate` contract of the inline field | Verified current behavior | -| FR23 | At `maxEntries`, unchecked rows carry `aria-disabled="true"`, stay focusable, and ignore activation; checked rows remain toggleable so the person can make room (`spec:AST-085/FR1`). | DEC-3; Lab touch-flow evidence; `spec:AST-085` | Verified current behavior | -| FR24 | Clear all is disabled while `value` is empty: `aria-disabled`, still in the focus order, with a tooltip saying nothing is selected, and a click opens nothing (`spec:AST-085/FR1`). Otherwise it opens an AlertDialog confirmation naming the field; confirming commits `[]` with a `remove` change naming the last selected item, closes the confirmation, and — once the confirmation has closed — moves focus to the sheet's search field, the destination this record names (`spec:AST-085/FR4`): the clear is spent, and the search field is where the next selection starts (WCAG 2.2 SC 2.4.3 through `spec:AST-020/FR1`), and announces "All items removed" inside the sheet (AR6); cancelling changes nothing and focus returns to Clear all. | DEC-3; Lab touch-flow evidence | Verified current behavior | +| ID | Invariant | Basis | Status | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| FR1 | Tokenizer MUST keep one native combobox input mounted across token addition, removal, clear, ordinary wrapping, and maximum-entry states. Reaching `maxEntries` may make the input zero-width, transparent, and out of flow, but MUST NOT replace its identity. | Current source and tests | Verified current behavior | +| FR2 | Tokenizer's suggestion menu MUST remain associated with the outer field/control surface. Existing `menuWidth` behavior remains an exact preferred pixel width plus the outer field's minimum width; it is not a maximum. | Current BaseTypeahead/Tokenizer source and docs | Verified current behavior; preserve | +| FR3 | `unfocusedInline` MAY replace direct token layout with OverflowList while blurred. `unfocusedLayer` MAY keep the real wrapper/input mounted in a context layer while an ordinary-flow placeholder represents the collapsed field. | Current source, tests, and stories | Verified current behavior | +| FR4 | Tokenizer MUST provide Core peers one package-internal descriptor with stable identities for its outer field/control surface and active opening control. Layer consumes the descriptor; public consumers cannot observe or configure it. | Approved package-internal contract; #5804 evidence; `spec:AST-002/DEC-1` | Current contract; implementation pending | +| FR5 | Add/search opens MUST identify the stable inner input as the opening control before the result row disappears. Selection, query clearing, result removal, refocus, or later input collapse MUST NOT substitute the transient result row or a pointer coordinate. | Current selection order plus approved descriptor contract | Current contract; implementation pending | +| FR6 | Edit opens MUST capture the actual activated token/control before state removes or replaces it. The Tokenizer-owned capture path MUST include custom `renderToken` output when activation originates from an identifiable descendant control; it MUST NOT require a new public ref contract. | Current owned wrapper plus approved capture contract | Current contract; implementation pending | +| FR7 | A non-null element is usable only while connected, rendered, and geometrically meaningful for the open being prepared. A disconnected, stale, hidden-layer, zero-width, or otherwise unusable opening control MUST fall back safely to the outer field at logical start before show. | Current max/overflow states plus approved safety rule | Current contract; implementation pending | +| FR8 | The descriptor MUST remain valid across focus movement, token add/remove/clear, wrapping, and `unfocusedLayer` reparenting. It MUST clear on unmount, perform no DOM work during SSR, and never retain an element beyond the open preparation that uses it. | Approved identity/lifetime contract | Current contract; implementation pending | +| FR9 | A consuming surface MUST resolve its preferred width before choosing horizontal placement. A surface at least as wide as the outer field MUST use outer-field logical-start alignment. Only a surface narrower than the outer field may use opening-control geometry. | Approved conditional-placement contract | Current contract; implementation pending | +| FR10 | For a narrower surface, Layer MUST compare the opening control with the outer field's physical start/end geometry, choose the nearer physical edge, map that result to logical start/end, and use logical start on a tie. Viewport collision runs last. | Approved Layer-consumer contract | Current contract; implementation pending | +| FR11 | Opening placement MUST be modality-neutral and latched for one open lifetime. Pointer, keyboard, touch, and programmatic activation of the same control MUST produce the same preferred edge. Pointer movement, later focus movement, resize, or target collapse MUST NOT recompute it. | `architecture:interaction-modality` plus approved stability contract | Current contract; implementation pending | +| FR12 | Placement state MUST commit while the surface is hidden, and Layer MUST show it on the next animation frame only after anchor, alignment, and width styles exist. A stale or invalid descriptor MUST never cause a visible wrong-position or first-frame flash. | Approved no-flash contract | Current contract; implementation pending | +| FR13 | Tokenizer MUST exclude every choice whose `SearchableItem.id` is already present in its committed `value` from displayed bootstrap and search cohorts, including results that settle after the controlled value changes. A caller-rejected proposed value remains eligible because it was not committed. Distinct IDs remain eligible even when their labels match. | Approved Tokenizer multi-selection contract | Current contract; implementation and verification pending | +| FR14 | With `hasEntriesOnFocus` enabled, committing a choice from the current settled empty-query bootstrap cohort MUST keep the input focused and the popup open when another eligible loaded choice remains. The active option advances to the next remaining choice in displayed order; when the committed choice was last, the nearest preceding choice becomes active. `aria-activedescendant` MUST reference that rendered option. | Approved rapid multi-select lifecycle | Current contract; implementation and verification pending | +| FR15 | The retained popup MUST close and clear its active descendant when no eligible loaded choice remains, `maxEntries` is reached, the control becomes disabled, focus leaves, or the person dismisses it. Disabled Tokenizer remains non-interactive. Pending or stale source work MUST NOT reopen a terminal popup or expose stale choices; loading has no selectable placeholder and its busy state clears when work is cancelled or settles. | Approved terminal-state safety boundary | Current contract; implementation and verification pending | +| FR16 | `experimental.presentation` selects the surface. `popover`, the value when `experimental` or the option is omitted, is the inline combobox with its anchored suggestion menu, unchanged. `bottom-sheet` is the trigger and modal sheet of FR17–FR24 on every device. `adaptive` resolves to `bottom-sheet` on compact coarse-pointer screens and to `popover` otherwise, through the one touch-presentation rule `spec:AST-094/FR13` owns, as the selection controls do. `adaptive` renders on the server, and hydrates, as the inline field, because the resolver answers `popover` where there is no `matchMedia` and in the hydrating render; on a compact coarse-pointer client the field swaps to the trigger in the first render after hydration; nothing is focused before interaction, so focus is undisturbed (`document.activeElement` stays the body), except with `hasAutoFocus`, whose server-rendered `autofocus` focuses the inline input on load: then the swap moves that focus to the trigger — the same element and visit for the caller, so no `onBlur`/`onFocus` pair is reported. The trigger keeps the field's box — the same width, top, label, and `size` block size — except the inline input's own row: when the tokens leave the input no room on their row, the inline field wraps the input onto a row of its own, and the trigger, which has no input, drops that row (one `md` row, 27px, on a 390 and a 414px phone with five tokens); with room for the input beside the tokens, nothing shifts. A resolution change while the sheet is open takes effect after it closes and focus has returned to the trigger — the presentation hold `architecture:layer-runtime/INV14` assigns to the layer runtime; INV14 gap: the runtime does not yet provide one (its record names INV14 as ownership not yet met), so until it does the field keeps its own hold, to be replaced by the runtime's when it exists. Whenever the presentation changes while its control holds focus — then, or while the sheet is closed — focus moves to the new presentation's control, the combobox input or the trigger, so it never lands on the page body (`spec:AST-085/FR4`). | DEC-3; touch-flow evidence from the Lab prototype | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR17 | In the sheet presentation the control surface is one button, named by the field label, with `aria-haspopup="dialog"` and `aria-expanded`. It shows the selected tokens without remove controls, or the placeholder when empty, wraps tokens onto further lines, and keeps the inline field's block size for its `size` step. Activating it opens one tall modal BottomSheet named by the field label; the suggestion popover, inline input, and end lane do not render. While disabled the trigger stays focusable with `aria-disabled="true"`, does not open, and shows `disabledMessage` as the inline field does (`spec:AST-085/FR1`); a field that becomes disabled while its sheet is open closes the sheet and returns focus to the trigger (`spec:AST-085/FR2`). | DEC-3; Lab touch-flow evidence; `spec:AST-085` | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR18 | The sheet is a single surface: a search row at the top using the panel search field the selection controls share, a scrolling list of rows, and, while the query is empty, a footer with Clear all and Done. There is no second sheet. On open, focus moves to the sheet's panel, not the search field, so the reading order starts at the top of the sheet and the on-screen keyboard rises only when the person reaches for the search (WCAG 2.2 SC 2.4.3 Focus Order and 3.2.1 On Focus through `spec:AST-020/FR1`). Done closes the sheet; swipe, scrim, and Escape dismissal follow BottomSheet `purpose="info"`; each of those closes clears the query and reports it through `onChangeQuery`, so the next open lists the bootstrap set, while a close the field forces (becoming disabled, FR17) keeps the query, since nothing the person did changed it; focus returns to the trigger after the sheet closes. | DEC-3; Lab touch-flow evidence; `component:BottomSheet` | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR19 | Each row is a `checkbox` named by the item label whose `aria-checked` mirrors membership in `value`. Activating an unchecked row commits an `add` change; a checked row commits a `remove` change; both apply immediately and are announced with the inline field's words through the sheet's own live region (AR6). Row order is stable while the sheet is open: the items selected when it opened, in `value` order, then selected items absent from the results, then the results in source order; a toggle never moves an existing row. The next open takes a fresh snapshot. The query filters the selected rows by case-insensitive label substring. A row renders an item without its `action` and warns once in development that the sheet does not render it, as the inline panel does (`spec:AST-058/FR9`). | DEC-3; Lab touch-flow evidence | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR20 | An empty query lists the source's bootstrap set, capped at `maxMenuItems` as the inline field caps its focus bootstrap (the prop keeps its meaning in the sheet), requested at once on open and on clearing the query, so an instant source shows no busy feedback and announces no busy start; a query shorter than `minQueryLength` is not searched: it lists only the selected rows it matches and, with `hasCreate`, the create row, with no busy feedback, no empty state, and no outcome announcement, as the inline field shows nothing for such a query; otherwise the source is searched `debounceMs` after the query change and the results are capped at `maxMenuItems`. `onChangeQuery` fires on every query change. From open, and from each query change, until that search settles, the list is pending: it shows named busy feedback above any rows still listed from the previous search, the list element carries the busy semantics (`aria-busy`), since the sheet has no combobox (`family:input-fields/FR7`, which places busy semantics on the list a presentation without a combobox updates), and no empty state shows; when the settled search removes the row that holds focus, focus moves to the search field (`spec:AST-085/FR4`). Once settled, the outcome is announced through the sheet's live region (AR6): the result count for a searched query, or the rendered text of the empty state when it shows (`spec:AST-056/FR3`). A source that rejects settles the search to no results, as the inline field does. With no rows and no create row, a searched query shows the `emptySearchText` empty state (its default message when omitted); an empty query shows no list, no message, and announces nothing — only the search field, above the footer — as the inline field opens no menu and reports nothing for a bootstrap set that is empty: nothing has failed, and typing is what finds items (a search-only source, `bootstrap: () => []`, is such a source). | DEC-3; Lab touch-flow evidence; `family:input-fields` | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR21 | Long lists render progressively: 50 rows at a time, the next batch when the list scrolls within 200px of its end, with the window reset to the first batch on open and on each query change. The rendered and total counts are exposed as `data-rendered-count` and `data-total-count` on the list, and the rows sit in a `list` whose `listitem` wrappers carry `aria-setsize` with the total and `aria-posinset` with the row's position — the `checkbox` role itself admits neither — so a person moving through the rows can learn that more exist before the list has scrolled; whether a screen reader speaks the position ("n of total") from the focused checkbox depends on the screen reader (`spec:AST-009/FR8`), and the accessibility tree proves the `list`, `listitem`, `aria-setsize`, and `aria-posinset` exposure; focus reaching the last rendered row renders the next batch (WCAG 2.2 SC 1.3.1 Info and Relationships and 4.1.2 Name, Role, Value through `spec:AST-020/FR1`). | DEC-3; Lab touch-flow evidence | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR22 | With `hasCreate`, a non-empty trimmed query that matches no selected id, no selected label, and no result label (case-insensitive) adds a create row above the list showing `Create "{text}"` with a trailing Add action named `Add {text}`. Activating it commits a `create` change whose item has the text as both id and label, clears the query, and moves focus to the sheet's search field, since the row it was on is gone. The create row is absent at `maxEntries`. | DEC-3; Lab touch-flow evidence; `hasCreate` contract of the inline field | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR23 | At `maxEntries`, unchecked rows carry `aria-disabled="true"`, stay focusable, and ignore activation; checked rows remain toggleable so the person can make room (`spec:AST-085/FR1`). | DEC-3; Lab touch-flow evidence; `spec:AST-085` | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | +| FR24 | Clear all is disabled while `value` is empty: `aria-disabled`, still in the focus order, with a tooltip saying nothing is selected, and a click opens nothing (`spec:AST-085/FR1`). Otherwise it opens an AlertDialog confirmation naming the field; confirming commits `[]` with a `remove` change naming the last selected item, closes the confirmation, and — once the confirmation has closed — moves focus to the sheet's search field, the destination this record names (`spec:AST-085/FR4`): the clear is spent, and the search field is where the next selection starts (WCAG 2.2 SC 2.4.3 through `spec:AST-020/FR1`), and announces "All items removed" inside the sheet (AR6); cancelling changes nothing and focus returns to Clear all. | DEC-3; Lab touch-flow evidence | Verified current behavior through the internal entry. Implementation gap: the sheet presentation is implemented but not yet exposed; `TokenizerProps` offers no `experimental.presentation` while its user experience is being finished, and every field renders the popover presentation. | ### Allowed variation @@ -501,6 +508,14 @@ single-select BaseTypeahead behavior. **Reference:** `component:Tokenizer/DEC-3` **Decider:** `imdreamrunner`, `2026-10-23` +Implementation gap: the sheet presentation is implemented but not yet +exposed; `TokenizerProps` offers no `experimental.presentation` while its user +experience is being finished, and every field renders the popover +presentation. Astryx's own packages reach the implementation through the +internal entry `@astryxdesign/core/internal/tokenizer-presentation` +(`architecture:react-component-runtime` INV13); the rows below are verified +through it. + Tokenizer offers its touch surface through the `presentation` option of the optional `experimental` prop object, with the values `popover`, `bottom-sheet`, and `adaptive` that the selection controls use. The option is marked diff --git a/packages/core/src/Tokenizer/Tokenizer.ssr.test.tsx b/packages/core/src/Tokenizer/Tokenizer.ssr.test.tsx index 8b85074fa0bb5..51d9bc5385f67 100644 --- a/packages/core/src/Tokenizer/Tokenizer.ssr.test.tsx +++ b/packages/core/src/Tokenizer/Tokenizer.ssr.test.tsx @@ -55,9 +55,10 @@ async function serverRenderThenHydrate( } = {}, ) { vi.resetModules(); - const {Tokenizer} = await import('./Tokenizer'); + const {TokenizerWithPresentation} = + await import('../internal/tokenizer-presentation'); const tree = ( - extends Omit< ref?: React.Ref; /** Imperative handle ref for focus/blur control. */ handleRef?: React.Ref; - /** - * Experimental options, outside the stable compatibility promise: anything - * in this object may change or be removed in a patch release. Omit it for - * the stable behavior. - * @experimental - */ - experimental?: TokenizerExperimentalOptions; } /** - * The props the experimental opt-in admits: `TokenizerProps` with - * `experimental` present and a `ref` that also accepts the sheet - * presentation's trigger button, the control it stands for there. Reached - * only through the overload below; `TokenizerProps` — an interface, so - * `interface X extends TokenizerProps`, `Omit, …>` and - * `ComponentProps` keep their released shapes — stays the - * component's declared props type. + * The props of the sheet-capable signature: `TokenizerProps` plus the + * presentation options and a `ref` that also accepts the sheet presentation's + * trigger button, the control it stands for there. Not public: reached only + * as `TokenizerWithPresentation` through the internal entry, so the released + * `TokenizerProps` — an interface, so `interface X extends TokenizerProps`, + * `Omit, …>` and `ComponentProps` keep + * their released shapes — names no presentation. */ -type TokenizerExperimentalProps = Omit< +export type TokenizerWithPresentationProps = Omit< TokenizerProps, - 'ref' | 'experimental' + 'ref' > & { - /** @experimental */ - experimental: TokenizerExperimentalOptions; + /** How the field presents its choices; see `TokenizerPresentationOptions`. */ + experimental?: TokenizerPresentationOptions; /** * Ref forwarded to the control: the combobox input in the popover * presentation, the trigger button in the sheet presentation. - * @experimental */ ref?: React.Ref; }; @@ -701,13 +699,10 @@ function EndLane({ ); } -// The experimental arm first: TypeScript resolves `ComponentProps` and a call without `experimental` against the last overload, the -// stable signature, while a call that passes `experimental` matches this one -// and may pass the wider `ref`. -export function Tokenizer( - props: TokenizerExperimentalProps, -): React.JSX.Element; +// The public signature names no presentation: `ComponentProps` and every public call resolve against `TokenizerProps`. The +// implementation also reads the presentation options, reached only through +// `TokenizerWithPresentation` below. export function Tokenizer( props: TokenizerProps, ): React.JSX.Element; @@ -760,7 +755,7 @@ export function Tokenizer({ id: idProp, 'aria-describedby': ariaDescribedByProp, ...restProps -}: TokenizerProps | TokenizerExperimentalProps): React.JSX.Element { +}: TokenizerWithPresentationProps): React.JSX.Element { const {rest, container} = useFieldInputStyling('Tokenizer', restProps, { containerXstyle, containerClassName, @@ -1736,3 +1731,19 @@ export function Tokenizer({ } Tokenizer.displayName = 'Tokenizer'; + +/** + * The same component with the sheet presentation reachable: `experimental` + * (`presentation: 'popover' | 'bottom-sheet' | 'adaptive'`) and a `ref` that + * also takes the trigger button. Not public — re-exported only by + * `@astryxdesign/core/internal/tokenizer-presentation`, the entry reserved + * for Astryx's own packages (`architecture:react-component-runtime` INV13), + * while the sheet's user experience is being finished (`component:Tokenizer` + * DEC-3). The public `Tokenizer` renders the popover presentation for every + * field. + */ +export const TokenizerWithPresentation = Tokenizer as < + T extends SearchableItem, +>( + props: TokenizerWithPresentationProps, +) => React.JSX.Element; diff --git a/packages/core/src/Tokenizer/TokenizerBottomSheet.test.tsx b/packages/core/src/Tokenizer/TokenizerBottomSheet.test.tsx index cb21c299e54bc..5b129908bcd0f 100644 --- a/packages/core/src/Tokenizer/TokenizerBottomSheet.test.tsx +++ b/packages/core/src/Tokenizer/TokenizerBottomSheet.test.tsx @@ -28,7 +28,8 @@ import { } from '@testing-library/react'; import {createRef, useState} from 'react'; import * as stylex from '@stylexjs/stylex'; -import {Tokenizer, type TokenizerChange} from './Tokenizer'; +import type {TokenizerChange} from './Tokenizer'; +import {TokenizerWithPresentation} from '../internal/tokenizer-presentation'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; import {__resetDevWarnings} from '../utils/devWarning'; import {colorVars} from '../theme/tokens.stylex'; @@ -80,7 +81,7 @@ function Harness({ }) { const [value, setValue] = useState(initial); return ( - vi.unstubAllGlobals()); describe('Tokenizer experimental.presentation (FR16)', () => { it('keeps the inline combobox when experimental is omitted', () => { render( - { it('keeps the inline combobox for an empty experimental object and for popover', () => { const {unmount} = render( - { it('keeps the inline popover presentation on the shared live region', async () => { render( - { ), }; render( - { it('searches within the same sheet and shows the no-results state (FR18, FR20)', async () => { const onChangeQuery = vi.fn(); const {rerender} = render( - { // A string emptySearchText becomes the empty state's title. rerender( - { }); try { render( - { bootstrap: () => many, }; render( - { // "Enzo" is a created value the source never returns; the source has // three "en" matches (Eng, Engineer, Energizer), capped to two rows. render( - { function InlineSourceHarness() { const [value, setValue] = useState([ITEMS[0]]); return ( - { debounceMs: 0, experimental: {presentation: 'bottom-sheet' as const}, }; - const {rerender} = render(); + const {rerender} = render(); fireEvent.click(openTrigger()); await screen.findByRole('dialog', {name: 'Tags'}); fireEvent.change(screen.getByLabelText('Search Tags'), { target: {value: 'En'}, }); onChangeQuery.mockClear(); - rerender(); + rerender(); fireEvent.transitionEnd(document.querySelector('.astryx-bottom-sheet')!, { propertyName: 'transform', }); // Nothing the person did changed the query, so nothing is reported, and // the caller's mirror and the sheet agree on the next open. expect(onChangeQuery).not.toHaveBeenCalled(); - rerender(); + rerender(); fireEvent.click(openTrigger()); await screen.findByRole('dialog', {name: 'Tags'}); expect(screen.getByLabelText('Search Tags')).toHaveValue('En'); @@ -1167,7 +1168,7 @@ describe('Tokenizer sheet presentation (single-sheet touch flow)', () => { minQueryLength: 3, experimental: {presentation: 'bottom-sheet' as const}, }; - const {rerender} = render(); + const {rerender} = render(); fireEvent.click(openTrigger()); await screen.findByRole('dialog', {name: 'Tags'}); // Two characters: below the threshold, so nothing is searched. @@ -1175,7 +1176,7 @@ describe('Tokenizer sheet presentation (single-sheet touch flow)', () => { target: {value: 'En'}, }); // The field becomes disabled: the sheet closes, keeping the query. - rerender(); + rerender(); fireEvent.transitionEnd(document.querySelector('.astryx-bottom-sheet')!, { propertyName: 'transform', }); @@ -1183,7 +1184,7 @@ describe('Tokenizer sheet presentation (single-sheet touch flow)', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), ); - rerender(); + rerender(); fireEvent.click(openTrigger()); const dialog = await screen.findByRole('dialog', {name: 'Tags'}); expect(screen.getByLabelText('Search Tags')).toHaveValue('En'); @@ -1215,7 +1216,7 @@ describe('Tokenizer sheet presentation (single-sheet touch flow)', () => { it('closes the sheet and returns focus to the trigger when the field becomes disabled (FR17; AST-085 FR2)', async () => { const {rerender} = render( - { expect(search).toHaveFocus(); rerender( - { expect(trigger).toHaveAttribute('aria-disabled', 'true'); // Re-enabling does not reopen it. rerender( - { render( <> Up to five - { const onFocus = vi.fn(); const onKeyDown = vi.fn(); render( - { const onBlur = vi.fn(); render( <> - { const onBlur = vi.fn(); render( <> - { event.preventDefault(); }); render( - { __resetDevWarnings(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const {rerender} = render( - { expect(warn.mock.calls[0][0]).toMatch(/`hasClear`, `menuWidth`/); expect(warn.mock.calls[0][0]).toMatch(/bottom-sheet presentation/); rerender( - { expect(warn).toHaveBeenCalledTimes(1); warn.mockClear(); render( - { // Default debounceMs (150), so the debounce window is under test too. // hasCreate, so the create row's timing is under test as well. render( - { ], }; render( - { it('types ref for the control it reaches: the input without experimental, input or trigger with it', () => { const inputRef = createRef(); const {unmount} = render( - { unmount(); const wideRef = createRef(); const {rerender} = render( - { ); expect(wideRef.current).toBe(screen.getByRole('combobox')); rerender( - { const search = vi.fn(source.search); const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - { }); try { render( - { }, }; render( - { ), }; render( - { ), }; render( - { const onChangeQuery = vi.fn(); const search = vi.fn(source.search); render( - { const [query, setQuery] = useState(''); return ( <> - { try { // Default debounceMs (150): the open must not wait for it. render( - { const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); try { render( - { // The default popover, with and without the experimental object: no // query is evaluated and no listener is registered. const {unmount} = render( - { it('carries hidden form inputs and the field chrome in the sheet presentation', () => { const {container} = render( - { isDisabled: boolean, ) => (
- - Promise> = { // The sheet's own content is a caller's `emptySearchText` element; the // default EmptyState announces itself. render( - { + it('re-exports the one Tokenizer component under the sheet-capable name, the same value the public entry ships', async () => { + const entry: Record = + await import('./tokenizer-presentation'); + const tokenizer = await import('../Tokenizer/Tokenizer'); + expect(entry.TokenizerWithPresentation).toBe(tokenizer.Tokenizer); + // Named re-exports only, nothing else. + expect(Object.keys(entry).sort()).toEqual(['TokenizerWithPresentation']); + }); + + it('the public Tokenizer barrel exports no presentation surface', async () => { + const pub: Record = await import('../Tokenizer'); + expect(Object.keys(pub).sort()).toEqual(['Tokenizer']); + expect(pub).not.toHaveProperty('TokenizerWithPresentation'); + expect(pub).not.toHaveProperty('TokenizerBottomSheet'); + }); +}); diff --git a/packages/core/src/internal/tokenizer-presentation.ts b/packages/core/src/internal/tokenizer-presentation.ts new file mode 100644 index 0000000000000..e2373db11936f --- /dev/null +++ b/packages/core/src/internal/tokenizer-presentation.ts @@ -0,0 +1,25 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file tokenizer-presentation.ts + * @input Tokenizer's sheet-capable signature + * @output `@astryxdesign/core/internal/tokenizer-presentation`: the Tokenizer + * with its `presentation` options reachable, for Astryx's own packages + * @position An internal entry reserved for Astryx's own packages: + * undocumented, outside the public API and its compatibility promise + * (spec:AST-017 FR21), versioned in lockstep with Core, and allowed only in + * Core and Lab by the `@astryx/no-core-internal-import` lint rule + * (architecture:react-component-runtime INV13). The sheet presentation is + * implemented but not yet exposed: the public `TokenizerProps` offers no + * `experimental.presentation` while its user experience is being finished + * (`component:Tokenizer` DEC-3, implementation gap), and every public field + * renders the popover presentation. Named re-exports only. + * + * SYNC: When modified, update tokenizer-presentation.test.ts and + * scripts/sync-exports.js. + */ +export {TokenizerWithPresentation} from '../Tokenizer/Tokenizer'; +export type { + TokenizerPresentationOptions, + TokenizerWithPresentationProps, +} from '../Tokenizer/Tokenizer'; diff --git a/scripts/sync-exports.js b/scripts/sync-exports.js index f366f12e26968..ee5316c7916a1 100644 --- a/scripts/sync-exports.js +++ b/scripts/sync-exports.js @@ -238,6 +238,7 @@ const FILE_MODULE_SUBPATH_EXPORTS = [ // public API, and allowed by lint only in Core and Lab // (architecture:react-component-runtime INV13). 'internal/touch', + 'internal/tokenizer-presentation', ]; /**