Repository navigation
feat(core): PowerSearch touch sheet flow behind experimental.presentation - #8001
imdreamrunner wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
b154a0f to
c07a91a
Compare
07d619c to
0efc86f
Compare
c07a91a to
36f8999
Compare
0efc86f to
7e07d10
Compare
36f8999 to
a017ec1
Compare
7e07d10 to
8b9c8d8
Compare
a017ec1 to
13206a2
Compare
8b9c8d8 to
11fb87a
Compare
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3 and FR10–FR21 into the current component:PowerSearch record, and an engineering owner still has to decide those requirements, including FR17/FR21's reliance on the Tokenizer sheet requirements from #7977 that are not on main yet. The approval on #8000 covers that pull request's head, not this one; an engineering owner records the decision here by commenting /approve-spec 11fb87a50dafe3d00fb7f54d4151c577eaa3ace9 on this pull request, and the author may do so as an engineering owner. Whatever that decision is, these fixes are needed:
- Put
data-testidon the sheet trigger (today it moves from the combobox input to adiv), and drop thefamily:input-fieldsmembership and FR10 citation, since that family's record excludes PowerSearch. - Show date-only values without a time of day in the Filters rows (the forced story shows
Mar 9, 2024, 7:00 PM). - Align FR16 (focus after a value suggestion commits) and FR20 (the relative-date row labels) with the code, and declare the stable result-count fix in its own
[fix]Changeset. - Split the 4,800-line change, for example by dropping the carried spec commit once #8000 lands and moving the stable fix out, or explain why it cannot be split.
[Automated review]
| onClick={handleTriggerWrapperClick} | ||
| onFocus={handleTriggerFocusWithin} | ||
| onBlur={handleTriggerBlurWithin} | ||
| data-testid={testId} |
There was a problem hiding this comment.
In the popover presentation data-testid reaches the combobox input (through Tokenizer); here it lands on the control-box div, so a test id changes element when an adaptive field switches. Putting it on the trigger <button> keeps it on the field's primary control in both presentations.
| config, | ||
| operator.value, | ||
| filter.value, | ||
| Number.MAX_SAFE_INTEGER, |
There was a problem hiding this comment.
formatFilterValue formats every date_absolute with hour and minute, so a date-only filter (isDateOnly: true) reads Mar 9, 2024, 7:00 PM in this row while its token says Mar 9, 2024. Could the row format date-only values without the time?
| value: filterValue, | ||
| }; | ||
| pendingSheetFocusRef.current = { | ||
| type: 'manager-filter', |
There was a problem hiding this comment.
With a controlled parent this moves focus to the new filter's row after a value suggestion commits, while FR16 says a creation commit focuses Add filter. Either the code or FR16 should change so they agree.
| ] | ||
| modules: [] | ||
| families: [family:overlay-dismissal] | ||
| families: [family:input-fields, family:overlay-dismissal] |
There was a problem hiding this comment.
family:input-fields lists PowerSearch as excluded ("higher-level compositions that consume member behavior"), and its FR10 sends data-* to the primary semantic control, which this record's prop-target paragraph does not follow. Drop the membership and the FR10 citation, or amend that family first.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [experimental] PowerSearch gains a touch surface behind `experimental={{presentation: 'bottom-sheet' | 'adaptive'}}`. `bottom-sheet` makes the whole field one trigger showing display-only filter tokens, and moves filter editing into a flow of tall modal sheets: a Filters sheet listing the active filters as rows with Add filter, Done, and Clear all; an Add filter sheet with the fields grouped (searchable from eight fields), or the content search with the field typeahead's suggestions listed inline; and a value step that stages field → operator → value and confirms from its footer, with Remove while editing. Closed vocabularies are full-width rows, numbers stage as typed, and `string_list` / `entity_list` values edit through the Tokenizer's own sheet presentation titled with the field label. `adaptive` uses the sheet on compact coarse-pointer screens and the popover otherwise, resolved the same way Selector resolves it. The sheet flow loads on demand, so a PowerSearch that does not opt in does not carry it. New `@astryx.powersearch.sheet.*` catalog keys for the sheets' strings; no new theming target. The result-count announcement is now StrictMode-safe in both presentations (no announcement on mount). Anything inside PowerSearch's `experimental` prop may change or be removed in a patch release; omitting it keeps the stable behavior. |
There was a problem hiding this comment.
The StrictMode-safe result count changes the stable presentation too; spec:AST-017 FR22 keeps that out of an [experimental] entry, so it needs its own [fix] Changeset.
13206a2 to
6f02169
Compare
11fb87a to
bf9ee1b
Compare
6f02169 to
1dc081e
Compare
bf9ee1b to
3cbfa4c
Compare
3cbfa4c to
c3bbf19
Compare
1dc081e to
c1d1852
Compare
c3bbf19 to
fc16c1d
Compare
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, and an engineering owner has to decide those requirements, including FR17/FR21's reliance on the Tokenizer sheet requirements from #7977 that are not on main yet. An engineering owner records that decision by commenting /approve-spec followed by this head's full commit SHA on this pull request, and the author may do so as an engineering owner. Whatever that decision is, these still need changes:
- Make a date-only filter show the same day on the trigger and in the Filters row; today the token formats in the device's time zone and the row in
timezoneID. - Announce the sheets' empty-result messages as AR6 says, or drop that clause from AR6.
- Split the 5,181-line change, for example by dropping the carried record change once #8000 lands and moving the stable result-count fix out, or explain why it cannot be split.
[Automated review]
| operator.value.type === 'date_absolute' && | ||
| filter.value.type === 'date_absolute' && | ||
| operator.value.isDateOnly !== false | ||
| ? formatDateAbsoluteCompact( |
There was a problem hiding this comment.
The row formats a date-only value in timezoneID, but the trigger's token (PowerSearch.tsx:430) formats the same instant with no time zone, so it follows the device. With timezoneID America/Los_Angeles on a Tokyo device, the forced story's token says Mar 10, 2024 while this row says Mar 9, 2024. Formatting both the same way (here, or in the token as its own [fix]) keeps one filter on one day, and the boundary test could assert the token too.
| "Search filters"; every sheet action is at least 44px tall. | ||
| - **AR6 — Announcements and focus.** While a sheet is open, every announcement | ||
| the flow makes — `Added {label}` for a committed suggestion, the result count | ||
| of FR19, and the sheets' empty-result messages — is delivered by a persistent |
There was a problem hiding this comment.
Nothing announces the empty-result messages: "No filters match your search" and "No results found" render as plain Text with no live region (PowerSearchBottomSheet.tsx:1320–1334). Either deliver them through the flow's polite region or drop this clause from AR6.
fc16c1d to
5e77abd
Compare
c1d1852 to
86731bf
Compare
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements on this exact head by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner). FR17 and FR21 also rely on the Tokenizer requirements in #7977, so that record has to land first. The nested value step now fits 390 and 414 px sheets and no other implementation defect was found, but at 7,561 authored lines the change still needs splitting: drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.
[Automated review]
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but it cannot be approved yet: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements on this exact head by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner), and FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. Two changes are needed either way:
- Editing a group filter can show and save another group's sub-filters: the value step keys
NestedEditorby field and operator only (PowerSearchBottomSheet.tsx:1778) while the sheet keeps it mounted, so give each opened filter its own key and add a test that edits two groups on one field in a row. - At 7,586 authored lines the change needs splitting: drop the carried record commit once #8000 lands, move the StrictMode guard, the
formatTokenValue.tsextraction and the story decorator into their own pull requests, and stage the flow.
[Automated review]
| {operatorGroup} | ||
| {isDraftNested ? ( | ||
| <NestedEditor | ||
| key={`${draft.field}-${draft.operator}`} |
There was a problem hiding this comment.
This key is the same for every group on one field and operator, and the switcher keeps the value step mounted between steps, while NestedEditor reads partialFilter only on mount. Opening a second group of the same field therefore shows the first group's sub-filters, and any edit there is committed over the second group (in a probe: onChange(…, 'edit', 1) with the first group's rows). A new group from Add filter also opens pre-filled, and Cancel does not discard. Keying the body by an id set on each open (in handleFilterEdit and openCreateEditor), plus a test that edits two groups on one field in a row, would hold it.
| // Whether a Filters row is a button (FR12): the field interactive and the | ||
| // filter not read-only. An editable operator's row opens the value step; | ||
| // a `nested` operator's row, and the row of a filter the config no longer | ||
| // knows, open the step's no-editor state, whose Remove is the way out — as | ||
| // the popover's token has its × for the same filter. |
There was a problem hiding this comment.
Nit: since nested rows open the built-in nested editor, this comment is stale — a nested operator's row opens an editable step, and only a stale filter's row opens the no-editor state. The header of powerSearchSheetUtils.ts ("nested-operator exclusion", "keeps nested-only fields out of the content search") has the same leftover.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. The group-editing issue from the last review is fixed and covered by a regression test. One change is still needed either way: at 7,805 authored lines the change needs splitting — drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow (the trigger and Filters sheet, then Add filter and the content search, then the value editors).
[Automated review]
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the new Field-boxed trigger or the trigger stops composing Field; FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. Two changes are needed either way:
- After a rotation while the flow is open, Done calls
onFocustwice (trigger, then input); routing that hand-off throughfocusAfterSwapatPowerSearch.tsx:638keeps it one visit. - At 7,921 authored lines the change needs splitting: drop the carried record commit once #8000 lands, move the StrictMode guard, the
formatTokenValue.tsextraction and the story decorator into their own pull requests, and stage the flow.
[Automated review]
| if (!isSheetPresentation) { | ||
| if (isInlineFocusPendingRef.current) { | ||
| isInlineFocusPendingRef.current = false; | ||
| tokenizerRef.current?.focus(); |
There was a problem hiding this comment.
This is the one hand-off that skips focusAfterSwap: after the flow closes with adaptive now resolving to popover, the trigger's return focus already called onFocus, and this focus() calls it again for the combobox input with no onBlur between (Chromium: onBlur, onFocus on the trigger, onFocus on the input). focusAfterSwap(tokenizerRef.current) here keeps it one visit, and the latch test at PowerSearchBottomSheet.test.tsx:1143 could assert the count.
| `onChange` reports and how saved filters reopen (`spec:AST-017/FR5`, | ||
| `FR7`), so the fix is a separate stable decision for the owner, not part of | ||
| DEC-3; the sheet matches the popover as it is. | ||
| - Known gap against `family:input-fields/FR10`, opened by DEC-3: the sheet |
There was a problem hiding this comment.
family:input-fields FR10 item 1 also puts ref (with id, data-*, aria-* and handlers) on the primary semantic control, while this record keeps ref on the root (line 145); if the gap stays, could it name ref as well as the root styling props?
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch and fixes the previous head's duplicate onFocus, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the Field-boxed trigger or the trigger stops composing Field. FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. One change is needed either way: at 7,933 authored lines the change needs splitting — drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow.
[Automated review]
| // under FR10. Omitting them from the props type is a stable API change for | ||
| // existing callers, so it waits for its own decision rather than riding the | ||
| // experimental option (`component:PowerSearch` Open questions). | ||
| const PENDING_FR10: ReadonlySet<string> = new Set<string>(['PowerSearch']); |
There was a problem hiding this comment.
Listing PowerSearch here keeps the family coverage test green while the sheet trigger composes <Field> with ref on the root and root xstyle/className/style still in the props type, which current family:input-fields FR10 does not allow for any component that composes Field. An engineering owner needs to amend FR10 for this experimental trigger, or the trigger needs to stop composing Field, before this exemption holds.
| `onChange` reports and how saved filters reopen (`spec:AST-017/FR5`, | ||
| `FR7`), so the fix is a separate stable decision for the owner, not part of | ||
| DEC-3; the sheet matches the popover as it is. | ||
| - Known gap against `family:input-fields/FR10`, opened by DEC-3: the sheet |
There was a problem hiding this comment.
This gap names the root xstyle/className/style, but FR10 item 1 also requires ref to reach the primary semantic control, and this record keeps ref on the root (line 145). If the gap stays, it should name ref too.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the Field-boxed trigger or the trigger stops composing Field. FR17 and FR21 also rely on the Tokenizer requirements in #7977, which are not on main yet. One change is needed either way: at 7,941 authored lines the change needs splitting — drop the carried record commit once #8000 lands, move the StrictMode guard, the formatTokenValue.ts extraction and the story decorator into their own pull requests, and stage the flow. Two small fixes would also help: name ref in the PENDING_FR10 comment, and either scope FR12's English-parity sentence or keep the token's text for exponent-form numbers such as 1e-7.
[Automated review]
| // Field-composed inputs not yet converted to FR10. Every member is converted. | ||
| // PowerSearch is outside the family (a composition that consumes member | ||
| // behavior) and composes Field only in its experimental sheet presentation, | ||
| // whose trigger renders the Field box the tokenizer field has; its stable | ||
| // props carry root `xstyle`, `className`, and `style` (documented, in use), | ||
| // which both presentations apply to that field box — the `container*` target | ||
| // under FR10. Omitting them from the props type is a stable API change for | ||
| // existing callers, so it waits for its own decision rather than riding the | ||
| // experimental option (`component:PowerSearch` Open questions). |
There was a problem hiding this comment.
This explains the pending entry by the root xstyle/className/style alone, while the record's known gap (PowerSearch.spec.md:539–557) now also names ref staying on the root (FR10 item 1). Naming ref here keeps the exemption and the record in step.
| // The provider locale's digits and decimal mark, but no grouping and no | ||
| // rounding, so in English the row reads exactly as the token does | ||
| // (`1234`, `2.3456`). | ||
| return t | ||
| ? formatSharedNumber(filterValue.value, locale, { | ||
| useGrouping: false, | ||
| maximumFractionDigits: 20, | ||
| }) | ||
| : String(filterValue.value); |
There was a problem hiding this comment.
In English this still differs from the token's String(value) for exponent-form values and negative zero: 1e-7 reads 0.0000001 on the row and in Removed …, 1e21 reads 1000000000000000000000, and -0 reads -0 beside the token's 0. FR12 says a row reads as the token does in English, so either the sentence names this exception or the row keeps the token's text for those values.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, three things need the author:
- Split the 8,027-line change: let the record land with #8000, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator out, and stage the flow. - Add fine-pointer before/after evidence for the StrictMode fix, such as a popover-presentation StrictMode test that fails on the base, as
spec:AST-017FR59 asks. - Re-measure the bundle and CSS size at this head, including DateInput and TimeInput, and justify or lazy-load the eager trigger code (
architecture:component-style-authoringINV6).
[Automated review]
| config={config} | ||
| filters={[]} | ||
| onChange={vi.fn()} | ||
| experimental={{presentation: 'bottom-sheet'}} |
There was a problem hiding this comment.
This is the only StrictMode result-count test, and it renders the sheet presentation; the fix also changes the stable popover presentation, and spec:AST-017 FR59 asks touch work to show fine-pointer evidence before and after such a change. The same test without experimental fails on the base (its live region reads 12 results right after mount) and passes here.
| ? String(value) | ||
| : formatSharedNumber(value, locale, { | ||
| useGrouping: false, | ||
| maximumFractionDigits: 20, |
There was a problem hiding this comment.
With maximumFractionDigits: 20, a value with more fraction digits is rounded in a locale that does not write numbers with Latin digits and a . decimal mark: in German 1e-21 reads 0 and 1.23e-19 reads 0,00000000000000000012, while FR12 promises no rounding. A cap of 100 (or narrower FR12 wording) closes it.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, two things need the author:
- Split the 8,080-line change: let the record land with #8000, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator out, and stage the flow. - Speak the Add filter sheet's empty-result messages through the shared
useAnnounceRenderedTextmodule, as Typeahead, Selector and CommandPalette do, instead of the sheet's own Effect (architecture:react-component-runtimeINV10).
[Automated review]
| const announcedEmptyResultRef = useRef<string | null>(null); | ||
| useEffect(() => { | ||
| if (emptyResultMessage === announcedEmptyResultRef.current) { | ||
| return; | ||
| } | ||
| announcedEmptyResultRef.current = emptyResultMessage; | ||
| if (emptyResultMessage != null) { | ||
| announce(emptyResultMessage); | ||
| } | ||
| }, [announce, emptyResultMessage]); |
There was a problem hiding this comment.
This Effect speaks the empty-result text the sheet also renders (:1684, :1692), the shape current architecture:react-component-runtime INV10 rules out for core components: an own-content announcement goes through the shared useAnnounceRenderedText module, as BaseTypeahead, Selector and CommandPalette do on main. Rendering each empty-state message through a small component inside the Add filter sheet that calls useAnnounceRenderedText on its own element keeps the in-dialog route (spec:AST-086 FR14) and lets this Effect go.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. The Add filter sheet's empty-result messages now go through the shared own-content announcement module, which settles the earlier finding. Independently of the owner's decision, two things need the author:
- Split the 8,129-line change: let the record land with #8000, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator out, and stage the flow. - Re-run the size report at this head against its base, with the CSS delta in gzip (
architecture:component-style-authoringINV6).
[Automated review]
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, three things need the author:
- Mount the sheet flow only while it is open or closing, or record an owner exception with element counts: at rest an
adaptivefield on a phone renders 296 elements against 167 with a mouse because three closed step dialogs stay mounted (architecture:component-style-authoringINV7). - Split the 8,211-line change: let the record land with #8000, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator out, and stage the flow. - Re-run the size report at this head against its base, and add the record's
## Writing rubric resultsto the description, which the owner's approval reads the record against.
[Automated review]
| {showsDisabledMessage && | ||
| disabledMessageTooltip.renderTooltip(disabledMessage)} | ||
| </Field> | ||
| <PowerSearchSheetHost |
There was a problem hiding this comment.
This host renders whenever the field is in the sheet presentation, so once the chunk loads the BottomSheetSwitcher mounts all three steps while the flow is closed, and each step renders its <dialog> and children unpresented. On the Experimental: adaptive story the field at rest is 296 elements under a coarse pointer against 167 under a fine one (three closed dialogs with 51, 99 and 18 descendants). architecture:component-style-authoring INV7 asks for measured counts for added elements and fails "more elements under a coarse pointer than a fine one". Could the host mount the flow only while it is open or exiting (keeping the chunk warm with a bare import()), or record an owner exception with the counts?
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend them by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner), and to decide whether family:input-fields FR10 is amended for this trigger or the trigger stops composing Field; FR17 and FR21 also rely on Tokenizer requirements from #7977 that are not on main yet. Whatever that decision, these still need the author:
- FR12 and FR14 promise raw keys for a filter whose operator the config dropped, but the row and the step title show the field's label (
PowerSearchBottomSheet.tsx:1154,:1412); align the record or the code, with a test. - At 8,330 authored lines the change needs splitting: let the record land with #8000, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator into their own pull requests, and stage the flow. - Refresh the bundle-size table to this head and add the
## Writing rubric resultssection the record approval reads.
[Automated review]
| // A filter whose field or operator the config no longer has still | ||
| // lists, by its raw keys, as its token does on the trigger; its row | ||
| // opens the unavailable step, whose Remove is the way out (FR12). | ||
| const fieldLabel = field?.label ?? filter.field; |
There was a problem hiding this comment.
For a saved filter whose operator the config no longer has but whose field it still has, this row reads the field's label beside the raw operator key ("Status gone"), and the step title at line 1412 does the same, while proposed FR12 says the row "lists by its raw keys". Either reword FR12 and FR14 ("the field's label, or its key when the field is gone, and the operator's key") or show the field key in both places.
| | FR11 | In the sheet presentation the field's control is one trigger: a button with `aria-haspopup="dialog"` and `aria-expanded`, whose accessible name is the field label followed by the trigger's visible text — the placeholder while no filter exists, otherwise the text of the filter tokens — so the name contains what is visible and exposes the selected filters. The tokens are display-only — each is the token's static structure (`component:Token/FR2`): no remove control, no click of its own, no inner control inside the trigger button — and the tokens, the placeholder, and the empty space around them belong to the trigger: a tap on any of them opens the flow. Tokens wrap onto further lines; the trigger does not reserve a row of its own once a token exists. The trigger's tokens are the popover presentation's tokens: the same component, the same words for a value, and the same zone handling, so a value reads on the trigger exactly as it reads on the tokenizer field. A `components[type].Token` override renders on the trigger in place of PowerSearch's token, receiving the props the popover presentation hands it — `config`, `filter`, `field`, `operator`, `maxLength`, `isDisabled` — with `onClick` and `onRemove` undefined, as the popover hands them for a read-only filter: the trigger's tokens are display-only, and the override formats its own value. `startIcon` renders in the trigger's lane. The result count and `endContent` render in the field's end lane, outside the trigger button and after it: an interactive `endContent` keeps its own tab stop, its own taps, and its own accessible name, and a tap on it does not open the flow, so a caller's control is never nested inside the component's button and the end lane's text is not part of the trigger's name. `size` sizes the trigger field and its tokens as it sizes the tokenizer field; `statusVariant` places `status.message` as the popover presentation places it; `hasAutoFocus` focuses the trigger on mount. `onFocus` fires when focus enters the field from outside and `onBlur` when it leaves the field: opening the flow moves focus into a sheet, outside the field, so `onBlur` fires, and the close that returns focus to the trigger fires `onFocus` — as the popover presentation fires them around its editor. While disabled the trigger stays focusable with `aria-disabled="true"`, never carries the native `disabled` attribute, does not open, and shows `disabledMessage` as the field does (`spec:AST-085/FR1`, `FR2`). When the component becomes disabled with the flow open, the flow closes and focus moves to the trigger, the destination this record names (`spec:AST-085/FR4`). | DEC-3; Lab touch-flow evidence; `spec:AST-085`; WCAG 2.5.3 | Current contract; verified by focused sheet tests and real-engine touch evidence. | | ||
| | FR12 | Opening the flow from a field with at least one filter, or from a read-only field, shows the Filters sheet, a tall modal sheet named "Filters": a heading, an Add filter action in the header while the field is interactive, one row per filter showing "{field label} {operator label}" (one catalog message, so a locale can reorder it) and, in bold, the value as the popover presentation's token shows it — the same function, so the same words and the same zone handling, before the token's width budget — with the token's summaries resolved from the catalog (`@astryx.powersearch.valueEditor.dateRange`, `itemsCount`, `filtersCount`, ICU plurals), so in English a row reads as the token does and another locale translates it: an `integer` or `float` value reads as the token's own text (`String(value)`: `2.3456`, `1e+21`, `1e-7`, and `0` for negative zero) wherever the locale writes numbers with the token's numerals — Latin digits and a `.` decimal mark — and elsewhere with the locale's digits and decimal mark, no grouping and no rounding, so that it differs from the token only in notation (`0,0000001`, `-0`); a `string_list`, `enum_list`, or `entity_list` value that fits reads as its items joined by the locale's narrow conjunction list (`Intl.ListFormat`, `{type: 'conjunction', style: 'narrow'}` — "Open, Closed" in English, "高、中" in Simplified Chinese and Japanese, "A, B und C" in German): a conjunction list keeps every item separated in every shipped locale, where the `unit` type runs some together, and of the conjunction styles the narrow one is the one that reads in English exactly as the token's ", " join does; a `date_absolute` value reads as a day with no time, a `date_range` as "date range", a `nested` value as "{n} filters", and a `date_relative` value as the value step's choice row reads it ("3 days ago", FR20) when a choice matches the stored value, otherwise as stored (the value is any string; the token always shows it as stored) — and a footer with Done and, while `hasClear` is set, the field is interactive, and at least one filter exists, Clear all. A row's label and value are one catalog message, "{label} {value}", the same message the removal announcement of AR6 uses, with the value segment bold, so a locale that reorders it reorders both. A filter the field can edit is a row button with a trailing chevron; a read-only filter, or every filter while the field is read-only, is a plain row, as the popover gives such a token no remove control. A `nested` filter's row is a row button like any other; its step edits the group with the popover presentation's built-in nested editor (FR14). An editable filter whose field or operator the config no longer has — which the trigger still shows as a token — lists by its raw keys as a row button that opens the step's "This filter is no longer available" state, where Remove is the way out, as the popover's token removal is; it is a plain row while the filter, or the field, is read-only. With no filter the body shows "No filters selected". Done closes the flow. Clear all commits `onChange([], 'remove', <index of the last filter>)` — the same change the popover presentation's clear control commits, read-only filters included — and closes the flow. After the flow closes focus returns to the trigger. | DEC-3; Lab touch-flow evidence; current popover clear semantics | Current contract; verified by focused sheet tests and real-engine touch evidence. | | ||
| | FR13 | Add filter — from the Filters sheet's header, or directly when an interactive field with no filter opens — is a tall modal sheet named "Add filter" with a Back action. Without a content-search field it lists the visible fields grouped by `group` (ungrouped first, each group a named list), each a row button with the field's icon and a trailing chevron, and, once the field count reaches eight, a "Search filters" field above the list that matches labels and `typeaheadAliases` case-insensitively, caps a non-empty query's matches at `maxSearchResults`, and shows "No filters match your search" when nothing matches. With a `contentSearchFieldKey` whose default operator takes a `string` value it shows instead a "Search" searchbox using `placeholder`, focused on arrival, with the same suggestions the popover presentation's typeahead offers listed inline as row buttons ("Search results"): fields, field-and-operator pairs, value matches, and a leading free-text item for the content field; Enter commits the first suggestion, ArrowDown and ArrowUp move between the search and the rows, an IME-composing Enter does nothing, and a query with no suggestion shows "No results found". A suggestion that carries a value commits `onChange([...filters, filter], 'add', filters.length)`, announces `Added {label}` through the flow's own polite region (AR6), and returns to the Filters sheet; any other suggestion or field row opens the value step with that field and operator. Back returns to the Filters sheet, or closes the flow when the sheet was the entry point. Every visible field lists, a field whose every operator is `nested` included: the value step edits every operator's value (FR14). | DEC-3; Lab touch-flow evidence; current typeahead source | Current contract; verified by focused sheet tests and real-engine touch evidence. | | ||
| | FR14 | The value step is a tall modal `form` sheet that edits every operator's value; its title is the dialog's name (AR5), and for a filter whose field or operator the config no longer has it reads the raw keys, "{field key} {operator key}", as the Filters row does (FR12). When the field offers more than one operator the body starts with an "Operator" radio group whose options read "{field label} {operator label}" and list the field's operators, with the operator the step opened with checked — the filter's operator while editing, and while creating the operator the suggestion named (FR13) or else the field's default operator, else its first; otherwise there is no group and the title is "{field label} {operator label}" naming that operator. When no operator label applies the title is the field label. The body below the group is the editor for the checked operator's value type: the sheet-native controls of FR17, or for a `nested` value the popover presentation's built-in nested editor — the sub-filter tree with its Add action, each sub-filter's field, operator, and value controls, and removal — rendered as the popover renders it, with one difference: the step's Operator group takes the place of the editor's root group-operator picker, which is not shown, and the tree root reads the checked operator's label; a sub-filter's controls are the popover editor's own, in their default presentation (a Selector's menu, the Tokenizer's inline field), not the sheet's choice rows (FR17) or Tokenizer sheet (FR21), which govern the step's top-level value only, because the sheet passes no `experimental` option down to them; the tree is its own width container, so below 400 CSS px its rows wrap instead of overflowing the sheet, as they wrap in a narrow popover. A group is created and edited the same way in both presentations; switching the operator swaps the body. A value chosen in the body is staged, not committed: no `onChange` fires until the footer confirms it. Changing the operator keeps a staged value only when the new operator reads the same value type; an `empty` operator stages `{type: 'empty'}`. The footer holds Cancel and the confirm action — `popoverSaveButtonLabel` when given, otherwise "Add filter" while creating and "Apply" while editing — disabled until the staged value is complete, plus Remove while editing. When `components[type].Editor` is set for the checked operator's value type it replaces the body below the Operator group and the footer — the group stays outside it, so switching operators still swaps the body — receiving the props the popover presentation hands it, with the same meaning: the caller's `config`, the partial `filter`, `mode`, `saveButtonLabel`, `isReadOnly`, `onSave`, and `onCancel`, and nothing the popover does not pass; `onSave(filter)` commits it, `onSave(null)` removes the filter while editing and returns to Filters unchanged while creating. A controlled update that removes the field or operator being edited replaces the body with "This filter is no longer available", announced from its rendered element inside the sheet (AR6), and keeps Remove (while editing) and Cancel as the ways out; when focus was on a control of the replaced body it moves to Remove, else to Cancel, so it never falls to the page body inside the open sheet (FR16). | DEC-3; Lab touch-flow evidence | Current contract; verified by focused sheet tests and real-engine touch evidence. | |
There was a problem hiding this comment.
This says a stale filter's title reads "{field key} {operator key}", but when only the operator is gone the code titles it with the field's label (PowerSearchBottomSheet.tsx:1412), as the row does; one of the two should change before an owner approves the text.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, so an engineering owner needs to approve or amend them by commenting /approve-spec <full head SHA> on this exact head (the author may do so as an engineering owner), and to settle whether family:input-fields FR10 is amended for the Field-boxed trigger or the trigger stops composing Field. FR17 and FR21 also rely on Tokenizer requirements from #7977 that are not on main yet. Two changes are needed either way:
- With
hasAutoFocus, anadaptiveresolution change moves focus into the field and firesonFocuseven when focus was elsewhere on the page (PowerSearch.tsxlines 1457 and 1531); applyingautoFocusonly on the first mount, with a test for that case, would fix it. - At 8,428 authored lines the change needs splitting: drop the carried record commit once #8000 lands, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator into their own pull requests, and stage the flow.
[Automated review]
| pending list, where it stays until that decision or an amendment of the | ||
| family record resolves which rule governs a composition that consumes | ||
| member behavior. | ||
| - Known gap against `spec:AST-086/FR14`, recorded as that record's own rule |
There was a problem hiding this comment.
This gap describes main's useAnnounce. The base this branch sits on already delivers inside the enclosing Astryx modal, and the Dialog test in PowerSearchBottomSheet.test.tsx ("announces inside the modal that holds the field…") passes on this head, while FR19's status says verified. Could the gap say it applies to main until that hook change lands, so the record does not state both?
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, but approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 (with an INV7 surface-budget exception) into the current component:PowerSearch record and keeps a known departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this experimental trigger or the trigger stops composing Field. Independently of that decision, these need the author:
- Split the 8,592-line change: let the record land with #8000, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator out, and stage the flow. - Apply
hasAutoFocusonly on the field's first mount: today everyadaptiveswap focuses the incoming control and firesonFocuseven when focus was elsewhere on the page. - Re-home focus after a controlled removal by an index among the rows that hold a button: with a read-only filter above, focus skips the row that followed the removed one.
- Add the
## Writing rubric resultssection a record change reports, from thedocs/contributing/spec-writing.mdrubric run over the record at this head.
[Automated review]
| // The field's primary control: `data-testid` lands here, as it | ||
| // lands on the combobox input in the popover presentation. | ||
| data-testid={testId} | ||
| autoFocus={hasAutoFocus} |
There was a problem hiding this comment.
autoFocus={hasAutoFocus} runs again every time an adaptive swap remounts the trigger (and the Tokenizer branch at line 1531 does the same for the combobox), so with hasAutoFocus a resolution change pulls focus into the field and fires onFocus even when focus was elsewhere on the page. FR10 says focus stays where it is in that case. Applying autoFocus only on the field's first mount, with a flip test that starts with focus outside the field, would fix it.
| .filter((button): button is HTMLElement => button != null); | ||
| let target: HTMLElement | null = null; | ||
| if (last.rowIndex != null && rowButtons.length > 0) { | ||
| const index = Math.min(last.rowIndex, rowButtons.length - 1); |
There was a problem hiding this comment.
last.rowIndex was recorded among every row in managerRowsRef (plain read-only rows included, line 750), but it indexes rowButtons, which holds only rows with a button. With one read-only row above, removing the focused row sends focus one row past the row that followed it (rows F0–F4, F0 read-only, F2 removed: focus lands on F4). Indexing the full li list and taking the first button row at or after it would match FR16; a test with a read-only row above would cover it.
There was a problem hiding this comment.
This pull request is on hold for a spec decision: the proposed component:PowerSearch record (DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6, with FR13's keyboard exception and DEC-3's surface-budget exception) and the sheet trigger's departure from family:input-fields FR10 need an engineering owner to approve them or name the requirements to change. An engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; FR17 and FR21 also depend on the Tokenizer record in #7977. Two changes are needed either way:
hasAutoFocusnow applies on the first mount only (PowerSearch.tsx:611-616), which removesdata-autofocusfrom the stable field after its first re-render, so a PowerSearch inside a Dialog that opens later no longer takes focus; keep it as passed until a presentation swap actually happens, and add a test with a Dialog that opens after mount.- The change is 8,668 authored lines; split out the separable pieces (the carried spec commit, the stable StrictMode fix, the formatter extraction) or land the size exception under current authority.
[Automated review]
| const hasMountedRef = useRef(false); | ||
| const isFirstMount = !hasMountedRef.current; | ||
| useEffect(() => { | ||
| hasMountedRef.current = true; | ||
| }, []); | ||
| const autoFocusOnMount = hasAutoFocus && isFirstMount; |
There was a problem hiding this comment.
autoFocusOnMount comes from a ref an Effect flips after the first commit, so it is false on every later render — including in the stable popover presentation, where it reaches the Tokenizer's hasAutoFocus and removes data-autofocus from the combobox. An Astryx Dialog keeps its children mounted while closed and focuses only [data-autofocus] after showModal(), so a <PowerSearch hasAutoFocus> in a Dialog that opens later no longer takes focus over the controls before it (in Chromium, with a button before the field: base focuses the combobox, head leaves focus on the button). Track the swap in state adjusted during render, as wasDisabled is, and keep hasAutoFocus constant until a swap actually happens.
There was a problem hiding this comment.
This adds an opt-in touch presentation to PowerSearch, and approval waits on a spec decision: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record and keeps a recorded departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this trigger or the trigger stops composing Field. FR17 and FR21 also depend on the Tokenizer requirements in #7977, which are not on main yet. Independently of that decision:
- Derive the swap-render
hasAutoFocusrule from state adjusted during render instead of the Effect-writtenrenderedPresentationRef(PowerSearch.tsx:617-622, react-component-runtime INV4 and INV3). - Show "No filters match your search" only for a non-empty query, as FR18 says (
PowerSearchBottomSheet.tsx:1852). - Split the 8,697-line change: land the record through its own spec pull request and move the StrictMode fix and the
formatTokenValue.tsextraction into their own pull requests.
[Automated review]
| const renderedPresentationRef = useRef(isSheetPresentation); | ||
| const isSwapRender = renderedPresentationRef.current !== isSheetPresentation; | ||
| useEffect(() => { | ||
| renderedPresentationRef.current = isSheetPresentation; | ||
| }, [isSheetPresentation]); | ||
| const autoFocusOnMount = hasAutoFocus && !isSwapRender; |
There was a problem hiding this comment.
This reads renderedPresentationRef, written by an Effect, during render to decide the trigger's autoFocus and the Tokenizer's hasAutoFocus. architecture:react-component-runtime INV4 says a ref mutation must not be the sole source for a value later read to produce rendered output, and INV3 asks a new Effect to name the external system it synchronizes. Deriving the swap from state adjusted during render (as wasDisabled is), for example a hasSwapped flag, keeps the intent without the ref.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 (with two INV7 owner exceptions) into the current component:PowerSearch record, keeps a recorded departure from family:input-fields FR10, and hosts the steps in BottomSheetSwitcher, which main now deprecates, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide the FR10 and switcher questions. Independently of that decision, these need the author:
- Split the 8,720-line change: let the record land with #8000, move the StrictMode fix, the
formatTokenValue.tsextraction and the story decorator out, and stage the flow. - Describe the stacked list-value sheet in DEC-3 and AV5 as an owner exception beyond the 5-element open-layer allowance, and drop "as it displays it" from AR6.
- Show "No filters match your search" only for a non-empty query, and keep a target for the content search's
aria-controlswhen nothing matches. - Add the writing-rubric results to the description and update the passages that still describe removed behavior.
[Automated review]
| list value (its Tokenizer trigger), and 401 with the Tokenizer's list-value | ||
| sheet open above the step (FR21) — every single-sheet state stays under the | ||
| open popover's 334; the stacked state does not, and is recorded under the | ||
| open-layer allowance (`spec:AST-017/FR60`, `DEC-29`) as this record's INV7 |
There was a problem hiding this comment.
spec:AST-017 FR60 and DEC-29 allow an open layer at most 5 extra elements under touch ("Anything beyond this needs an owner exception with before and after counts"). By this record's own counts the stacked state is 67 over the open popover (401 against 334), so it is an owner exception beyond the allowance rather than something recorded under it; could DEC-3 and AV5 say so?
| for a filter removed from the value step (the label is the Filters row's | ||
| text — the same "{label} {value}" catalog message the row renders, its | ||
| summaries and relative-date values resolved from the catalog as FR12 | ||
| states; each presentation announces a removed filter as it displays it: the |
There was a problem hiding this comment.
The popover presentation announces the Tokenizer item's label, which formatFilterValue builds (PowerSearch.tsx:841: a date with its time of day in timezoneID, a number with its units), not the token's visible text, and the first known gap below says the two differ in four places. "As it displays it" holds for the sheet only; could this say the popover announces its item label?
| // root styling props is a stable API change for existing callers, so it waits | ||
| // for its own decision rather than riding the experimental option | ||
| // (`component:PowerSearch` Open questions). | ||
| const PENDING_FR10: ReadonlySet<string> = new Set<string>(['PowerSearch']); |
There was a problem hiding this comment.
This keeps the family coverage test green while the trigger composes <Field> with ref on the root and root xstyle / className / style in the props type, which current family:input-fields FR10 does not allow for any component that composes Field. The exemption should follow an engineering owner's decision on FR10 rather than precede it.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 (with two INV7 owner exceptions) into the current component:PowerSearch record and keeps a recorded departure from family:input-fields FR10, so an engineering owner needs to approve or amend those requirements by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide the FR10 question. Independently of that decision, these need the author:
- Split the 8,800-line change: let the record land with #8000, move the StrictMode fix and the
formatTokenValue.tsextraction out, and stage the flow. - Update the description and the test file header where they still say nested-only fields are left out and nested rows are plain, and where the i18n list names the removed
editFilterandselectedChoicekeys.
[Automated review]
| * sheet, Add filter, the operator step, each value type (list values | ||
| * through the Tokenizer's sheet), content search, focus after each step, | ||
| * disabled while open, the StrictMode-safe result count, onChange metadata, | ||
| * nested exclusion |
There was a problem hiding this comment.
This header still lists "nested exclusion", which this head removed: nested-only fields now list in Add filter and their steps use the built-in nested editor. The description has the same outdated passages.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner. The head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6, with two INV7 owner exceptions, into the current component:PowerSearch record, and keeps recorded departures from family:input-fields FR10 and from the deprecated BottomSheetSwitcher. An engineering owner needs to approve or amend those requirements 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 need the author:
- Order Add filter's field groups with the ungrouped fields first, as FR13 says and the popover's field list already does (
PowerSearchBottomSheet.tsx:1012-1023), and add a test whose first field is grouped. - Split the 8,800-line change: let the record land with #8000, move the StrictMode fix and the
formatTokenValue.tsextraction into their own pull requests, and stage the flow. - Update the description where its Evidence list still names a nested-only exclusion test and where its catalog-key counts disagree with the 24 keys
en.jsonadds.
[Automated review]
| const groupedFields = useMemo(() => { | ||
| const groups = new Map<string, PowerSearchField[]>(); | ||
| for (const field of matchingFields) { | ||
| const key = field.group ?? ''; | ||
| const existing = groups.get(key); | ||
| if (existing) { | ||
| existing.push(field); | ||
| } else { | ||
| groups.set(key, [field]); | ||
| } | ||
| } | ||
| return [...groups.entries()]; |
There was a problem hiding this comment.
Add filter's groups follow insertion order here, so a config whose first field is grouped lists that group above the ungrouped fields. FR13 at this head says "ungrouped first", and the popover's field list already orders them that way through groupItems(items, {ungroupedFirst: true}) in usePowerSearchSource.ts. Reusing that ordering (or putting the '' group first) keeps the two presentations of one config consistent; a test with a grouped first field would cover it.
There was a problem hiding this comment.
This adds an opt-in touch flow for PowerSearch, and approval waits on an engineering owner: the head writes DEC-3, FR10–FR21, AR4–AR6, PR5 and AV5–AV6 into the current component:PowerSearch record, with two INV7 owner exceptions and a recorded departure from family:input-fields FR10, so an engineering owner needs to approve or amend them by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) and decide whether FR10 is amended for this trigger or the trigger stops composing Field. The previous review's Add filter group-order finding is fixed, and FR10 now records the layer runtime's missing presentation hold. Independently of that decision, the 8,856-line change still needs splitting: let the record land through #8000, and move the StrictMode fix and the formatTokenValue.ts extraction into their own pull requests.
[Automated review]
There was a problem hiding this comment.
The touch sheet flow now matches the component:PowerSearch DEC-3 contract that merged on main, and its tests, typecheck and browser checks hold up; the remaining blocker is size. At 8,480 authored lines the change needs to be broken up, and parts of it are separable: please land the StrictMode result-count [fix] (with its test and Changeset) and the formatTokenValue.ts extraction ahead of the flow, then put the sheet presentation up as a stack that lands together, for example the trigger, latch and Filters sheet, then Add filter with the content search, then the value step with list and nested values.
[Automated review]
There was a problem hiding this comment.
The touch sheet flow now matches the component:PowerSearch DEC-3 contract on main, with standalone step sheets, and its tests, typecheck and browser checks hold up; the remaining blocker is size. At 8,521 authored lines the change needs to be broken up: please land the StrictMode result-count [fix] (with its test and Changeset) and the formatTokenValue.ts extraction ahead of the flow, then put the sheet presentation up as a stack that lands together. While the record is being edited, please also refresh the AV5 and DEC-3 element counts, which were measured with the switcher host.
[Automated review]
| @@ -218,8 +216,7 @@ FR10–FR21 govern. | |||
| standalone BottomSheets driven by one step state — a step that closes in the | |||
| update another opens is replaced by it in place (`component:BottomSheet` | |||
| FR10), the pattern `component:BottomSheetSwitcher` DEP-0015 names as the | |||
| deprecated switcher's replacement — or, until that replace is shipped, in a | |||
| BottomSheetSwitcher; it may compose List, RadioList, TextInput, Button, Heading, and Text in any | |||
| deprecated switcher's replacement; it may compose List, RadioList, TextInput, Button, Heading, and Text in any | |||
There was a problem hiding this comment.
With the switcher allowance gone, the counts a few lines below (296 Filters, 296 Add filter, 318, 328 and 401 stacked) and DEC-3's "adds 73 above it" still describe the switcher host. On this head Chromium measures 290, 290, 307, 322 and 393 (+71) on the Experimental Adaptive story at 390 × 844, so every relation holds; please re-measure and update the figures in this upkeep commit, as DEC-3 asks ("The counts are re-measured when either surface changes").
There was a problem hiding this comment.
The touch sheet flow follows the current component:PowerSearch record and holds up in the tests, the typecheck and a phone-sized Chromium run, but two things need changes before it can be approved. At 8,555 authored lines it needs splitting: please land the StrictMode result-count [fix] (with its test and Changeset) and the formatTokenValue.ts extraction first, then put the sheet presentation up as a stack that lands together. Separately, the Filters rows are keyed by position (PowerSearchBottomSheet.tsx:1519), so when the parent removes a filter above the focused row, focus jumps to the next filter or the focused row silently shows a different one; keying rows by filter identity, with a test, fixes it.
[Automated review]
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [fix] PowerSearch no longer announces its initial `resultCount` on mount under React StrictMode. The result-count live-region effect guarded its first run with a mounted flag that StrictMode's effect replay reset, so the count present on mount was spoken unprompted; the guard now compares against the last announced text, so only a change after mount is announced in every render mode. |
There was a problem hiding this comment.
This [fix] and its popover StrictMode test change the stable presentation and stand on their own (the record says it "ships as a [fix] patch release of its own"); landing them ahead of the sheet flow is the first step of the split this review asks for.
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewModified ComponentsPowerSearch (@astryxdesign/core) · View in Storybook
Component Size
Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.
|
| Component | JavaScript | CSS |
|---|---|---|
| core/PowerSearch | +3.4KB |
+686B |
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 | +37B |
Accessibility Audit
Status: 1 accessibility violation(s) found — 1 serious.
PowerSearch - 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/35 stories · Learn more - WCAG: 1.4.3 (Level AA)
- Rule:
Visual Regression
55 of 502 shot(s) changed.
A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.
| component | story | theme | mode | pixels |
|---|---|---|---|---|
| Selector | Indicator Space Bottom Sheet Narrow End RTL | neutral | light | 753,933 |
| Selector | Indicator Space Bottom Sheet Wide Start | neutral | light | 740,072 |
| RichTextEditor | Markdown parity: side by side | probe | dark | 558,840 |
| RichTextEditor | Markdown parity: side by side | probe | light | 540,756 |
| Chat | Full AI Chat | probe | light | 230,823 |
| Chat | Full AI Chat | probe | dark | 229,805 |
| AppShell | With Mobile Nav | probe | light | 217,010 |
| AppShell | With Mobile Nav | probe | dark | 216,960 |
| Collapsible | Dividers | probe | dark | 66,445 |
| Collapsible | Dividers | probe | light | 66,445 |
| AppShell | Playground | neutral | dark | 65,198 |
| AppShell | Playground | neutral | light | 63,788 |
| Chat | Default | neutral | dark | 36,304 |
| Chat | Default | neutral | light | 35,767 |
| Chat | Empty State | probe | light | 23,531 |
| Chat | Empty State | probe | dark | 22,622 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | dark | 15,378 |
| ChatAutoScroll | Scroll Behavior Comparison | neutral | light | 14,107 |
| Chat | With Attachments | probe | light | 13,406 |
| Chat | With Attachments | probe | dark | 11,232 |
and 35 more.
Generated by PR Enrichment workflow | Storybook | Sandbox | View full report
There was a problem hiding this comment.
The touch sheet flow follows the current component:PowerSearch DEC-3 contract, but two things need changes before it can be approved. At 8,512 authored lines it needs splitting: land the formatTokenValue.ts extraction on its own, then put the sheet flow up as a stack that lands together (for example the trigger, latch and Filters sheet; Add filter with the content search; the value step), each with its tests. Separately, the Filters rows are keyed by position (PowerSearchBottomSheet.tsx:1519), so when the parent reorders the filters or removes one above the focused row, focus moves to a different filter; keying rows by filter identity, with tests for both cases, fixes it.
[Automated review]
| filter, | ||
| index, | ||
| isEditable, | ||
| key: `${index}-${filter.field}-${filter.operator}`, |
There was a problem hiding this comment.
Keying rows by ${index}-${filter.field}-${filter.operator} remounts the focused row when the parent reorders the filters or removes one above it, and the recovery effect then focuses the row at the old index: with focus on "Author is Ada" in [Status, Author, Priority], dropping Status lands focus on "Priority is P1". Keying each row by filter identity (a per-object id from a WeakMap, falling back to filterSignature for clones), with tests for a reorder and a removal above the focused row, keeps focus on the filter the person was on.
…tion
`experimental={{presentation: 'bottom-sheet' | 'adaptive'}}` makes the whole
field one trigger with display-only filter tokens and moves filter editing
into a flow of tall modal sheets: Filters (rows, Add filter, Done, Clear all),
Add filter (grouped fields or the content search with inline suggestions),
and a value step that stages field → operator → value and confirms from its
footer. The flow loads on demand; `adaptive` resolves through the shared
compact-touch policy and stays latched until the flow's exit has returned
focus. Value editors are Core's own in a sheet presentation; list values edit
through the Tokenizer's sheet titled with the field label. Announcements made
while a sheet is open go through the open dialog's own live region.
Implements `component:PowerSearch` DEC-3 (FR10–FR21, AR4–AR6, PR5) and
deletes the record's implementation-gap notes.
There was a problem hiding this comment.
Recommending approval: the touch sheet flow matches the current component:PowerSearch contract (DEC-3, FR10–FR21), the stable popover presentation is unchanged, and the focused tests, typecheck and a phone-sized Chromium run all hold up. The size is accepted here because the record defines the sheet presentation as one complete flow, so it cannot land in parts without shipping an option that breaks its own requirements. The two earlier blockers are resolved: the StrictMode result-count fix is already on main, and Filters rows are now keyed by filter identity, with tests. What remains is the repository's spec-owner approval for the small record update.
[Automated review]
Built on the merged Tokenizer sheet presentation (#7977), whose sheet the list-value editors here open; on #7648, whose standalone-sheet stack and same-update replace host the three steps; and on #8009, which makes the public
useAnnouncedeliver inside the open Astryx modal that encloses its caller (spec:AST-086FR14) — the route the sheets here announce through. The record this implements,component:PowerSearchDEC-3, FR10–FR21, AR4–AR6, PR5, AV5–AV6, is merged (#8000) and current onmain; this PR's only record change deletes its implementation-gap notes and the AST-086 gap note the mergeduseAnnouncefix closes, and addsPowerSearchBottomSheet.test.tsxandPowerSearch.ssr.test.tsxtoverified_by.On the size. The diff is one capability: 17 files, +8,159 / −353 against
main. Of its lines, ≈4,100 are the sheet test file, ≈2,100 the sheet flow, ≈680 the trigger and lazy host inPowerSearch.tsx, ≈200 each the token-value module and the value editor's presentation branches, ≈180 the sheet helpers and the stories, ≈170 the SSR test, and the rest docs, strings, the record's gap-note removal and the one Changeset. It is not split, on the owner's call: "i don't mind large PR. i can bypass [the review size] limit". The StrictMode result-count[fix]was split out and merged first (#8650), so this PR no longer carries it. The two pieces a further split would carve out are not separable from the flow in practice: theformatTokenValue.tsextraction exists so the sheet's rows andRemoved …text can call the token's own value function — alone it is a no-op refactor with no reader; and the two-line story-decorator change only matters because the touch stories need a phone-width column. The rest is one flow that cannot ship missing a step.User need
On a compact touch screen the PowerSearch field fights the software keyboard: focusing the tokenizer raises the keyboard over the half of the screen where the field menu and the editor popover open, the editor's Selector and Tokenizer open further anchored layers inside an already-cramped popover, and each filter token grows the field under the thumb. Builders shipping PowerSearch on phones have had no supported surface for it; the Lab
MobilePowerSearchprototype validated one (a Filters sheet, an Add filter sheet, and a value step that stages field → operator → value), but Lab publishes only canary builds, the prototype is a separate component with its own draft state, and its strings, date handling, and trigger naming were open review findings. Without a Core surface, every product either keeps the popover on touch or rebuilds the flow itself.Core/PowerSearchstories' decorator is aminmax(0, 600px)grid column instead of a fixedwidth: 600, so every PowerSearch story (the touch presentations above all) fits a 390px phone canvas and stays 600px on desktop.Problem and solution fit
experimental.presentationonPowerSearch:'popover'(default; the tokenizer field and editor popover, unchanged),'bottom-sheet'(the touch surface on every device),'adaptive'(the sheet on compact coarse-pointer screens, the popover otherwise, throughuseAdaptivePresentation— the policy Selector, MultiSelector, DropdownMenu and Tokenizer share, so a PowerSearch and a Tokenizer on one page switch together). Inside theexperimentalobject perspec:AST-017FR21, with@experimentalon the declarations andstability: 'experimental'in the doc, as Drawer'sexperimental.isFullWidthOnMobileand Tokenizer'sexperimental.presentationare.PowerSearch.tsx: the field's control box with display-only tokens and one button as its only tab stop; a tap anywhere on the field opens the flow. The button's name isaria-labelledbythe field label plus the field's visible content — the placeholder, or the tokens — so speech input can say what it sees (WCAG 2.5.3) and assistive technology hears the selected filters on the trigger.startIcon,endContent, the result count,status,statusVariant,size,disabledMessage,hasAutoFocus,onFocus/onBlur,handleRef,ref,data-testid,xstyle/className/styleand thepower-searchtheming target carry over. Disabled isaria-disabledand focusable, never nativedisabled(spec:AST-085FR1); becoming disabled with the flow open closes it and returns focus to the trigger (FR2).PowerSearchBottomSheet.tsx(new, lazy): the Lab touch flow ported behavior for behavior — the Filters sheet (heading, Add filter, one row per filter with the value in bold, Done, Clear all), the Add filter sheet (Back; grouped field rows with chevrons; a "Search filters" box from eight fields capped bymaxSearchResults; or, with a string content-search field, a "Search" searchbox with the field typeahead's own suggestions listed inline, Enter/ArrowDown/ArrowUp, an IME guard, andAdded {label}on a value suggestion), and the value step (title, "Operator" radios when a field has several, a staged editor, Cancel and the confirm action —popoverSaveButtonLabel, else "Add filter"/"Apply" — Remove while editing;components[type].Editorreplaces the body). ExactonChangemetadata for add/edit/remove, identity following across controlled reorders, and focus after each step are the prototype's. Every visible field lists, nested-only fields included: the value step edits anestedvalue with the popover's built-in nested editor (below).powerSearchSheetUtils.ts(new): which operators the sheet's own controls edit (every type butnested, which the built-in nested editor takes), field matching, filter identity across controlled updates, the focus target after a removal, and the content search's suggestion source over the field typeahead. The suggestions themselves come fromusePowerSearchSource, the popover presentation's own source, so the two presentations suggest the same things.PowerSearchValueEditorinstead of the prototype's rebuild from public inputs. It gains two internal props,presentationandfieldLabel: in thebottom-sheetpresentation the inputs fill the sheet's width, the number editors stage every parseable keystroke through the existingresolveNumberInputCommit(the sheet confirms from its footer, so the action must enable while typing), the date range shows one month, andstring_list/entity_listrender the Tokenizer withexperimental={{presentation: 'bottom-sheet'}}and the field label as its name — so the Tokenizer's sheet opens above the step, titled with the field (the Core port of what the Lab now does with MobileTokenizer).enum,enum_listanddate_relativerender as full-width rows in the sheet file (a Selector or Tokenizer popover inside a sheet stacks a second layer);buildRelativeDateOptionsis shared so both presentations list the same relative dates.architecture:component-style-authoringINV7): the flow's chunk loads withReact.lazybehind a host that renders nothing on the server or in the hydrating render, and is preloaded while the sheet presentation is active (one shared import promise, as Tokenizer r35 does) so the first press does not wait for the network — but the host itself is mounted only from the press until the exit has completed and focus is back on the trigger (isSheetOpen || isSheetPresented). At rest the field renders its trigger alone: no closed<dialog>s, rows, search field or editors in the DOM (a first cut mounted all three steps unpresented at rest: 296 elements on a phone against 167 with a mouse). Test: nodialogand no sheet content before a press, dialogs while open and through the exit, none again once focus is back on the trigger (fails without the change). The flow loads the wayDropdownMenuand the Tokenizer load their sheets, so a PowerSearch that does not opt in never requests it (component:PowerSearchPR5). Two import paths; no behavior change.mainsince fix(PowerSearch): the result count is announced once under StrictMode #8650; this PR keeps it and re-routes the same effect through the sheet's in-dialog region while a sheet is open (AR6), holding a change that lands during the flow's exit for the after-close messages.spec:AST-086FR3/FR4/FR14; AR6, FR19): while a native modal dialog is open the page-body live channel is not exposed to assistive technology, so every announcement the flow makes while a sheet is open —Added {label}after a content-search suggestion commits,Removed {label}, the result count, the empty-result messages — is spoken through plainuseAnnouncefrom a component rendered inside that step's dialog (SheetAnnouncer, see "Announcements route by location" below); FR14, which fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009 implements inuseAnnounce, routes a message to the nearest open Astryx modal enclosing its caller, so the message lands in the sheet's own persistent polite region. The suggestion and removal announcements are queued until the Filters sheet is the active step, so they land in the dialog that stays rather than the one about to close. The Tokenizer's own sheet announces its additions the same way.adaptiveresolved topopovermeanwhile (a phone turned to landscape), the tokenizer input takes the focus over once it mounts, so focus never drops to the page body.isolation: isolateand the sticky header and footer sit atz-index: 1inside it, so the List rows (position: relative) that scroll under the header no longer paint over it, while the panel's grab handle (z-index 1 outside the wrapper) still paints above — the same fix the Lab prototype received.date_absolutevalue as a day (Mar 9, 2024), as its token does, instead offormatFilterValue's date-and-time;formatDateAbsoluteCompactgains an optionaltimezoneIDso the row and the token agree on the day.data-testidlands on the trigger button in the sheet presentation — the field's primary control, as the combobox input is in the popover presentation, which is unchanged — so a test id does not change element when anadaptivefield switches.endContentbeside the trigger (FR11;spec:AST-055FR3): the field's end lane — the result count andendContent— renders outside the trigger button, after it. A caller's action button there keeps its own tab stop, taps and accessible name; a tap on it no longer opens the flow (the field-wide tap handler skips the end lane), and the end lane's text is not part of the trigger's name.adaptiveflips while the closed field holds it (FR10): rotating the phone or attaching a mouse with focus anywhere in the field — the trigger, the combobox input, a token or its remove control, the clear control, an interactiveendContent— may remove the focused control; focus then moves to the other presentation's primary control unless the focused element survived the switch (the Effect checks whether the active element is still inside the root), and stays put when focus was outside the field. The field tracks focus-within through its ownonFocus/onBlur(Tokenizer's already fire for focus-within; a removed element fires no blur), and one Effect on the presentation swap hands focus over — the same Effect that completes the latch hand-off. Each presentation mounts its ownendContentelement, so a focusedendContentdoes not survive the switch either and the primary control takes focus.Editorprops (FR14): the sheet hands acomponents[type].Editorexactly what the popover hands it —config,filter,mode,saveButtonLabel,isReadOnly,onSave,onCancel— and no sheet-onlytimezoneID, so a custom date editor cannot show one filter on two days asadaptiveswitches.timezoneIDchanges are reverted (PowerSearchEditPopover.tsxandformatFilterValue.tsare untouched; the editors store UTC midnight and read the UTC day as released) and thepowersearch-date-only-timezoneChangeset is deleted — changing what the editor stores changesonChange'sunixSecondsand how saved filters reopen, a compatibility decision outside this experimental feature. Instead the token's value text is extracted, unchanged, intoformatTokenValueText(formatTokenValue.ts; the token renders its result), and the sheet's Filters rows andRemoved {label}text call the same function (untruncated) — same words, same zone handling, so within one zone setup the sheet agrees with the popover everywhere. The pre-existing popover inconsistency (editor UTC midnight vs. device-zone tokens vs.timezoneIDinformatFilterValue) is recorded in#8000as a known gap for a separate owner decision. ThetimezoneIDprop the lab port had left onPowerSearchValueEditorand the sheet, which nothing read, is removed.architecture:internationalizationINV1a):@astryx.powersearch.sheet.fieldOperator("{field} {operator}") builds the value step's title, the operator radio names and the Filters row's label;@astryx.powersearch.sheet.labelValue("{label} {value}") builds theRemoved …text. No more space-joins.Removed …),formatTokenValueTextformats a list throughformatSharedList— the provider locale'sIntl.ListFormatwith{type: 'conjunction', style: 'narrow'}, the one list form that keeps every item separated in all 30 shipped locales ("Bug, Feature, Docs" in English, "Bug、Feature、Docs" in Simplified Chinese and Japanese, "Bug, Feature und Docs" in German; theunittype ran Chinese items together and gave Japanese and Korean a bare space, andunitis a type, not a style). FR12 states the rule; tests cover en, zh-CN, ja-JP and de-DE (zh-CN and ja-JP fail with theunittype) and aninteger/floatas the token reads it: where the locale writes numbers with the token's numerals — Latin digits and a.decimal mark, probed once per locale throughformatSharedNumber— the row isString(value)itself, so1e21,1e-7and-0read "1e+21", "1e-7" and "0" exactly as on the token; elsewhere the locale's digits and decimal mark with no grouping and no rounding ("2,3456", "0,0000001", "-0" in German), differing from the token only in notation. FR12 states the rule in those terms. Tests: four English values row-vs-token, and the German notation-only case. Both helpers live informatFilterValue.ts, the modulearchitecture:internationalizationnames as the owner of PowerSearch's rawIntlaccess;formatFilterValue's own number formatting now goes through the same helper. The popover token's no-translator path keeps its literal", "andString()(known gap). Test renders underlocale="de"and asserts the row, theRemoved …text and the untouched token.adaptiveswitches to the sheet while the editor popover is open, the same Effect that moves focus to the trigger returnspopoverStateto idle (raw setter — the tokenizer input is gone) and hides the editor's layer (popover.hide()), so switching back neither reopens a stale editor nor leaves the layer open and empty for the next Escape to close. The test tracks the layer's shown state through stubbedshowPopover/hidePopover: after the round trip no open layer remains (fails without the fix: the layer came back open), no Apply button, focus stays outside.nestedworks like every other type: the popover's built-in nested editor in the value step (DEC-3, FR12, FR13, FR14, FR17; the sub-filters' own controls are the popover editor's, in their default presentation — the sheet passes noexperimentaldown, FR14 says so): the popover'sNestedEditor(the sub-filter tree with its Add action and per-row field/operator/value controls) is now exported fromPowerSearchEditPopover.tsxand rendered as the value step's body for anestedoperator, with the sheet's draft as itspartialFilterandhasRootOperatorPicker={false}(a newNestedEditorprop, defaulttrueso the popover is unchanged) so the editor's root group-operator picker is not shown — the step's own Operator group takes its place — and the tree root reads the checked operator's label. Test: on a mixed field no "Group operator" combobox renders and the tree root reads "all of" (fails when the editor is handed every operator with the picker on). The editable-operator rule from the previous rounds is gone: every visible field lists (the nested-only drop and its dev warning are removed), the content search offers every suggestion (createSheetSuggestionSourceonly drops a field the config no longer knows), the Operator group lists every operator, and the remove-only "can't be edited here" state and its catalog key are gone. Chosen over namingnestedas a DEC-3 exception because the editor already exists, is self-contained (config + partial filter in, partial filter out), and sits in the same body slot as every other editor — a group is created and edited the same way in both presentations. Tests: a nested filter's row opens the tree with Apply and Remove; a nested-only field and its "Group all of" suggestion are offered and the step creates the filter end to end through the built-in editor (add sub-filter → pick a value → Add filter →onChange([…], 'add', 0)); nested-only and mixed fields list with no warning; the suggestion source keeps the nested field.onFocus(bug): whenadaptivestopped resolving to the sheet while the flow was open, the close focused the trigger (the focus FR11 reports) and the pending inline hand-off then calledtokenizerRef.focus()directly — a secondonFocuswith noonBlurbetween. That hand-off now goes throughfocusAfterSwap, so the input taking over is the same visit. FR10 says so. Test: the open-flow rotation case now asserts oneonBlur(flow open), oneonFocus(close), none for the input taking over (fails without the fix: two).[fix]rides along: the StrictMode result-count fix, its popover-presentation test and its[fix]Changeset merged separately (fix(PowerSearch): the result count is announced once under StrictMode #8650) and are identical here tomain;PowerSearch.test.tsx's only addition is thedata-autofocus-across-re-renders test.maximumFractionDigits: 100); German1e-21reads "0,000000000000000000001" where a cap of 20 read "0". Test added.hasAutoFocusis suppressed only on the swap render (FR10): a presentation swap remounts the field's control (combobox input ↔ trigger), and React'sautoFocuswould focus it again — pulling focus from wherever on the page it was and firingonFocus. The first cut suppressed it on every render after the first, which also strippeddata-autofocusfrom the stable popover presentation's combobox and would have broken autofocus inside an Astryx Dialog that keeps the field mounted. NowhasAutoFocuspasses through exactly as on main until the firstadaptiveswap, after which every control a swap mounts stays without it. The derivation is render-pure (architecture:react-component-runtimeINV3/INV4): state{presentation, isFirstControl}updated during render by the previous-value pattern — the first render whose presentation differs flipsisFirstControloff for good — so the renderedautoFocusderives from state, not from a ref mutated in an Effect (the previous cut did that and was reverted). Tests: a popover field keepsdata-autofocuson its combobox across re-renders (fails on the first cut); autofocused on mount, focus a button elsewhere, flip the resolution both ways → focus stays elsewhere,onFocusonce; SSR withhasAutoFocusstill ends on the trigger with noonFocus.${index}-${field}-${operator}, so a parent's reorder, or a removal above the focused row, remounted the focused row and the re-home effect then focused the row at the old index (focus on "Author is Ada" in [Status, Author, Priority], drop Status → "Priority is P1" took focus). Rows now take a stable id fromcreateFilterIdentitiesinpowerSearchSheetUtils.ts: a filter object seen before keeps its id (aWeakMap); a new object takes the id of a previous-update filter with the samefilterSignaturethat no present object claims (parents that clone their values); anything else mints a new id, and equal filters are told apart by order. A reorder or a removal above now moves elements instead of remounting them, so focus stays on the same filter; only a filter that is itself removed loses its element, and the re-home rule (buttonRowsOf, the following button row) is unchanged. Tests: reorder with the same objects and with clones keeps focus on the same element; removing a filter above keeps focus on the same element, not the old index (both fail without the change); the remove-focused-row tests pass unchanged. Chromium (Adaptive story, iPhone profile, the story'sfiltersstate set from the page): focus on "Priority is P1 - High" at index 1 → parent rotates to [Created, Status, Priority] → the same DOM node, now at index 2, keeps focus; parent then removes Status above it → the same node keeps focus at index 1.onSave(null)while creating already lands on Add filter; FR16 now says so. Tests: middle row removed → following row, then last row removed → new last row; a read-only row above the removed row → the row that followed, not one too far; Clear all removed with the last filter → Add filter; dropped focused field → a remaining row (all fail without the change).spec:AST-017FR60; Chromium, Experimental Adaptive story, 390×844,document.body.querySelectorAll('*').length): Chromium, iPhone touch profile for the coarse pointer, each state read once it has settled — every open dialog is that state's own and two readings one second apart agree — the method the record states: fine pointer 166 at rest / 334 with the editor popover open; coarse pointer 123 at rest / 289 Filters open / 289 Add filter open / 306 value step open on a choice-row value while creating (311 while editing a Filters row, which adds Remove) / 321 on a list value (the step holds the Tokenizer's trigger) / 392 with the Tokenizer's list-value sheet open above the step (FR21; two modal layers at once) — each open state 7 lower than with the switcher's host. (A reviewer's run read every open state one element higher with the same +71 for Tokenizer's sheet; the record carries these figures and their method.) Every single-sheet state stays under the open popover's 334; the stacked state exceeds it by more than FR60's 5-element open-layer allowance, so DEC-3 records it as an owner exception with before/after counts: 321 before the Tokenizer's sheet opens (value step on a list value), 392 after — the step's dialog stays mounted as a covered sheet of the stack (component:BottomSheetFR6, receded and blocked with its staged state kept) and the Tokenizer's sheet adds 71 above it; existing elements cannot do the job because the covered step must keep its state and take focus back (FR21) and the top sheet is the Tokenizer's own surface. DEC-3 records an owner exception to INV7's surface budget foradaptive: the closed field swaps elements by pointer (tokenizer field + combobox input for one trigger button) rather than restyling the same ones, because the two differ in role and a combobox cannot be restyled into a dialog trigger without lying about it; it renders fewer elements (123 vs 166) and only on a resolution change. The counts are also in AV5 (structure proviso cites INV7) and the verification row. The impl copy of the record also drops the AST-086 FR14 gap note, which describes main'suseAnnounce; this branch's base carries fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009 and the Dialog-hosted test passes.nearestEditableIndex), else Add filter — previously Apply went straight to Add filter and Cancel looked for a row that no longer existed. Test: middle filter removed while editing → Apply focuses the following row; last filter removed → Cancel focuses Add filter (fails without the change).@astryx.powersearch.sheet.editFiltercatalog key is removed. Test asserts the heading and the dialog'saria-label.SheetEmptyMessage, so its text is announced inside the sheet's dialog by the shared own-content module (once per opened filter), and a layout effect moves focus from the replaced body to Remove (Cancel when there is no Remove) when the body's control that held focus unmounted — focus never falls to the page body inside the open sheet. FR14 and FR16 name the destination, AR6 the announcement. Test: focus a choice row, remove the field from the config → the unavailable text, focus on Remove, the message in the dialog's region and nothing on the page body (fails without the fix: focus on the body).NestedSubFilterRow'schipRowcollapses from one line to wrapped lines under a container query (@container (max-width: 399px)) whose container is the popover's ownstyles.container; the sheet renders no such container, so at 390 and 414 CSS px every sub-filter's operator and value controls ran past the sheet's edge (Chromium, before: controls with right edges at 416 and 474 px inside a 390 px sheet, same at 414 px).NestedEditortakes anxstylefor its tree root and the sheet passescontainerType: inline-size; overflow: hidden, so the tree root is the query container (356 px wide inside the 390 px sheet) and the rows wrap. Chromium after, on the nested story in the sheet presentation at 390 and 414 px: no control's right edge past the sheet's, tree rootcontainer-type: inline-size, sheetscrollWidth === clientWidth.nestedfilter on the same field reused the first filter's editor state, and Apply could save it. The draft now carries agenerationcounter bumped on every opened step, and both the built-in nested editor and theEditoroverride are keyedgeneration-field-operator, so each opened filter gets its own editor. Test: two groups on one field — open the first (sub-filter "Status"), Cancel, open the second → "Author", Apply writes that group unchanged at index 1, reopen the first → "Status" (fails without the fix: the second showed "Status").adaptive(FR10): newPowerSearch.ssr.test.tsx—renderToStringof anadaptivefield yields the combobox and no trigger;hydrateRootunder a coarse-pointermatchMediareports no recoverable error and no console error, then the field is the trigger, anddocument.activeElementstays on the body. WithhasAutoFocusthe server markup'sautofocushas focused the combobox input before hydration (no React focus event), and the swap removes that input: a layout effect on every commit of the closed field records that the root holdsdocument.activeElement, so the swap knows the field was focused, and focus moves to the trigger within the same visit — the swap's ownfocus()call is flagged, and a focus React'sautoFocusgives the incoming control in the swap's commit (before the Effect has recorded the new presentation, while the field still counts as focused) is recognised the same way — so neitheronBlurnoronFocusreaches the caller for the hand-off. That continuation rule now covers every FR10 hand-off (trigger ↔ combobox on anadaptiveflip), not only the autofocus case. Tests: SSR withhasAutoFocus(input focused as a browser would, hydrate under a coarse pointer →activeElementis the trigger,onBlur/onFocusnot called; fails without the fix:onFocusfired for the trigger); the existing flip round-trip asserts oneonFocusfor the user's focus and none for the two hand-offs. Chromium at 390 and 414 px on the adaptive story, fine pointer (popover) vs coarse pointer (sheet): same x, y, width; height identical at 390 px (86/86) and one token row shorter for the trigger at 414 px (86 → 59) because the popover field's inline input wrapped onto its own row — recorded in FR10 as the swap's only layout effect.PowerSearchBottomSheet.tsxand thepowerSearchSheetUtils.tsheader no longer describe a nested exclusion.family:input-fieldsFR10 coverage —fieldInputFamily.test.tsxcounts every component that renders<Field>as Field-composed, and the sheet trigger now composes Field for the box the tokenizer field has, so PowerSearch tripped "every Field-composed component type-checks its props, or is pending". PowerSearch is outside the family (a composition that consumes member behavior) and composes Field only in the experimental presentation; its stable props carry rootxstyle/className/style— documented and in use — which both presentations apply to the field box (FR10'scontainer*target), so the styling already lands where FR10 puts it. Omitting the root names fromPowerSearchPropswould be a stable API change for existing callers, so PowerSearch is listed inPENDING_FR10with that reason (the comment namesrefas well as the root styling props), and docs(spec): PowerSearch records the experimental touch sheet presentation (DEC-3) #8000 records the gap — FR10 item 1 (refon the root, the INV8 promise; onlydata-testidrouted to the primary control; noid,aria-*, otherdata-*,hidden,inert,diror handler forwarded at all, since the stable component destructures its props with no rest) and items 2–4 (root styling props) — as a separate stable decision with its own migration, pending the family owner's answer on amendingfamily:input-fields. The family type check is not added (it would fail by design until that conversion).formatFilterValuelabel, not the token's text —1500withunits: 'ms'shows "1500" on the token and is announced "1,500 ms" — so "as it displays it" is gone; and the first known gap gains a fifth token-vs-formatFilterValuedifference: a single value is cut at a different length and with a different mark (the token's budget ismaxTokenLengthless the label lengths, ≥ 10, + 3 slack, "...";formatFilterValuecuts atmaxTokenLengthwith "…").groupItems(…, {ungroupedFirst: true})the popover's field suggestions use (usePowerSearchSource), so both presentations list a config's fields in one order. Test: a config whose first field is grouped lists Title, then the People and Dates groups (fails without the change).aria-controlsalways names an element (FR13): a config with no field opens Add filter with no "no match" message; the content searchbox'saria-controlsnames the results list, the empty-result message while a query matches nothing (the message carries the results id), or an empty results region while an empty query lists nothing. Tests for both (fail without the change).components[type].Editoroverride and the sheet's own editors — so on a mixed field (anestedoperator beside a plain one) the override opens on the default operator with the group above it, and switching operators swaps the body (override ↔ sheet editor). The step checks the operator it opened with — the filter's while editing; while creating the suggestion's operator (FR13), else the field's default, else its first — and the title is "{field} {operator}" only when no group shows. Tests: mixed field with an override (group above, swap both ways, title "Mixed"); a "Status is not" suggestion checks "is not", not the default "is".formatTokenValueTexttakes an optional translator; with it (the sheet's rows andRemoved …) the list, date-range and nested summaries resolve through the existing@astryx.powersearch.valueEditor.itemsCount/dateRange/filtersCount(ICU plurals); without it (the popover token, and so the sheet's trigger, FR11) the token's own literals stay byte-for-byte.buildRelativeDateOptionslikewise takes an optional translator; the sheet's choice rows use six new ICU messages@astryx.powersearch.sheet.relative{Days,Weeks,Months}{Ago,FromNow}, the popover editor's labels are untouched. In English every word is identical to the popover. Both popover literals are recorded in#8000as a known gap.PowerSearchValueEditorgains an internalonUnstagethe sheet wires to clear the draft's value when the draft is empty or does not parse (only thebottom-sheetpresentation stages as typed; the popover never calls it). Test: type 1, delete it → Add filter disabled and a tap commits nothing;-→ still disabled;12→ enabled.@astryx.powersearch.sheet.labelValuetoo — formatted with a private-use placeholder for the value, split on it, the value segment bold — so a locale that reorders the message reorders the row and the announcement together. Test overrides the message to{value} ({label})and asserts the row reads "Open (Status is)" with "Open" bold and Remove announces "Removed Open (Status is)".3d_agoreads "3 days ago" on the Filters row and inRemoved …through FR20's catalog keys, consistent with the value step's choice rows; a value no choice row matches (any string, e.g.7d) reads as stored, as the token shows it; the trigger shows the popover token as it is (3d_ago, known gap). Test covers trigger, row, selected choice row and announcement.onChange(rest, 'remove', index)and announcesRemoved legacy was. Test added.enumanddate_relativerows areradiobuttons in aradiogroupnamed by the field label,enum_listrows arecheckboxbuttons in agroup, each witharia-checkedand aRadioIndicator/CheckboxIndicator, as the Tokenizer's sheet rows; the ", selected" name suffix and its catalog key are gone. Disabled rows stay focusable witharia-disabled(spec:AST-085FR1). The rows still run edge to edge on the body inset. Keyboard model: a radio group is one tab stop — the checked row, else the first (rovingtabIndex) — and ArrowDown/Right, ArrowUp/Left (wrapping), Home and End move focus and check the row they land on, as a native radio group does; checkbox rows are one tab stop each, toggled by Space/Enter (the button's activation), arrows inert. Unit test covers tab stops, every arrow, wrap, Home/End and the checkbox rows. Chromium + axe (wcag2a/aa, 2.1, best-practice) on the Filters sheet, anenumstep and anenum_liststep: 0 violations; on "Priority is", Shift+Tab from the footer lands on the checked P1 (the onlytabIndex=0row), ArrowDown focuses and checks P2, ArrowUp ×2 focuses and checks P0, another Shift+Tab leaves the group; a Tags checkbox togglesaria-checkedtrue → false.nearestEditableIndextakes the sheet's ownisRowButton(field interactive, filter not read-only; anestedor stale row is a button too), one rule the rows and the focus share; its doc comment says so. Test: Status, nested Mixed, Author → removing Status focuses the nested row.onFiltersCleared()and PowerSearch composes the after-close announcement itself —@astryx.powersearch.sheet.filtersClearedWithCount("All filters removed. {resultCount}") when a count changed during the exit,filtersClearedalone otherwise, the bare count when only it changed. Test overrides the message to "{resultCount} — all filters removed" and asserts the shared channel reads exactly that; Chromium (motion on): "All filters removed. 240 results" in the page-body region 375 ms after the tap, nothing while any dialog was open or modal.dir="rtl"asserts it on all three, and Chromium withdir="rtl"on the document shows each rendering withmatrix(-1, 0, 0, 1, 0, 0)(scaleX(-1)). No code change.spec:AST-086FR14, current onmain; the publicuseAnnounceimplements it since the reworked fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009): every announcing call is plainuseAnnounce. PowerSearch itself announces only with the flow closed (the closed-field count, and Clear all's after-close message), which FR14 routes into whatever Astryx modal holds the field — a Dialog, a modal Drawer — or the shared channel. While the flow is open, every message about a sheet's content (Added …,Removed …, the result count, the empty-result messages) is spoken from inside that sheet's dialog: each step's body renders aSheetAnnouncer, a component inside theBottomSheetwhoseuseAnnounceresolves the sheet's dialog as the enclosing modal, and the flow's effects speak through the speaker it publishes. No hold or queue sits between the speaker anduseAnnounce: an earlier Chromium run had shown "Removed Priority is P1 - High 80 results" reaching the page body, because the handoff back from the value step closes the step's dialog while the Filters dialog is presented again, and the closing dialog handed the unexposed message off to the body; fix(core): useAnnounce delivers inside the open Astryx modal that encloses its caller #8009 now lets a provider ignore aclosewhose dialog has reopened a frame later and hand a real close off to the top open modal, and with that fix the message is written once, straight into the Filters dialog's region (3/3 Chromium runs, no detour via the body). The result count while a sheet is open is passed into the sheet (resultCountText) and announced there; the owner's count Effect stays quiet while the flow is open. The sheet-body ref routing and the bridge helper are gone; the after-close queue is unchanged. Test: a PowerSearch inside an open AstryxDialogannounces "3 results" (popover), "5 results" (sheet, closed) and "All filters removed. 240 results" (after Clear all's exit) in that Dialog's region, with the page-body channel empty throughout.adaptiveflips while the editor popover is open (FR10): the popover is portaled, so its focused control was neither inside the field's root nor seen by the field's focus events; when the switch to the sheet unmounts it, focus now moves to the trigger (an Effect reads the popover's open state from the render before the swap). Test: open the editor on a token, focus Apply, flip the compact-touch query → the trigger has focus (fails without the fix).spec:AST-020FR15): each row is a nativebutton, so Space and Enter already fire its activation — a radio row checks, a checkbox row toggles; the code comment now says so and a unit test drives it with real key events (user-event): Space checks an unchecked radio row, Enter keeps the arrowed row checked, Space toggles a checkbox row on and off, Enter toggles it on. The Operator group isRadioList's native radio inputs, whose Space and arrow keys are the browser's. Chromium: on a create step with nothing checked, Shift+Tab lands on the first radio row (unchecked), Space checks it (0 → 1 checked in the group); Space on a checkbox row flipsaria-checkedfalse → true.isRenderable(item.icon)(@astryx/no-nullish-jsx-guard).nestedoperator (the operator group appears only above one); the record now says "one non-nestedoperator" so a field with one editable operator beside anestedone has a title and no group.isSheetPresented && !isSheetOpen(Clear all's commit, where the sheet body is already detached and the dialog still modal) joins the after-close queue instead of the page-body channel; the queue is spoken as one announcement in order once focus is back on the trigger ("All filters removed 240 results"). Chromium (iPhone-size, motion on, forced story, FR14useAnnounce): nothing in any region while a dialog is open or modal; the combined text reaches the page-body region 358 ms after the tap with focus on the trigger; in the same run a removal from the value step delivers "Removed Priority is P1 - High" and the count change inside the Filters dialog's own region only. The forced story gains aresultCountthat follows the filters so the behavior is visible there.enumordate_relativerow whatever its state and toggles anenum_listrow; the code is unchanged and the record now says exactly that.Removed {label}— the Filters row's exact text, produced by the samedescribeFilterPartshelper the rows render from (value viaformatTokenValueText, joined through the catalog) — through the in-dialog region of the Filters sheet the flow returns to. Clear all announces "All filters removed" (new key@astryx.powersearch.sheet.filtersCleared; the popover's clear-all announces nothing today, so there was no message to reuse) only after the flow has fully closed: the sheet hands the message to PowerSearch (onAnnounceAfterClose), which speaks it when focus returns to the trigger — the same point that releases the presentation latch — because the closing dialog stays modal through its exit and hides both its own region and the page-body channel. Chromium (iPhone-size, motion on): the page-body region receives it 377 ms after the tap with no dialog open and none:modal(re-run this round together with the result count, see below).onFocus/onBlur,size,statusVariant,hasAutoFocus(FR11): opening the flow firesonBlur(focus moves into the sheet) and the close that returns focus to the trigger firesonFocus, as the popover fires them around its editor; a test asserts the counts. The other three already applied to the trigger; FR11 now says so.components[type].Tokenrenders on the trigger with exactly the popover's props andonClick/onRemoveundefined; the test now asserts the exact prop set.architecture:react-component-runtimeINV10): "No results found" and "No filters match your search" are rendered by aSheetEmptyMessageelement inside the Add filter sheet's dialog that callsuseAnnounceRenderedText(ref, true, query)— the module reads the rendered text and speaks it once per query that matches nothing, as Typeahead, Selector, CommandPalette and the Tokenizer's sheet do, and because the call is inside the dialog,useAnnounceroutes it into the dialog (FR14). The sheet's own Effect that spoke these messages is gone.PowerSearchBottomSheet.tsxhas its entry inhooks/useAnnounceRenderedText.adopters.test.tsx(main's INV10 adopter coverage, fix(ChatComposer): the status bar announces through the shared own-content module #8363): its own rendered content is the two empty-result texts and the unavailable state, all handed to the shared module; the flow'sAdded,Removed, result-count and after-close messages are not rendered content and go throughuseAnnouncedirectly. The messages are plainText, not anEmptyState, so main's self-announcing EmptyState (fix(EmptyState): announce once when it appears after load, not as a live root #8294) is not involved and nothing speaks them twice — the test asserts the region holds the one message, that the same query is not re-announced, and that a new non-matching query is.@astryx.powersearch.sheet.*catalog keys (twenty-four new under@astryx.powersearch.sheet.*; Cancel, Apply, Operator, Value, Values, Entities, the placeholders and the result count reuse the existing editor keys; the value-suggestion announcement reuses@astryx.tokenizer.tokenAdded, the same phrase the popover presentation's add makes), the trigger's name contains its visible text, the live region is StrictMode-safe, and the surface has a spec contract.Deliberate differences from the prototype, each recorded in DEC-3 or an open question:
component:BottomSheetFR5–FR7, FR10;component:BottomSheetSwitcherDEP-0015): the three steps are siblingBottomSheets, eachisOpen={step === …}withfinalFocusRef={triggerRef}(andpurpose="form"on the value step); a step change closes one in the update that opens the next, so BottomSheet replaces it in place — one scrim, the entering step above the leaving one, the flow's opener carried through the handoff. OnehandleStepOpenChange(sheetStep, isOpen)guard, bound per step, ends the flow only on the current step's own close request (a dismissal itspurposeallows), so a replaced sheet's report and a stale one are ignored; every step change still goes through the flow's handlers. The draft outlives a step change (the outgoing sheet stays presented through the handoff). The deprecated switcher, itssheetIds andonActiveSheetChangehandler are gone; theTouchDateField/TouchTimeFielddeep-import edits went with them (they existed only because the switcher reached the eager chunk through theBottomSheetbarrel; measured again below, the barrel imports now cost nothing). Chromium (Adaptive story, iPhone profile): during a handoff the leaving dialog isinertwith the scrim it owns at 1 and the entering one at 0, then one dialog with one scrim; Back lands on Add filter, Cancel on the edited row, a commit on the new row; Escape closes only the Tokenizer's sheet above the value step, then the flow, with focus on the trigger and the scroll lock released;Added "open"is announced from the Filters dialog's region; a resize past compact while a sheet is open takes effect after close, the combobox taking focus (FR10). No page errors.onChange([], 'remove', lastIndex)— the change the field's clear control commits through the Tokenizer. The prototype kept read-only filters and reported the first removed index. Whether both presentations should keep read-only filters is an open question in the record.adaptiveswitches. The prototype read them intimezoneID. Whether both editors should usetimezoneID, as the tokens and Filters rows do, is an open question.date_absolutewithisDateOnly: falsegets the popover presentation's date-only editor, not the prototype'sDateTimeInput; a date-and-time editor for both presentations is an open question.spec:AST-085FR2 and the Tokenizer review finding.packages/lab/src/MobilePowerSearch/is untouched; the Lab prototype can be retired after this lands.Current specification
component:PowerSearch(packages/core/src/PowerSearch/PowerSearch.spec.md, current) DEC-3, FR10–FR21, AR4–AR6, PR5, AV5–AV6 — merged in #8000.spec:AST-017FR21/FR22/FR27 for the experimental boundary, the[experimental]patch Changeset, and no new stable theming target.component:SelectorFR4 for the sharedadaptiveresolution.component:TokenizerDEC-3 for the list-value sheet.component:BottomSheet(its sheet stack: one scrim per stack, dismissal at the top sheet, same-update replace) andfamily:overlay-dismissalown the sheets' dialogs, gestures, handoff and dismissal;component:BottomSheetSwitcheris deprecated (DEP-0015) and is not used.spec:AST-085FR1/FR2 for the disabled trigger.family:input-fieldsFR10 andarchitecture:public-component-apiINV8 for whererefand the base props land.Public delta and caller burden
API (
PowerSearchProps):No new exports; the option type is inlined on the prop. No change to any stable prop, default or type.
PowerSearchHandle.focusTypeahead()/blurTypeahead()act on the trigger in the sheet presentation.Behavior in the sheet presentation (FR11–FR21): one trigger named by the label plus its visible text; the Filters sheet with rows, Add filter, Done and Clear all; Add filter with grouped fields or the content search; the value step staging field → operator → value with Cancel, the confirm action and Remove; exact
onChangemetadata; focus after each step; list values through the Tokenizer's sheet titled with the field label; Escape dismisses only the topmost sheet; the result count announces only on change.Inert in the sheet presentation (no surface to act on; documented on the prop and in the record; each warns once in development rather than being silently dropped):
menuWidth,tokenOverflowBehavior.Prop targets:
refreaches the root in both presentations;xstyle/className/stylethe field container in both;data-testidthe combobox input in the popover presentation (unchanged) and the trigger button in the sheet presentation.endContentrenders in the field's end lane outside the trigger in both presentations, with its own tab stop.Theming: none added while the option is experimental (
spec:AST-017FR27); the root keeps the existingpower-searchtarget in both presentations. The probe theme is unchanged.i18n: twenty-four
@astryx.powersearch.sheet.*keys (filtersTitle,addFilter,noFilters,clearAll,done,back,search,searchResults,searchFields,searchFieldsPlaceholder,noMatchingFields,noResults,filterUnavailable,remove,filtersCleared,filtersClearedWithCount,fieldOperator,labelValue,relative{Days,Weeks,Months}{Ago,FromNow});Removed {label}reuses@astryx.tokenizer.tokenRemoved; the value summaries reuse@astryx.powersearch.valueEditor.{itemsCount,dateRange,filtersCount}.Accessibility: trigger
buttonnamed by the label plus its visible content,aria-haspopup="dialog"/aria-expanded,aria-describedbythe status message and the disabled reason;aria-disabled(never nativedisabled) on the trigger; each step a modaldialognamed by its title; rows as buttons named by their text (", selected" suffix on a chosen choice row); the operator step a radio group named "Operator"; the content search a searchbox named "Search" controlling the "Search results" list; every sheet action at least 44px tall; announcements made while a sheet is open go through arole="status"region inside that open dialog, not the page-body channel; focus returns to the trigger after the flow closes.Bundle (
architecture:public-component-apiINV6; each subpath bundled alone from a freshdistwith esbuild — minified, ESM, code splitting, gzip level 9;react,react-dom,react/jsx-runtime,@stylexjs/stylexexternal; base = this branch's base, the Tokenizer branch rebased onmainafter #7648; this head = the migrated commit):PowerSearchPowerSearchBottomSheet11.3 KB gz (new; the three sheets, the sheet utils, the choice rows),TokenizerBottomSheet8.8 KB gz (unchanged, from #7977)DateInputTimeInputTokenizerCSS (
astryx.css, gzip level 9): 33,421 → 33,457 bytes (+36 B gz; 209,623 → 209,970 raw) — the trigger, choice-row and nested-editor-container styles.What changes in DateInput, TimeInput and Tokenizer is only the catalog:
en.generated.jsgrows by ≈0.3 KB gz with the 24@astryx.powersearch.sheet.*strings, and every consumer that bundles the default locale carries them; nothing else in those subpaths changes.formatFilterValue's shared number/list helpers are new exports, reached only from PowerSearch.What stays eager for PowerSearch (+4.8 KB gz) and why:
PowerSearch.tsxgrows by ≈1.6 KB gz (the trigger branch, the lazy host, presentation resolution, the swap/focus/after-close effects),PowerSearchValueEditor.tsxby ≈0.35 KB (the presentation branches),formatTokenValue.tsis ≈0.8 KB (the token's value formatting, moved out ofPowerSearch.tsxand shared with the sheet), the catalog ≈0.3 KB, and the rest is what the trigger newly reaches eagerly (Fieldfor the box, whichTokenizeralready pulled, the announce and adaptive-presentation hooks). The trigger is not lazy-loaded, the way Tokenizer keeps its own trigger eager and lazy-loads only its sheet: the trigger is the closed field in the sheet presentation — it server-renders forbottom-sheet, and underadaptiveit is what the first client render after hydration swaps in (FR10) — so a lazy trigger would paint nothing, or the wrong field, until its chunk arrived. Everything that renders only while the flow is open is in the lazyPowerSearchBottomSheetchunk (powerSearchSheetUtilsincluded; verified not reachable from the eager graph).Design and alternatives
Chosen: the Lab three-sheet flow behind an experimental opt-in;
adaptiveresolved by the shared policy; the steps as standalone BottomSheets that replace one another on a same-update switch (component:BottomSheetFR10; the deprecated switcher is not used); Core's own value editors with a sheet presentation; list values through the Tokenizer's sheet; the popover presentation's Clear all and date semantics so one field reads the same in both presentations. Rejected (recorded in DEC-3): automatic switching onpointer: coarsewithout an opt-in (FR21); a top-level stablepresentationprop before consumer evidence; a separate touch component chosen per breakpoint; sheet-only Clear all semantics that keep read-only filters; sheet-only date editors readingtimezoneIDwhile the popover's read UTC.Two implementation notes: the flow's step follows the controlled
isOpenin the render that sees the change (React's adjust-state-on-prop-change pattern), not in an effect, so the entry step's sheet opens in the same commit as the flow; and the two focus effects in the sheet file name the system they synchronize (the document's focus) and acquire nothing, so they need no cleanup (architecture:react-component-runtimeINV3).Evidence
packages/coresuite: 519 files, 14,563 tests — 518 files / 14,562 pass in the full parallel run;src/postinstall.test.mjs"still nudges when no folder up to the repository root ran init" fails identically on this branch's base (feat/tokenizer-experimental-bottom-sheet) and depends on the machine's parent directories; (an earlier full run sawBreadcrumbs.test.tsxandTable.perf.test.tsxfail once under load; both pass on their own and in this run, and neither imports anything this PR touches).PowerSearchBottomSheet.test.tsx(144 tests),PowerSearch.ssr.test.tsx(2 tests),PowerSearch.test.tsx(42 tests; two new),Field/fieldInputFamily.test.tsx(8 tests): presentation resolution —experimentalomitted,{}and'popover'keep the tokenizer field;'bottom-sheet'forces the trigger and opens a modaldialognamed "Filters";'adaptive'withmatchMediastubbed to match(max-width: 768px) and (pointer: coarse)opens the sheet and without it keeps the field (the selectors' test shape);menuWidth/tokenOverflowBehaviorwarn once in the sheet presentation and not in the popover. Trigger: name is the label plus the placeholder, or plus the tokens; display-only tokens; a token tap opens the flow;aria-disabledwith no nativedisabledand no open; the disabled reason describes it; becoming disabled with the edit step open closes the flow and returns focus to the trigger, and re-enabling does not reopen it;hasAutoFocus,onFocus/onBlur,handleRef,refon the root /data-testidon the trigger /classNameon the field, anddata-testidstill on the combobox input in the popover; the result count under<StrictMode>announces nothing on mount and announces a change; with:modalstood in for (jsdom has noshowModal()), a result-count change while a sheet is open lands in that dialog'srole="status"region and the page-body channel stays empty, and after Done the next change uses the shared channel;adaptiveflipping topopoverwhile the flow is open changes nothing until Done, keeps the trigger mounted through the exit, then mounts the tokenizer field with focus on its input and no trigger left; an action button inendContentis a sibling of the trigger with its own tab stop, fires its own click without opening the flow, and is absent from the trigger's name while a tap on the field's empty space still opens it; flipping the compact-touch query while the closed trigger holds focus mounts the combobox input with focus, flipping back mounts the trigger with focus, a focused token remove control hands focus to the trigger, a focusedendContent(remounted by the other presentation) hands it to the primary control both ways, and flipping while focus is outside the field leaves it there. Ported Lab cases: Filters rows with bold values and chevrons, header/footer order, edge-to-edge rows on the body inset, Done, Clear all (nowonChange([], 'remove', lastIndex), read-only included),hasClear, read-only; the sticky header and footer at computedz-index: 1inside anisolation: isolatecontent wrapper (read from the injected StyleX rules; fails with the old values); Add filter direct entry, Back both ways, grouped fields with ungrouped fields first even when the config's first field is grouped, field search from eight fields withmaxSearchResults, nested-only and mixed fields listed like any other, read-only while open; the value step's staging, exact add/edit/remove metadata, operator radios only for several operators, single-operator title, value kept across same-type operators, confirm disabled until a value, focus after Cancel/commit/Remove, nearest editable row after a removal, identity following across reorders, no edit when the target disappears, the way out when the config drops the field, Editor overrides (a custom Editor receives exactly the popover's prop set,timezoneIDexcluded); content search with inline suggestions, ArrowDown/ArrowUp, Enter with theAdded "…"announcement delivered inside the Filters sheet's dialog region (page-body channel empty), the IME guard, the no-results state only for a non-empty query, "No results found" and "No filters match your search" announced inside the open sheet once per settle (not again on the next keystroke that keeps the list empty; again after results came back); Remove from the value step announcesRemoved Status is Openin the Filters sheet's region with the page-body channel empty, and Clear all announces "All filters removed. 240 results" (one catalog message carrying the count that changed in the closing commit) on the shared channel only after the dialog's exit has completed and focus is back on the trigger (nothing in either region before); adate_rangerow reads "date range" and a nested row "1 filter" from the catalog; every value type, withstring_listandentity_listthrough the Tokenizer's sheet (named with the field,Search ID,Add ABC-1, Escape closes only that sheet, focus returns to its trigger, the draft survives); for a boundary date undertimezoneIDUTC, Tokyo, Honolulu and unset, the sheet's trigger token, the Filters row's bold value and theRemoved …announcement carry exactly the text the popover presentation's token shows for the same filter (read from a popover render in the same test); astring_list, anintegerand adate_rangerow read "ID is any of a, b", "Lines is greater than 7", "Window is between date range", the popover token's words; the editor reads the UTC day as the popover does; a nested filter's row opens the built-in nested editor with Apply and Remove, and a mixed field's Operator group lists both its operators with the editor swapping underneath;onBlurfires once when the flow opens andonFocusagain when it closes. Helpers: identity resolution, nearest editable index, and the suggestion filter over the popover presentation's own source.pnpm exec vitest run packages/core/src/PowerSearch packages/core/src/Tokenizer packages/core/src/BottomSheet packages/core/src/hooks— 56 files, 1239 tests passed (the Tokenizer suite as revised on the base, including the list-value tests against the Loading-row and latch changes);packages/core/src/DateInput packages/core/src/TimeInput packages/core/src/DateRangeInput(the deep-import change) — 11 files, 513 tests passed.packages/lab/is untouched.p2; the edit step has Remove, and Cancel returns focus to the edited row; Assignee renders the Tokenizer trigger (no combobox), tapping it opens the Tokenizer's sheet titled "Assignee" above the step,Search Assigneefilters, a row tap checks it, Escape closes only that sheet and focus returns to its trigger showing the staged token, Add filter commits the entity filter; Title (two operators) shows radios and a text input at 338px of the 390px step, Cancel returns focus to the Title row, Back returns to Filters with focus on Add filter; Escape on the Filters sheet closes the flow, focus returns to the trigger, the scroll lock releases; the Default story keeps the combobox on touch;adaptiveopens the sheet on the touch context and is the tokenizer field at 1280×800 with a fine pointer, still inline when that window narrows to 390px; the disabled trigger isaria-disabled, focusable, without nativedisabled, and does not open; the read-only Filters sheet has Done but no Add filter or Clear all; no page errors.component:BottomSheetFR5–FR9,component:TokenizerDEC-3, FR21; Chromium 156 headless shell, Adaptive story, iPhone profile 390×844 — the hosted case the Tokenizer PR's stack tests cover in jsdom but could not drive in a browser, sincemain's PowerSearch hosts no Tokenizer): Add filter → Assignee opens the value step (onedialog"Assignee", scrim 1, panel top 68 px, focus inside); tapping the Tokenizer trigger ("Search…") opens Tokenizer's sheet on top — two open dialogs in open order, the value step's first and Tokenizer's "Assignee" sheet (search field, six rows) last, one scrim (the stack's owner at 1, the top sheet at 0), the value step's panel receded (top edge 68 → 57 px, behind the top sheet's 68 px) and notinert, because it contains the presented sheet (FR6); checking "Alice Johnson" then Escape closes only Tokenizer's sheet — its dialog goesinert/aria-hiddenand slides out (translateY 674 px at 250 ms) while the value step stays open with its scrim at 1 — and once the exit settles focus is on the Tokenizer trigger inside the value step, now reading "Alice Johnson" (the staged token),aria-expanded="false", with the step's Add filter enabled: the staged state survived (FR21). During the top sheet's exit motion the active element is the body for a moment (the closing dialog is inert and the return focus lands after its exit), which is BottomSheet's close sequence, not the flow's. No page errors.component:BottomSheetFR6): with the Tokenizer's sheet open over the value step, the top sheet's dialog owns the scrim (--_sheet-scrim-opacity1) and the covered step's is 0; during a step handoff the leaving dialog keeps the scrim until the entering one takes it over. The page behind is dimmed once..github/scripts/accessibility-audit.js --components PowerSearch --baseline .github/a11y-baseline.json --fail-on-newon the same build: 0 new violations (2 baselined, pre-existing on the Disabled stories), the four new stories at 0 issues; no baseline change.Writing rubric results
Run over this PR's change to
PowerSearch.spec.md(main's merged record with its implementation-gap notes and the AST-086 gap removed, andPowerSearchBottomSheet.test.tsxandPowerSearch.ssr.test.tsxadded toverified_by):Rejected:entries.#+ digits,/pull/,/issues/).Scope
PowerSearch.doc.mjsprop withstability: 'experimental', an example; zh and dense variants), four stories under Core/PowerSearch (forced, adaptive, content search, states), focused tests and the[experimental]Changeset ship with the feature.Testing
pnpm exec vitest run packages/core/src/PowerSearch packages/core/src/Tokenizer packages/core/src/BottomSheet packages/core/src/hooks packages/core/src/Dialog— 66 files, 1456 tests passed;packages/core/src/DateInput packages/core/src/TimeInput packages/core/src/DateRangeInput— 513 passed (previous head).bash -cwith its exit code:@astryxdesign/core typecheck0,@astryxdesign/core typecheck:docs0,@astryxdesign/lab typecheck:docs0,@astryxdesign/storybook typecheck0,@astryxdesign/a11y-spec typecheck0,@astryxdesign/cli typecheck:strict0,@astryxdesign/cli typecheck:template-docs0.ASTRYX_STRICT_LINT=1 pnpm exec eslint packages/core/src/PowerSearch/ apps/storybook/stories/PowerSearch.stories.tsx— 0 errors, 0 new warnings (the remaining warnings are pre-existing onPowerSearchEditPopover.tsxand the popover's result-count node).PowerSearch.doc.mjsis not Prettier-formatted on main and is left as is).pnpm check:repo(knowledge, changesets, sync, i18n catalog and runtime, package boundaries, use-client, …) — exit 0;pnpm sync:exports:check— up to date;pnpm -F @astryxdesign/core build:css— 193.2 KB;pnpm visual:probe-theme:check— current, unchanged.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— all three exit 0 (783 sandbox routes and 73 embeds prerendered and verified; 431 docsite pages).pnpm storybook:build— succeeded at the previous head.git diff --check— clean.