@astryxdesign/core 0.7.4 → 0.7.5-canary.2a50a80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"Tokenizer.d.ts","sourceRoot":"","sources":["../../src/Tokenizer/Tokenizer.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"Tokenizer.d.ts","sourceRoot":"","sources":["../../src/Tokenizer/Tokenizer.tsx"],"names":[],"mappings":"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"}
@@ -70,8 +70,9 @@ const TOKEN_ROW_INSET = `calc(${spacingVars['--spacing-1']} - 1px)`;
70
70
  // InputClearButton grows 2px past its visible box under `(pointer: coarse)`.
71
71
  // Keep this maximum reserve in sync with Field/InputClearButton.tsx.
72
72
  const INPUT_CLEAR_COARSE_POINTER_OUTSET = '2px';
73
- // Kept in sync with useEndLaneReserve: inputCompact reads the custom property
74
- // directly so its empty-query pseudo-class can suppress the inner reserve.
73
+ // Kept in sync with useEndLaneReserve: wrapperWithTokens and inputCompact read
74
+ // the custom property directly, to keep token rows and the empty-query hit
75
+ // surface short of the lane.
75
76
  const END_LANE_RESERVE_VAR = '--_tokenizer-end-lane-reserve';
76
77
  const styles = {
77
78
  wrapper: {
@@ -85,12 +86,14 @@ const styles = {
85
86
  wrapperWithTokens: {
86
87
  k8WAf4: "x1x7cmac",
87
88
  kg3NbH: "xx2oswg",
89
+ kwRFfy: "xx0flbe",
88
90
  khm7nJ: "xajebq8",
89
91
  $$css: true
90
92
  },
91
93
  token: {
92
94
  k1xSpc: "x78zum5",
93
95
  kmuXW: "x2lah0s",
96
+ ks0D6T: "x193iq5w",
94
97
  kVAEAm: "x1n2onr6",
95
98
  kY2c9j: "x1vjfegm",
96
99
  $$css: true
@@ -105,6 +108,11 @@ const styles = {
105
108
  kY2c9j: "x1vjfegm",
106
109
  $$css: true
107
110
  },
111
+ idleSpinner: {
112
+ k33iCy: "xlshs6z",
113
+ kCygrm: "xdps06",
114
+ $$css: true
115
+ },
108
116
  inputAtMax: {
109
117
  kzqmXN: "xnalus7",
110
118
  k7Eaqz: "xeuugli",
@@ -123,7 +131,7 @@ const styles = {
123
131
  kUk6DE: "xfoc8du xf0xpto",
124
132
  kzqmXN: "xnalus7 xbuu7xz",
125
133
  kZCmMZ: "x2a0j69 xf0ofad",
126
- kwRFfy: "x1p6hqjl x1wg34a1",
134
+ kwRFfy: "xyri2b x1wg34a1",
127
135
  k9WMMc: "x1yc453h x75j1tr",
128
136
  $$css: true
129
137
  },
@@ -271,6 +279,13 @@ const CREATABLE_ID_PREFIX = '__xds_create__';
271
279
  *
272
280
  * Renders nothing when the lane would be empty, so `useEndLaneReserve` sees no
273
281
  * element, publishes no width, and the input keeps its full content box.
282
+ *
283
+ * With `hasBusySlot` the Spinner stays mounted while idle, unrendered, not
284
+ * animating and out of the accessibility tree, so its box keeps the themed
285
+ * Spinner's own size.
286
+ * Token rows reserve the lane's measured width, so a Spinner that came and
287
+ * went with the search would re-wrap them, and widen a content-sized field,
288
+ * on every search.
274
289
  */
275
290
  function EndLane({
276
291
  lane,
@@ -279,6 +294,7 @@ function EndLane({
279
294
  loadingLabel,
280
295
  anchorRef,
281
296
  hasStaticContent,
297
+ hasBusySlot,
282
298
  children
283
299
  }) {
284
300
  const isBusy = useIsBusy(lane);
@@ -286,17 +302,22 @@ function EndLane({
286
302
  // live region; the field announces each search's busy start instead
287
303
  // (spec:AST-086 FR8).
288
304
  useBusyAnnouncement(isBusy, loadingLabel, anchorRef);
289
- if (!isBusy && !hasStaticContent) {
305
+ if (!isBusy && !hasStaticContent && !hasBusySlot) {
290
306
  return null;
291
307
  }
292
308
  return /*#__PURE__*/_jsxs("div", {
293
309
  ref: laneRef,
294
310
  ...stylex.props(laneXStyle),
295
- children: [isBusy && /*#__PURE__*/_jsx(Spinner, {
311
+ children: [(isBusy || hasBusySlot) && /*#__PURE__*/_jsx(Spinner
312
+ // Remounts when a search starts, so the ring syncs its phase on
313
+ // mount exactly as a newly shown Spinner did before.
314
+ , {
296
315
  size: "sm",
297
316
  "aria-label": loadingLabel,
298
- role: "img"
299
- }), children]
317
+ role: "img",
318
+ "aria-hidden": isBusy ? undefined : true,
319
+ xstyle: isBusy ? undefined : styles.idleSpinner
320
+ }, isBusy ? 'busy' : 'idle'), children]
300
321
  });
301
322
  }
302
323
  export function Tokenizer({
@@ -633,7 +654,7 @@ export function Tokenizer({
633
654
  if (renderToken) {
634
655
  return /*#__PURE__*/_jsx("span", {
635
656
  ...{
636
- className: "x78zum5 x2lah0s x1n2onr6 x1vjfegm"
657
+ className: "x78zum5 x2lah0s x193iq5w x1n2onr6 x1vjfegm"
637
658
  },
638
659
  children: renderToken(item, onRemoveItem)
639
660
  }, item.id);
@@ -755,7 +776,12 @@ export function Tokenizer({
755
776
  laneXStyle: [styles.endSection, endSectionSizeStyles[size]],
756
777
  loadingLabel: t('@astryx.typeahead.loading'),
757
778
  anchorRef: wrapperRef,
758
- hasStaticContent: hasStaticEndLane,
779
+ hasStaticContent: hasStaticEndLane
780
+ // Wherever a search can run beside tokens: enabled and below
781
+ // maxEntries. A collapsed overflow row keeps it too, since a search
782
+ // started while focused can settle after focus leaves.
783
+ ,
784
+ hasBusySlot: value.length > 0 && !isDisabled && !isAtMax,
759
785
  children: [endContent, hasClearButton && /*#__PURE__*/_jsx(InputClearButton, {
760
786
  label: t('@astryx.tokenizer.clearAll'),
761
787
  onClick: e => {
package/dist/astryx.css CHANGED
@@ -928,6 +928,7 @@
928
928
  .xawy1a7:not(#\#):not(#\#):not(#\#){contain:layout style}
929
929
  .x12h1iku:not(#\#):not(#\#):not(#\#){container-type:inline-size}
930
930
  .xb5mbof:not(#\#):not(#\#):not(#\#){content-visibility:auto}
931
+ .xdps06:not(#\#):not(#\#):not(#\#){content-visibility:hidden}
931
932
  .xfrknyr:not(#\#):not(#\#):not(#\#){counter-increment:astryx-list}
932
933
  .xif0320:not(#\#):not(#\#):not(#\#){counter-reset:astryx-list}
933
934
  .x1khind5:not(#\#):not(#\#):not(#\#){counter-reset:var(--x-counterReset)}
@@ -1193,6 +1194,7 @@
1193
1194
  .x1mzt3pk:not(#\#):not(#\#):not(#\#){overflow-wrap:break-word}
1194
1195
  .x1h4wwuj:not(#\#):not(#\#):not(#\#){overflow-wrap:normal}
1195
1196
  .xyri2b:not(#\#):not(#\#):not(#\#){padding-inline-end:0}
1197
+ .xx0flbe:not(#\#):not(#\#):not(#\#){padding-inline-end:calc(calc(var(--spacing-1) - 1px) + var(--_tokenizer-end-lane-reserve,0px))}
1196
1198
  .xtvv6yu:not(#\#):not(#\#):not(#\#){padding-inline-end:calc(var(--_tab-strip-bleed,0px) + max(var(--container-padding-inline-end,0px) - var(--spacing-3),0px))}
1197
1199
  .x1g2apga:not(#\#):not(#\#):not(#\#){padding-inline-end:calc(var(--_textarea-inline-padding) + var(--spacing-12))}
1198
1200
  .xp2dy4z:not(#\#):not(#\#):not(#\#){padding-inline-end:calc(var(--_textarea-inline-padding) + var(--spacing-6))}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.4",
3
+ "version": "0.7.5-canary.2a50a80",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,6 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
+ "@astryxdesign/a11y-spec": "0.7.5-canary.2a50a80",
796
+ "@astryxdesign/cli": "0.7.5-canary.2a50a80",
795
797
  "@babel/cli": "^7.29.7",
796
798
  "@babel/core": "^7.29.7",
797
799
  "@babel/preset-react": "^7.29.7",
@@ -802,9 +804,7 @@
802
804
  "@testing-library/jest-dom": "^6.6.0",
803
805
  "@testing-library/react": "^16.3.2",
804
806
  "@types/babel__core": "^7.20.5",
805
- "tailwindcss": "^4.3.2",
806
- "@astryxdesign/a11y-spec": "0.0.0",
807
- "@astryxdesign/cli": "0.7.4"
807
+ "tailwindcss": "^4.3.2"
808
808
  },
809
809
  "dependencies": {
810
810
  "intl-messageformat": "^11.2.9"
@@ -27,7 +27,7 @@ architecture:
27
27
  architecture:public-component-api,
28
28
  ]
29
29
  contributing: []
30
- system_specs: []
30
+ system_specs: [spec:AST-087]
31
31
  ---
32
32
 
33
33
  # Icon component contract
@@ -53,6 +53,7 @@ Consumer migration instructions belong in consumer docs and release notes.
53
53
  **Owns**
54
54
 
55
55
  - Accepting a semantic icon key or a supplied icon component as the glyph source.
56
+ - Passing the resolved size and a variant the entry can receive to a registry entry.
56
57
  - Resolving explicit, component-owned contextual, and standalone size defaults.
57
58
  - Applying Icon's size, color, theming target, and accessibility semantics to
58
59
  the rendered glyph.
@@ -71,12 +72,13 @@ Consumer migration instructions belong in consumer docs and release notes.
71
72
 
72
73
  ## Public concepts
73
74
 
74
- | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
75
- | ------------- | --------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------- | ---------------- | --------- | ------------------------------------------------------ |
76
- | Glyph source | semantic key, namespaced extension key, or icon component | Selects the visual symbol. | Every render | Required | `component:Icon` | Stable | TypeScript rejects unsupported built-in string values. |
77
- | Size | `xsm`, `sm`, `md`, `lg` | Selects the icon box size. | Every rendered glyph | nearest component-owned default; otherwise `md` | `component:Icon` | Stable | TypeScript rejects unsupported values. |
78
- | Color | documented semantic and palette values | Selects the glyph color or inherits it from context. | Every rendered glyph | `inherit` | `component:Icon` | Stable | TypeScript rejects unsupported values. |
79
- | Accessibility | decorative or meaningfully labelled | Controls whether assistive technology receives the glyph. | Every rendered glyph | Decorative | `component:Icon` | Stable | Empty labels use the decorative behavior. |
75
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
76
+ | ------------- | --------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------ | ----------------------------------------------- | ---------------- | --------- | --------------------------------------------------------------------------------- |
77
+ | Glyph source | semantic key, namespaced extension key, or icon component | Selects the visual symbol. | Every render | Required | `component:Icon` | Stable | TypeScript rejects unsupported built-in string values. |
78
+ | Size | `xsm`, `sm`, `md`, `lg` | Selects the icon box size. | Every rendered glyph | nearest component-owned default; otherwise `md` | `component:Icon` | Stable | TypeScript rejects unsupported values. |
79
+ | Color | documented semantic and palette values | Selects the glyph color or inherits it from context. | Every rendered glyph | `inherit` | `component:Icon` | Stable | TypeScript rejects unsupported values. |
80
+ | Accessibility | decorative or meaningfully labelled | Controls whether assistive technology receives the glyph. | Every rendered glyph | Decorative | `component:Icon` | Stable | Empty labels use the decorative behavior. |
81
+ | Variant | names in `IconVariantMap`; the active theme lists its own | Requests a theme's alternate artwork for a registry glyph. | Registry glyphs only; never a supplied component | None | `component:Icon` | Stable | TypeScript rejects unknown names; an unlisted variant renders the normal artwork. |
80
82
 
81
83
  A namespaced key that does not resolve currently renders nothing. This is
82
84
  existing behavior, not an intentional fallback promise.
@@ -86,13 +88,15 @@ existing behavior, not an intentional fallback promise.
86
88
  Requirements identify their basis so observed code is not mistaken for an
87
89
  intentional decision.
88
90
 
89
- | ID | Candidate invariant | Basis | Draft review state |
90
- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
91
- | FR1 | Icon MUST present at most one glyph from the supplied semantic key, namespaced key, or icon component. | Documented promise and current tests. | Settled intent. |
92
- | FR2 | Every rendered glyph MUST carry the `icon` theming target with its resolved size and selected color reflected as target data. | Current source, docs, and theming tests. | Settled intent. |
93
- | FR3 | Icon MUST apply the resolved size as width and height for every glyph, plus font size where needed for 1em-based icon sources. This sizing contract MUST NOT promise an HTML or SVG element type. | Human decision, current source, and tests. | Settled intent. |
94
- | FR4 | Supported SVG and styling escape hatches MUST retain their established merge and override behavior. | Current source and regression tests. | Current compatibility behavior; verify before changing. |
95
- | FR5 | An explicit Icon `size` MUST win. Without one, Icon MUST use the nearest default supplied by an owning Astryx component for its documented icon slot; without such a default, Icon MUST use `md`. | Component-size precedence and Button sizing decision. | Settled intent. |
91
+ | ID | Candidate invariant | Basis | Draft review state |
92
+ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
93
+ | FR1 | Icon MUST present at most one glyph from the supplied semantic key, namespaced key, or icon component. | Documented promise and current tests. | Settled intent. |
94
+ | FR2 | Every rendered glyph MUST carry the `icon` theming target with its resolved size and selected color reflected as target data. | Current source, docs, and theming tests. | Settled intent. |
95
+ | FR3 | Icon MUST apply the resolved size as width and height for every glyph, plus font size where needed for 1em-based icon sources. This sizing contract MUST NOT promise an HTML or SVG element type. | Human decision, current source, and tests. | Settled intent. |
96
+ | FR4 | Supported SVG and styling escape hatches MUST retain their established merge and override behavior. | Current source and regression tests. | Current compatibility behavior; verify before changing. |
97
+ | FR5 | An explicit Icon `size` MUST win. Without one, Icon MUST use the nearest default supplied by an owning Astryx component for its documented icon slot; without such a default, Icon MUST use `md`. | Component-size precedence and Button sizing decision. | Settled intent. |
98
+ | FR6 | In registry mode, Icon MUST pass `{size, variant}` to the selected entry, where `size` is the size resolved by ORD1 and `variant` is the requested variant when the active theme lists it and, for a theme-supplied entry, the theme that defined the entry lists it too, otherwise `undefined`. A supplied icon component MUST NOT receive `variant`, Icon MUST NOT forward `variant` to the DOM, and the `icon` target MUST NOT reflect it. | `spec:AST-087` FR1–FR5 and FR9. | Settled intent. |
99
+ | FR7 | The `icon` target MUST guarantee `width` and `height` on its `size:*` selectors as reviewed target-specific additions, so a theme sets each size's dimension. Without theme styles, sizes are 12, 16, 20, and 24px at a 16px root and scale with the root font size. | `spec:AST-087` FR10. | Settled intent. |
96
100
 
97
101
  ### Allowed variation
98
102
 
@@ -109,15 +113,18 @@ intentional decision.
109
113
 
110
114
  ### Representative states
111
115
 
112
- | State | Required invariant | Allowed variation |
113
- | -------------------------------- | -------------------------------------------------------- | ---------------------------------------------- |
114
- | Semantic key | One resolved glyph carries the `icon` target. | Theme, registry, or built-in artwork. |
115
- | Namespaced extension key | A resolved extension glyph carries the `icon` target. | Consumer- or library-owned artwork. |
116
- | Supplied icon component | The supplied glyph carries the `icon` target. | Component implementation and SVG internals. |
117
- | Standalone Icon without `size` | The resolved size is `md`. | Glyph source and color. |
118
- | Owned icon slot without `size` | The nearest documented component default is used. | The owning component's declared size mapping. |
119
- | Explicit Icon size in owned slot | The explicit Icon size wins over the contextual default. | Any supported Icon size. |
120
- | Unresolved namespaced key | No glyph is currently rendered. | No fallback behavior is established by intent. |
116
+ | State | Required invariant | Allowed variation |
117
+ | -------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------- |
118
+ | Semantic key | One resolved glyph carries the `icon` target. | Theme, registry, or built-in artwork. |
119
+ | Namespaced extension key | A resolved extension glyph carries the `icon` target. | Consumer- or library-owned artwork. |
120
+ | Supplied icon component | The supplied glyph carries the `icon` target. | Component implementation and SVG internals. |
121
+ | Standalone Icon without `size` | The resolved size is `md`. | Glyph source and color. |
122
+ | Owned icon slot without `size` | The nearest documented component default is used. | The owning component's declared size mapping. |
123
+ | Explicit Icon size in owned slot | The explicit Icon size wins over the contextual default. | Any supported Icon size. |
124
+ | Unresolved namespaced key | No glyph is currently rendered. | No fallback behavior is established by intent. |
125
+ | Listed variant | The entry receives the size and the variant when its defining theme also lists it. | The theme's artwork for that request. |
126
+ | Unlisted or omitted variant | The entry receives `variant: undefined`. | The theme's normal artwork. |
127
+ | Variant on a supplied component | The component renders as given without `variant`. | Component implementation. |
121
128
 
122
129
  ### Transformation and precedence order
123
130
 
@@ -172,6 +179,8 @@ not promise a `span`, `svg`, or other element type.
172
179
  rules for the `icon` target.
173
180
  - `architecture:icon-resolution-and-component-slots` owns semantic-key and
174
181
  component-slot resolution before Icon renders the selected source.
182
+ - `spec:AST-087` decides the `variant` request, theme-listed variant names, and
183
+ theme-set size dimensions.
175
184
  - `architecture:public-component-api` owns admission and compatibility rules for
176
185
  Icon's public props and observable defaults.
177
186
  - The owning component or family specifies each contextual Icon-size mapping.
@@ -181,12 +190,14 @@ not promise a `span`, `svg`, or other element type.
181
190
 
182
191
  ## Verification map
183
192
 
184
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
185
- | ------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
186
- | FR1, FR3, FR5 | `Icon.test.tsx` sizing suites and `IconButton.test.tsx` contextual-size suites | standalone default; Button sizes; explicit override; both modes | Breaking source rendering, standalone fallback, contextual mapping, explicit precedence, or cross-mode sizing fails assertions. | `audit:Icon/behavior` |
187
- | FR2, FR4 | `Icon.test.tsx` target/styling suites plus source inspection | Both source modes; size and color variants | Removing the target or breaking override composition fails existing assertions; `data-color` reflection currently lacks focused assertions. | `audit:Icon/theming` |
188
- | AR1, AR2, AR3 | `Icon.test.tsx` accessible-name suites | Decorative, labelled, explicit ARIA override | Changing default or labelled semantics fails accessibility assertions. | `audit:Icon/accessibility` |
189
- | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical consumer anatomy and current target | Missing, extra, prefixed, or stale mappings fail repository validation. | `audit:Icon/theming` |
193
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
194
+ | ------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
195
+ | FR1, FR3, FR5 | `Icon.test.tsx` sizing suites and `IconButton.test.tsx` contextual-size suites | standalone default; Button sizes; explicit override; both modes | Breaking source rendering, standalone fallback, contextual mapping, explicit precedence, or cross-mode sizing fails assertions. | `audit:Icon/behavior` |
196
+ | FR2, FR4 | `Icon.test.tsx` target/styling suites plus source inspection | Both source modes; size and color variants | Removing the target or breaking override composition fails existing assertions; `data-color` reflection currently lacks focused assertions. | `audit:Icon/theming` |
197
+ | AR1, AR2, AR3 | `Icon.test.tsx` accessible-name suites | Decorative, labelled, explicit ARIA override | Changing default or labelled semantics fails accessibility assertions. | `audit:Icon/accessibility` |
198
+ | FR6 | `Icon.test.tsx` variant suites (pending) | listed, unlisted, and omitted variant; no active list; inherited entry under a replaced list; supplied component | An unlisted variant, or one the entry's defining theme does not list, reaches an entry, or `variant` reaches a supplied component or the DOM. | `audit:Icon/behavior` |
199
+ | FR7 | Theme styling tests for each size (pending) | each size with and without theme styles | A themed size does not change the glyph dimension, or an unstyled size changes. | `audit:Icon/theming` |
200
+ | Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical consumer anatomy and current target | Missing, extra, prefixed, or stale mappings fail repository validation. | `audit:Icon/theming` |
190
201
 
191
202
  Focused coverage for `data-color` on both glyph source modes is a checkable test
192
203
  gap; source inspection is the current evidence for that part of FR2.
@@ -1692,6 +1692,27 @@ describe('Tokenizer end-lane reserve', () => {
1692
1692
  );
1693
1693
  });
1694
1694
 
1695
+ it('keeps token rows, not only the input, short of the lane', () => {
1696
+ // The lane is out of flow, so a token reaching the end of the first row
1697
+ // ran under the clear button. The field's own inline-end padding spends
1698
+ // the reserve, and every row of tokens wraps inside it. Real geometry is
1699
+ // proved in TokenizerEndLane.a11y.chromium.spec.ts.
1700
+ const {container} = render(
1701
+ <Tokenizer
1702
+ label="Users"
1703
+ searchSource={userSource}
1704
+ value={[users[0]]}
1705
+ onChange={() => {}}
1706
+ hasClear
1707
+ />,
1708
+ );
1709
+ const field = laneHost(container);
1710
+ expect(field).not.toBeNull();
1711
+ expect(getComputedStyle(field!).paddingInlineEnd).toContain(
1712
+ '--_tokenizer-end-lane-reserve',
1713
+ );
1714
+ });
1715
+
1695
1716
  it('does not pad the input on the collapsed paths', () => {
1696
1717
  // Collapsed: with tokens truncated the input is given no width to pad —
1697
1718
  // `inputAtMax` zeroes its padding outright — so a reserve there would be
@@ -1707,16 +1728,13 @@ describe('Tokenizer end-lane reserve', () => {
1707
1728
  tokenOverflowBehavior="unfocusedInline"
1708
1729
  />,
1709
1730
  );
1731
+ // The collapsed row keeps the Spinner's slot, so the lane is up and
1732
+ // publishing; the input still must not spend the reserve.
1733
+ expect(reserveOf(container)).toBeTruthy();
1710
1734
  const input = screen.getByRole('combobox');
1711
- expect(input.className).not.toBe('');
1712
- // The reserve is the only rule that reads the lane variable; the
1713
- // collapsed input must not carry it.
1714
- const reserveClasses = new Set(
1715
- (laneHost(container)?.className ?? '').split(' '),
1735
+ expect(getComputedStyle(input).paddingInlineEnd).not.toContain(
1736
+ '--_tokenizer-end-lane-reserve',
1716
1737
  );
1717
- expect(
1718
- [...input.classList].some(c => reserveClasses.has(c) && c !== ''),
1719
- ).toBe(false);
1720
1738
  });
1721
1739
 
1722
1740
  it('costs no commit of its own across a whole search', async () => {
@@ -275,8 +275,9 @@ const TOKEN_ROW_INSET = `calc(${spacingVars['--spacing-1']} - 1px)`;
275
275
  // InputClearButton grows 2px past its visible box under `(pointer: coarse)`.
276
276
  // Keep this maximum reserve in sync with Field/InputClearButton.tsx.
277
277
  const INPUT_CLEAR_COARSE_POINTER_OUTSET = '2px';
278
- // Kept in sync with useEndLaneReserve: inputCompact reads the custom property
279
- // directly so its empty-query pseudo-class can suppress the inner reserve.
278
+ // Kept in sync with useEndLaneReserve: wrapperWithTokens and inputCompact read
279
+ // the custom property directly, to keep token rows and the empty-query hit
280
+ // surface short of the lane.
280
281
  const END_LANE_RESERVE_VAR = '--_tokenizer-end-lane-reserve';
281
282
 
282
283
  const styles = stylex.create({
@@ -297,6 +298,10 @@ const styles = stylex.create({
297
298
  // = 8 - 4 - 1 = 3px.
298
299
  paddingBlock: TOKEN_ROW_INSET,
299
300
  paddingInline: TOKEN_ROW_INSET,
301
+ // The end lane is out of flow, so tokens would wrap under it. Every row
302
+ // stops short of the measured lane instead; with no lane up the variable
303
+ // is absent and this is the ordinary inset.
304
+ paddingInlineEnd: `calc(${TOKEN_ROW_INSET} + var(${END_LANE_RESERVE_VAR}, 0px))`,
300
305
  // Row gap must match paddingBlock so wrapped rows look evenly spaced.
301
306
  rowGap: TOKEN_ROW_INSET,
302
307
  },
@@ -310,6 +315,9 @@ const styles = stylex.create({
310
315
  token: {
311
316
  display: 'flex',
312
317
  flexShrink: 0,
318
+ // A renderToken wrapper sizes to its content; cap it at the row so the
319
+ // pill inside truncates instead of running past the field.
320
+ maxWidth: '100%',
313
321
  position: 'relative',
314
322
  zIndex: 1,
315
323
  },
@@ -325,6 +333,14 @@ const styles = stylex.create({
325
333
  flexShrink: 0,
326
334
  zIndex: 1,
327
335
  },
336
+ idleSpinner: {
337
+ // Hides the Spinner's own box too, which a theme may paint, and covers
338
+ // engines without content-visibility. Both keep the box's size.
339
+ visibility: 'hidden',
340
+ // Skips the ring's rendering, and with it the infinite arc animation,
341
+ // while the root keeps its own explicit, themed width and height.
342
+ contentVisibility: 'hidden',
343
+ },
328
344
  inputAtMax: {
329
345
  width: 0,
330
346
  minWidth: 0,
@@ -367,7 +383,8 @@ const styles = stylex.create({
367
383
  ':placeholder-shown': 0,
368
384
  },
369
385
  paddingInlineEnd: {
370
- default: `var(${END_LANE_RESERVE_VAR}, 0px)`,
386
+ // wrapperWithTokens already keeps the in-flow input short of the lane.
387
+ default: 0,
371
388
  ':placeholder-shown': spacingVars['--spacing-1'],
372
389
  },
373
390
  textAlign: {
@@ -517,6 +534,13 @@ const CREATABLE_ID_PREFIX = '__xds_create__';
517
534
  *
518
535
  * Renders nothing when the lane would be empty, so `useEndLaneReserve` sees no
519
536
  * element, publishes no width, and the input keeps its full content box.
537
+ *
538
+ * With `hasBusySlot` the Spinner stays mounted while idle, unrendered, not
539
+ * animating and out of the accessibility tree, so its box keeps the themed
540
+ * Spinner's own size.
541
+ * Token rows reserve the lane's measured width, so a Spinner that came and
542
+ * went with the search would re-wrap them, and widen a content-sized field,
543
+ * on every search.
520
544
  */
521
545
  function EndLane({
522
546
  lane,
@@ -525,6 +549,7 @@ function EndLane({
525
549
  loadingLabel,
526
550
  anchorRef,
527
551
  hasStaticContent,
552
+ hasBusySlot,
528
553
  children,
529
554
  }: {
530
555
  lane: BusyIndicatorLane;
@@ -533,6 +558,7 @@ function EndLane({
533
558
  loadingLabel: string;
534
559
  anchorRef: RefObject<Element | null>;
535
560
  hasStaticContent: boolean;
561
+ hasBusySlot: boolean;
536
562
  children: ReactNode;
537
563
  }) {
538
564
  const isBusy = useIsBusy(lane);
@@ -540,12 +566,23 @@ function EndLane({
540
566
  // live region; the field announces each search's busy start instead
541
567
  // (spec:AST-086 FR8).
542
568
  useBusyAnnouncement(isBusy, loadingLabel, anchorRef);
543
- if (!isBusy && !hasStaticContent) {
569
+ if (!isBusy && !hasStaticContent && !hasBusySlot) {
544
570
  return null;
545
571
  }
546
572
  return (
547
573
  <div ref={laneRef} {...stylex.props(laneXStyle)}>
548
- {isBusy && <Spinner size="sm" aria-label={loadingLabel} role="img" />}
574
+ {(isBusy || hasBusySlot) && (
575
+ <Spinner
576
+ // Remounts when a search starts, so the ring syncs its phase on
577
+ // mount exactly as a newly shown Spinner did before.
578
+ key={isBusy ? 'busy' : 'idle'}
579
+ size="sm"
580
+ aria-label={loadingLabel}
581
+ role="img"
582
+ aria-hidden={isBusy ? undefined : true}
583
+ xstyle={isBusy ? undefined : styles.idleSpinner}
584
+ />
585
+ )}
549
586
  {children}
550
587
  </div>
551
588
  );
@@ -1093,7 +1130,11 @@ export function Tokenizer<T extends SearchableItem>({
1093
1130
  laneXStyle={[styles.endSection, endSectionSizeStyles[size]]}
1094
1131
  loadingLabel={t('@astryx.typeahead.loading')}
1095
1132
  anchorRef={wrapperRef}
1096
- hasStaticContent={hasStaticEndLane}>
1133
+ hasStaticContent={hasStaticEndLane}
1134
+ // Wherever a search can run beside tokens: enabled and below
1135
+ // maxEntries. A collapsed overflow row keeps it too, since a search
1136
+ // started while focused can settle after focus leaves.
1137
+ hasBusySlot={value.length > 0 && !isDisabled && !isAtMax}>
1097
1138
  {endContent}
1098
1139
  {hasClearButton && (
1099
1140
  <InputClearButton
@@ -0,0 +1,471 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file TokenizerEndLane.a11y.chromium.spec.ts
5
+ * @input `Core/Tokenizer` and `Core/PowerSearch` stories in a built Storybook.
6
+ * @output Real-engine proof that tokens, the overflow count and the input's
7
+ * text box never paint under the end lane (clear-all and busy indicator),
8
+ * across field widths, both writing directions and both overflow modes.
9
+ * @position Run with `pnpm test:a11y-contract`. The lane is absolutely
10
+ * positioned, so whether a token reaches it depends on how rows wrap at a
11
+ * given width, which only a shipping layout engine can show.
12
+ */
13
+
14
+ import {expect, test, type Page} from '@playwright/test';
15
+ import {
16
+ DEFAULT_STORYBOOK_DIR,
17
+ serveStorybook,
18
+ type StaticServer,
19
+ } from '@astryxdesign/a11y-spec/storybook';
20
+
21
+ let storybook: StaticServer;
22
+
23
+ test.beforeAll(async () => {
24
+ storybook = await serveStorybook(
25
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
26
+ );
27
+ });
28
+
29
+ test.afterAll(async () => {
30
+ await storybook?.close();
31
+ });
32
+
33
+ type Direction = 'ltr' | 'rtl';
34
+
35
+ async function open(
36
+ page: Page,
37
+ id: string,
38
+ direction: Direction,
39
+ args = '',
40
+ ): Promise<void> {
41
+ await page.goto(
42
+ `${storybook.origin}/iframe.html?id=${id}&viewMode=story&globals=direction:${direction}${args ? `&args=${args}` : ''}`,
43
+ {waitUntil: 'load'},
44
+ );
45
+ await page
46
+ .locator('button[aria-label="Clear all"]')
47
+ .first()
48
+ .waitFor({state: 'attached'});
49
+ await page.evaluate(async () => {
50
+ await document.fonts.ready;
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Sets the story's sizing container to `width` and, for every field with a
56
+ * visible clear-all lane, returns each visible box that intersects the lane:
57
+ * tokens, the "+N more" count, and the input's content box (where its text
58
+ * and caret paint).
59
+ */
60
+ async function overlapsAt(page: Page, width: number): Promise<string[]> {
61
+ return page.evaluate(async width => {
62
+ // The story decorator's fixed-width box; the layer mode's popover takes
63
+ // its width from the in-flow field inside it.
64
+ const host = document.querySelector<HTMLElement>(
65
+ '#storybook-root [style*="width"]',
66
+ );
67
+ if (!host) {
68
+ throw new Error('No sizing container in the story');
69
+ }
70
+ host.style.width = `${width}px`;
71
+ for (let frame = 0; frame < 4; frame++) {
72
+ await new Promise(resolve => requestAnimationFrame(resolve));
73
+ }
74
+
75
+ const isShown = (el: Element) =>
76
+ el.checkVisibility({visibilityProperty: true}) &&
77
+ !el.closest('[aria-hidden="true"]');
78
+ const lanes = [
79
+ ...document.querySelectorAll('button[aria-label="Clear all"]'),
80
+ ]
81
+ .filter(isShown)
82
+ .map(clear => clear.parentElement)
83
+ .filter(lane => lane?.parentElement != null) as HTMLElement[];
84
+ if (lanes.length === 0) {
85
+ throw new Error('No clear-all lane is visible');
86
+ }
87
+
88
+ return lanes.flatMap((lane, fieldIndex) => {
89
+ const field = lane.parentElement as HTMLElement;
90
+ const boxes: {name: string; rect: DOMRect}[] = [];
91
+ field.querySelectorAll('.astryx-token').forEach((token, index) => {
92
+ if (isShown(token)) {
93
+ boxes.push({
94
+ name: `token ${index}`,
95
+ rect: token.getBoundingClientRect(),
96
+ });
97
+ }
98
+ });
99
+ field.querySelectorAll('span').forEach(span => {
100
+ if (/^\+\d+ more$/.test(span.textContent ?? '') && isShown(span)) {
101
+ boxes.push({
102
+ name: 'overflow count',
103
+ rect: span.getBoundingClientRect(),
104
+ });
105
+ }
106
+ });
107
+ const input = field.querySelector<HTMLInputElement>('[role="combobox"]');
108
+ if (input && isShown(input) && getComputedStyle(input).opacity !== '0') {
109
+ const style = getComputedStyle(input);
110
+ const rect = input.getBoundingClientRect();
111
+ const left = rect.left + parseFloat(style.paddingLeft);
112
+ const right = rect.right - parseFloat(style.paddingRight);
113
+ if (right > left) {
114
+ boxes.push({
115
+ name: 'input text box',
116
+ rect: new DOMRect(left, rect.top, right - left, rect.height),
117
+ });
118
+ }
119
+ }
120
+
121
+ const laneRect = lane.getBoundingClientRect();
122
+ return boxes.flatMap(({name, rect}) => {
123
+ const x =
124
+ Math.min(rect.right, laneRect.right) -
125
+ Math.max(rect.left, laneRect.left);
126
+ const y =
127
+ Math.min(rect.bottom, laneRect.bottom) -
128
+ Math.max(rect.top, laneRect.top);
129
+ return x > 0.5 && y > 0.5
130
+ ? [
131
+ `${width}px field ${fieldIndex}: ${name} overlaps the end lane by ${x.toFixed(1)}px`,
132
+ ]
133
+ : [];
134
+ });
135
+ });
136
+ }, width);
137
+ }
138
+
139
+ async function sweep(page: Page, from: number, to: number): Promise<string[]> {
140
+ const found: string[] = [];
141
+ for (let width = from; width <= to; width += 4) {
142
+ found.push(...(await overlapsAt(page, width)));
143
+ }
144
+ return found;
145
+ }
146
+
147
+ /**
148
+ * The same sweep with a search in flight at every width. The busy Spinner joins
149
+ * the lane only while a search runs, so each width is measured while the
150
+ * combobox reports `aria-busy`, typing again whenever the last search settled.
151
+ */
152
+ async function sweepWhileBusy(
153
+ page: Page,
154
+ from: number,
155
+ to: number,
156
+ ): Promise<string[]> {
157
+ const search = page.getByRole('combobox');
158
+ const found: string[] = [];
159
+ for (let width = from; width <= to; width += 4) {
160
+ for (;;) {
161
+ if ((await search.getAttribute('aria-busy')) !== 'true') {
162
+ await search.press('a');
163
+ await expect(search).toHaveAttribute('aria-busy', 'true');
164
+ }
165
+ const overlaps = await overlapsAt(page, width);
166
+ if ((await search.getAttribute('aria-busy')) === 'true') {
167
+ found.push(...overlaps);
168
+ break;
169
+ }
170
+ }
171
+ }
172
+ return found;
173
+ }
174
+
175
+ /**
176
+ * Sizes the story container (a pixel width, or `max-content` for a
177
+ * content-sized field) and returns the field's border box.
178
+ */
179
+ async function fieldBoxAt(
180
+ page: Page,
181
+ width: string,
182
+ ): Promise<{width: number; height: number}> {
183
+ return page.evaluate(async width => {
184
+ const host = document.querySelector<HTMLElement>(
185
+ '#storybook-root [style*="width"]',
186
+ );
187
+ const field = document.querySelector('button[aria-label="Clear all"]')
188
+ ?.parentElement?.parentElement;
189
+ if (!host || !field) {
190
+ throw new Error('Field or sizing container did not render');
191
+ }
192
+ host.style.width = width;
193
+ for (let frame = 0; frame < 4; frame++) {
194
+ await new Promise(resolve => requestAnimationFrame(resolve));
195
+ }
196
+ const {width: w, height: h} = field.getBoundingClientRect();
197
+ return {
198
+ width: Math.round(w * 100) / 100,
199
+ height: Math.round(h * 100) / 100,
200
+ };
201
+ }, width);
202
+ }
203
+
204
+ /** Adds tokens through the menu, as a person would. */
205
+ async function addTokens(page: Page, names: string[]): Promise<void> {
206
+ const search = page.getByRole('combobox');
207
+ for (const name of names) {
208
+ await search.fill(name.split(' ')[0]);
209
+ await page.getByRole('option', {name}).click();
210
+ await expect(search).not.toHaveAttribute('aria-busy', 'true');
211
+ }
212
+ }
213
+
214
+ /**
215
+ * The field's box with a search settled and again with one in flight. The
216
+ * query is non-empty both times, so only the busy state differs.
217
+ */
218
+ async function settledAndBusy(
219
+ page: Page,
220
+ width: string,
221
+ ): Promise<string | null> {
222
+ const search = page.getByRole('combobox');
223
+ await search.fill('a');
224
+ await expect(search).not.toHaveAttribute('aria-busy', 'true');
225
+ const settled = await fieldBoxAt(page, width);
226
+ await search.press('b');
227
+ await expect(search).toHaveAttribute('aria-busy', 'true');
228
+ const busy = await fieldBoxAt(page, width);
229
+ const stillBusy = (await search.getAttribute('aria-busy')) === 'true';
230
+ if (!stillBusy) {
231
+ throw new Error(`The search settled before ${width} was measured`);
232
+ }
233
+ return settled.width === busy.width && settled.height === busy.height
234
+ ? null
235
+ : `${width}: settled ${settled.width}x${settled.height}, busy ${busy.width}x${busy.height}`;
236
+ }
237
+
238
+ /**
239
+ * With four tokens on the Loading story, every width from 200px to 400px and a
240
+ * content-sized container where the field's box differs settled vs busy.
241
+ */
242
+ async function busyLayoutChanges(page: Page, step: number): Promise<string[]> {
243
+ await addTokens(page, ['Bob Smith', 'Charlie Brown', 'Diana Prince']);
244
+ await expect(page.locator('.astryx-token')).toHaveCount(4);
245
+ const changed: string[] = [];
246
+ for (let width = 200; width <= 400; width += step) {
247
+ const change = await settledAndBusy(page, `${width}px`);
248
+ if (change) {
249
+ changed.push(change);
250
+ }
251
+ }
252
+ // Content-sized: the field may not widen while busy (input-fields FR1).
253
+ const contentSized = await settledAndBusy(page, 'max-content');
254
+ if (contentSized) {
255
+ changed.push(contentSized);
256
+ }
257
+ return changed;
258
+ }
259
+
260
+ /**
261
+ * The blurred, collapsed field at `width`: its border box, how many tokens
262
+ * show, and the "+N more" count.
263
+ */
264
+ async function collapsedRowAt(page: Page, width: string): Promise<string> {
265
+ const box = await fieldBoxAt(page, width);
266
+ const row = await page.evaluate(() => {
267
+ const field = document.querySelector('button[aria-label="Clear all"]')
268
+ ?.parentElement?.parentElement;
269
+ if (!field) {
270
+ throw new Error('Field did not render');
271
+ }
272
+ const shown = [...field.querySelectorAll('.astryx-token')].filter(
273
+ token =>
274
+ token.checkVisibility({visibilityProperty: true}) &&
275
+ !token.closest('[aria-hidden="true"]'),
276
+ ).length;
277
+ const count = [...field.querySelectorAll('span')].find(span =>
278
+ /^\+\d+ more$/.test(span.textContent ?? ''),
279
+ );
280
+ return `${shown} tokens, ${count?.textContent ?? 'no count'}`;
281
+ });
282
+ return `${box.width}x${box.height}, ${row}`;
283
+ }
284
+
285
+ for (const direction of ['ltr', 'rtl'] as const) {
286
+ test.describe(direction, () => {
287
+ test('PowerSearch tokens (custom renderToken) wrap before the end lane', async ({
288
+ page,
289
+ }) => {
290
+ await open(page, 'core-powersearch--near-full-token-row', direction);
291
+ expect(await sweep(page, 160, 640)).toEqual([]);
292
+ });
293
+
294
+ test('a token wider than the row truncates short of the end lane', async ({
295
+ page,
296
+ }) => {
297
+ await open(page, 'core-tokenizer--long-token-end-lane', direction);
298
+ expect(await sweep(page, 160, 400)).toEqual([]);
299
+ });
300
+
301
+ test('default Tokenizer tokens wrap before the end lane', async ({
302
+ page,
303
+ }) => {
304
+ await open(
305
+ page,
306
+ 'core-tokenizer--with-preselected',
307
+ direction,
308
+ 'hasClear:!true',
309
+ );
310
+ expect(await sweep(page, 160, 400)).toEqual([]);
311
+ });
312
+
313
+ test('tokens stay clear of the lane while a search is busy', async ({
314
+ page,
315
+ }) => {
316
+ await open(page, 'core-tokenizer--loading', direction);
317
+ await page.getByRole('combobox').focus();
318
+ expect(await sweepWhileBusy(page, 200, 400)).toEqual([]);
319
+ });
320
+
321
+ test('a search starting does not move the field or its token rows', async ({
322
+ page,
323
+ }) => {
324
+ test.setTimeout(240_000);
325
+ await open(page, 'core-tokenizer--loading', direction);
326
+ expect(await busyLayoutChanges(page, 4)).toEqual([]);
327
+ });
328
+
329
+ test('the reserved Spinner costs no frames and stays silent while idle', async ({
330
+ page,
331
+ }) => {
332
+ await open(page, 'core-tokenizer--loading', direction);
333
+ const search = page.getByRole('combobox');
334
+ const cdp = await page.context().newCDPSession(page);
335
+ await cdp.send('Performance.enable');
336
+ // Style recalculations over a short window: a ticking ring animation
337
+ // recalculates every frame (about 30 in 500ms), an unrendered one does
338
+ // not. The idle allowance absorbs a stray one-off recalculation.
339
+ const recalcsOver = async (ms: number) => {
340
+ const count = async () => {
341
+ const {metrics} = await cdp.send('Performance.getMetrics');
342
+ return metrics.find(m => m.name === 'RecalcStyleCount')?.value ?? 0;
343
+ };
344
+ const before = await count();
345
+ await page.waitForTimeout(ms);
346
+ return (await count()) - before;
347
+ };
348
+ const spinnerState = async () =>
349
+ page.evaluate(() => {
350
+ const spinner = document
351
+ .querySelector('button[aria-label="Clear all"]')
352
+ ?.parentElement?.querySelector('.astryx-spinner');
353
+ if (!spinner) {
354
+ throw new Error('The lane holds no Spinner');
355
+ }
356
+ return {
357
+ hidden: spinner.getAttribute('aria-hidden') === 'true',
358
+ width: spinner.getBoundingClientRect().width,
359
+ };
360
+ });
361
+ const idle = await spinnerState();
362
+ expect(idle.hidden).toBe(true);
363
+ expect(await recalcsOver(500)).toBeLessThanOrEqual(2);
364
+ await search.focus();
365
+ await search.press('a');
366
+ await expect(search).toHaveAttribute('aria-busy', 'true');
367
+ const busy = await spinnerState();
368
+ expect(busy.hidden).toBe(false);
369
+ expect(await recalcsOver(500)).toBeGreaterThanOrEqual(10);
370
+ expect(busy.width).toBe(idle.width);
371
+ });
372
+
373
+ test('the idle Spinner paints nothing, even when a theme styles its box', async ({
374
+ page,
375
+ }) => {
376
+ await page.goto(
377
+ `${storybook.origin}/iframe.html?id=core-tokenizer--loading&viewMode=story&globals=direction:${direction};astryxTheme:probe`,
378
+ {waitUntil: 'load'},
379
+ );
380
+ await page
381
+ .locator('button[aria-label="Clear all"]')
382
+ .waitFor({state: 'attached'});
383
+ const search = page.getByRole('combobox');
384
+ const spinnerPaint = async () =>
385
+ page.evaluate(() => {
386
+ const spinner = document
387
+ .querySelector('button[aria-label="Clear all"]')
388
+ ?.parentElement?.querySelector('.astryx-spinner');
389
+ if (!spinner) {
390
+ throw new Error('The lane holds no Spinner');
391
+ }
392
+ const style = getComputedStyle(spinner);
393
+ return {
394
+ visible: spinner.checkVisibility({visibilityProperty: true}),
395
+ visibility: style.visibility,
396
+ background: style.backgroundColor,
397
+ };
398
+ });
399
+ const idle = await spinnerPaint();
400
+ // The probe theme paints the Spinner's own box, so a box that is only
401
+ // content-skipped would still show beside Clear all.
402
+ expect(idle.background).not.toBe('rgba(0, 0, 0, 0)');
403
+ expect(idle.visibility).toBe('hidden');
404
+ expect(idle.visible).toBe(false);
405
+ await search.focus();
406
+ await search.press('a');
407
+ await expect(search).toHaveAttribute('aria-busy', 'true');
408
+ expect((await spinnerPaint()).visible).toBe(true);
409
+ });
410
+
411
+ test('a themed, larger Spinner keeps its space too', async ({page}) => {
412
+ test.setTimeout(240_000);
413
+ await open(page, 'core-tokenizer--loading', direction);
414
+ // The public size var a theme sets for a named Spinner size.
415
+ await page.addStyleTag({
416
+ content: '.astryx-spinner { --spinner-diameter: 24px !important; }',
417
+ });
418
+ expect(await busyLayoutChanges(page, 8)).toEqual([]);
419
+ });
420
+
421
+ test('a search settling after blur does not change the collapsed row', async ({
422
+ page,
423
+ }) => {
424
+ test.setTimeout(240_000);
425
+ await open(
426
+ page,
427
+ 'core-tokenizer--loading',
428
+ direction,
429
+ 'tokenOverflowBehavior:unfocusedInline',
430
+ );
431
+ const search = page.getByRole('combobox');
432
+ await search.focus();
433
+ await addTokens(page, ['Bob Smith', 'Charlie Brown', 'Diana Prince']);
434
+ const changed: string[] = [];
435
+ for (let width = 200; width <= 400; width += 8) {
436
+ await search.focus();
437
+ await search.fill('');
438
+ await search.press('a');
439
+ await expect(search).toHaveAttribute('aria-busy', 'true');
440
+ await search.blur();
441
+ const busy = await collapsedRowAt(page, `${width}px`);
442
+ await expect(search).not.toHaveAttribute('aria-busy', 'true');
443
+ const settled = await collapsedRowAt(page, `${width}px`);
444
+ if (busy !== settled) {
445
+ changed.push(`${width}px: busy ${busy}, settled ${settled}`);
446
+ }
447
+ }
448
+ expect(changed).toEqual([]);
449
+ });
450
+
451
+ for (const mode of ['overflow-inline', 'overflow-layer']) {
452
+ test(`${mode}: blurred and focused tokens stay clear of the end lane`, async ({
453
+ page,
454
+ }) => {
455
+ await open(page, `core-powersearch--${mode}`, direction);
456
+ // Blurred: only the inline mode keeps its lane on the page.
457
+ if (mode === 'overflow-inline') {
458
+ expect(await sweep(page, 320, 640)).toEqual([]);
459
+ }
460
+ // The field's own corner, clear of every token: the click a person
461
+ // makes to start typing, which expands both modes.
462
+ await page
463
+ .locator('.astryx-tokenizer')
464
+ .first()
465
+ .click({position: {x: 1, y: 1}});
466
+ await expect(page.getByRole('combobox')).toBeFocused();
467
+ expect(await sweep(page, 320, 640)).toEqual([]);
468
+ });
469
+ }
470
+ });
471
+ }