Repository navigation
feat(core): Tokenizer touch sheet behind experimental.presentation - #7977
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
Approval waits on a maintainer decision. This implementation depends on component:Tokenizer DEC-3 (FR16–FR24, AR6, PR6) from #7976, which is not yet approved or merged to main, where the record still states that Tokenizer adds no public API. A maintainer needs to approve or amend DEC-3 for main, including whether the five new sheet theming targets ship as stable theme surface while the option itself is experimental. Whatever that decision, three fixes are needed:
- Expose the selected tokens on the sheet trigger: today assistive technology hears only the field label.
- Avoid showing "No items available" before the first, debounced fetch runs; the tests pass
debounceMs={0}, which hides it. - Add a comment on the sheet's search Effect naming the external system it synchronizes.
[Automated review]
| aria-describedby={ | ||
| [ariaDescribedByProp, ariaDescribedBy].filter(Boolean).join(' ') || | ||
| undefined | ||
| } |
There was a problem hiding this comment.
The trigger takes its name from <label for>, so the tokens inside the button never reach assistive technology. Chromium names it "Tags" and marks both token children ignored, so a screen-reader user cannot tell what is selected without opening the sheet. Consider referencing the token labels here (for example a visually hidden list merged into aria-describedby) and asserting the computed description in a test (spec:AST-020 FR1, WCAG 4.1.2).
| // ---- sheet session ---- | ||
| const [query, setQuery] = useState(''); | ||
| const [results, setResults] = useState<T[]>([]); | ||
| const [isSearching, setIsSearching] = useState(false); |
There was a problem hiding this comment.
With results=[] and isSearching=false as the initial state, and the bootstrap fetch delayed by debounceMs, the empty-state branch renders "No items available" on open until the timer fires (150 ms by default) even when the source has items. Treat the sheet as pending from open and from each query change until that search settles, and cover it with a test that keeps the default debounce (architecture:react-component-runtime INV1).
| } | ||
| } | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
This new Effect calls the caller's searchSource and sets state; architecture:react-component-runtime INV3 asks for a comment naming the external system it synchronizes, plus its cleanup (the timer clear and the sequence guard).
b154a0f to
c07a91a
Compare
e33be18 to
0beb6ba
Compare
c07a91a to
36f8999
Compare
36f8999 to
a017ec1
Compare
a017ec1 to
13206a2
Compare
0beb6ba to
1edddb0
Compare
There was a problem hiding this comment.
Approval waits on the governing spec. This implementation depends on component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is approved in #7976 but not yet on main, where the record still says Tokenizer adds no public API, and this head has no approval of its own. Either #7976 lands on main first, or an engineering owner records the decision for this head by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that step, these changes are needed:
- Drop the adaptive docs block, or publish it on a marked experimental route, because its registry and sandbox routes present an experimental option as an ordinary example (AST-017 FR27).
- Show the Loading indicator whenever a search is pending, because today the previous query's rows stay listed and tappable with no visible cue.
- Keep focus when
adaptiveswitches while the sheet is open, because closing it now drops focus to the page body. - Bring the change under 2,000 authored lines, or show that the rest is inseparable, because it is 2,181 now.
[Automated review]
| /> | ||
| </div> | ||
| ) : null} | ||
| {isPending && visibleItems.length === 0 && createItem == null ? ( |
There was a problem hiding this comment.
While a search is pending, this branch shows the Loading spinner only when nothing is listed. After a query change the previous results stay listed unfiltered (line 336), so with a slow source the person sees, and can tap, rows that do not match what they typed, and only aria-busy on the list changes. Consider rendering the named Loading row whenever isPending is true (above any rows), and asserting it with rows on screen.
| const isSheetPresentation = | ||
| isSheetOpen || resolvedPresentation === 'bottom-sheet'; |
There was a problem hiding this comment.
Suppose the compact-touch query stops matching while the sheet is open, for example a phone turned to landscape. Closing the sheet then flips this to false in the same render, which unmounts both the sheet and the trigger it returns focus to, and focus lands on the page body. Consider latching the sheet presentation until the exit completes and focus is back on the trigger, or moving focus to the inline input and naming that destination in FR16.
| name: 'Tokenizer \u2014 Adaptive presentation', | ||
| displayName: 'Tokenizer \u2014 Adaptive presentation', | ||
| description: | ||
| 'Tokenizer with the experimental presentation option set to adaptive: a searchable bottom sheet on compact touch screens, the inline field with its popover everywhere else. Experimental; it may change or be removed in a patch release.', |
There was a problem hiding this comment.
This block ships an example of an experimental option on an ordinary registry route (examples/tokenizer/adaptive-presentation), with only this sentence as its experimental marking. AST-017 FR27 asks docs for experimental surfaces to use marked experimental routes. Consider leaving the block out of this pull request until the option is promoted or a marked route exists.
13206a2 to
6f02169
Compare
1edddb0 to
d19e19f
Compare
There was a problem hiding this comment.
Approval waits on the governing spec: this implementation depends on component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main, and the owner approval on #7976 covered an earlier version of that record, not its current text. An engineering owner can settle it by approving #7976 again and landing it on main, or by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, these changes are needed:
- Keep
TokenizerPropsan interface, because as a union type it breaksinterface … extends TokenizerPropsandOmit<TokenizerProps, …>wrappers that compile today. - Offer the sheet's create row only after the current search settles, because while it is pending the sheet offers to create a value the source already holds.
- Forward a caller
onClickon the sheet trigger, which currently replaces it. - Bring the change under 2,000 authored lines, or show the rest is inseparable once DEC-3 is approved; it is 2,805 now.
[Automated review]
| handleRef?: React.Ref<TokenizerHandle>; | ||
| } | ||
|
|
||
| export type TokenizerProps<T extends SearchableItem> = TokenizerBaseProps<T> & |
There was a problem hiding this comment.
Turning the exported TokenizerProps from an interface into this intersection with a union breaks consumer code that compiles today: interface TagsFieldProps extends TokenizerProps<Item> fails with TS2312, and a wrapper typed Omit<TokenizerProps<Item>, 'label'> that spreads into <Tokenizer> fails with TS2322. Consider keeping TokenizerProps an interface (adding experimental? and leaving ref?: Ref<HTMLInputElement> as released), carrying the widened ref on the component's own parameter type, and adding a type test for both patterns.
| .filter(Boolean) | ||
| .join(' ') || undefined | ||
| } | ||
| onClick={() => { |
There was a problem hiding this comment.
This handler replaces a caller onClick that arrives in rest (spread at line 1378), so it never runs in the sheet presentation. The inline input composes it (composeEventHandlers in BaseTypeahead) and Selector's bottom-sheet trigger forwards it. Consider composing the caller's handler with the open, and covering it in the base-props test.
| ); | ||
|
|
||
| const trimmedQuery = query.trim(); | ||
| const createItem = useMemo<T | null>(() => { |
There was a problem hiding this comment.
While a search is pending, results still hold the previous query's rows, so this offers Create "Tag 500" with Add for a value the source holds. Tapping Add in that window commits a create change with id Tag 500, a duplicate of the source's item. The inline field derives its create entry from the fetched results of the same query. Consider returning null while isPending, with a slow-source test.
| </button> | ||
| {value.length > 0 && ( | ||
| <VisuallyHidden id={triggerTokensId}> | ||
| {value.map(item => item.label).join(', ')} |
There was a problem hiding this comment.
This description is assembled from labels and a literal ', ', so locales with another list separator hear English punctuation. The internationalization rules ask for joined labels to come from one catalog message (or a shared list-formatting helper) rather than call-site concatenation.
d19e19f to
d7ee224
Compare
6f02169 to
1dc081e
Compare
d7ee224 to
a3bbf10
Compare
1dc081e to
c1d1852
Compare
There was a problem hiding this comment.
Approval waits on an engineering-owner decision. This implementation depends on component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head or at the current head of #7976. An engineering owner can settle it by approving #7976's current head and landing it on main, or by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever that decision, these changes are needed:
- Announce what the empty state actually rendered when
emptySearchTextis an element, because today the sheet announces "No results found" over the caller's message (spec:AST-056FR3). - Settle a rejected search to an empty result, because today the previous query's rows stay listed and their count is announced again.
- Bring the change under 2,000 authored lines, or show that the rest is inseparable once DEC-3 is current; it is 3,112 lines now, 2,639 without the commit carried from #8009.
[Automated review]
| } | ||
| outcomePendingRef.current = false; | ||
| if (isSettledEmpty) { | ||
| announce(isQueryEmpty ? t('@astryx.tokenizer.noItems') : noResultsTitle); |
There was a problem hiding this comment.
When emptySearchText is an element, this announces the built-in "No results found" while the sheet renders the caller's node (line 541). spec:AST-056 FR3 asks a component to announce what it rendered. Consider reading the rendered empty state's text from a ref after render, as BaseTypeahead does with emptyStateRef, and adding a test with an element emptySearchText.
| if (seqRef.current === seq) { | ||
| setResults(items.slice(0, maxMenuItems)); | ||
| } | ||
| } finally { |
There was a problem hiding this comment.
With no catch, a rejecting searchSource.search() or bootstrap() leaves results holding the previous query's rows. Busy clears, the outcome announcement repeats the old count, and void run() surfaces an unhandled rejection. Consider catching inside the sequence guard and settling to an empty result, as BaseTypeahead does, plus a rejecting-source test.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA (the author may do so as an engineering owner) or by approving and landing #7976 at its current head, and can also say whether family:input-fields FR7 should cover the sheet's busy list and whether a confirmed Clear all is announced. Two changes are needed whatever the decision:
- At 6,418 changed lines this is over the size limit: landing #8009 first removes 2,540 of them, and the rest needs splitting or a shown reason it cannot split.
- The carried
useAnnouncechange now conflicts with #8244 onmain, and as written it does not join same-frame page-body messages the waymainnow does (spec:AST-086FR9), so #8009 needs a rebase that keeps #8244's logic.
[Automated review]
| onAction={() => { | ||
| onClearAll(); | ||
| focusSearchAfterClearRef.current = true; | ||
| setIsClearConfirmationOpen(false); | ||
| }} |
There was a problem hiding this comment.
A confirmed Clear all commits [] through handleClearAll, which announces nothing, so a screen-reader user only hears focus land on the search field. The head's AR6 announces removals from inside the sheet; consider announcing a catalog message (for example "All items removed") here, or saying in AR6 that Clear all is not announced.
| function announceMessage( | ||
| message: string, | ||
| politeness: AnnouncePoliteness, | ||
| ): void { | ||
| const r = getRegions(); | ||
| if (!r) { | ||
| return; | ||
| } | ||
| const target = politeness === 'assertive' ? r.assertive : r.polite; | ||
| // Clearing first guarantees the mutation is observed even when the new | ||
| // message equals the current text — some AT deduplicate identical content. | ||
| target.textContent = ''; | ||
| // A microtask/rAF-free re-set in the next frame is more reliable across AT | ||
| // than an immediate re-set, which can be coalesced with the clear. | ||
| const frame = requestAnimationFrame(() => { | ||
| pendingFrames[politeness].delete(frame); | ||
| target.textContent = message; | ||
| }); | ||
| pendingFrames[politeness].add(frame); | ||
| // Auto-clear so the last message does not linger in the accessibility tree | ||
| // indefinitely. Scheduled per announce, so a newer announcement always | ||
| // resets the countdown (the delay comfortably outlasts the rAF re-set). | ||
| scheduleClear(politeness, target); | ||
| } |
There was a problem hiding this comment.
This keeps the base's page-body channel, which main has since replaced (#8244) with one that joins same-frame messages and says an identical repeat once (spec:AST-086 FR9); main's own useAnnounce.test.tsx fails 4 of its 17 tests against this file. When #8009 is rebased, this function and useBusyAnnouncement.ts need #8244's joining and withdraw logic carried over (this belongs to #8009).
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA (the author may do so as an engineering owner), or by approving and landing #7976 at its current head. Two changes are needed whatever the decision:
- At 6,715 changed lines this is over the size limit: landing #8009 first removes 2,790 of them, and the rest needs splitting or a shown reason it cannot split.
- After a search settles, the sheet announces the source's result count instead of the rows it lists, so typing part of a created value says "0 results" over a listed, checked row (
TokenizerBottomSheet.tsx638–645).
[Automated review]
| } | ||
| announcedSearchIdRef.current = searchId; | ||
| if (!isSettledEmpty && !isQueryEmpty) { | ||
| announce(t('@astryx.typeahead.resultCount', {count: results.length})); |
There was a problem hiding this comment.
This announces the source's count, but the list shows visibleItems (491–515), which also holds selected rows the results lack. A selected value the source does not return, such as one created with hasCreate, makes a matching query say "0 results" over a listed, checked row; with maxMenuItems={2} and "Energizer" selected, "e" lists 3 rows and says "2 results". The inline field announces the options it shows and never says 0 (BaseTypeahead.tsx 744–746). Counting the listed rows, skipping a zero count, and testing a selected value absent from the results would fix it.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA (the author may do so as an engineering owner), or by approving and landing #7976 at its current head. Two changes are needed whatever the decision:
- At 6,927 changed lines this is over the size limit: landing #8009 first removes 2,942 of them, and the rest needs splitting or a shown reason it cannot split.
- With
hasCreate, a search that finds nothing announces "0 results" while the sheet offers the create row, where the inline field says "1 result" for the same query (TokenizerBottomSheet.tsx643–650).
[Automated review]
| // return, such as a created value — as the inline field announces | ||
| // the rows it shows, not the source's count. | ||
| announce( | ||
| t('@astryx.typeahead.resultCount', {count: visibleItems.length}), |
There was a problem hiding this comment.
With hasCreate, a miss settles with visibleItems.length === 0 and a create row, so this announces "0 results" while the sheet offers Create "…". The inline field counts its create entry (BaseTypeahead.tsx 727–746 announces shown, which includes __queryEntries) and says "1 result" for the same query. Counting the create row (visibleItems.length + (createItem ? 1 : 0)) would match the comment above.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA (the author may do so as an engineering owner), or by approving and landing #7976 at its current head. Two changes are needed whatever the decision:
- At 7,017 changed lines this is over the size limit: landing #8009 first removes 2,984 of them, and the remaining 4,033 need splitting or a shown reason, tied to DEC-3 once it is current, that they cannot be split.
- The size report that
architecture:component-style-authoringINV6 asks for covers only Tokenizer's JavaScript; the other changed components (Dialog, BottomSheet, Drawer, Lightbox, MobileNav, PanelSearchInput) need their figures, or the CI size report once the stack targetsmain.
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA (the author may do so as an engineering owner), or by approving and landing #7976 at its current head. Beside that decision:
- At 7,198 changed lines this is over the size limit: landing #8009 first removes 3,044 of them, and the remaining 4,154 need splitting or a shown reason, tied to DEC-3 once it is current, that they cannot be split.
- The lazy sheet import pulls about 7 KB gzip of BottomSheet and AlertDialog dependencies (
useScrollableArea,scrollKeyboardDelegation,useScrollLock,Heading) into every Tokenizer import, which PR6 says a popover field should not load; importing those hooks by path in BottomSheet and AlertDialog, as the description proposes, moves them into the lazy chunk unless an owner amends PR6.
[Automated review]
| // the empty state) only renders for the bottom-sheet presentation. Loading it | ||
| // on demand keeps it out of pages whose tokenizers stay inline combobox fields | ||
| // (`component:Tokenizer` PR6). | ||
| const LazyTokenizerBottomSheet = lazy(async () => |
There was a problem hiding this comment.
This lazy import keeps TokenizerBottomSheet itself out of the eager bundle, but bundled the way the description measures it (esbuild, splitting, minified, default gzip), it still pulls 7,238 bytes of BottomSheet and AlertDialog dependencies (useScrollableArea, scrollKeyboardDelegation, useScrollLock, Heading, most of padding.stylex) into chunks every Tokenizer import loads: removing the sheet import drops the eager size from 67,137 to 59,899 bytes. PR6 says a popover field does not load the sheet surface's code; importing those hooks by path in BottomSheet and AlertDialog should move them into the lazy chunk.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA (the author may do so as an engineering owner), or by approving and landing #7976 at its current head. Beside that decision:
- At 7,345 changed lines this is over the size limit: landing #8009 first removes 3,123 of them, and the remaining 4,222 need splitting or a shown reason, tied to DEC-3 once it is current, that they cannot be split.
- The lazy sheet import still leaves about 3.2 KB gzip of sheet-only code (
padding.stylexrows,ModalAnnounceProvider,useMediaQuery,focusReturn) in every Tokenizer import, which the description reports as inline-only; move it out or flag and justify it, and add the CSS size figure. - Clear all is natively
disabledwhile nothing is selected, so it leaves the tab order;spec:AST-085FR1 asks for a focusablearia-disabledcontrol.
[Automated review]
| label={t('@astryx.tokenizer.clearAll')} | ||
| variant="secondary" | ||
| size="lg" | ||
| isDisabled={value.length === 0} |
There was a problem hiding this comment.
isDisabled on Button renders the native disabled attribute (no tooltip), so with an empty selection Clear all leaves the tab order and cannot take focus; in Chromium after a confirmed clear it has disabled, no aria-disabled, and focus() does not take. spec:AST-085 FR1 says a component must not put native disabled on any element it renders and that a disabled control keeps its focus order. Passing aria-disabled and ignoring the click while value is empty would keep it focusable; the tests at lines 682 and 715 would then assert aria-disabled instead of toBeDisabled().
| // the empty state) only renders for the bottom-sheet presentation. Loading it | ||
| // on demand keeps it out of pages whose tokenizers stay inline combobox fields | ||
| // (`component:Tokenizer` PR6). | ||
| const LazyTokenizerBottomSheet = lazy(async () => |
There was a problem hiding this comment.
The sheet module itself now loads lazily, but bundled the way the description measures it (esbuild, splitting, minified, default gzip) the chunks every Tokenizer import loads still hold 3,272 bytes the sheet alone needs: padding.stylex grows from 1,438 to 6,637 minified bytes, useAnnounce from 5,615 to 6,520 (its ModalAnnounceProvider), and useMediaQuery, focusReturn and isRenderable appear only for the sheet; stubbing this import() takes the eager size from 63,194 to 59,922 bytes. The description says the eager shared chunk holds only inline-field code, and proposed PR6 says a popover field does not load the sheet's code.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA (the author may do so as an engineering owner), or by approving and landing #7976 at its current head. Beside that decision:
- At 7,400 changed lines this is over the size limit: landing #8009 first removes 3,142 of them, and the remaining 4,258 need splitting or a shown reason, tied to DEC-3 once it is current, that they cannot be split.
- The size note says
useMediaQuery,focusReturnandisRenderableare already eager onmain; they are not, and withuseAnnounce's provider-only internals they reach every Tokenizer import only through the lazy sheet import, so correct the note and move or justify them.
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it; the author may do so as an engineering owner, and the decision should also say whether PR6 accepts the small amount of sheet-only code every Tokenizer import still carries. Two changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,258 authored lines, since the change is over the 2,000-line review limit without a current-authority exception.
- The open sheet asks the caller's
searchSourceagain on every row toggle when the source object is rebuilt in render (the search effect inTokenizerBottomSheet.tsxdepends on its identity), so read the source through a ref and request only on open, on a query change and on clearing.
[Automated review]
| return () => clearTimeout(timer); | ||
| } | ||
| run(); | ||
| }, [isOpen, query, searchSource, minQueryLength, maxMenuItems, debounceMs]); |
There was a problem hiding this comment.
With searchSource in these dependencies, a caller that writes its source inline (searchSource={{search, bootstrap}}) gets a new object on every render, and every row toggle re-renders the caller through onChange, so the open sheet asks the source again for an answer it already holds: a probe on this head saw bootstrap() go from 1 to 3 calls over two toggles, and search('En') called twice when a row was toggled under a typed query. The inline field avoids this (BaseTypeahead cancels a replaced source without refetching). architecture:react-component-runtime INV3 says ordinary rerender synchronization must not duplicate consumer-visible callbacks. Consider reading the source through a ref kept current after render and requesting only on open, on a query change and on clearing, with a test that counts calls across toggles.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it; the author may do so if they are an engineering owner, and the decision should also cover the sheet under a mouse or trackpad (spec:AST-017 FR59) and the small amount of sheet-only code every Tokenizer import still carries. Two changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,322 authored lines, since the change is over the 2,000-line review limit without a current-authority exception.
- After rebasing onto
main, the no-results test inTokenizerBottomSheet.test.tsxlooks for EmptyState's oldrole="status"root, which EmptyState no longer renders; EmptyState now announces itself, so the sheet's own empty-state announcement is only needed for a customemptySearchTextelement.
[Automated review]
| const title = await screen.findByRole('heading', { | ||
| name: 'No results found', | ||
| }); | ||
| const emptyState = title.closest('[role="status"]'); |
There was a problem hiding this comment.
main's EmptyState no longer renders role="status" on its root (its FR1 now forbids a live role, since #8294), so after rebasing this closest('[role="status"]') returns null and the assertion fails. Locate the empty state by its own root instead, and consider keeping the sheet's useAnnounceRenderedText only for a caller-element emptySearchText, since EmptyState now announces itself when it appears after load.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record it by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also settle the minimal-DOM exception adaptive needs for swapping the input for a button (architecture:component-style-authoring INV7), the sheet under a mouse (spec:AST-017 FR59), and the sheet-only code every Tokenizer import still carries (PR6). Changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,367 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- On compact touch, mount the sheet only while it is presented: today its closed DOM, with a checkbox row per selected token, takes the field from 127 to 215 elements at rest.
- Describe the trigger from the rendered tokens instead of a hidden copy of every label, and report INV7 element counts at rest and open under a fine and a coarse pointer.
[Automated review]
| <VisuallyHidden id={triggerTokensId}> | ||
| {/* One catalog message for the count, then each label as its own | ||
| block: the accessible-description algorithm separates blocks | ||
| itself, so no locale's list punctuation is written here. */} | ||
| <div> | ||
| {t('@astryx.tokenizer.selectedCount', {count: value.length})} | ||
| </div> | ||
| {value.map(item => ( | ||
| <div key={item.id}>{item.label}</div> | ||
| ))} | ||
| </VisuallyHidden> |
There was a problem hiding this comment.
This block renders every selected label a second time, beside the tokens the trigger already shows (in Chromium: “2 selected”, “Design”, “Eng” next to the visible “Design”, “Eng”). architecture:component-style-authoring INV7 asks for no duplicated content in an input. Keeping only the count as a hidden element, giving the rendered tokens ids, and listing them in aria-describedby would still give the description AR6 asks for, with each label rendered once.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6), which is not on main and has no owner approval at this head. An engineering owner can record it by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also settle the minimal-DOM exception adaptive needs for swapping the input for a button (architecture:component-style-authoring INV7), the sheet under a mouse (spec:AST-017 FR59), and the sheet-only code every Tokenizer import still carries (PR6). Changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,392 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- Mount the sheet only while it is presented: its closed DOM stays in the page at rest, 209 elements against 155 under a fine pointer, and 520 with 50 rows after one open and close.
- State the INV7 element counts at rest and open under a fine and a coarse pointer, and the exception the input-to-button swap needs.
[Automated review]
| disabled={isDisabled} | ||
| /> | ||
| ))} | ||
| {(!isDisabled || keepsSheetWhileDisabled) && ( |
There was a problem hiding this comment.
This mounts the sheet whenever the sheet presentation is active, so once the lazy chunk resolves the closed BottomSheet keeps its content in the DOM at rest. In Chromium (iPhone 13 profile) the Experimental: adaptive story has 209 elements at rest against 155 under a fine pointer; the closed dialog holds 87 elements, including a checkbox row named for each selected token (the labels the trigger already shows), the search field and a closed confirmation dialog. architecture:component-style-authoring INV7 asks that content render once and that inputs at rest carry no duplicated content. Mounting the host only from the press until the sheet's exit completes (the isSheetOpen / isSheetPresented latch already tracks that), or recording the at-rest DOM as an INV7 exception with counts, would resolve it.
| type: "{presentation?: 'popover' | 'bottom-sheet' | 'adaptive'}", | ||
| stability: 'experimental', | ||
| description: | ||
| "实验性选项:此对象中的任何内容都可能在补丁版本中更改或移除;省略它即保持稳定行为。presentation(默认 'popover')决定字段如何呈现选项:popover 为内联组合框及其锚定建议菜单;bottom-sheet 改为一个显示已选标记的触发按钮,点按后打开一个高的模态底部面板——顶部为搜索行,下方为整行复选框行(面板打开期间位置保持不变),hasCreate 时有「创建」行,底部为需确认的「全部清除」和「完成」;adaptive 在紧凑触摸屏上使用底部面板,其他情况使用 popover,解析方式与 Selector 相同。在底部面板呈现中,触发按钮即为控件(ref、id、aria-* 和 data-* 都作用于它);renderItem、renderToken、endContent、hasClear、hasEntriesOnFocus、menuWidth、tokenOverflowBehavior 和 hasAutoFocus 没有作用对象,传入时开发模式下会警告一次。", |
There was a problem hiding this comment.
The English entry tells callers that with experimental passed ref also accepts the trigger button, that an input-only ref receives the button at runtime under adaptive or bottom-sheet, and that TokenizerProps is unchanged, and it ends with a usage example; this Chinese entry has neither. Carrying both over keeps zh readers from typing the ref for the input only.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 amendment from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also settle the minimal-DOM exception adaptive needs for swapping the input for a button and for its larger open sheet (architecture:component-style-authoring INV7), and whether PR6 accepts the sheet-only code every Tokenizer import still carries. Changes are needed either way:
- A sheet reopened after its field was disabled with a query shorter than
minQueryLengthstaysaria-busywith its Loading row and never searches; startingwasOpenatfalseinTokenizerSheetContent(TokenizerBottomSheet.tsx387) fixes it, and the forced-close test should cover the case. - Land #8009 first, then split or justify the remaining 4,523 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- State the INV7 element counts at rest and open under a fine and a coarse pointer, with the reason for the exception.
[Automated review]
| // Pending from open, and from each query change, until that search settles | ||
| // (`component:Tokenizer` FR20). Set where the change happens rather than in | ||
| // the Effect, so the debounce window never shows a stale empty state. | ||
| const [isPending, setIsPending] = useState(true); |
There was a problem hiding this comment.
The sheet now mounts already open (Tokenizer.tsx mounts the host only from the press), so the open branch at 387–408 never runs for this first render and isPending stays true from here. With a query kept across a forced close that is shorter than minQueryLength, the search effect returns early at 455, and the reopened list stays aria-busy with the Loading row, a busy start is announced and the create row is withheld, with no search ever made. Starting it from the opening query, by the rule handleQueryChange uses, would fix it:
const [isPending, setIsPending] = useState(() => {
const opening = initialQuery.trim();
return opening === '' || opening.length >= minQueryLength;
});There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also settle the minimal-DOM exception adaptive needs for swapping the input for a button and for its larger open sheet (architecture:component-style-authoring INV7), and whether PR6 accepts the sheet-only code every Tokenizer import still carries. Changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,591 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- State the INV7 exception in the description, with element counts at rest and open under a fine and a coarse pointer (155 and 121 at rest, 158 and 186 open with "e") and why a button stands in for the input.
- Correct the description where it calls
TokenizerPropsa type alias, lists twelve catalog keys, quotes the trigger's description as "Design, Eng", sizes the Tokenizer commit at about 3,750 lines, and calls the spent Clear all natively disabled.
[Automated review]
| if (isSheetPresentation) { | ||
| tokenizerContent = ( | ||
| <> | ||
| <button |
There was a problem hiding this comment.
On compact touch, adaptive renders this <button> in place of the combobox <input>, and with the query "e" the open sheet has 186 elements against 158 for the inline menu under a fine pointer (Chromium, Experimental: adaptive story). architecture:component-style-authoring INV7 allows a pointer-specific presentation only as attribute and style changes on the same elements, unless the pull request states an exception with element counts for an owner to record. Adding that statement to the description (155/121 at rest, 158/186 open) would let the owner decide it with DEC-3.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 result-list clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also say whether DEC-3 records a minimal-DOM (INV7) exception for adaptive rendering a button in place of the input at rest, whether its recorded open counts should cover the sheet's first open state (499 elements with 50 bootstrap rows, against the 165 recorded for the query "e"), and whether PR6 accepts the sheet-only code every Tokenizer import still carries. Changes are needed either way:
- Land #8009 first, then split, or justify against an approved DEC-3, the remaining 4,666 authored lines, since the change is over the 2,000-line limit.
- Render the sheet presentation's hidden form inputs only while the field is enabled (
Tokenizer.tsx1598–1607): nativedisabledon them breaksspec:AST-085FR1, whichmainalready fixed for the inline field in #8408. - Rebase onto
main, which now conflicts inTokenizer.tsx, keeping #8408's disabled guards, and correct the description where it misstates the shipped code (for exampleTokenizerPropsas a type alias and the trigger's description as "Design, Eng").
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 result-list clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record it by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also say whether DEC-3's element-count exception covers the sheet's first open state with an empty query, and whether PR6 accepts the sheet-only code every Tokenizer import still carries. Changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,667 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- Render the sheet presentation's hidden form inputs only while the field is enabled (
Tokenizer.tsx1595–1603): today a disabled field submits its values there, againstspec:AST-085FR3. - Correct the description where it misstates the shipped code (for example
TokenizerPropsas a type alias, the trigger's description as "Design, Eng", and the "twelve" catalog keys).
[Automated review]
| {t('@astryx.tokenizer.selectedCount', {count: value.length})} | ||
| </VisuallyHidden> | ||
| )} | ||
| {htmlName != null && |
There was a problem hiding this comment.
A disabled field still submits these. The inline branch renders its carriers only while enabled (htmlName != null && !isDisabled, line 1424), but this branch has no isDisabled guard, so new FormData(form).getAll(name) returns the token ids for a disabled field in the sheet presentation and [] in the popover. spec:AST-085 FR3 says a disabled field contributes no value to form submission. Suggest htmlName != null && !isDisabled && value.map(...), plus a disabled case in the hidden-input test.
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 result-list clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also say whether DEC-3's element-count exception covers the sheet's first open state with an empty query (499 elements against the 165 recorded), and whether PR6 accepts the sheet-only code every Tokenizer import still carries. Changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,701 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- Correct the description where it misstates the shipped code:
TokenizerPropsas a type alias, the trigger's description as "Design, Eng", the "twelve" catalog keys, the doc example, the test count and the commit's size.
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 result-list clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also say whether DEC-3's element-count exception covers the sheet's first open (499 elements with 50 rows) and its 7-element rows beyond the 5-element allowance in spec:AST-017 FR60, and whether PR6 accepts the sheet-only code every Tokenizer import still carries. Changes are needed either way:
- Land #8009 first, then split or justify the remaining 4,702 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- Correct the description where it calls
TokenizerPropsa type alias, quotes the trigger's description as "Design, Eng", lists twelve catalog keys includingnoItems, keeps an adaptive doc snippet, calls the spent Clear all natively disabled, counts 47 tests and sizes the commit at about 3,750 lines.
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 result-list clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also rule on the field's own presentation hold now that architecture:layer-runtime INV14 assigns that hold to the layer runtime, on the sheet's element counts beyond the spec:AST-017 FR60 touch allowance, and on the sheet-only code every Tokenizer import still loads (PR6).
- Required either way: land #8009 first, then split or justify the remaining 4,702 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- Recommended: correct the description where it calls
TokenizerPropsa type alias, quotes the trigger's description as "Design, Eng", lists twelve catalog keys includingnoItems, says the doc keeps an adaptive example, counts 47 tests and sizes the commit at about 3,750 lines.
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 result-list clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also rule on the field's own presentation hold under architecture:layer-runtime INV14, on the open sheet's element counts beyond the spec:AST-017 FR60 touch allowance, and on the sheet-only code every Tokenizer import still loads (PR6).
- Required either way: land #8009 first, then split or justify the remaining 4,748 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
- Minor: two Evidence lines in the description still give the trigger's description as "Design, Eng", while the code gives the count followed by each token ("2 selected Design Eng").
[Automated review]
There was a problem hiding this comment.
Approval waits on an engineering-owner decision: this experimental Tokenizer sheet implements component:Tokenizer DEC-3 (FR16–FR24, AR6, AV7, PR6) and the family:input-fields FR7 result-list clause from #7976, which are not on main and have no owner approval at this head. An engineering owner can record that decision by commenting /approve-spec followed by this pull request's full head SHA, or by approving #7976 at its current head and landing it (the author may do so as an engineering owner); the decision should also rule on the field's own presentation hold under architecture:layer-runtime INV14, on the open sheet's element counts beyond the spec:AST-017 FR60 touch allowance, and on the sheet-only code every Tokenizer import still loads (PR6).
- Required either way: land #8009 first, then split or justify the remaining 4,788 authored lines, since the change is over the 2,000-line limit without a current-authority exception.
[Automated review]
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewModified ComponentsAlertDialog (@astryxdesign/core) · View in Storybook
Tokenizer (@astryxdesign/core) · View in Storybook
Component Size
Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.
|
| Component | JavaScript | CSS |
|---|---|---|
| core/PowerSearch | +3.1KB |
+1.2KB |
| core/Tokenizer | +3.9KB |
+4.8KB |
Bundle Size Summary
Gzip figures measure each package's entry file, not the code an import pulls in.
| Package | Entry (ESM) | Entry gzip before | Entry gzip after | Delta |
|---|---|---|---|---|
| @astryxdesign/core | 4.9KB | 1.2KB | 1.2KB | 0B |
| Package stylesheet | Gzip before | Gzip after | Delta |
|---|---|---|---|
| @astryxdesign/core | 34.6KB | 34.6KB | +20B |
Accessibility Audit
Status: 1 accessibility violation(s) found — 1 serious.
Tokenizer - 1 issue(s)
- 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
- Rule:
color-contrast· Affects 2/29 stories · Learn more - WCAG: 1.4.3 (Level AA)
- Rule:
Visual Regression
55 of 502 shot(s) changed.
A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.
| component | story | theme | mode | pixels |
|---|---|---|---|---|
| Selector | Indicator Space Bottom Sheet Narrow End RTL | neutral | light | 753,933 |
| Selector | Indicator Space Bottom Sheet Wide Start | neutral | light | 740,072 |
| RichTextEditor | Markdown parity: side by side | probe | dark | 558,840 |
| RichTextEditor | Markdown parity: side by side | probe | light | 540,756 |
| Chat | Full AI Chat | probe | light | 230,823 |
| Chat | Full AI Chat | probe | dark | 229,805 |
| AppShell | With Mobile Nav | probe | light | 217,010 |
| AppShell | With Mobile Nav | probe | dark | 216,960 |
| Collapsible | Dividers | probe | dark | 66,445 |
| Collapsible | Dividers | probe | light | 66,445 |
| AppShell | Playground | neutral | dark | 65,198 |
| AppShell | Playground | neutral | light | 63,788 |
| Chat | Default | neutral | dark | 36,304 |
| Chat | Default | neutral | light | 35,767 |
| Chat | Empty State | probe | light | 23,531 |
| Chat | Empty State | probe | dark | 22,622 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | dark | 15,378 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | light | 14,107 |
| Chat | With Attachments | probe | light | 13,406 |
| Chat | With Attachments | probe | dark | 11,232 |
and 35 more.
Generated by PR Enrichment workflow | Storybook | Sandbox | View full report
There was a problem hiding this comment.
Changes requested: the governing Tokenizer record (DEC-3, FR16–FR24) is now on main, and the sheet behaved as it specifies in the tests and in a real browser, but at 4,788 authored lines this pull request is more than twice the 2,000-line review limit without evidence that it cannot be split. Please ship the useAdaptivePresentation change and the by-path import moves on their own, and split the sheet along the record's rows using its implementation-gap notes (for example the option, trigger and rows; then search, busy state and announcements; then batches, the create row, the limit and Clear all), or show concretely why a row cannot ship without another. The size report should also add base-against-head JavaScript figures for Field and BottomSheetSwitcher, whose code this change touches.
[Automated review]
Ports the Lab MobileTokenizer touch flow into Tokenizer as the
`bottom-sheet` value of the experimental `presentation` option
(`experimental={{presentation: 'popover' | 'bottom-sheet' | 'adaptive'}}`,
spec:AST-017 FR21). `adaptive` resolves through useAdaptivePresentation,
the policy Selector, MultiSelector and DropdownMenu share. Omitting
`experimental` keeps the inline combobox unchanged.
The sheet is one standalone BottomSheet driven by `isOpen`: the shared
PanelSearchInput on top (grown to the 44px rows through a new internal
fieldXstyle), checkbox rows that keep their place while the sheet is open
and regroup selected-first on the next open, 50-row progressive rendering,
a Create row with a trailing Add action under hasCreate, and a footer with
a confirmed Clear all and Done. The list is busy from open and from each
query change until the search settles. Selection stays in Tokenizer so the
add, remove and create paths and their announcements are the inline
field's own. The trigger is the control: base props and `ref` reach it, its
accessible description lists the selected tokens, and a field that becomes
disabled while the sheet is open closes it and takes focus back. Props with
no sheet surface warn once in development. The surface loads with
React.lazy and renders nothing during hydration, so a Tokenizer that does
not opt in does not carry it. The sheet's parts expose no theming targets
while the option is experimental.
Also: @astryx.tokenizer.* catalog keys for the sheet's strings, doc.mjs
prop and examples, a `Tokenizer — Adaptive presentation` block template
(and its ShadCN route), three Core/Tokenizer stories, tests for the
resolution and the ported flow, an [experimental] core changeset and a
[docs] cli changeset, and the deletion of the spec's implementation-gap
notes.
There was a problem hiding this comment.
Changes requested: the experimental sheet behaves as component:Tokenizer DEC-3 specifies, in the tests and in a real browser, and the stable field is unchanged, but at 4,907 authored lines the change is still more than twice the 2,000-line review limit. The new "Why one pull request" note shows that the trigger, the sheet, search and the busy state belong together, but not that the batches, the create row, the at-limit rows and the confirmed Clear all (FR21–FR24) do: those build on the rows and can follow the core, as the record's per-row gap notes allowed, and the useAdaptivePresentation change and the by-path imports can ship on their own. Please split along those lines, or show concretely why one of FR21–FR24 cannot ship after the core; if an owner has waived the limit, a link to that decision would let reviewers see it. Separately, the record edit still needs an engineering owner to comment /approve-spec <full head SHA> for the repository's spec gate, and the author may do so as an engineering owner.
[Automated review]
|
/approve-spec 77a5a31 |
Builds on the merged Tokenizer record (#7976,
component:TokenizerDEC-3) and on the merged #8009, which makesuseAnnouncedeliver inside the open Astryx modal that encloses its caller, asspec:AST-086FR14 requires. The sheet's announcing code renders inside the BottomSheet, so plainuseAnnouncereaches the sheet's dialog. This PR deletes the record's implementation-gap notes and adds its test files toverified_by.User need
On a compact touch screen the inline Tokenizer competes with the software keyboard: focusing the combobox raises the keyboard over the half of the screen where the suggestion menu opens, each added chip grows the field by a line under the thumb, and the two core gestures — typing between chips and Backspace on an empty input — assume a hardware keyboard. Builders shipping Tokenizer on phones have had no supported surface for it; the Lab
MobileTokenizerprototype validated one (a single searchable sheet) but Lab publishes only canary builds and the prototype's API is a separate import. Without a Core surface, every product either keeps the inline field on touch or rebuilds the sheet itself.Problem and solution fit
experimental.presentationonTokenizer:'popover'(default; the inline combobox, unchanged),'bottom-sheet'(the touch surface on every device),'adaptive'(the sheet on compact coarse-pointer screens, the popover otherwise, throughuseAdaptivePresentation— the policy Selector, MultiSelector and DropdownMenu share, so a Tokenizer and a Selector on one page switch together). Inside theexperimentalobject perspec:AST-017FR21, with@experimentalon the declarations andstability: 'experimental'in the doc, as Drawer'sexperimental.isFullWidthOnMobileis.TokenizerBottomSheet.tsx(new): the Lab touch flow, ported behavior for behavior — one tall modal sheet, the sharedPanelSearchInputon top, full-widthcheckboxrows that keep their place while the sheet is open and regroup selected-first on the next open, 50-row batches that advance within 200px of the list end, aCreate "…"row with a trailingAdd …action underhasCreate, a footer with Clear all (confirmed throughAlertDialog) and Done while the query is empty,aria-disabledrows atmaxEntries, and forced-colorsGrayTextfor the disabled trigger, placeholder, tokens and indicator. Selection stays inTokenizer: the sheet calls the field's ownhandleAdd/handleRemove/handleClearAll, so the create sentinel, duplicate checks and the polite live-region announcements are the inline field's.Trigger in
Tokenizer.tsx: the field's own control box as a<button aria-haspopup="dialog">, showing the tokens (display-only) or the placeholder, wrapping, at the inline field's height for itssizestep (--size-element-md, 32px, by default), as Selector's bottom-sheet trigger is; the sheet's search field and rows carry the 44px touch targets. The trigger is the control: the publicref,id,aria-describedby,data-*and the other base props reach it (family:input-fieldsFR10,architecture:public-component-apiINV8),handleRefacts on it, and its accessible description is a visually hidden list of the selected token labels (spec:AST-020FR1) — Chromium names the button "Tags" alone and leaves the token children out, so without it the selection was invisible to assistive technology.disabledMessage,status,startIcon,htmlNamehidden inputs,onFocus/onBlur/onKeyDown,controlXstyle/className/styleand thetokenizertheming target carry over. A disabled trigger stays focusable and does not open; a field that becomes disabled while its sheet is open closes the sheet and takes focus back after the exit (spec:AST-085FR2). Props with no sheet surface (renderItem,renderToken,endContent,hasClear,hasEntriesOnFocus,menuWidth,tokenOverflowBehavior,hasAutoFocus) warn once in development, naming the props, through the samewarnOnceSelector uses (INV3).Announcements inside the modal sheet: the sheet uses
showModal(), and the shared page-body channel is not exposed to assistive technology while a native modal is open. Every announcement about the sheet's content — a row toggled, the create row, the busy start, the search outcome — is made by code rendered inside the sheet's dialog and goes through the publicuseAnnounce/useBusyAnnouncement/useAnnounceRenderedTextwith no special route:spec:AST-086FR14 (fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009) routes it into the dialog's own container while the sheet is open. To make that true,TokenizerBottomSheetis split: an outer part renders theBottomSheet, an innerTokenizerSheetContentrenders search, rows, footer and the announcements as the sheet's child — FR14 says a component that renders a modal and announces about its content is inside it, and that holds only when the announcing code is. Tokenizer'shandleAdd/handleRemovetake{silent: true}from the sheet and return whether the change applied, so the sheet announces exactly the changes that happened, and the inline presentation's own announcements are unchanged. The create row announces the created value (trimmedQuery), as the inline field does — the create item's label is the row's caption ("Create …") — and the create test asserts the sheet's region reads "Added Custom".Stale rows while a search is pending: the named
Loadingrow renders whenever the list is pending, above the rows still listed from the previous search, so the person sees that those rows are not the answer yet (previously onlyaria-busychanged).adaptiveresolving away while the sheet is open: the sheet presentation is latched until BottomSheet has run its exit and handed focus back to the trigger; if the resolution has moved topopoverby then, the inline input takes the focus over once it mounts, so focus never lands on the page body (FR16). Previously the sheet and the trigger it returns focus to unmounted in the same render.reftype: the exportedTokenizerPropsstays an interface —ref?: Ref<HTMLInputElement>as released, plus the optionalexperimentalmember — sointerface X extends TokenizerProps<Item>andOmit<TokenizerProps<Item>, …>wrappers compile as before. The widening is an overload:Tokenizer(props: TokenizerExperimentalProps<T>)(experimental required,ref?: Ref<HTMLInputElement | HTMLButtonElement>) declared before the stableTokenizer(props: TokenizerProps<T>), so a call passingexperimentalmay pass the wider ref whileComponentProps<typeof Tokenizer>resolves to the last overload — exactlyTokenizerProps(a union parameter type had madeComponentProps<typeof Tokenizer>a union, breakingextendsandOmiton it).Tokenizer.props-type.test.tsxproves all of it at the type level: an interface that extendsTokenizerPropsand one that extendsComponentProps<typeof Tokenizer>,Omitwrappers of each that spread into<Tokenizer>,ComponentProps<typeof Tokenizer>mutually assignable withTokenizerProps<Item>, the input ref with and withoutexperimental, the wide ref with it, and (@ts-expect-error) the wide ref rejected without it.adaptiveserver render and hydration (FR16): newTokenizer.ssr.test.tsx(listed in the record'sverified_by):renderToStringof anadaptivefield yields the inline combobox and no trigger;hydrateRootunder a coarse-pointermatchMediastub reports no recoverable error, the first post-hydration render shows the trigger and no combobox, anddocument.activeElementis the body; a fine-pointer client keeps the combobox. Chromium box measurement (iPhone profile, 390 and 414px, the same coarse CSS media in both runs, the resolver forced topopoverto stand for the hydrating render): the trigger keeps the field's width, top and label; with five tokens the hydrating field is 59px (tokens on two rows, the inline input on the second) and the trigger 32px (one row) — the one shift, the inline input's own row, as FR16 states; theExperimental: adaptivestory now carries five tokens so a phone shows that case.The settle announcement counts the entries the sheet lists (
visibleItems.lengthplus the create row when it is offered: the source's results capped bymaxMenuItems, selected items the source did not return such as a created value, and the create row), as the inline field announces the entries it shows with its create entry included — not the source's count, which said "0 results" over a listed, checked created row or over the create row. Tests: withhasCreate, a miss that offers "Add Custom" announces "1 result", an exact match that offers no create row announces "1 result" for its row (fails on the previous head); a selected created value "Enzo" withmaxMenuItems={2}: "enz" lists Enzo plus the create row and announces two; "en" lists the two capped source rows, Enzo and the create row and announces four.adaptiveresolves against the active theme's compact definition (merged withmain's feat(core): one touch presentation resolver #8062, "one touch presentation resolver"):useAdaptivePresentationreadsuseCompactDefinition().coarsePointerQuery— so a theme that movesmdis honored (spec:AST-012FR9) — and keeps this pull request's rule that only anadaptivepresentation subscribes tomatchMedia(architecture:react-component-runtimeINV5); the query is a dependency of the subscription, so a theme change re-subscribes. The sheet tests stubmatchMediaagainst the exportedCOMPACT_TOUCH_PRESENTATION_QUERY, asmain's Selector tests do, rather than a literal.A disabled field submits nothing in the sheet presentation too (FR10, FR17): the sheet trigger's hidden
namecarriers are gated exactly as the inline branch gates its own (htmlName != null && !isDisabled), soFormData.getAll(name)is empty for a disabled field under either presentation (the previous head dropped the carriers'disabledattribute instead of gating them — a misreading of fix(Tokenizer): block token changes while disabled #8408, which this corrects). Test: forpopoverandbottom-sheet, enabled →getAll('tags')is['design', 'eng']; disabled → no form keys and no hidden input (fails on the previous head for the sheet).A sheet that mounts open takes the open path (FR18, FR20): since the field mounts the sheet from the press, the sheet's first render is its open, and the render-branch open logic (
isOpen !== wasOpen) never ran for it:isPendingstartedtrueand only the search effect cleared it — so a query kept across a forced close that is shorter thanminQueryLength(no search runs) left the sheet pending forever. One predicate,willSearchFor(trimmedQuery), now decides every way a query comes to be: the initialisPendingiswillSearchFor(initialQuery)when mounting open, the initial announced-search marker is set when no search runs for the initial query (no "0 results" owed for search 0), and the open branch and the change handler use the same predicate. Test:minQueryLength={3}, type "En", the field becomes disabled (forced close, query kept), re-enable, reopen → the field holds "En", no busy indicator, noaria-busy, no rows (no selected row matches), no empty state, nothing announced; typing "Eng" searches as usual (fails on the previous head: stuck on the Loading row).Nothing of the sheet is in the DOM at rest (INV7): the sheet host used to mount whenever the sheet presentation was active, so once the lazy chunk resolved the closed BottomSheet kept its dialog, rows, search field and confirmation in the DOM (87 elements, with a checkbox row per selected token beside the trigger's tokens). The host now mounts from the press until the sheet's exit has completed and focus is back on the trigger (
isSheetOpen || isSheetPresented, the latch the focus return already clears); at rest the field renders its trigger alone. The chunk is still preloaded: one sharedimport()promise feeds both a preload effect (while the sheet presentation is active) and the lazy element, so the first press does not wait for the network. Because the sheet unmounts between presentations, the query it keeps across a forced close (FR18: the field becoming disabled while open) now lives in the field —onChangeQueryis wrapped to keep a ref current, the press snapshots it into theinitialQuerythe sheet starts from, and a person's close still clears and reports it. Test: at rest nodialog, no.astryx-bottom-sheet, no checkbox, no search field, noalertdialog, each selected label in the DOM once; present after a press; gone again once Done's exit has completed and the trigger has focus (fails on the previous head). The FR18 test (query kept across a forced close, not reported) still passes.The trigger's description is the inline input's, in its order, then the selection (AR6;
spec:AST-085FR5):aria-describedbyon the trigger lists the caller's ids, the fielddescription, the status message, thedisabledMessagereason (when disabled — a disabled control exposes its description and reason on itself), and then the hidden count and the token elements; previously the count and tokens came first. Test: a disabled trigger with a caller id,description, an error status anddisabledMessageresolves its ids, in order, to "Up to five", "Pick some", "Pick at least three", "Locked while syncing", "2 selected", "Design", "Eng" (fails on the previous head on the order).Under the BottomSheet stack (feat(core): BottomSheet stacks by open order, replaces on a same-update switch, and BottomSheetSwitcher is deprecated #7648,
component:BottomSheetFR8/FR9;spec:AST-003FR18): the field's sheet is an ordinary standaloneBottomSheet, so it registers with the stack like any other. Hosted inside another sheet's content (PowerSearch's value step is this shape), opening it puts it on top: the host isdata-sheet-stack="covered", recedes, and — containing the field sheet — is left to the browser's modal blocking rather than marked inert; the field sheet isaria-modaland not inert; Escape closes only the field sheet, the host is top again, and focus returns to the trigger inside the host (FR9: the opener, inside the revealed sheet). The Clear all confirmation is a Dialog, so it does not join the sheet stack (only sheets count): the field sheet stays the top sheet while covered; the confirmation is the active toast outlet while open and the field sheet's dialog before and after (FR18), so a toast raised from inside the sheet lands in its dialog. Tests: both cases, against the realbottomSheetStack. Chromium (iPhone 13, forcedbottom-sheet): a lone field sheet carries no stack state; rows toggle; the confirmation opens over it with both modal and no stack attribute; after confirm focus is on the search field; Escape closes the sheet and returns focus to the trigger (5/5).Presentation hold (
architecture:layer-runtimeINV14): INV14 assigns the presentation hold — keeping a layer's chosen presentation from the open until the close settles — to the layer runtime. The runtime does not yet provide one (its record names INV14 as ownership it does not yet meet), so this field keeps its own hold (isSheetPresented, released when focus returns to the trigger after the exit) and docs(spec): Tokenizer records the experimental touch sheet presentation (DEC-3) #7976 FR16 records the gap; the hold moves to the runtime's when it exists.The empty-query bootstrap set is capped at
maxMenuItems(FR20): as the inline field slices its focus bootstrap, the sheet settles every answer — bootstrap or search — toitems.slice(0, maxMenuItems), so the prop keeps its meaning in the sheet (this was already the code's behaviour; the record now says so). Test: twenty bootstrap items, two selected (one outside the window),maxMenuItems={5}→ the selected rows first, then the capped window's remaining rows, six rows in all.An empty query with nothing to list shows only the search field (FR20): a search-only source (
{search, bootstrap: () => []}, e.g. withminQueryLength={3}) used to show and announce "No items available" on every open although typing finds items. Decision, matching the inline field (BaseTypeahead opens no menu and reports nothing for a bootstrap that returned nothing — "not a search that failed"): with an empty query and no rows (nothing selected, empty bootstrap) the sheet renders no list and no empty state and announces nothing; the footer stays (Done is the sheet's close, FR18; Clear all is spent). "No items available" had no inline equivalent, so the message and its two catalog keys are removed; theemptySearchTextempty state remains the answer to a searched query. Test: search-only source → no list, no heading, no empty state, no busy indicator, nothing in the sheet's or the page's live region; Done visible, Clear allaria-disabled; typing "En" lists rows; clearing returns to the search-only state silently (fails on the previous head).zh
experimentalentry mirrors the English one: therefsentences (the widerReftype, an input-only ref receiving the trigger at runtime underadaptive/bottom-sheet,TokenizerPropsunchanged) and the usage example.Each selected label renders once (AR6;
architecture:component-style-authoringINV7): the trigger's description used to be a hidden block repeating every selected label beside the visible tokens. Now the hidden element holds only the count ("2 selected"), each renderedTokencarries an id, and the trigger'saria-describedbylists the count element then the token elements in order — the accessible-description algorithm reads the referenced elements and separates them, so no locale punctuation is written and nothing renders twice. Chromium 149 AX tree (390×844 touch profile, forcedbottom-sheet, two selected):button "Tags", description"2 selected Design Eng"; "Design" and "Eng" each appear as exactly one StaticText node (the rendered token) and the count once. Test: the threearia-describedbyids resolve to the count element and the two.astryx-tokenelements, and among the trigger's and the count's leaf text nodes each label occurs once (fails on the previous head).Empty state announced once (FR20;
spec:AST-086FR6), aftermain's EmptyState change (fix(EmptyState): announce once when it appears after load, not as a live root #8294): EmptyState is no longer arole="status"root; it announces its own title and description once when it appears. The sheet therefore speaks nothing for the default empty state — itsuseAnnounceRenderedTextover the empty host now runs only for a caller'semptySearchTextelement, the sheet's own rendered content — so there is one owner per message. Tests: the default empty state is announced exactly once inside the sheet (one "No results found", one "Try a different search.", nothing on the page body); the no-results test locates the empty state by its own root (.astryx-empty-state) rather than a live role.Element counts (
architecture:component-style-authoringINV7;spec:AST-017FR60): Chromium 149, elements underbody, Experimental stories (adaptive: five tokens selected); fine = desktop 1280×800pointer: fine, coarse = iPhone 13 profilepointer: coarse; open = the query "e", a searching state in which the sheet renders no footer (FR18).At rest, the coarse pointer never has more elements than the fine one (120 vs 154 under
adaptive): nothing of the sheet is in the DOM until a press (see the at-rest change above), and the trigger with its tokens is smaller than the inline field with its token remove actions and input. Open underadaptive, the sheet exceeds the inline menu (165 vs 157) — this is the FR60/DEC-29 open-layer allowance DEC-3 in docs(spec): Tokenizer records the experimental touch sheet presentation (DEC-3) #7976 records with these counts: the sheet is a different surface replacing an anchored listbox, not the same one restyled. Fine open 157 = the inline field with its menu open on the one story item matching "e" + the two shared page live regions the first announcement creates. Coarse open reconciles exactly: 165 = 120 at rest − 2 (the app-global surfaces host, the toast host the open modal re-hosts inside itself) + 47 (the sheet's dialog); the 47 by the record's parts: BottomSheet's dialog, panel, handle and edge tint 7; the sheet body 2; the search row with its clear action 19; the list 16 (the list element, its scroll host, two checkbox rows of 7 with indicator and position); the sheet's live region 1; the re-hosted surfaces host 2. A pending Clear all confirmation adds 20 while open; the footer, shown only while the query is empty, adds 9. The forcedbottom-sheetpresentation renders the same elements under either pointer (its dialog 53: the story's create row of 6 more).The Clear all confirmation is mounted only while pending (FR24; INV7): it used to be an AlertDialog mounted closed for the sheet's whole life (20 elements, a nested closed
<dialog>). It now mounts on the Clear all press and unmounts on close. Dialog has an entrance animation and no exit, so a close is its unmount — but Dialog restores focus only in its close effect, not on unmount, so the sheet places focus itself once the modal is gone: on the search field after a confirmed clear (and announces "All items removed" there, inside the sheet, FR14), back on Clear all after a cancel — the destinations FR24 names. Chromium (adaptive, coarse): none pending → 0alertdialog, 0 nesteddialog; pending → 1/1, focus on Cancel; after cancel → 0, focus Clear all, selection intact; after confirm → 0, focus "Search Tags", region "All items removed", 0 checked. Test: noalertdialogand no nesteddialogwhile the sheet is open with none pending; present on press inside the sheet; gone after Cancel with focus on Clear all and nothing changed; gone after Clear selection with focus on the search field and "All items removed" in the sheet's region (fails on the previous head). The existing confirmed-clear tests (focus, announcement once, Clear all spent) still pass.Fine-pointer evidence (
spec:AST-017FR59): this is touch work, so fine-pointer behaviour must stay as it was unless named. Chromium 149 on a desktop profile with a true fine pointer (1280×800,hasTouch: false,isMobile: false, mediapointer: fine/hover: hover; notehasTouch: trueflips Chromium to coarse, so it is off): forcedbottom-sheet, mouse- and keyboard-driven — a mouse click on the trigger opens the modal sheet; mouse clicks check and uncheck a row with "Added Tag 03" / "Removed Tag 03" in the sheet's region; typing "Tag 1" filters to 111 rows (50 rendered) and the sheet announces 112 — the rows plus the create row — joined after the intermediate queries' counts (FR9); Clear all → confirm clears every row, leaves Clear allaria-disabledand focusable, focuses the search field and announces "All items removed"; Done closes and returns focus to the trigger (8/8). Defaultpopoverunder the same fine pointer, before (main39690bf4c) and after (this head): combobox present, no sheet trigger, typing opens the anchored listbox with 7 options, a mouse pick adds one token, field box 400×32 — identical on both.adaptiveunder a fine pointer is the inline combobox with no trigger. No fine-pointer behaviour or appearance changes in this pull request; the sheet reaches a fine pointer only when a caller forcesbottom-sheet.The source is read through a ref at request time (FR20): the sheet's search effect depended on the
searchSourceobject, so a caller that writes the source inline (searchSource={{search, bootstrap}}) — a new object every render — re-ran the bootstrap or search on every row toggle, since each selection change re-renders. The sheet now keeps the source in a ref updated in a layout effect, as the inline field reads its source inside its search callbacks, and requests only on open, on a query change, and on clearing. Test: with an inline source object, open → onebootstrap; three row toggles → no new call; a query → onesearch; clearing → one morebootstrap(fails on the previous head). Theexperimentalprop doc gains one clause: an input-onlyrefstill type-checks against the wider type, but receives the trigger button at runtime underadaptive/bottom-sheet, so type it for both.Clear all stays focusable while spent (FR24;
spec:AST-085FR1):isDisabledalone renders Button's nativedisabled, which leaves the tab order and cannot take the confirmation's return focus. The footer's Clear all now also passes a tooltip ("Nothing selected", new key@astryx.tokenizer.nothingToClear) whilevalueis empty, which is Button's focusable-disabled mode:aria-disabled="true", no nativedisabled, the click guarded by Button, the tooltip as the accessible description. Tests: a spent Clear all hasaria-disabledand nodisabled, takes focus, is described "Nothing selected", and a click opens no confirmation (fails on the previous head); the two existing assertions now checkaria-disabledandnot.toBeDisabled().A confirmed Clear all is announced ("All items removed", new catalog key
@astryx.tokenizer.allRemoved) from inside the sheet, in the effect that runs once the confirmation's dialog has closed and moves focus to the search field — so the sheet is the modal on top again and FR14 delivers the words inside it, rather than into a sheet the confirmation still covers.component:TokenizerAR6 names it (docs(spec): Tokenizer records the experimental touch sheet presentation (DEC-3) #7976). Test asserts the sheet's region reads "All items removed" after the confirmation closes.hasAutoFocuswithadaptive(FR16): the inline input server-rendered withautofocusis focused on load, before hydration, so the field has seen no focus event when the swap removes it. The swapping render readsdocument.activeElement(a read only, during render — React detaches the old control before any effect of that commit runs, so an effect is too late) and, when it is the inline input or the trigger, marks the control focused so the existing hand-off focuses the trigger after the commit, under the hand-off guard: the same element and visit for the caller, noonBlur/onFocuspair. The SSR test's new case:autofocus=""in the server HTML, the input focused beforehydrateRoot, the trigger holds focus after the swap, neither callback called (fails on the previous head: focus fell to the body).onFocus/onBlurcome as a pair: in Safari a click or tap does not focus a button, so a pointer-opened sheet reports no entry; the field tracks whether it reported focus for this visit and reports the trigger's return focus as the entry when it did not, so a session never reportsonBlurwithout itsonFocus. Test: click the trigger without focusing it, Done, leave — oneonFocus(on the return), oneonBlur(fails on the previous head: zero and one).onFocus/onBlurtreat the sheet as part of the field: the props' JSDoc saysonFocusfires "when focus enters the tokenizer from outside" andonBlur"when focus leaves the tokenizer entirely", and the inline field honours that by ignoring moves inside itself. The sheet trigger now does the same: focus moving from the trigger into the field's own sheet (isSheetOpen) reports noonBlur, focus returning from the sheet to the trigger (the latch still set) reports noonFocus, and a presentation swap that hands focus from one control to the other (FR16) reports neither; a real exit — from the trigger to somewhere else — and a real entry from outside still report (family:input-fieldsFR10). Previously each open and close read as leaving and re-entering the field, so a form validating on blur marked an empty required field invalid as the sheet opened.Caller
onClick: the sheet trigger composes its open handler with a caller'sonClickthroughcomposeEventHandlers, open first, caller second, the order the released inline field uses for its click (BaseTypeahead: own handling, then the caller's): the caller observes the click but cannot cancel the field's own handling (family:input-fieldsFR10). The test asserts the sheet opens even when the caller callspreventDefault(), and that the caller saw the click.Create row waits for the search:
createItemisnullwhileisPending, so a create row is judged only against the current query's results; previously, during the debounce window, a value the source already holds could be offered for creation from the previous query's rows.Trigger description, i18n: the description is a catalog message for the count (
@astryx.tokenizer.selectedCount, "{count} selected") followed by each label as its own block element; the accessible-description algorithm supplies the separators, so no locale's list punctuation is written at the call site (INV1a). Chromium computes "2 selected Tag 01 Tag 02".Search outcome announced in the sheet: once each search settles, the sheet announces the result count for a searched query (
@astryx.typeahead.resultCount, its own action announcement) throughuseAnnounce, and the empty state's rendered text is announced by the shared own-content moduleuseAnnounceRenderedText(emptyStateRef, isOpen && isSettledEmpty, String(searchId))— unchanged frommain; it announces throughuseAnnounce, which FR14 routes — so the sheet holds no Effect of its own to read rendered text or write that announcement (architecture:react-component-runtimeINV10;spec:AST-056FR3;spec:AST-086FR11, FR14), and a caller's elementemptySearchTextis announced as shown witharia-hiddenparts left out (AR6, FR20). Once per search: each search that runs takes a number in state and the announcement is keyed on it, so a passive effect left over from an earlier commit cannot claim a newer search's outcome (a flag in a ref did exactly that under the test runner).Bootstrap at once; only typed queries debounce (FR20): the search effect is a layout effect that runs the empty-query bootstrap without the
debounceMstimer — on open and when the query clears — as the inline field fetches its focus bootstrap at once; a source that answers synchronously (Array.isArray) is settled before the browser paints the open commit, so theLoadingrow never reaches the screen and no busy start is announced for it (a passive effect settled after paint: Chromium showed the Loading row in the first frames and announced "Loading"). Anything else the source returns is awaited throughPromise.resolve(...), so a native Promise, one from another realm, or a library thenable all work, as the inline field'sawaitdoes (aninstanceof Promisecheck made a thenable source throw onitems.slice). A typed query still waitsdebounceMs.Focus when a settled search removes the focused row (FR20): the list tracks through its own focus events whether a checkbox row holds focus (a row unmounted while focused fires no blur); after a settle, if that was so and
document.activeElementhas fallen to the body, the search field takes focus (spec:AST-085FR4).matchMediaonly foradaptive(runtime INV5): the shared internaluseAdaptivePresentationnow subscribes towindow.matchMediaonly when the presentation it is given isadaptive; a fixedpopoverorbottom-sheetholds no listener for a policy it never reads. It subscribes throughuseSyncExternalStoredirectly with a no-op subscription when not adaptive (the publicuseMediaQueryis unchanged — widening its signature would be an API change). Selector and MultiSelector pass through the same hook and keep their behavior.Below
minQueryLength: a query shorter than the threshold is not searched: the derived result list is empty, nothing is pending, no busy row, no empty state, no outcome announcement; the selected rows it matches and, withhasCreate, the create row still show, as the inline field shows nothing for such a query (FR20). A below-threshold query that follows a still-unsettled search retires that search — its sequence number is superseded so its rows cannot land, and its outcome number is marked announced so no "0 results" is said for a query that was never searched.After the create row: the query is cleared through
handleQueryChange(''), the path a typed change takes, so the bootstrap reload is pending (aria-busy, theLoadingrow) andonChangeQuery('')is reported.Rows reach assistive technology past the first batch (FR21): the checkbox rows sit in a
role="list"whoserole="listitem"wrappers (display: contents, so the layout is unchanged) carryaria-setsize(the total) andaria-posinset;checkboxadmits neither attribute, and axe 4.12.1 reports a criticalaria-allowed-attrwhen they sit on the checkbox (reproduced in jsdom with the repository's pinned axe: the checkbox markup failsaria-allowed-attr, the list/listitem markup passesaria-allowed-attr,aria-required-children,aria-required-parent,listandlistitem). Focus reaching the last rendered row renders the next batch, so Tab and arrow keys reach every row without a scroll.Focus after a confirmed Clear all (FR24): Clear all is natively disabled once the selection is empty, so the confirmation's focus return fell to
<body>behind the open sheet (seen in Chromium); once the confirmation has closed after a confirmed clear, the search field takes focus — the destination the record names: the clear is spent and the search field is where the next selection starts. Cancelling returns focus to Clear all as before.A forced close keeps the query (FR18): when the field becomes disabled while the sheet is open, the query is kept and nothing is reported — nothing the person did changed it, and the render branch that sees that close may not call the caller (INV2) — so the caller's mirror and the sheet agree on the next open.
Every user close clears the query (FR18): Done, swipe, scrim and Escape alike go through the outer part's
requestClose, which asks the content to clear and report its query through an imperative handle (useImperativeHandle) and then closes — from the closing event, never during render (architecture:react-component-runtimeINV2) — so a caller that mirrors the query into its own state is not updated while another component renders; a close the field forces never passes through it and keeps the query. The render-time branch that reacts toisOpenturning false no longer touches the query. The next open lists the bootstrap set; Done no longer clears the query on its own. The open-time pending decision followshandleQueryChange's rule — take a search number only when the query will be searched, otherwise settle with the outcome marked announced — so a below-threshold query can never leave the list pending for a search the Effect skips. Focus on open lands on the sheet's panel (BottomSheet's default; the sheet marks no autofocus target), not the search field, so the keyboard does not rise unasked.Rejecting source:
search()/bootstrap()rejecting is caught inside the sequence guard and settles the search to no results, so the previous query's rows do not stay listed, busy clears, the outcome announced is the empty state, and nothing surfaces as an unhandled rejection (FR20, asBaseTypeaheaddoes).Create row focus: activating the create row commits, clears the query and moves focus to the sheet's search field, since the row it sat on is gone (FR22).
Presentation change while the sheet is closed: the focus hand-off is now general: a ref tracks whether the field's control (inline input or trigger) holds focus, maintained by both controls' focus/blur handlers (a control unmounted while focused fires no blur); when the presentation swaps and
document.activeElementhas fallen to the body, the new presentation's control takes focus — the input after a rotation out of compact, the trigger after a rotation into it (FR16,spec:AST-085FR4). This replaces the one-way latch-release flag.Rows and
action: a sheet row renders an item without itsactionand warns once in development, asBaseTypeaheaddoes for the inline panel (spec:AST-058FR9).Adaptive example block dropped: an earlier head added a
Tokenizer — Adaptive presentationblock on an ordinary registry route;spec:AST-017FR27 wants experimental surfaces on marked experimental routes, which blocks do not have today, and Drawer's experimental option likewise ships only as adoc.mjssnippet. The adaptive example lives in theexperimentalprop description inTokenizer.doc.mjs(English and Chinese entries both end with it), not as a separate snippet; the live block, its route-lock and sandbox-route entries, and the cli Changeset are gone.Pending search: the list is busy from open and from each query change until the search settles — a named
Spinner(Loading) witharia-busyon the list, and no "No items available" / "No results found" during the debounce window (family:input-fieldsFR7). The pending flag is set where the change happens, not in the Effect, so the defaultdebounceMsnever shows a stale empty state. The search Effect's comment names the external system it synchronizes (the caller'ssearchSource) and its cleanup: the debounce timer clear and the sequence guard (architecture:react-component-runtimeINV3).Lazy surface:
TokenizerBottomSheetloads withReact.lazybehind a host that renders nothing on the server or in the hydrating render, the wayDropdownMenuloads its touch sheet, so a Tokenizer that does not opt in never requests it (component:TokenizerPR6).PanelSearchInputgains an internalfieldXstyleso the sheet can grow the field box to its 44px rows; the Selector/MultiSelector panels pass nothing and render exactly as before.Lab deltas the prototype named are closed: the filter is Core's own panel search field (not a bordered
TextInput), the strings are@astryx.tokenizer.*catalog keys, and the surface has a spec contract. The sheet's parts expose no Tokenizer theming targets while the option is experimental (spec:AST-017FR27; DEC-3): the trigger keeps the field'stokenizertarget, and the sheet is themed through the components it composes.The one deliberate difference from the prototype: the sheet is a standalone
BottomSheetwithisOpen, not aBottomSheetSwitcher— there is one sheet, and the switcher is headed for deprecation. The prototype'sAlertDialogfor Clear all stays as is.packages/lab/src/MobileTokenizer/is untouched (#7648 edits it); the Lab prototype can be retired after this lands.Current specification
component:Tokenizer(packages/core/src/Tokenizer/Tokenizer.spec.md, current) DEC-3, FR16–FR24, AR6, PR6 — the stacked spec PR.spec:AST-017FR21/FR22 for the experimental boundary and the[experimental]patch Changeset.component:SelectorFR4 for the sharedadaptiveresolution.component:BottomSheetandfamily:overlay-dismissalown the sheet's dialog, gestures and dismissal.spec:AST-085FR1/FR2/FR6 for the disabled trigger and rows.Public delta and caller burden
API (
TokenizerProps):No new exports;
TokenizerPropsstays an exported interface whoserefisRef<HTMLInputElement>; theexperimentaloption type is inlined on the prop, and only withexperimentalpassed does the component accept the widerRef<HTMLInputElement | HTMLButtonElement>(a non-exported overload, see the type-level test). No change to any stable prop, default or type.TokenizerHandle.focus()/blur()act on the trigger in the sheet presentation.Behavior in the sheet presentation (FR17–FR24): one trigger; one tall modal sheet named by the label; search on top;
checkboxrows named by item label, togglingadd/removeimmediately with the live-region announcement; stable row order while open, selected-first on the next open;minQueryLength/debounceMs/maxMenuItems/onChangeQuery/emptySearchTextkeep their meaning (a stringemptySearchTextbecomes the empty state's title; a non-string node renders in its place); 50-row progressive rendering;Create "{text}"+Add {text}underhasCreate, absent atmaxEntries; Clear all disabled when empty, otherwise confirmed; Done closes and clears the query; swipe/scrim/Escape perpurpose="info"; focus returns to the trigger.Inert in the sheet presentation (no surface to act on; documented on the prop and in the record):
renderItem,renderToken,endContent,hasClear(the sheet's Clear all replaces it),hasEntriesOnFocus(the sheet always lists the bootstrap set),menuWidth,tokenOverflowBehavior,hasAutoFocus, the inputref.Theming: the trigger carries the existing
tokenizertarget (data-size,data-status,data-disabled). No new targets: the sheet's parts take the composed components' targets until the option is promoted (DEC-3). The probe theme is untouched.i18n: thirteen new
@astryx.tokenizer.*keys (search,searchPlaceholder,noResultsDescription,create,add,addValue,done,clearAllTitle,clearAllDescription,clearSelection,selectedCount,allRemoved,nothingToClear); the search field's clear reuses@astryx.textInput.clearLabel; the default no-results title reuses@astryx.typeahead.emptySearchResults.Accessibility: trigger
buttonnamed by the field label and described by the selected count then the rendered tokens themselves ("2 selected Design Eng" — the hidden count element and eachTokenby id inaria-describedby, so no label renders twice), witharia-haspopup="dialog"/aria-expanded; modaldialognamed by the label; search input namedSearch {label}with a named clear; a namedLoadingspinner andaria-busyon the list while a search is pending;checkboxrows witharia-checked;aria-disabled(never nativedisabled) on the trigger and at-limit rows; add/remove and busy-start announcements through a persistent polite live region inside the open sheet dialog (the shared channel is not exposed while a native modal is open), and through the shared channel in the inline presentation; focus returns to the trigger after close, including a close caused by the field becoming disabled.Bundle (each subpath bundled alone from
distwith esbuild, minified, ESM with code splitting, gzip level 9;react,react-dom,@stylexjs/stylexexternal; base = this PR's parentmainc22cbe2fd, which already contains #7976 and #8009; head =1ed2b3237):TokenizerBottomSheetchunk + its split; loaded only while a field resolves tobottom-sheet)Field.tsx,FieldLabel.tsx;PanelSearchInput'sfieldXstyle)BottomSheet/BottomSheetSwitcher.js; by-pathuseScrollLockimport)useMediaQuery)The +0.1–0.3 KB on components this commit does not edit (TextInput, Dialog, Drawer, Lightbox, MobileNav) is the new catalog strings in the shared
enchunk and chunk re-assignment; Field and BottomSheetSwitcher, whose code this commit touches, move +0.1 KB and 0. Selector shrinks becauseField.tsx/FieldLabel.tsximporting by path lets its bundle drop thehooksandTextbarrels' unused exports.CSS (
spec:AST-080FR8 / INV6; gzip level 9 of the StyleX rules each component's own modules contribute, and of the package stylesheet):astryx.css31.56 → 31.58 KB gz (+≈20 B); Tokenizer's own rules 0.87 → 1.32 KB gz (+0.45 KB) — the trigger, the sheet body, rows, footer and create row; BottomSheet, Dialog, Drawer, Lightbox, MobileNav, Field and AlertDialog rules unchanged (their CSS delta is 0; #8009 adds no StyleX rules). The sheet's CSS runs only when a field resolves tobottom-sheet, so it is coarse-pointer-only cost, and at +0.45 KB it is under the 1 KB flag.The +4.9 KB eager on Tokenizer is ≈2.5 KB of
Tokenizer.tsxand the split sheet host (trigger, styles, focus pairing, silent add/remove, the swap's focus read, the description composition), ≈0.4 KB of catalog strings, the preload effect, and the rest bundler chunk assignment for the lazy import. What still sits eagerly and why (esbuildmetafile, bytes in output, base against head; "eager" = chunks every Tokenizer import loads):useAnnounce's in-dialog region writer and context (main's, via #8009);Layout/padding.stylexrows 1,438 → 6,637 B — the inline popover reaches the module viaLayer/useLayer/Popover/usePopover, the sheet'sLayoutreaches other rows of the samestylex.createobjects, and neither StyleX nor esbuild splits one object literal across chunks (moving it means splittingpadding.stylexper consumer — a Layout change);hooks/useMediaQuery282 B,utils/focusReturn265 B,utils/isRenderable59 B (≈0.3 KB gz) — reachable onmainbut tree-shaken to 0 B, hoisted into the shared chunk because the lazy chunk also reaches them; moving them needs by-path imports inTokenandBaseTypeahead, which this pull request does not otherwise touch. For comparison, the superseded automatic-switch draft (#5315) measured +16.4 KB gz eager for every consumer.Design and alternatives
Chosen: the Lab single-sheet flow as-is behind an experimental opt-in;
adaptiveresolved by the shared policy; a standaloneisOpensheet; selection logic kept inTokenizerand the sheet lazy. Rejected (recorded in DEC-3): automatic switching onpointer: coarsewithout an opt-in (changes stable behavior; FR21); a top-level stablepresentationprop before consumer evidence; separate manage/add sheets; hosting the one sheet in aBottomSheetSwitcher.Two implementation notes: the sheet snapshots the selection and resets its rendered window in the render that opens it (React's adjust-state-on-prop-change pattern), not in an effect, so the first open frame already shows selected-first order; and the list is not a second
role="group"named by the label — the sheet's own content area already is, so the counters live on plaindata-rendered-count/data-total-countattributes.Evidence
Unit —
packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx(type level, above) andpackages/core/src/Tokenizer/TokenizerBottomSheet.test.tsx(62 tests) andTokenizer.ssr.test.tsx(3): presentation resolution —experimentalomitted,{}and'popover'keep the combobox;'bottom-sheet'forces the trigger and opens a modaldialognamed by the label;'adaptive'withmatchMediastubbed to match(max-width: 768px) and (pointer: coarse)opens the sheet and without it keeps the combobox (the selectors' test shape). Ported Lab cases: stable rows while open and selected-first on reopen; create with trailing Add (and no create row for text matching a selected label or a result); 50-row batches and window reset on search; same-sheet no-results and no-items empty states,emptySearchTextas title; large equal-width Clear all / Done with the guarded bulk clear; disabled trigger focusable and closed,disabledMessagetooltip; at-limit row focusable but inert while the checked row still toggles; forced-colorsGrayTextatoms on trigger, placeholder, tokens and indicator. Plus hidden-input/status chrome in the sheet presentation; announcements — with:modalstood in, theLoadingbusy start,Added Energizer, andRemoved Designland in the region inside the open sheet dialog while the page-body channel stays empty, and the inline presentation's Backspace removal still uses the shared channel with no in-dialog region created. New this round: theLoadingrow precedes the staleDesignrow in the DOM while a query is pending; a row withactionrenders without it and one development warning namesactionand the sheet rows;adaptiveflipping to non-compact while the sheet is open (amatchMediastub with a change listener) keeps the sheet and trigger until Done and the exit transition, after which the combobox is on screen and focused and the trigger is gone;refreceives the input withoutexperimental, and with it the input underpopoverand the trigger underbottom-sheet. Latest round: belowminQueryLength(3) a two-letter query lists only the matching selected row and the create row, noLoading, noaria-busy, no empty state, andsearchis not called until the third letter; an elementemptySearchTextwith anaria-hiddenspan is announced as "Nothing called that" in the dialog region; a source that rejects on one query settles to the empty state with no stale rows,aria-busycleared, "No results found" announced and nounhandledrejection; activatingAdd Opsleaves focus on the search field; with the sheet closed, flippingadaptivewhile the trigger is focused focuses the combobox, flipping back focuses the trigger, and an unfocused field moves nothing; withminQueryLength={3}and a slow source, "Eng" then "En" before it settles clearsaria-busy, lets noEngrow land, announces no count, and a later "Ener" announces "1 result"; afterAdd Opswith a slow source the list isaria-busywith theLoadingrow andonChangeQuery('')fires until the bootstrap set returns; two row toggles in one frame are announced as "Added Eng Removed Design" in the dialog region; withminQueryLength={3}, typing "De" then pressing Escape reportsonChangeQuery(''), and the reopened sheet has an empty search field, lists the bootstrap set with noaria-busy, noLoadingrow, no create row and nosearchcall; on open the dialog contains the focused element and the search field is not it; a caller withonChangeQuery={setState}sees its mirror go "En" → "" on Escape with no "Cannot update a component while rendering" on aconsole.errorspy (the test fails on the previous head); the 50listitems in the rows'listcarryaria-setsize="120"/aria-posinset1–50 and the checkboxes carry neither, focusing row 50 renders rows 51–100 (row 51's listitem ataria-posinset="51") and focusing row 100 renders the rest; after a confirmed Clear all the (now disabled) Clear all is not focused, the search field is, andactiveElementis not<body>(fails without the fix); disabling the field with "En" typed reports nothing throughonChangeQuery, and the reopened sheet still shows "En"; with the default debounce and an instant source, the opened sheet has noaria-busy, noLoadingrow and no in-dialog region at all, a typed query is busy until the debounce passes, and clearing it reloads at once (the AR6 announcement test now proves the busy start on a typed query, not on open); focusing theDesignrow then typing "En" removes it and leaves focus on the search field, not the body; a source returning{then}thenables (not Promises) lists the bootstrap set and a search's results with no console error (fails with aninstanceof Promisecheck); a default Tokenizer,presentation: 'popover'and'bottom-sheet'evaluate no compact-touch query and register nomatchMedialistener,'adaptive'registers one (fails against the previous hook); focusing the trigger reports oneonFocus, open → search field → Done → trigger reports noonBlurand no furtheronFocus, focusing an outside button reports oneonBlur, and a second cycle adds oneonFocusonly; the FR16 swap test asserts the hand-off reports neither (both fail on the previous head). Earlier: a calleronClickruns on the trigger after the open and itspreventDefault()does not keep the sheet closed; withhasCreateand a slow source noAdd …row is offered during the debounce window and the row appears once settled; the in-dialog region announces "3 results" after a typed query and "No results found" when the empty state shows; the trigger's accessible description is "2 selected Design Eng". New this round: the trigger's computed accessible description ("2 selected Design Eng", and none when empty); base props andrefon the trigger (id,data-testid, calleraria-describedbymerged,descriptionreachable, the label'sfor,onFocus/onKeyDown,ref.currentis the trigger); the one-time development warning naminghasClear,menuWidth(no second warning on re-render, none in the inline presentation); busy-not-empty with fake timers and the defaultdebounceMs—aria-busyand theLoadingspinner at open and inside the debounce window after a query change, the empty state only once the search settles; and disabling while open — the dialog goesaria-hidden, the trigger reports collapsed, focus returns to the trigger after the exit transition, and re-enabling does not reopen the sheet.Unchanged paths —
Tokenizer.test.tsx,Tokenizer.fr10.test.tsx, BottomSheet, hooks, Field, Selector, MultiSelector and the LabMobileTokenizersuites all pass unchanged.Real browser (Chromium 149 via Playwright on Linux, static Storybook build of this head, iPhone 13 profile 390×844 with touch emulation). Rounds so far — latest (this head, iPhone 13 profile, the public
useAnnounceroute): the sheet is:modal; a row tap writes "Added Tag 03" to the dialog's ownstatuscontainer and nothing to the page body, and Chromium's accessibility tree lists that container asrole: status,live: polite, not ignored; "111 results" and "0 results" land there for a typed query and a miss; opening with a synchronous source paints no Loading row in any of the first ten frames and creates no container; the create row's Add writes "Added zzzz" — the created value — to the sheet's container (7/7). Previous round: with a MutationObserver installed before the tap and a rAF sampler over the first ten frames, opening the bottom-sheet story (synchronous source,debounceMs={0}) and the adaptive story paints no Loading row in any frame and creates no in-dialog live region (nothing announced on open); typing "zzz" then announces "0 results" (the adaptive story, withhasCreateoff, announces its rendered "No results found" empty state) — never "Loading 0 results". Previous round (the sheet story open): axe 4.12.1 injected into the page and run on the open dialog witharia-allowed-attr,aria-required-children,aria-required-parent,list,listitem,aria-allowed-roleandnested-interactivereports no violations; 50 checkbox rows, each inside alistitemcarryingaria-setsize="1000"/aria-posinset, none on a checkbox; focusing row 50 renders 100 rows. Chromium's accessibility tree reports the wrappers asrole: listitem(the DevTools protocol surfaceslevelbut notsetsize/posinsetas node properties, so the position itself is attested by the markup and axe, not by a protocol read). Previous round, 6/6: Chromium's accessibility tree names the trigger "Tags" and describes it "2 selected Design Eng" (no literal separator),hasPopup: dialog; a typed query writes "111 results" todialog > [data-astryx-modal-live-region="polite"], and a miss on thehasCreatestory writes "0 results" there with the create row offered, the page-body channel empty throughout. Earlier, 9/9: the sheet dialog matches:modal; a row tap writes "Added Tag 03" todialog > [data-astryx-modal-live-region="polite"]and nothing to the page-body channel, and Chromium's accessibility tree lists that node asrole: status,live: polite, not ignored; a removal writes "Removed Tag 03" the same way; with the sheet open, widening the viewport past 768px keeps the sheet and trigger, and after Done the combobox is on screen withdocument.activeElementon it, no trigger, no open dialog. Earlier rounds, 35/35: Chromium's own accessibility tree reports the trigger asbutton, name "Tags", description "2 selected Design Eng",hasPopup: dialog; the touch context reports the compact coarse-pointer query; no combobox in the sheet presentation; trigger one line tall with two tokens and with three (measured with the earlier 44px floor; the trigger now follows the field's size step, 32px atmd); sheet panel 824px of an 844px viewport; dialogaria-modalnamedTags; search row 47px below the panel top and above the first row; search field and rows 44px; first batch 50 of 1000; selected items lead; a row tap adds the token immediately and moves no row; scrolling to the end renders 100; withhasCreatea dead-end query offersCreate "zzz"with Add, the footer hidden while searching; Add commits the token and clears the query; Done closes anddocument.activeElementis the trigger; reopen showsDesign | Eng | Tag 03 | Custom; Clear all confirms and clears;adaptiveis the sheet on the touch context and the inline combobox at 1280×800 with a fine pointer, and still inline when that fine-pointer window is narrowed to 390px; the Default story keeps the combobox on touch; the disabled trigger is focusable and does not open; at the limit the unchecked row isaria-disabled, ignores the tap, and the checked row still toggles. No-results empty state verified on theadaptivestory (nohasCreate).Accessibility audit —
.github/scripts/accessibility-audit.js --components Tokenizer --baseline .github/a11y-baseline.json --fail-on-newon the same build: 0 new violations (2 baselined, pre-existing), the three new stories at 0 issues; no baseline change.Phone width — at 390px the experimental stories' field is 358px (the canvas minus padding) and the sheet panel 390px with no horizontal scroll.
Size — the diff against
main(now containing docs(spec): Tokenizer records the experimental touch sheet presentation (DEC-3) #7976 and fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009) is 4,907 added+deleted lines over 20 files:TokenizerBottomSheet.test.tsx2,717 andTokenizer.ssr.test.tsx145 andTokenizer.props-type.test.tsx114 are the record's verification — one or more tests per FR16–FR24/AR6 row — so tests are 2,976 lines, over half;TokenizerBottomSheet.tsx957 andTokenizer.tsx+561/−24 are one surface and its wiring;useAdaptivePresentation.ts+36/−6 (subscribe only when adaptive, the theme query),Field/PanelSearchInput.tsx+11 (one optionalfieldXstyle), seven one-line by-path imports inField,BottomSheetandAlertDialog; the rest is i18n (52), stories (140), docs (16), the changeset (7), the adopters-test entry (21), and the record's gap-note deletions (41/44).Why one pull request. The size rule allows a change that cannot be split without shipping a head that does not meet the record it claims, and this one cannot: every row of DEC-3's contract depends on another. The trigger (FR16) is useless without a sheet to open; the sheet's search (FR18, FR20) is what the trigger exists for; the busy state and its announcements (FR20, AR6,
family:input-fieldsFR7) are meaningless without the search they report on; the checkbox rows (FR19, FR21) are what the search lists and what Clear all (FR24) and the create row (FR22) act on; the focus rules (FR16, FR17, FR23,spec:AST-085FR4) tie the trigger, the search field, the rows and the confirmation into one tab order. A head with the trigger but no sheet, or a sheet with rows but no search, would render a control that does nothing or announces nothing — a regression against the inline field, not a step toward the record. The 2,717-line test file is the per-row verification of exactly those couplings (a row toggle that announces inside the sheet; a search that settles into rows and a count; Clear all returning focus to the search field), so it cannot land before the rows it pins. The small pieces outside Tokenizer are small and inseparable too:useAdaptivePresentation(+36/−6) only stops a fixed presentation subscribing tomatchMedia— needed the moment Tokenizer passesadaptive, and nothing else exercises it; the seven by-path imports andfieldXstyleexist only so the sheet's lazy chunk and the inline field share nothing they need not — split out, they would be a no-op pull request, and left out, the sheet would be unshippable alone because the eager bundle would carry sheet-only code every Tokenizer consumer pays for. The announcement route that could stand alone was split out and merged first (fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009), as was the record (docs(spec): Tokenizer records the experimental touch sheet presentation (DEC-3) #7976); what remains is the implementation of one experimental option, shipped behindexperimental.presentationso omitting it changes nothing. The owner has waived the review size limit for this change: "i don't mind large PR. i can bypass [the review size] limit".Scope
useAdaptivePresentation, the by-path imports,fieldXstyle) are inseparable from the sheet they exist for.Tokenizer.doc.mjsdiffers frommainby exactly the threeexperimentalentries — the en and zh prop entries and the dense description; no other line is touched: theexperimentalprop entry, which carriesstability: 'experimental', holds the description and the one usage snippet; the component'sexamplescarry no experimental entry, so the experimental boundary is the marked prop entry alone —spec:AST-017FR21, FR27; dense and zh variants), focused tests, and the[experimental]core Changeset ship with the feature.Testing
pnpm exec vitest run packages/core/src/Tokenizer packages/core/src/BottomSheet packages/core/src/hooks packages/core/src/__tests__/BusyOwnerAnnouncement.test.tsx packages/core/src/DateInput packages/core/src/CommandPalette packages/core/src/Selector packages/core/src/MultiSelector packages/core/src/Typeahead— passed (counts in the run log).pnpm -F @astryxdesign/core typecheckandpnpm -F @astryxdesign/cli typecheck:template-docs— clean.ASTRYX_STRICT_LINT=1 pnpm exec eslintover the changed files — 0 errors, 0 new warnings..ts/.tsx/.mdfiles — clean (Tokenizer.doc.mjsis not Prettier-formatted on main and is left as is).pnpm check:repo(knowledge, changesets, sync, i18n catalog and runtime, package boundaries, use-client, …) — exit 0;node scripts/release/changeset-coverage.mjs pr— ships to@astryxdesign/core, named;DOCSITE_TARGET=canary node apps/docsite/scripts/generate-data.mjs— route lock matches (no new routes).pnpm build && VERCEL_ENV=preview VERCEL_GIT_COMMIT_SHA=$(git rev-parse HEAD) node apps/docsite/scripts/build-previews.mjs && pnpm -F @astryxdesign/docsite build— succeeded (783 sandbox routes verified, docsite template-gallery and function-size checks passed).pnpm storybook:build— succeeded;pnpm -F @astryxdesign/core build— succeeded (bundle measurement above).git diff --check— clean.