@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.
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +35 -9
- package/dist/astryx.css +2 -0
- package/package.json +4 -4
- package/src/Icon/Icon.spec.md +40 -29
- package/src/Tokenizer/Tokenizer.test.tsx +26 -8
- package/src/Tokenizer/Tokenizer.tsx +47 -6
- package/src/Tokenizer/__tests__/TokenizerEndLane.a11y.chromium.spec.ts +471 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Tokenizer.d.ts","sourceRoot":"","sources":["../../src/Tokenizer/Tokenizer.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,EAOZ,KAAK,SAAS,EAEf,MAAM,OAAO,CAAC;AASf,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,gBAAgB,CAAC;AAG9C,OAAO,EAGL,KAAK,WAAW,EAKhB,KAAK,kBAAkB,EACxB,MAAM,UAAU,CAAC;AAIlB,OAAO,EAAiB,KAAK,QAAQ,EAAC,MAAM,SAAS,CAAC;AAWtD,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAC,MAAM,oBAAoB,CAAC;AAMrE,YAAY,EACV,WAAW,IAAI,eAAe,EAC9B,eAAe,IAAI,mBAAmB,GACvC,MAAM,UAAU,CAAC;AAMlB;;GAEG;AACH,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,cAAc,IAChD;IAAC,IAAI,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAC,GACtB;IAAC,IAAI,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GACzB;IAAC,IAAI,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,GACzB;IAAC,IAAI,EAAE,SAAS,CAAA;CAAC,CAAC;AAEtB,MAAM,MAAM,aAAa,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE/C;;;;;GAKG;AACH,MAAM,MAAM,yBAAyB,GACnC,MAAM,GAAG,iBAAiB,GAAG,gBAAgB,CAAC;AAEhD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,iCAAiC;IACjC,KAAK,IAAI,IAAI,CAAC;IACd,gCAAgC;IAChC,IAAI,IAAI,IAAI,CAAC;CACd;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,cAAc,CAAE,SAAQ,IAAI,CACpE,mBAAmB,CAAC,gBAAgB,CAAC,EACrC,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,CAChD;IACC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,KAAK,CAAC,oBAAoB,CAAC,WAAW,CAAC,CAAC;IACpD,mCAAmC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,8CAA8C;IAC9C,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,mBAAmB;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,qCAAqC;IACrC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yBAAyB;IACzB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAC;IACnC;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IACjC;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,qBAAqB;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qCAAqC;IACrC,YAAY,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IAC9B,gCAAgC;IAChC,KAAK,EAAE,CAAC,EAAE,CAAC;IAEX;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,QAAQ,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;IAC3D,kEAAkE;IAClE,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,SAAS,CAAC;IACpC,iFAAiF;IACjF,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,IAAI,KAAK,SAAS,CAAC;IAC3D,gCAAgC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,sCAAsC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC;IAC5B,oDAAoD;IACpD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;;OASG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,0CAA0C;IAC1C,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gCAAgC;IAChC,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;;;;;OAMG;IACH,qBAAqB,CAAC,EAAE,yBAAyB,CAAC;IAClD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,6BAA6B;IAC7B,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,0DAA0D;IAC1D,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IACxC,sDAAsD;IACtD,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,KAAK,IAAI,CAAC;IACvC,2CAA2C;IAC3C,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAClC,oDAAoD;IACpD,SAAS,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;CACxC;
|
|
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:
|
|
74
|
-
// directly
|
|
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: "
|
|
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
|
-
|
|
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.
|
|
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"
|
package/src/Icon/Icon.spec.md
CHANGED
|
@@ -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
|
|
75
|
-
| ------------- | --------------------------------------------------------- |
|
|
76
|
-
| Glyph source | semantic key, namespaced extension key, or icon component | Selects the visual symbol.
|
|
77
|
-
| Size | `xsm`, `sm`, `md`, `lg` | Selects the icon box size.
|
|
78
|
-
| Color | documented semantic and palette values | Selects the glyph color or inherits it from context.
|
|
79
|
-
| Accessibility | decorative or meaningfully labelled | Controls whether assistive technology receives the glyph.
|
|
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
|
|
90
|
-
| --- |
|
|
91
|
-
| FR1 | Icon MUST present at most one glyph from the supplied semantic key, namespaced key, or icon component.
|
|
92
|
-
| FR2 | Every rendered glyph MUST carry the `icon` theming target with its resolved size and selected color reflected as target data.
|
|
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.
|
|
94
|
-
| FR4 | Supported SVG and styling escape hatches MUST retain their established merge and override behavior.
|
|
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`.
|
|
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
|
|
113
|
-
| -------------------------------- |
|
|
114
|
-
| Semantic key | One resolved glyph carries the `icon` target.
|
|
115
|
-
| Namespaced extension key | A resolved extension glyph carries the `icon` target.
|
|
116
|
-
| Supplied icon component | The supplied glyph carries the `icon` target.
|
|
117
|
-
| Standalone Icon without `size` | The resolved size is `md`.
|
|
118
|
-
| Owned icon slot without `size` | The nearest documented component default is used.
|
|
119
|
-
| Explicit Icon size in owned slot | The explicit Icon size wins over the contextual default.
|
|
120
|
-
| Unresolved namespaced key | No glyph is currently rendered.
|
|
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
|
|
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
|
|
187
|
-
| FR2, FR4 | `Icon.test.tsx` target/styling suites plus source inspection | Both source modes; size and color variants
|
|
188
|
-
| AR1, AR2, AR3 | `Icon.test.tsx` accessible-name suites | Decorative, labelled, explicit ARIA override
|
|
189
|
-
|
|
|
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.
|
|
1712
|
-
|
|
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:
|
|
279
|
-
// directly
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|