@astryxdesign/core 0.7.4 → 0.7.5-canary.1093026

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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"}
@@ -67,11 +67,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
67
67
  // because the input's reserve is derived from the same value.
68
68
  const END_LANE_INSET = spacingVars['--spacing-2'];
69
69
  const TOKEN_ROW_INSET = `calc(${spacingVars['--spacing-1']} - 1px)`;
70
- // InputClearButton grows 2px past its visible box under `(pointer: coarse)`.
71
- // Keep this maximum reserve in sync with Field/InputClearButton.tsx.
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.
70
+ // Kept in sync with useEndLaneReserve: wrapperWithTokens reads the custom
71
+ // property directly, to keep token rows and the input short of the lane.
75
72
  const END_LANE_RESERVE_VAR = '--_tokenizer-end-lane-reserve';
76
73
  const styles = {
77
74
  wrapper: {
@@ -85,12 +82,14 @@ const styles = {
85
82
  wrapperWithTokens: {
86
83
  k8WAf4: "x1x7cmac",
87
84
  kg3NbH: "xx2oswg",
85
+ kwRFfy: "xx0flbe",
88
86
  khm7nJ: "xajebq8",
89
87
  $$css: true
90
88
  },
91
89
  token: {
92
90
  k1xSpc: "x78zum5",
93
91
  kmuXW: "x2lah0s",
92
+ ks0D6T: "x193iq5w",
94
93
  kVAEAm: "x1n2onr6",
95
94
  kY2c9j: "x1vjfegm",
96
95
  $$css: true
@@ -105,6 +104,11 @@ const styles = {
105
104
  kY2c9j: "x1vjfegm",
106
105
  $$css: true
107
106
  },
107
+ idleSpinner: {
108
+ k33iCy: "xlshs6z",
109
+ kCygrm: "xdps06",
110
+ $$css: true
111
+ },
108
112
  inputAtMax: {
109
113
  kzqmXN: "xnalus7",
110
114
  k7Eaqz: "xeuugli",
@@ -115,16 +119,15 @@ const styles = {
115
119
  $$css: true
116
120
  },
117
121
  inputCompact: {
118
- kVAEAm: "x1n2onr6 x1utg46d",
122
+ kVAEAm: "x1n2onr6",
119
123
  kB7OPa: "x9f619",
120
- kLqNvP: "xhi6v0a x1cs7wtd",
121
124
  kY2c9j: "x1ja2u2z",
122
- k7Eaqz: "x1fns5xo xcax4zt",
123
- kUk6DE: "xfoc8du xf0xpto",
124
- kzqmXN: "xnalus7 xbuu7xz",
125
- kZCmMZ: "x2a0j69 xf0ofad",
126
- kwRFfy: "x1p6hqjl x1wg34a1",
127
- k9WMMc: "x1yc453h x75j1tr",
125
+ k7Eaqz: "x1fns5xo x14dcwvw",
126
+ kUk6DE: "xfoc8du x1q9bidn",
127
+ kzqmXN: "xnalus7",
128
+ keTefX: "x1lziwak x1rq9t4t",
129
+ kZCmMZ: "x2a0j69 x1jwxvv4",
130
+ kwRFfy: "xyri2b",
128
131
  $$css: true
129
132
  },
130
133
  truncatedWrapper: {
@@ -168,23 +171,6 @@ const endSectionSizeStyles = {
168
171
  $$css: true
169
172
  }
170
173
  };
171
- const compactInputSizeStyles = {
172
- sm: {
173
- k87sOh: "x80663w x1a21zuh",
174
- k3aq6I: "x1c071of xv6p7n0",
175
- $$css: true
176
- },
177
- md: {
178
- k87sOh: "x80663w x811pv0",
179
- k3aq6I: "x1c071of xv6p7n0",
180
- $$css: true
181
- },
182
- lg: {
183
- k87sOh: "x80663w x1o13q2w",
184
- k3aq6I: "x1c071of xv6p7n0",
185
- $$css: true
186
- }
187
- };
188
174
  const truncatedSizeStyles = {
189
175
  sm: {
190
176
  kZKoxP: "x6k0iem",
@@ -271,6 +257,13 @@ const CREATABLE_ID_PREFIX = '__xds_create__';
271
257
  *
272
258
  * Renders nothing when the lane would be empty, so `useEndLaneReserve` sees no
273
259
  * element, publishes no width, and the input keeps its full content box.
260
+ *
261
+ * With `hasBusySlot` the Spinner stays mounted while idle, unrendered, not
262
+ * animating and out of the accessibility tree, so its box keeps the themed
263
+ * Spinner's own size.
264
+ * Token rows reserve the lane's measured width, so a Spinner that came and
265
+ * went with the search would re-wrap them, and widen a content-sized field,
266
+ * on every search.
274
267
  */
275
268
  function EndLane({
276
269
  lane,
@@ -279,6 +272,7 @@ function EndLane({
279
272
  loadingLabel,
280
273
  anchorRef,
281
274
  hasStaticContent,
275
+ hasBusySlot,
282
276
  children
283
277
  }) {
284
278
  const isBusy = useIsBusy(lane);
@@ -286,17 +280,22 @@ function EndLane({
286
280
  // live region; the field announces each search's busy start instead
287
281
  // (spec:AST-086 FR8).
288
282
  useBusyAnnouncement(isBusy, loadingLabel, anchorRef);
289
- if (!isBusy && !hasStaticContent) {
283
+ if (!isBusy && !hasStaticContent && !hasBusySlot) {
290
284
  return null;
291
285
  }
292
286
  return /*#__PURE__*/_jsxs("div", {
293
287
  ref: laneRef,
294
288
  ...stylex.props(laneXStyle),
295
- children: [isBusy && /*#__PURE__*/_jsx(Spinner, {
289
+ children: [(isBusy || hasBusySlot) && /*#__PURE__*/_jsx(Spinner
290
+ // Remounts when a search starts, so the ring syncs its phase on
291
+ // mount exactly as a newly shown Spinner did before.
292
+ , {
296
293
  size: "sm",
297
294
  "aria-label": loadingLabel,
298
- role: "img"
299
- }), children]
295
+ role: "img",
296
+ "aria-hidden": isBusy ? undefined : true,
297
+ xstyle: isBusy ? undefined : styles.idleSpinner
298
+ }, isBusy ? 'busy' : 'idle'), children]
300
299
  });
301
300
  }
302
301
  export function Tokenizer({
@@ -633,7 +632,7 @@ export function Tokenizer({
633
632
  if (renderToken) {
634
633
  return /*#__PURE__*/_jsx("span", {
635
634
  ...{
636
- className: "x78zum5 x2lah0s x1n2onr6 x1vjfegm"
635
+ className: "x78zum5 x2lah0s x193iq5w x1n2onr6 x1vjfegm"
637
636
  },
638
637
  children: renderToken(item, onRemoveItem)
639
638
  }, item.id);
@@ -734,10 +733,10 @@ export function Tokenizer({
734
733
  onKeyDown: handleKeyDown,
735
734
  anchorRef: wrapperRef,
736
735
  size: size,
737
- inputXStyle: [isAtMax || isTruncated ? styles.inputAtMax : value.length > 0 ? [styles.inputCompact, compactInputSizeStyles[size]] : undefined,
738
- // The compact-token style owns the same reserve with an empty-query
739
- // exception. This standalone rule is only for an empty Tokenizer;
740
- // it still resolves to zero until the lane publishes a width.
736
+ inputXStyle: [isAtMax || isTruncated ? styles.inputAtMax : value.length > 0 ? styles.inputCompact : undefined,
737
+ // With tokens, wrapperWithTokens reserves the lane for every row.
738
+ // This rule is only for an empty Tokenizer; it still resolves to
739
+ // zero until the lane publishes a width.
741
740
  !(isAtMax || isTruncated || value.length > 0) && laneReserve]
742
741
  })
743
742
  })
@@ -755,7 +754,12 @@ export function Tokenizer({
755
754
  laneXStyle: [styles.endSection, endSectionSizeStyles[size]],
756
755
  loadingLabel: t('@astryx.typeahead.loading'),
757
756
  anchorRef: wrapperRef,
758
- hasStaticContent: hasStaticEndLane,
757
+ hasStaticContent: hasStaticEndLane
758
+ // Wherever a search can run beside tokens: enabled and below
759
+ // maxEntries. A collapsed overflow row keeps it too, since a search
760
+ // started while focused can settle after focus leaves.
761
+ ,
762
+ hasBusySlot: value.length > 0 && !isDisabled && !isAtMax,
759
763
  children: [endContent, hasClearButton && /*#__PURE__*/_jsx(InputClearButton, {
760
764
  label: t('@astryx.tokenizer.clearAll'),
761
765
  onClick: e => {
@@ -23,8 +23,9 @@ import * as stylex from '@stylexjs/stylex';
23
23
  *
24
24
  * Takes the lane's inset from the field's inline-end border, as the CSS
25
25
  * expression that positions it. Returns a ref callback for the lane, and the
26
- * ordinary input reserve style. Tokenizer's compact input reads the same custom
27
- * property directly so its empty-query hit surface can stop before the lane.
26
+ * ordinary input reserve style. With tokens, Tokenizer's field wrapper reads the
27
+ * same custom property directly so every token row, and the input after the
28
+ * last token, stops before the lane.
28
29
  * With no lane mounted the variable is absent and either rule resolves to its
29
30
  * no-lane fallback, so neither path needs to know whether a search is running.
30
31
  */
@@ -1 +1 @@
1
- {"version":3,"file":"useEndLaneReserve.d.ts","sourceRoot":"","sources":["../../src/Tokenizer/useEndLaneReserve.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AA+B3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,GAChB,CAAC,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,EAAE,MAAM,CAAC,YAAY,CAAC,CAuD3D"}
1
+ {"version":3,"file":"useEndLaneReserve.d.ts","sourceRoot":"","sources":["../../src/Tokenizer/useEndLaneReserve.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,MAAM,MAAM,kBAAkB,CAAC;AA+B3C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,GAChB,CAAC,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,EAAE,MAAM,CAAC,YAAY,CAAC,CAuD3D"}
@@ -81,8 +81,9 @@ const reserveStyles = {
81
81
  *
82
82
  * Takes the lane's inset from the field's inline-end border, as the CSS
83
83
  * expression that positions it. Returns a ref callback for the lane, and the
84
- * ordinary input reserve style. Tokenizer's compact input reads the same custom
85
- * property directly so its empty-query hit surface can stop before the lane.
84
+ * ordinary input reserve style. With tokens, Tokenizer's field wrapper reads the
85
+ * same custom property directly so every token row, and the input after the
86
+ * last token, stops before the lane.
86
87
  * With no lane mounted the variable is absent and either rule resolves to its
87
88
  * no-lane fallback, so neither path needs to know whether a search is running.
88
89
  */
package/dist/astryx.css CHANGED
@@ -650,7 +650,7 @@
650
650
  .x2uiyk:has(input:focus):not(#\#):not(#\#){border-color:var(--color-accent)}
651
651
  .xb0m1lw:has(> div > .astryx-layout-content):not(#\#):not(#\#){margin-inline:0}
652
652
  .x16s19ga:has(:focus-visible):not(#\#):not(#\#){outline:var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color)}
653
- .xf0xpto:placeholder-shown:not(#\#):not(#\#){flex:0 0 auto}
653
+ .x1q9bidn:placeholder-shown:not(#\#):not(#\#){flex:1 1 0}
654
654
  .x64lkcz:focus:not(#\#):not(#\#){border-color:var(--color-error)}
655
655
  .x1lglk5v:focus:not(#\#):not(#\#){border-color:var(--color-success)}
656
656
  .x12sck4v:focus:not(#\#):not(#\#){border-color:var(--color-warning)}
@@ -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))}
@@ -1491,6 +1493,7 @@
1491
1493
  .x9o1np9.x9o1np9:hover:where(:not(:disabled,[aria-disabled="true"])):not(#\#):not(#\#):not(#\#){color:var(--color-text-primary)}
1492
1494
  .x16khyan:is(:disabled,[aria-disabled="true"]):not(#\#):not(#\#):not(#\#){cursor:default}
1493
1495
  .x1y40yee:popover-open:not(#\#):not(#\#):not(#\#){display:flex}
1496
+ .x1rq9t4t:placeholder-shown:is(:not(:focus), :read-only):not(#\#):not(#\#):not(#\#){margin-inline-start:calc(-1 * var(--spacing-1))}
1494
1497
  .x17xqekc:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){mask-image:linear-gradient(to left,transparent 0%,rgba(0,0,0,.3) 2px,black var(--spacing-1))}
1495
1498
  .x1nwey30:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){mask-image:linear-gradient(to left,transparent var(--_tab-strip-bleed),black calc(var(--_tab-strip-bleed) + var(--spacing-8)))}
1496
1499
  .x8ulde1:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){mask-image:linear-gradient(to right,transparent 0%,rgba(0,0,0,.3) 2px,black var(--spacing-1))}
@@ -1513,6 +1516,7 @@
1513
1516
  .x1vidyx5:focus-visible:not(#\#):not(#\#):not(#\#){outline-width:var(--focus-outline-width)}
1514
1517
  .x1n6uwha:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){padding-inline-end:max(var(--spacing-4),env(safe-area-inset-left,0px))}
1515
1518
  .xtjjor6:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){padding-inline-end:var(--astryx-dialog-padding-inline-end,var(--astryx-dialog-padding-inline,var(--astryx-dialog-padding,max(var(--spacing-4),env(safe-area-inset-left,0px)))))}
1519
+ .x1jwxvv4:placeholder-shown:is(:not(:focus), :read-only):not(#\#):not(#\#):not(#\#){padding-inline-start:0}
1516
1520
  .x19efkpl:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){padding-inline-start:max(var(--spacing-4),env(safe-area-inset-right,0px))}
1517
1521
  .x1i7f2ot:is([dir="rtl"] *):not(#\#):not(#\#):not(#\#){padding-inline-start:var(--astryx-dialog-padding-inline-start,var(--astryx-dialog-padding-inline,var(--astryx-dialog-padding,max(var(--spacing-4),env(safe-area-inset-right,0px)))))}
1518
1522
  .xbt4iw:focus-within:not(#\#):not(#\#):not(#\#){pointer-events:auto}
@@ -1576,12 +1580,6 @@
1576
1580
  .x189zj9d:nth-child(even of :not([data-expansion-panel])):not(#\#):not(#\#):not(#\#){background-color:var(--color-background-muted)}
1577
1581
  .xjl7jj:empty:not(#\#):not(#\#):not(#\#){display:none}
1578
1582
  .x1e0d1ib:empty:not(#\#):not(#\#):not(#\#){padding-inline-start:0}
1579
- .x1cs7wtd:placeholder-shown:not(#\#):not(#\#):not(#\#){inset-inline-start:calc(var(--spacing-1) - 1px)}
1580
- .x1wg34a1:placeholder-shown:not(#\#):not(#\#):not(#\#){padding-inline-end:var(--spacing-1)}
1581
- .xf0ofad:placeholder-shown:not(#\#):not(#\#):not(#\#){padding-inline-start:0}
1582
- .x1utg46d:placeholder-shown:not(#\#):not(#\#):not(#\#){position:absolute}
1583
- .x75j1tr:placeholder-shown:not(#\#):not(#\#):not(#\#){text-align:end}
1584
- .xv6p7n0:placeholder-shown:not(#\#):not(#\#):not(#\#){transform:translateY(-50%)}
1585
1583
  .x1c52tdz:focus:not(#\#):not(#\#):not(#\#){background-color:var(--color-overlay-hover)}
1586
1584
  .x1rsz1da:focus:not(#\#):not(#\#):not(#\#){clip-path:none}
1587
1585
  .xodanix:focus:not(#\#):not(#\#):not(#\#){inset-inline-start:var(--spacing-2)}
@@ -1963,7 +1961,6 @@
1963
1961
  .xs7f9wi:not(#\#):not(#\#):not(#\#):not(#\#){top:2px}
1964
1962
  .xwa60dl:not(#\#):not(#\#):not(#\#):not(#\#){top:50%}
1965
1963
  .x1ltwjim:not(#\#):not(#\#):not(#\#):not(#\#){top:anchor(--outline-active top,0px)}
1966
- .x80663w:not(#\#):not(#\#):not(#\#):not(#\#){top:auto}
1967
1964
  .x7rbydg:not(#\#):not(#\#):not(#\#):not(#\#){top:calc(50% - (28px / 2))}
1968
1965
  .x1oysc8t:not(#\#):not(#\#):not(#\#):not(#\#){top:calc(var(--size-element-lg) / 2 - 1px)}
1969
1966
  .xkmsrut:not(#\#):not(#\#):not(#\#):not(#\#){top:calc(var(--size-element-md) / 2 - 1px)}
@@ -2056,11 +2053,7 @@
2056
2053
  .x1t1lzn6:last-child:not(#\#):not(#\#):not(#\#):not(#\#){border-bottom-width:0}
2057
2054
  .xlayyun:last-child:not(#\#):not(#\#):not(#\#):not(#\#){margin-bottom:calc(-1 * var(--container-padding-block-end,0px))}
2058
2055
  .xup0pd7:last-child:not(#\#):not(#\#):not(#\#):not(#\#){padding-bottom:0}
2059
- .xcax4zt:placeholder-shown:not(#\#):not(#\#):not(#\#):not(#\#){min-width:1px}
2060
- .x1o13q2w:placeholder-shown:not(#\#):not(#\#):not(#\#):not(#\#){top:calc(var(--size-element-lg) / 2 - 1px)}
2061
- .x811pv0:placeholder-shown:not(#\#):not(#\#):not(#\#):not(#\#){top:calc(var(--size-element-md) / 2 - 1px)}
2062
- .x1a21zuh:placeholder-shown:not(#\#):not(#\#):not(#\#):not(#\#){top:calc(var(--size-element-sm) / 2 - 1px)}
2063
- .xbuu7xz:placeholder-shown:not(#\#):not(#\#):not(#\#):not(#\#){width:calc(100% - calc(var(--spacing-1) - 1px) - var(--_tokenizer-end-lane-reserve,calc(var(--spacing-1) - 1px)) - 2px - 1px)}
2056
+ .x14dcwvw:placeholder-shown:not(#\#):not(#\#):not(#\#):not(#\#){min-width:0}
2064
2057
  .x15cytp8:focus:not(#\#):not(#\#):not(#\#):not(#\#){height:auto}
2065
2058
  .x1rw3289:focus:not(#\#):not(#\#):not(#\#):not(#\#){top:var(--spacing-2)}
2066
2059
  .x1jqxupm:focus:not(#\#):not(#\#):not(#\#):not(#\#){width:auto}
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.1093026",
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.1093026",
796
+ "@astryxdesign/cli": "0.7.5-canary.1093026",
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"
@@ -18,6 +18,7 @@ export const docs = {
18
18
  {
19
19
  name: 'label',
20
20
  type: 'ReactNode',
21
+ required: true,
21
22
  description:
22
23
  'Badge text content. A badge is one line, so a label wider than the space available is cut with an ellipsis rather than escaping its container; a string or number label is also set as the badge\'s title so the full text stays reachable on hover.',
23
24
  },
@@ -93,7 +94,7 @@ export const docsZh = {
93
94
  description: '视觉样式变体。语义变体使用实色背景,非语义颜色变体使用浅色背景配彩色文字。',
94
95
  default: "'neutral'",
95
96
  },
96
- {name: 'label', type: 'ReactNode', description: '徽章文本内容。'},
97
+ {name: 'label', type: 'ReactNode', required: true, description: '徽章文本内容。'},
97
98
  {name: 'icon', type: 'ReactNode', description: '可选的前置图标。'},
98
99
  ],
99
100
  theming: {
@@ -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 () => {
@@ -272,11 +272,8 @@ export interface TokenizerProps<T extends SearchableItem> extends Omit<
272
272
  // because the input's reserve is derived from the same value.
273
273
  const END_LANE_INSET = spacingVars['--spacing-2'];
274
274
  const TOKEN_ROW_INSET = `calc(${spacingVars['--spacing-1']} - 1px)`;
275
- // InputClearButton grows 2px past its visible box under `(pointer: coarse)`.
276
- // Keep this maximum reserve in sync with Field/InputClearButton.tsx.
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.
275
+ // Kept in sync with useEndLaneReserve: wrapperWithTokens reads the custom
276
+ // property directly, to keep token rows and the input short of the lane.
280
277
  const END_LANE_RESERVE_VAR = '--_tokenizer-end-lane-reserve';
281
278
 
282
279
  const styles = stylex.create({
@@ -297,6 +294,10 @@ const styles = stylex.create({
297
294
  // = 8 - 4 - 1 = 3px.
298
295
  paddingBlock: TOKEN_ROW_INSET,
299
296
  paddingInline: TOKEN_ROW_INSET,
297
+ // The end lane is out of flow, so tokens would wrap under it. Every row
298
+ // stops short of the measured lane instead; with no lane up the variable
299
+ // is absent and this is the ordinary inset.
300
+ paddingInlineEnd: `calc(${TOKEN_ROW_INSET} + var(${END_LANE_RESERVE_VAR}, 0px))`,
300
301
  // Row gap must match paddingBlock so wrapped rows look evenly spaced.
301
302
  rowGap: TOKEN_ROW_INSET,
302
303
  },
@@ -310,6 +311,9 @@ const styles = stylex.create({
310
311
  token: {
311
312
  display: 'flex',
312
313
  flexShrink: 0,
314
+ // A renderToken wrapper sizes to its content; cap it at the row so the
315
+ // pill inside truncates instead of running past the field.
316
+ maxWidth: '100%',
313
317
  position: 'relative',
314
318
  zIndex: 1,
315
319
  },
@@ -325,6 +329,14 @@ const styles = stylex.create({
325
329
  flexShrink: 0,
326
330
  zIndex: 1,
327
331
  },
332
+ idleSpinner: {
333
+ // Hides the Spinner's own box too, which a theme may paint, and covers
334
+ // engines without content-visibility. Both keep the box's size.
335
+ visibility: 'hidden',
336
+ // Skips the ring's rendering, and with it the infinite arc animation,
337
+ // while the root keeps its own explicit, themed width and height.
338
+ contentVisibility: 'hidden',
339
+ },
328
340
  inputAtMax: {
329
341
  width: 0,
330
342
  minWidth: 0,
@@ -334,46 +346,36 @@ const styles = stylex.create({
334
346
  position: 'absolute',
335
347
  },
336
348
  inputCompact: {
337
- position: {
338
- default: 'relative',
339
- ':placeholder-shown': 'absolute',
340
- },
349
+ position: 'relative',
341
350
  boxSizing: 'border-box',
342
- insetInlineStart: {
343
- default: 'auto',
344
- ':placeholder-shown': TOKEN_ROW_INSET,
345
- },
346
351
  zIndex: 0,
352
+ // The input follows the last token in flow, so a focused empty input's
353
+ // caret sits where typed text will start. Empty, it asks only for that
354
+ // inset, and only while focused and editable: blurred or read-only (a
355
+ // disabled field with a disabledMessage stays focusable), it claims no
356
+ // room at all, not even the column gap, so it never grows the field a
357
+ // blank row. Typing restores the full editing width, which may wrap.
347
358
  minWidth: {
348
359
  default: '40px',
349
- ':placeholder-shown': '1px',
360
+ ':placeholder-shown': 0,
350
361
  },
351
362
  flex: {
352
363
  default: '1 1 40px',
353
- ':placeholder-shown': '0 0 auto',
364
+ ':placeholder-shown': '1 1 0px',
354
365
  },
355
- width: {
366
+ width: 0,
367
+ marginInlineStart: {
356
368
  default: 0,
357
- ':placeholder-shown': `calc(100% - ${TOKEN_ROW_INSET} - var(${END_LANE_RESERVE_VAR}, ${TOKEN_ROW_INSET}) - ${INPUT_CLEAR_COARSE_POINTER_OUTSET} - 1px)`,
369
+ ':placeholder-shown:is(:not(:focus), :read-only)': `calc(-1 * ${spacingVars['--spacing-1']})`,
358
370
  },
359
- // The empty input is a first-row background hit surface. Taking it out of
360
- // flex layout means it can neither become a blank final row nor compete
361
- // with the pills for width. Logical insets stop it before the measured end
362
- // lane plus InputClearButton's maximum invisible pointer outset; tokens and
363
- // controls sit above it, leaving only genuine whitespace clickable as
364
- // search. Typing restores the ordinary in-flow input.
371
+ // Restore normal text inset when input follows tokens, since the
372
+ // wrapper padding is reduced for border concentricity.
365
373
  paddingInlineStart: {
366
374
  default: `calc(${spacingVars['--spacing-2']} - ${spacingVars['--spacing-1']} + 1px)`,
367
- ':placeholder-shown': 0,
368
- },
369
- paddingInlineEnd: {
370
- default: `var(${END_LANE_RESERVE_VAR}, 0px)`,
371
- ':placeholder-shown': spacingVars['--spacing-1'],
372
- },
373
- textAlign: {
374
- default: 'start',
375
- ':placeholder-shown': 'end',
375
+ ':placeholder-shown:is(:not(:focus), :read-only)': 0,
376
376
  },
377
+ // wrapperWithTokens already keeps the in-flow input short of the lane.
378
+ paddingInlineEnd: 0,
377
379
  },
378
380
  truncatedWrapper: {
379
381
  flexWrap: 'nowrap',
@@ -415,39 +417,6 @@ const endSectionSizeStyles = stylex.create({
415
417
  },
416
418
  });
417
419
 
418
- const compactInputSizeStyles = stylex.create({
419
- sm: {
420
- insetBlockStart: {
421
- default: 'auto',
422
- ':placeholder-shown': `calc(${sizeVars['--size-element-sm']} / 2 - 1px)`,
423
- },
424
- transform: {
425
- default: 'none',
426
- ':placeholder-shown': 'translateY(-50%)',
427
- },
428
- },
429
- md: {
430
- insetBlockStart: {
431
- default: 'auto',
432
- ':placeholder-shown': `calc(${sizeVars['--size-element-md']} / 2 - 1px)`,
433
- },
434
- transform: {
435
- default: 'none',
436
- ':placeholder-shown': 'translateY(-50%)',
437
- },
438
- },
439
- lg: {
440
- insetBlockStart: {
441
- default: 'auto',
442
- ':placeholder-shown': `calc(${sizeVars['--size-element-lg']} / 2 - 1px)`,
443
- },
444
- transform: {
445
- default: 'none',
446
- ':placeholder-shown': 'translateY(-50%)',
447
- },
448
- },
449
- });
450
-
451
420
  const truncatedSizeStyles = stylex.create({
452
421
  sm: {height: sizeVars['--size-element-sm']},
453
422
  md: {height: sizeVars['--size-element-md']},
@@ -517,6 +486,13 @@ const CREATABLE_ID_PREFIX = '__xds_create__';
517
486
  *
518
487
  * Renders nothing when the lane would be empty, so `useEndLaneReserve` sees no
519
488
  * element, publishes no width, and the input keeps its full content box.
489
+ *
490
+ * With `hasBusySlot` the Spinner stays mounted while idle, unrendered, not
491
+ * animating and out of the accessibility tree, so its box keeps the themed
492
+ * Spinner's own size.
493
+ * Token rows reserve the lane's measured width, so a Spinner that came and
494
+ * went with the search would re-wrap them, and widen a content-sized field,
495
+ * on every search.
520
496
  */
521
497
  function EndLane({
522
498
  lane,
@@ -525,6 +501,7 @@ function EndLane({
525
501
  loadingLabel,
526
502
  anchorRef,
527
503
  hasStaticContent,
504
+ hasBusySlot,
528
505
  children,
529
506
  }: {
530
507
  lane: BusyIndicatorLane;
@@ -533,6 +510,7 @@ function EndLane({
533
510
  loadingLabel: string;
534
511
  anchorRef: RefObject<Element | null>;
535
512
  hasStaticContent: boolean;
513
+ hasBusySlot: boolean;
536
514
  children: ReactNode;
537
515
  }) {
538
516
  const isBusy = useIsBusy(lane);
@@ -540,12 +518,23 @@ function EndLane({
540
518
  // live region; the field announces each search's busy start instead
541
519
  // (spec:AST-086 FR8).
542
520
  useBusyAnnouncement(isBusy, loadingLabel, anchorRef);
543
- if (!isBusy && !hasStaticContent) {
521
+ if (!isBusy && !hasStaticContent && !hasBusySlot) {
544
522
  return null;
545
523
  }
546
524
  return (
547
525
  <div ref={laneRef} {...stylex.props(laneXStyle)}>
548
- {isBusy && <Spinner size="sm" aria-label={loadingLabel} role="img" />}
526
+ {(isBusy || hasBusySlot) && (
527
+ <Spinner
528
+ // Remounts when a search starts, so the ring syncs its phase on
529
+ // mount exactly as a newly shown Spinner did before.
530
+ key={isBusy ? 'busy' : 'idle'}
531
+ size="sm"
532
+ aria-label={loadingLabel}
533
+ role="img"
534
+ aria-hidden={isBusy ? undefined : true}
535
+ xstyle={isBusy ? undefined : styles.idleSpinner}
536
+ />
537
+ )}
549
538
  {children}
550
539
  </div>
551
540
  );
@@ -1065,11 +1054,11 @@ export function Tokenizer<T extends SearchableItem>({
1065
1054
  isAtMax || isTruncated
1066
1055
  ? styles.inputAtMax
1067
1056
  : value.length > 0
1068
- ? [styles.inputCompact, compactInputSizeStyles[size]]
1057
+ ? styles.inputCompact
1069
1058
  : undefined,
1070
- // The compact-token style owns the same reserve with an empty-query
1071
- // exception. This standalone rule is only for an empty Tokenizer;
1072
- // it still resolves to zero until the lane publishes a width.
1059
+ // With tokens, wrapperWithTokens reserves the lane for every row.
1060
+ // This rule is only for an empty Tokenizer; it still resolves to
1061
+ // zero until the lane publishes a width.
1073
1062
  !(isAtMax || isTruncated || value.length > 0) && laneReserve,
1074
1063
  ]}
1075
1064
  />
@@ -1093,7 +1082,11 @@ export function Tokenizer<T extends SearchableItem>({
1093
1082
  laneXStyle={[styles.endSection, endSectionSizeStyles[size]]}
1094
1083
  loadingLabel={t('@astryx.typeahead.loading')}
1095
1084
  anchorRef={wrapperRef}
1096
- hasStaticContent={hasStaticEndLane}>
1085
+ hasStaticContent={hasStaticEndLane}
1086
+ // Wherever a search can run beside tokens: enabled and below
1087
+ // maxEntries. A collapsed overflow row keeps it too, since a search
1088
+ // started while focused can settle after focus leaves.
1089
+ hasBusySlot={value.length > 0 && !isDisabled && !isAtMax}>
1097
1090
  {endContent}
1098
1091
  {hasClearButton && (
1099
1092
  <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
+ }
@@ -81,8 +81,9 @@ const reserveStyles = stylex.create({
81
81
  *
82
82
  * Takes the lane's inset from the field's inline-end border, as the CSS
83
83
  * expression that positions it. Returns a ref callback for the lane, and the
84
- * ordinary input reserve style. Tokenizer's compact input reads the same custom
85
- * property directly so its empty-query hit surface can stop before the lane.
84
+ * ordinary input reserve style. With tokens, Tokenizer's field wrapper reads the
85
+ * same custom property directly so every token row, and the input after the
86
+ * last token, stops before the lane.
86
87
  * With no lane mounted the variable is absent and either rule resolves to its
87
88
  * no-lane fallback, so neither path needs to know whether a search is running.
88
89
  */