Skip to content

feat(core): Tokenizer touch sheet behind experimental.presentation - #7977

Merged
imdreamrunner merged 1 commit into
mainfrom
feat/tokenizer-experimental-bottom-sheet
Oct 27, 2026
Merged

imdreamrunner merged 1 commit into
mainfrom
feat/tokenizer-experimental-bottom-sheet

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Targets main directly, one commit. #7976 (the Tokenizer record, DEC-3) and #8009 (useAnnounce delivers inside the open Astryx modal that encloses its caller — spec:AST-086 FR14) are both merged; this pull request is the implementation alone, rebased onto main after those merges and after #7648 (the BottomSheet stack).

Builds on the merged Tokenizer record (#7976, component:Tokenizer DEC-3) and on the merged #8009, which makes useAnnounce deliver inside the open Astryx modal that encloses its caller, as spec:AST-086 FR14 requires. The sheet's announcing code renders inside the BottomSheet, so plain useAnnounce reaches the sheet's dialog. This PR deletes the record's implementation-gap notes and adds its test files to verified_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 MobileTokenizer prototype 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.presentation on Tokenizer: '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, through useAdaptivePresentation — the policy Selector, MultiSelector and DropdownMenu share, so a Tokenizer and a Selector on one page switch together). Inside the experimental object per spec:AST-017 FR21, with @experimental on the declarations and stability: 'experimental' in the doc, as Drawer's experimental.isFullWidthOnMobile is.

  • TokenizerBottomSheet.tsx (new): the Lab touch flow, ported behavior for behavior — one tall modal sheet, the shared PanelSearchInput on top, full-width checkbox rows that keep their place while the sheet is open and regroup selected-first on the next open, 50-row batches that advance within 200px of the list end, a Create "…" row with a trailing Add … action under hasCreate, a footer with Clear all (confirmed through AlertDialog) and Done while the query is empty, aria-disabled rows at maxEntries, and forced-colors GrayText for the disabled trigger, placeholder, tokens and indicator. Selection stays in Tokenizer: the sheet calls the field's own handleAdd / 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 its size step (--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 public ref, id, aria-describedby, data-* and the other base props reach it (family:input-fields FR10, architecture:public-component-api INV8), handleRef acts on it, and its accessible description is a visually hidden list of the selected token labels (spec:AST-020 FR1) — 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, htmlName hidden inputs, onFocus/onBlur/onKeyDown, controlXstyle/className/style and the tokenizer theming 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-085 FR2). Props with no sheet surface (renderItem, renderToken, endContent, hasClear, hasEntriesOnFocus, menuWidth, tokenOverflowBehavior, hasAutoFocus) warn once in development, naming the props, through the same warnOnce Selector 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 public useAnnounce / useBusyAnnouncement / useAnnounceRenderedText with no special route: spec:AST-086 FR14 (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, TokenizerBottomSheet is split: an outer part renders the BottomSheet, an inner TokenizerSheetContent renders 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's handleAdd/handleRemove take {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 Loading row 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 only aria-busy changed).

  • adaptive resolving 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 to popover by 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.

  • ref type: the exported TokenizerProps stays an interface — ref?: Ref<HTMLInputElement> as released, plus the optional experimental member — so interface X extends TokenizerProps<Item> and Omit<TokenizerProps<Item>, …> wrappers compile as before. The widening is an overload: Tokenizer(props: TokenizerExperimentalProps<T>) (experimental required, ref?: Ref<HTMLInputElement | HTMLButtonElement>) declared before the stable Tokenizer(props: TokenizerProps<T>), so a call passing experimental may pass the wider ref while ComponentProps<typeof Tokenizer> resolves to the last overload — exactly TokenizerProps (a union parameter type had made ComponentProps<typeof Tokenizer> a union, breaking extends and Omit on it). Tokenizer.props-type.test.tsx proves all of it at the type level: an interface that extends TokenizerProps and one that extends ComponentProps<typeof Tokenizer>, Omit wrappers of each that spread into <Tokenizer>, ComponentProps<typeof Tokenizer> mutually assignable with TokenizerProps<Item>, the input ref with and without experimental, the wide ref with it, and (@ts-expect-error) the wide ref rejected without it.

  • adaptive server render and hydration (FR16): new Tokenizer.ssr.test.tsx (listed in the record's verified_by): renderToString of an adaptive field yields the inline combobox and no trigger; hydrateRoot under a coarse-pointer matchMedia stub reports no recoverable error, the first post-hydration render shows the trigger and no combobox, and document.activeElement is 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 to popover to 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; the Experimental: adaptive story now carries five tokens so a phone shows that case.

  • The settle announcement counts the entries the sheet lists (visibleItems.length plus the create row when it is offered: the source's results capped by maxMenuItems, 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: with hasCreate, 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" with maxMenuItems={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.

  • adaptive resolves against the active theme's compact definition (merged with main's feat(core): one touch presentation resolver #8062, "one touch presentation resolver"): useAdaptivePresentation reads useCompactDefinition().coarsePointerQuery — so a theme that moves md is honored (spec:AST-012 FR9) — and keeps this pull request's rule that only an adaptive presentation subscribes to matchMedia (architecture:react-component-runtime INV5); the query is a dependency of the subscription, so a theme change re-subscribes. The sheet tests stub matchMedia against the exported COMPACT_TOUCH_PRESENTATION_QUERY, as main'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 name carriers are gated exactly as the inline branch gates its own (htmlName != null && !isDisabled), so FormData.getAll(name) is empty for a disabled field under either presentation (the previous head dropped the carriers' disabled attribute instead of gating them — a misreading of fix(Tokenizer): block token changes while disabled #8408, which this corrects). Test: for popover and bottom-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: isPending started true and only the search effect cleared it — so a query kept across a forced close that is shorter than minQueryLength (no search runs) left the sheet pending forever. One predicate, willSearchFor(trimmedQuery), now decides every way a query comes to be: the initial isPending is willSearchFor(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, no aria-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 shared import() 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 — onChangeQuery is wrapped to keep a ref current, the press snapshots it into the initialQuery the sheet starts from, and a person's close still clears and reports it. Test: at rest no dialog, no .astryx-bottom-sheet, no checkbox, no search field, no alertdialog, 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-085 FR5): aria-describedby on the trigger lists the caller's ids, the field description, the status message, the disabledMessage reason (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 and disabledMessage resolves 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:BottomSheet FR8/FR9; spec:AST-003 FR18): the field's sheet is an ordinary standalone BottomSheet, 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 is data-sheet-stack="covered", recedes, and — containing the field sheet — is left to the browser's modal blocking rather than marked inert; the field sheet is aria-modal and 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 real bottomSheetStack. Chromium (iPhone 13, forced bottom-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-runtime INV14): 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 — to items.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. with minQueryLength={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; the emptySearchText empty 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 all aria-disabled; typing "En" lists rows; clearing returns to the search-only state silently (fails on the previous head).

  • zh experimental entry mirrors the English one: the ref sentences (the wider Ref type, an input-only ref receiving the trigger at runtime under adaptive/bottom-sheet, TokenizerProps unchanged) and the usage example.

  • Each selected label renders once (AR6; architecture:component-style-authoring INV7): 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 rendered Token carries an id, and the trigger's aria-describedby lists 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, forced bottom-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 three aria-describedby ids resolve to the count element and the two .astryx-token elements, 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-086 FR6), after main's EmptyState change (fix(EmptyState): announce once when it appears after load, not as a live root #8294): EmptyState is no longer a role="status" root; it announces its own title and description once when it appears. The sheet therefore speaks nothing for the default empty state — its useAnnounceRenderedText over the empty host now runs only for a caller's emptySearchText element, 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-authoring INV7; spec:AST-017 FR60): Chromium 149, elements under body, Experimental stories (adaptive: five tokens selected); fine = desktop 1280×800 pointer: fine, coarse = iPhone 13 profile pointer: coarse; open = the query "e", a searching state in which the sheet renders no footer (FR18).

    Story Pointer → presentation At rest Open
    Experimental: adaptive (five tokens selected) fine → popover 154 157
    Experimental: adaptive coarse → sheet 120 165
    Experimental: bottom-sheet fine → sheet 114 165
    Experimental: bottom-sheet coarse → sheet 114 165
    Default (popover) fine / coarse 114 / 114 151 / 151

    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 under adaptive, 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 forced bottom-sheet presentation 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 → 0 alertdialog, 0 nested dialog; 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: no alertdialog and no nested dialog while 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-017 FR59): 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, media pointer: fine / hover: hover; note hasTouch: true flips Chromium to coarse, so it is off): forced bottom-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 all aria-disabled and focusable, focuses the search field and announces "All items removed"; Done closes and returns focus to the trigger (8/8). Default popover under the same fine pointer, before (main 39690bf4c) 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. adaptive under 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 forces bottom-sheet.

  • The source is read through a ref at request time (FR20): the sheet's search effect depended on the searchSource object, 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 → one bootstrap; three row toggles → no new call; a query → one search; clearing → one more bootstrap (fails on the previous head). The experimental prop doc gains one clause: an input-only ref still type-checks against the wider type, but receives the trigger button at runtime under adaptive/bottom-sheet, so type it for both.

  • Clear all stays focusable while spent (FR24; spec:AST-085 FR1): isDisabled alone renders Button's native disabled, 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) while value is empty, which is Button's focusable-disabled mode: aria-disabled="true", no native disabled, the click guarded by Button, the tooltip as the accessible description. Tests: a spent Clear all has aria-disabled and no disabled, takes focus, is described "Nothing selected", and a click opens no confirmation (fails on the previous head); the two existing assertions now check aria-disabled and not.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:Tokenizer AR6 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.

  • hasAutoFocus with adaptive (FR16): the inline input server-rendered with autofocus is focused on load, before hydration, so the field has seen no focus event when the swap removes it. The swapping render reads document.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, no onBlur/onFocus pair. The SSR test's new case: autofocus="" in the server HTML, the input focused before hydrateRoot, the trigger holds focus after the swap, neither callback called (fails on the previous head: focus fell to the body).

  • onFocus/onBlur come 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 reports onBlur without its onFocus. Test: click the trigger without focusing it, Done, leave — one onFocus (on the return), one onBlur (fails on the previous head: zero and one).

  • onFocus/onBlur treat the sheet as part of the field: the props' JSDoc says onFocus fires "when focus enters the tokenizer from outside" and onBlur "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 no onBlur, focus returning from the sheet to the trigger (the latch still set) reports no onFocus, 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-fields FR10). 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's onClick through composeEventHandlers, 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-fields FR10). The test asserts the sheet opens even when the caller calls preventDefault(), and that the caller saw the click.

  • Create row waits for the search: createItem is null while isPending, 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) through useAnnounce, and the empty state's rendered text is announced by the shared own-content module useAnnounceRenderedText(emptyStateRef, isOpen && isSettledEmpty, String(searchId)) — unchanged from main; it announces through useAnnounce, which FR14 routes — so the sheet holds no Effect of its own to read rendered text or write that announcement (architecture:react-component-runtime INV10; spec:AST-056 FR3; spec:AST-086 FR11, FR14), and a caller's element emptySearchText is announced as shown with aria-hidden parts 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 debounceMs timer — 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 the Loading row 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 through Promise.resolve(...), so a native Promise, one from another realm, or a library thenable all work, as the inline field's await does (an instanceof Promise check made a thenable source throw on items.slice). A typed query still waits debounceMs.

  • 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.activeElement has fallen to the body, the search field takes focus (spec:AST-085 FR4).

  • matchMedia only for adaptive (runtime INV5): the shared internal useAdaptivePresentation now subscribes to window.matchMedia only when the presentation it is given is adaptive; a fixed popover or bottom-sheet holds no listener for a policy it never reads. It subscribes through useSyncExternalStore directly with a no-op subscription when not adaptive (the public useMediaQuery is 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, with hasCreate, 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, the Loading row) and onChangeQuery('') is reported.

  • Rows reach assistive technology past the first batch (FR21): the checkbox rows sit in a role="list" whose role="listitem" wrappers (display: contents, so the layout is unchanged) carry aria-setsize (the total) and aria-posinset; checkbox admits neither attribute, and axe 4.12.1 reports a critical aria-allowed-attr when they sit on the checkbox (reproduced in jsdom with the repository's pinned axe: the checkbox markup fails aria-allowed-attr, the list/listitem markup passes aria-allowed-attr, aria-required-children, aria-required-parent, list and listitem). 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-runtime INV2) — 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 to isOpen turning 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 follows handleQueryChange'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, as BaseTypeahead does).

  • 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.activeElement has 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-085 FR4). This replaces the one-way latch-release flag.

  • Rows and action: a sheet row renders an item without its action and warns once in development, as BaseTypeahead does for the inline panel (spec:AST-058 FR9).

  • Adaptive example block dropped: an earlier head added a Tokenizer — Adaptive presentation block on an ordinary registry route; spec:AST-017 FR27 wants experimental surfaces on marked experimental routes, which blocks do not have today, and Drawer's experimental option likewise ships only as a doc.mjs snippet. The adaptive example lives in the experimental prop description in Tokenizer.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) with aria-busy on the list, and no "No items available" / "No results found" during the debounce window (family:input-fields FR7). The pending flag is set where the change happens, not in the Effect, so the default debounceMs never shows a stale empty state. The search Effect's comment names the external system it synchronizes (the caller's searchSource) and its cleanup: the debounce timer clear and the sequence guard (architecture:react-component-runtime INV3).

  • Lazy surface: TokenizerBottomSheet loads with React.lazy behind a host that renders nothing on the server or in the hydrating render, the way DropdownMenu loads its touch sheet, so a Tokenizer that does not opt in never requests it (component:Tokenizer PR6).

  • PanelSearchInput gains an internal fieldXstyle so 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-017 FR27; DEC-3): the trigger keeps the field's tokenizer target, and the sheet is themed through the components it composes.

The one deliberate difference from the prototype: the sheet is a standalone BottomSheet with isOpen, not a BottomSheetSwitcher — there is one sheet, and the switcher is headed for deprecation. The prototype's AlertDialog for 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-017 FR21/FR22 for the experimental boundary and the [experimental] patch Changeset. component:Selector FR4 for the shared adaptive resolution. component:BottomSheet and family:overlay-dismissal own the sheet's dialog, gestures and dismissal. spec:AST-085 FR1/FR2/FR6 for the disabled trigger and rows.

Public delta and caller burden

API (TokenizerProps):

/** @experimental */
experimental?: {
  /** @experimental @default 'popover' */
  presentation?: 'popover' | 'bottom-sheet' | 'adaptive';
};
// without `experimental`:  ref?: Ref<HTMLInputElement>                        (unchanged)
// with `experimental`:     ref?: Ref<HTMLInputElement | HTMLButtonElement>    @experimental

No new exports; TokenizerProps stays an exported interface whose ref is Ref<HTMLInputElement>; the experimental option type is inlined on the prop, and only with experimental passed does the component accept the wider Ref<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.

// before and after — no opt-in, no change
<Tokenizer label="Tags" searchSource={source} value={tags} onChange={setTags} />

// opt in: the sheet on compact touch screens, the inline field elsewhere
<Tokenizer label="Tags" searchSource={source} value={tags} onChange={setTags}
  experimental={{presentation: 'adaptive'}} />

Behavior in the sheet presentation (FR17–FR24): one trigger; one tall modal sheet named by the label; search on top; checkbox rows named by item label, toggling add/remove immediately with the live-region announcement; stable row order while open, selected-first on the next open; minQueryLength / debounceMs / maxMenuItems / onChangeQuery / emptySearchText keep their meaning (a string emptySearchText becomes the empty state's title; a non-string node renders in its place); 50-row progressive rendering; Create "{text}" + Add {text} under hasCreate, absent at maxEntries; Clear all disabled when empty, otherwise confirmed; Done closes and clears the query; swipe/scrim/Escape per purpose="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 input ref.

Theming: the trigger carries the existing tokenizer target (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 button named by the field label and described by the selected count then the rendered tokens themselves ("2 selected Design Eng" — the hidden count element and each Token by id in aria-describedby, so no label renders twice), with aria-haspopup="dialog" / aria-expanded; modal dialog named by the label; search input named Search {label} with a named clear; a named Loading spinner and aria-busy on the list while a search is pending; checkbox rows with aria-checked; aria-disabled (never native disabled) 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 dist with esbuild, minified, ESM with code splitting, gzip level 9; react, react-dom, @stylexjs/stylex external; base = this PR's parent main c22cbe2fd, which already contains #7976 and #8009; head = 1ed2b3237):

Import Base, eager gz Head, eager gz Δ eager Lazy (base → head)
Tokenizer — what every consumer loads 60.4 KB 65.3 KB +4.9 KB 0.1 → 29.5 KB (TokenizerBottomSheet chunk + its split; loaded only while a field resolves to bottom-sheet)
Field (by-path imports in Field.tsx, FieldLabel.tsx; PanelSearchInput's fieldXstyle) 46.3 KB 46.4 KB +0.1 KB 0.1 → 0.1
BottomSheetSwitcher (BottomSheet/BottomSheetSwitcher.js; by-path useScrollLock import) 5.3 KB 5.3 KB 0 —
BottomSheet (by-path imports) 25.9 KB 25.9 KB 0 —
AlertDialog (by-path useMediaQuery) 52.3 KB 52.5 KB +0.2 KB 0.9 → 0.9
Selector (a Field consumer; the same barrel effect) 75.6 KB 72.9 KB −2.7 KB 16.7 → 19.9
TextInput (a Field consumer) 49.2 KB 49.4 KB +0.2 KB 0.1 → 0.1
Dialog / Drawer / Lightbox / MobileNav 52.0 / 56.2 / 47.0 / 46.7 KB 52.1 / 56.5 / 47.1 / 46.9 KB +0.1–0.3 KB unchanged

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 en chunk and chunk re-assignment; Field and BottomSheetSwitcher, whose code this commit touches, move +0.1 KB and 0. Selector shrinks because Field.tsx/FieldLabel.tsx importing by path lets its bundle drop the hooks and Text barrels' unused exports.

CSS (spec:AST-080 FR8 / INV6; gzip level 9 of the StyleX rules each component's own modules contribute, and of the package stylesheet): astryx.css 31.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 to bottom-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.tsx and 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 (esbuild metafile, 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.stylex rows 1,438 → 6,637 B — the inline popover reaches the module via Layer/useLayer/Popover/usePopover, the sheet's Layout reaches other rows of the same stylex.create objects, and neither StyleX nor esbuild splits one object literal across chunks (moving it means splitting padding.stylex per consumer — a Layout change); hooks/useMediaQuery 282 B, utils/focusReturn 265 B, utils/isRenderable 59 B (≈0.3 KB gz) — reachable on main but tree-shaken to 0 B, hoisted into the shared chunk because the lazy chunk also reaches them; moving them needs by-path imports in Token and BaseTypeahead, 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; adaptive resolved by the shared policy; a standalone isOpen sheet; selection logic kept in Tokenizer and the sheet lazy. Rejected (recorded in DEC-3): automatic switching on pointer: coarse without an opt-in (changes stable behavior; FR21); a top-level stable presentation prop before consumer evidence; separate manage/add sheets; hosting the one sheet in a BottomSheetSwitcher.

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 plain data-rendered-count / data-total-count attributes.

Evidence

  • Unit — packages/core/src/Tokenizer/Tokenizer.props-type.test.tsx (type level, above) and packages/core/src/Tokenizer/TokenizerBottomSheet.test.tsx (62 tests) and Tokenizer.ssr.test.tsx (3): presentation resolution — experimental omitted, {} and 'popover' keep the combobox; 'bottom-sheet' forces the trigger and opens a modal dialog named by the label; 'adaptive' with matchMedia stubbed 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, emptySearchText as title; large equal-width Clear all / Done with the guarded bulk clear; disabled trigger focusable and closed, disabledMessage tooltip; at-limit row focusable but inert while the checked row still toggles; forced-colors GrayText atoms on trigger, placeholder, tokens and indicator. Plus hidden-input/status chrome in the sheet presentation; announcements — with :modal stood in, the Loading busy start, Added Energizer, and Removed Design land 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: the Loading row precedes the stale Design row in the DOM while a query is pending; a row with action renders without it and one development warning names action and the sheet rows; adaptive flipping to non-compact while the sheet is open (a matchMedia stub 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; ref receives the input without experimental, and with it the input under popover and the trigger under bottom-sheet. Latest round: below minQueryLength (3) a two-letter query lists only the matching selected row and the create row, no Loading, no aria-busy, no empty state, and search is not called until the third letter; an element emptySearchText with an aria-hidden span 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-busy cleared, "No results found" announced and no unhandledrejection; activating Add Ops leaves focus on the search field; with the sheet closed, flipping adaptive while the trigger is focused focuses the combobox, flipping back focuses the trigger, and an unfocused field moves nothing; with minQueryLength={3} and a slow source, "Eng" then "En" before it settles clears aria-busy, lets no Eng row land, announces no count, and a later "Ener" announces "1 result"; after Add Ops with a slow source the list is aria-busy with the Loading row and onChangeQuery('') fires until the bootstrap set returns; two row toggles in one frame are announced as "Added Eng Removed Design" in the dialog region; with minQueryLength={3}, typing "De" then pressing Escape reports onChangeQuery(''), and the reopened sheet has an empty search field, lists the bootstrap set with no aria-busy, no Loading row, no create row and no search call; on open the dialog contains the focused element and the search field is not it; a caller with onChangeQuery={setState} sees its mirror go "En" → "" on Escape with no "Cannot update a component while rendering" on a console.error spy (the test fails on the previous head); the 50 listitems in the rows' list carry aria-setsize="120"/aria-posinset 1–50 and the checkboxes carry neither, focusing row 50 renders rows 51–100 (row 51's listitem at aria-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, and activeElement is not <body> (fails without the fix); disabling the field with "En" typed reports nothing through onChangeQuery, and the reopened sheet still shows "En"; with the default debounce and an instant source, the opened sheet has no aria-busy, no Loading row 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 the Design row 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 an instanceof Promise check); a default Tokenizer, presentation: 'popover' and 'bottom-sheet' evaluate no compact-touch query and register no matchMedia listener, 'adaptive' registers one (fails against the previous hook); focusing the trigger reports one onFocus, open → search field → Done → trigger reports no onBlur and no further onFocus, focusing an outside button reports one onBlur, and a second cycle adds one onFocus only; the FR16 swap test asserts the hand-off reports neither (both fail on the previous head). Earlier: a caller onClick runs on the trigger after the open and its preventDefault() does not keep the sheet closed; with hasCreate and a slow source no Add … 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 and ref on the trigger (id, data-testid, caller aria-describedby merged, description reachable, the label's for, onFocus/onKeyDown, ref.current is the trigger); the one-time development warning naming hasClear, menuWidth (no second warning on re-render, none in the inline presentation); busy-not-empty with fake timers and the default debounceMs — aria-busy and the Loading spinner 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 goes aria-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 Lab MobileTokenizer suites 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 useAnnounce route): the sheet is :modal; a row tap writes "Added Tag 03" to the dialog's own status container and nothing to the page body, and Chromium's accessibility tree lists that container as role: 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, with hasCreate off, 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 with aria-allowed-attr, aria-required-children, aria-required-parent, list, listitem, aria-allowed-role and nested-interactive reports no violations; 50 checkbox rows, each inside a listitem carrying aria-setsize="1000"/aria-posinset, none on a checkbox; focusing row 50 renders 100 rows. Chromium's accessibility tree reports the wrappers as role: listitem (the DevTools protocol surfaces level but not setsize/posinset as 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" to dialog > [data-astryx-modal-live-region="polite"], and a miss on the hasCreate story 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" to dialog > [data-astryx-modal-live-region="polite"] and nothing to the page-body channel, and Chromium's accessibility tree lists that node as role: 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 with document.activeElement on it, no trigger, no open dialog. Earlier rounds, 35/35: Chromium's own accessibility tree reports the trigger as button, 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 at md); sheet panel 824px of an 844px viewport; dialog aria-modal named Tags; 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; with hasCreate a dead-end query offers Create "zzz" with Add, the footer hidden while searching; Add commits the token and clears the query; Done closes and document.activeElement is the trigger; reopen shows Design | Eng | Tag 03 | Custom; Clear all confirms and clears; adaptive is 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 is aria-disabled, ignores the tap, and the checked row still toggles. No-results empty state verified on the adaptive story (no hasCreate).

  • Accessibility audit — .github/scripts/accessibility-audit.js --components Tokenizer --baseline .github/a11y-baseline.json --fail-on-new on 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.tsx 2,717 and Tokenizer.ssr.test.tsx 145 and Tokenizer.props-type.test.tsx 114 are the record's verification — one or more tests per FR16–FR24/AR6 row — so tests are 2,976 lines, over half; TokenizerBottomSheet.tsx 957 and Tokenizer.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 optional fieldXstyle), seven one-line by-path imports in Field, BottomSheet and AlertDialog; 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-fields FR7) 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-085 FR4) 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 to matchMedia — needed the moment Tokenizer passes adaptive, and nothing else exercises it; the seven by-path imports and fieldXstyle exist 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 behind experimental.presentation so 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

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split (the existing blocks' phone-width fix is its own documentation PR; the in-dialog announcement route and the AST-086 record are their own PRs).
  • Size: the owner has waived the review size limit for this change — "i don't mind large PR. i can bypass [the review size] limit" — and the "Why one pull request" paragraph under Evidence gives the necessity evidence row by row: the trigger, the search, the busy state, the rows and Clear all each depend on another, over half the lines are the test file pinning those couplings, and the small changes outside Tokenizer (useAdaptivePresentation, the by-path imports, fieldXstyle) are inseparable from the sheet they exist for.
  • Consumer docs (Tokenizer.doc.mjs differs from main by exactly the three experimental entries — the en and zh prop entries and the dense description; no other line is touched: the experimental prop entry, which carries stability: 'experimental', holds the description and the one usage snippet; the component's examples carry no experimental entry, so the experimental boundary is the marked prop entry alone — spec:AST-017 FR21, FR27; dense and zh variants), focused tests, and the [experimental] core Changeset ship with the feature.
  • Public text and artifacts contain no internal Meta context.

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 typecheck and pnpm -F @astryxdesign/cli typecheck:template-docs — clean.
  • ASTRYX_STRICT_LINT=1 pnpm exec eslint over the changed files — 0 errors, 0 new warnings.
  • Prettier over the changed .ts/.tsx/.md files — clean (Tokenizer.doc.mjs is 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).
  • Vercel's build locally: 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.
  • Real-browser matrix and accessibility audit as under Evidence.

@vercel

vercel Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 27, 2026 3:27am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 27, 2026, 3:53 AM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Comment on lines +1281 to +1284
aria-describedby={
[ariaDescribedByProp, ariaDescribedBy].filter(Boolean).join(' ') ||
undefined
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 adaptive switches 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 ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +752 to +753
const isSheetPresentation =
isSheetOpen || resolvedPresentation === 'bottom-sheet';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TokenizerProps an interface, because as a union type it breaks interface … extends TokenizerProps and Omit<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 onClick on 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> &

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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={() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(', ')}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@imdreamrunner
imdreamrunner force-pushed the spec/tokenizer-experimental-presentation branch from d19e19f to d7ee224 Compare October 23, 2026 22:10
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from 6f02169 to 1dc081e Compare October 23, 2026 22:10
@imdreamrunner
imdreamrunner force-pushed the spec/tokenizer-experimental-presentation branch from d7ee224 to a3bbf10 Compare October 23, 2026 22:33
@imdreamrunner
imdreamrunner force-pushed the feat/tokenizer-experimental-bottom-sheet branch from 1dc081e to c1d1852 Compare October 23, 2026 22:33

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 emptySearchText is an element, because today the sheet announces "No results found" over the caller's message (spec:AST-056 FR3).
  • 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useAnnounce change now conflicts with #8244 on main, and as written it does not join same-frame page-body messages the way main now does (spec:AST-086 FR9), so #8009 needs a rebase that keeps #8244's logic.

[Automated review]

Comment on lines +876 to +880
onAction={() => {
onClearAll();
focusSearchAfterClearRef.current = true;
setIsClearConfirmationOpen(false);
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/hooks/useAnnounce.tsx Outdated
Comment on lines +156 to +179
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsx 638–645).

[Automated review]

}
announcedSearchIdRef.current = searchId;
if (!isSettledEmpty && !isQueryEmpty) {
announce(t('@astryx.typeahead.resultCount', {count: results.length}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsx 643–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}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-authoring INV6 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 targets main.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.stylex rows, 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 disabled while nothing is selected, so it leaves the tab order; spec:AST-085 FR1 asks for a focusable aria-disabled control.

[Automated review]

label={t('@astryx.tokenizer.clearAll')}
variant="secondary"
size="lg"
isDisabled={value.length === 0}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, focusReturn and isRenderable are already eager on main; they are not, and with useAnnounce'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]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 searchSource again on every row toggle when the source object is rebuilt in render (the search effect in TokenizerBottomSheet.tsx depends 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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in TokenizerBottomSheet.test.tsx looks for EmptyState's old role="status" root, which EmptyState no longer renders; EmptyState now announces itself, so the sheet's own empty-state announcement is only needed for a custom emptySearchText element.

[Automated review]

const title = await screen.findByRole('heading', {
name: 'No results found',
});
const emptyState = title.closest('[role="status"]');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Comment on lines +1557 to +1567
<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) && (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 没有作用对象,传入时开发模式下会警告一次。",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 minQueryLength stays aria-busy with its Loading row and never searches; starting wasOpen at false in TokenizerSheetContent (TokenizerBottomSheet.tsx 387) 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
});

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TokenizerProps a 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsx 1598–1607): native disabled on them breaks spec:AST-085 FR1, which main already fixed for the inline field in #8408.
  • Rebase onto main, which now conflicts in Tokenizer.tsx, keeping #8408's disabled guards, and correct the description where it misstates the shipped code (for example TokenizerProps as a type alias and the trigger's description as "Design, Eng").

[Automated review]

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsx 1595–1603): today a disabled field submits its values there, against spec:AST-085 FR3.
  • Correct the description where it misstates the shipped code (for example TokenizerProps as 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 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: TokenizerProps as 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]

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TokenizerProps a type alias, quotes the trigger's description as "Design, Eng", lists twelve catalog keys including noItems, 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]

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TokenizerProps a type alias, quotes the trigger's description as "Design, Eng", lists twelve catalog keys including noItems, says the doc keeps an adaptive example, counts 47 tests and sizes the commit at about 3,750 lines.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

AlertDialog (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 191B 191B 0B
Lines of Code 182 182 -
Complexity Medium Medium (12) -
Tokenizer (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 331B 331B 0B
Lines of Code 871 1922 -
Complexity Very High Very High (308) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 57.0KB +155B 12.4KB 12.4KB 0B
core/BottomSheet 34.0KB 33.9KB -24B 7.9KB 7.9KB 0B
core/BottomSheetSwitcher 14.8KB 14.8KB 0B 3.3KB 3.3KB 0B
core/CheckboxInput 49.9KB 50.1KB +159B 9.3KB 9.3KB 0B
core/CheckboxList 46.0KB 46.2KB +174B 8.1KB 8.1KB 0B
core/CheckboxListItem 54.0KB 54.2KB +209B 10.5KB 10.5KB 0B
core/ComplexSelector 55.8KB 56.0KB +193B 10.6KB 10.6KB 0B
core/ContextMenu 77.7KB 74.7KB -3.0KB 14.6KB 14.6KB 0B
core/DateInput 99.9KB 99.7KB -199B 14.8KB 14.8KB 0B
core/DateRangeInput 69.4KB 69.5KB +177B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 109.8KB -280B 15.2KB 15.2KB 0B
core/DropdownMenu 72.7KB 72.9KB +163B 14.7KB 14.7KB 0B
core/Field 44.2KB 44.4KB +160B 8.0KB 8.0KB 0B
core/FieldLabel 39.8KB 39.9KB +145B 6.5KB 6.5KB 0B
core/FileInput 54.1KB 54.3KB +161B 10.3KB 10.3KB 0B
core/InputGroup 44.8KB 45.0KB +177B 8.0KB 8.0KB 0B
core/ListItem 53.5KB 53.7KB +202B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 144.5KB +47B 17.4KB 17.4KB 0B
core/MoreMenu 72.9KB 73.2KB +272B 14.7KB 14.7KB 0B
core/MultiSelector 76.2KB 73.0KB -3.2KB 14.6KB 14.6KB 0B
core/NumberInput 55.7KB 55.8KB +144B 10.7KB 10.7KB 0B
core/Pagination 87.7KB 84.9KB -2.9KB 15.3KB 15.3KB 0B
core/Popover 49.8KB 50.0KB +169B 12.1KB 12.1KB 0B
core/PowerSearch 155.1KB 158.1KB +3.1KB ⚠️ 18.6KB 19.9KB +1.2KB ⚠️
core/RadioList 45.6KB 45.8KB +187B 8.0KB 8.0KB 0B
core/RadioListItem 52.1KB 52.4KB +228B 9.7KB 9.7KB 0B
core/Selector 79.1KB 76.4KB -2.7KB 15.0KB 15.0KB 0B
core/Slider 48.5KB 48.6KB +152B 8.5KB 8.5KB 0B
core/Switch 48.4KB 48.5KB +144B 9.3KB 9.3KB 0B
core/Table 84.0KB 83.9KB -37B 15.1KB 15.1KB 0B
core/TableCell 78.8KB 75.8KB -3.0KB 14.8KB 14.8KB 0B
core/TableHeaderCell 78.6KB 75.5KB -3.1KB 14.8KB 14.8KB 0B
core/TextArea 49.4KB 49.6KB +150B 9.4KB 9.4KB 0B
core/TextInput 53.5KB 53.7KB +184B 10.5KB 10.5KB 0B
core/TimeInput 86.8KB 86.9KB +94B 13.8KB 13.8KB 0B
core/Tokenizer 64.8KB 68.8KB +3.9KB ⚠️ 11.7KB 16.5KB +4.8KB ⚠️
core/Typeahead 61.5KB 61.6KB +164B 11.3KB 11.3KB 0B

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.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

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)

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

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

@imdreamrunner

Copy link
Copy Markdown
Contributor Author

/approve-spec 77a5a31

This branch was successfully deployed

1 active deployment
Preview — 77a5a31e Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant