@astryxdesign/core 0.7.0-canary.fe57f54 → 0.7.0

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +5 -4
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  7. package/dist/Calendar/styles.d.ts +6 -6
  8. package/dist/Chat/ChatComposerInput.d.ts +0 -11
  9. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposerInput.js +0 -11
  11. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  12. package/dist/Chat/useTriggerMenu.js +1 -1
  13. package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
  14. package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
  15. package/dist/CommandPalette/CommandPaletteList.js +3 -2
  16. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  17. package/dist/DateInput/TouchDateField.js +4 -1
  18. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  19. package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +173 -11
  23. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
  25. package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
  26. package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
  27. package/dist/DropdownMenu/menuRegionsContext.js +17 -0
  28. package/dist/Field/inputStyles.stylex.d.ts +3 -3
  29. package/dist/Layout/container.stylex.d.ts +18 -20
  30. package/dist/Layout/container.stylex.d.ts.map +1 -1
  31. package/dist/Layout/container.stylex.js +15 -25
  32. package/dist/Layout/padding.stylex.d.ts +231 -231
  33. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  34. package/dist/Lightbox/Lightbox.js +15 -6
  35. package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
  36. package/dist/Markdown/plugins/protocol.js +33 -8
  37. package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
  38. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  39. package/dist/PowerSearch/PowerSearch.js +21 -1
  40. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  41. package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
  42. package/dist/Stack/stack.stylex.d.ts +22 -22
  43. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  44. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
  45. package/dist/Table/table.stylex.d.ts +6 -6
  46. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  47. package/dist/TimeInput/TouchTimeField.js +4 -1
  48. package/dist/Tokenizer/Tokenizer.d.ts +1 -12
  49. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  50. package/dist/Tokenizer/Tokenizer.js +1 -13
  51. package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
  52. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  53. package/dist/Typeahead/BaseTypeahead.js +1 -9
  54. package/dist/Typeahead/Typeahead.d.ts +1 -12
  55. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/Typeahead.js +1 -13
  57. package/dist/astryx.css +17 -12
  58. package/dist/hooks/useFocusTrap.d.ts +8 -0
  59. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  60. package/dist/hooks/useFocusTrap.js +5 -1
  61. package/dist/hooks/useRenamedProp.d.ts +6 -6
  62. package/dist/hooks/useRenamedProp.js +6 -6
  63. package/dist/reset.a11y.chromium.spec.d.ts +2 -0
  64. package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
  65. package/dist/reset.a11y.chromium.spec.js +176 -0
  66. package/dist/theme/tokens.stylex.d.ts +30 -30
  67. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  68. package/dist/theme/tokens.stylex.js +18 -15
  69. package/dist/theme/useTheme.d.ts +1 -1
  70. package/package.json +4 -4
  71. package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
  72. package/src/BottomSheet/BottomSheet.tsx +5 -4
  73. package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
  74. package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
  75. package/src/Chat/ChatComposerInput.doc.mjs +3 -3
  76. package/src/Chat/ChatComposerInput.test.tsx +7 -55
  77. package/src/Chat/ChatComposerInput.tsx +0 -36
  78. package/src/Chat/useTriggerMenu.tsx +1 -4
  79. package/src/CommandPalette/CommandPalette.spec.md +2 -0
  80. package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
  81. package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
  82. package/src/CommandPalette/CommandPaletteList.tsx +3 -2
  83. package/src/DateInput/TouchDateField.tsx +3 -1
  84. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
  85. package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
  86. package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
  87. package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
  88. package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
  89. package/src/DropdownMenu/DropdownMenu.tsx +278 -49
  90. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
  91. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
  92. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
  93. package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
  94. package/src/DropdownMenu/menuRegionsContext.ts +17 -0
  95. package/src/Layout/container.stylex.ts +11 -21
  96. package/src/Lightbox/Lightbox.test.tsx +58 -0
  97. package/src/Lightbox/Lightbox.tsx +12 -6
  98. package/src/Markdown/plugins/protocol.ts +45 -12
  99. package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
  100. package/src/PowerSearch/PowerSearch.tsx +28 -1
  101. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
  102. package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
  103. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
  104. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
  105. package/src/TimeInput/TouchTimeField.tsx +3 -1
  106. package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
  107. package/src/Tokenizer/Tokenizer.test.tsx +12 -63
  108. package/src/Tokenizer/Tokenizer.tsx +1 -25
  109. package/src/Toolbar/Toolbar.test.tsx +29 -0
  110. package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
  111. package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
  112. package/src/Typeahead/BaseTypeahead.tsx +1 -21
  113. package/src/Typeahead/Typeahead.doc.mjs +0 -7
  114. package/src/Typeahead/Typeahead.test.tsx +24 -42
  115. package/src/Typeahead/Typeahead.tsx +1 -25
  116. package/src/hooks/useFocusTrap.ts +5 -1
  117. package/src/hooks/useRenamedProp.ts +6 -6
  118. package/src/reset.a11y.chromium.spec.ts +178 -0
  119. package/src/reset.css +8 -8
  120. package/src/reset.test.ts +112 -0
  121. package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
  122. package/src/theme/tokens.stylex.ts +18 -15
  123. package/src/theme/tokens.test.ts +1 -1
  124. package/src/theme/useTheme.test.tsx +4 -4
  125. package/src/theme/useTheme.ts +1 -1
@@ -0,0 +1,178 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file reset.a11y.chromium.spec.ts
5
+ * @input packages/core/src/reset.css, alone on a blank Chromium page
6
+ * @output Browser evidence that the reset reaches ::before, ::after,
7
+ * ::placeholder and the search field's cancel button
8
+ * @position reset.css integration proof. A pseudo-element inside :where() is
9
+ * invalid, and the forgiving list drops it without an error, so only a
10
+ * shipping engine shows which of the reset's rules still match.
11
+ */
12
+
13
+ import * as fs from 'node:fs';
14
+ import {fileURLToPath} from 'node:url';
15
+ import {expect, test, type Page} from '@playwright/test';
16
+
17
+ const RESET_CSS = fs.readFileSync(
18
+ fileURLToPath(new URL('./reset.css', import.meta.url)),
19
+ 'utf8',
20
+ );
21
+
22
+ // No Astryx CSS is loaded, so --color-text-secondary is undefined and a
23
+ // placeholder takes the reset's fallback: the token's own light-dark() default.
24
+ const PLACEHOLDER_FALLBACK = {
25
+ light: 'rgb(78, 96, 111)',
26
+ dark: 'rgb(170, 175, 181)',
27
+ };
28
+
29
+ /** Every style rule's selector, in source order, comments stripped. */
30
+ function sourceSelectors(css: string): string[] {
31
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '');
32
+ return [...stripped.matchAll(/([^{}]+)\{[^{}]*\}/g)].map(([, selector]) =>
33
+ selector.trim(),
34
+ );
35
+ }
36
+
37
+ /** Whitespace and quote style are the only differences serialization adds. */
38
+ function normalize(selector: string): string {
39
+ return selector.replace(/\s+/g, '').replace(/'/g, '"');
40
+ }
41
+
42
+ async function loadReset(page: Page) {
43
+ await page.setContent(`
44
+ <div id="probe"></div>
45
+ <input id="name" placeholder="Name">
46
+ <textarea id="notes" placeholder="Notes"></textarea>
47
+ <input id="query" type="search" value="query" style="width: 200px">
48
+ `);
49
+ await page.addStyleTag({content: RESET_CSS});
50
+ // Width 100 plus 10px padding a side: 100 wide under border-box, 120
51
+ // under content-box. The ::before border sets only its width.
52
+ await page.addStyleTag({
53
+ content: `
54
+ #probe { display: inline-block; }
55
+ #probe::before, #probe::after {
56
+ content: '';
57
+ display: block;
58
+ width: 100px;
59
+ height: 20px;
60
+ padding: 10px;
61
+ }
62
+ #probe::before { border-width: 5px; }
63
+ `,
64
+ });
65
+ }
66
+
67
+ test.beforeEach(async ({page}) => {
68
+ await loadReset(page);
69
+ });
70
+
71
+ test('Chromium keeps every selector the reset writes', async ({page}) => {
72
+ // A forgiving list drops an invalid entry instead of the whole rule, so a
73
+ // dropped pseudo-element shows up as a shorter serialized selector.
74
+ const parsed = await page.evaluate(() => {
75
+ const layer = document.styleSheets[0].cssRules[0] as CSSLayerBlockRule;
76
+ return [...layer.cssRules].map(rule => (rule as CSSStyleRule).selectorText);
77
+ });
78
+ expect(parsed.map(normalize)).toEqual(
79
+ sourceSelectors(RESET_CSS).map(normalize),
80
+ );
81
+ });
82
+
83
+ test('::before and ::after are border-box and take the border reset', async ({
84
+ page,
85
+ }) => {
86
+ const measured = await page.evaluate(() => {
87
+ const probe = document.getElementById('probe');
88
+ if (!probe) {
89
+ throw new Error('missing probe');
90
+ }
91
+ const before = getComputedStyle(probe, '::before');
92
+ const after = getComputedStyle(probe, '::after');
93
+ return {
94
+ beforeBoxSizing: before.boxSizing,
95
+ afterBoxSizing: after.boxSizing,
96
+ beforeBorderStyle: before.borderTopStyle,
97
+ beforeBorderWidth: before.borderTopWidth,
98
+ afterBorderWidth: after.borderTopWidth,
99
+ width: probe.getBoundingClientRect().width,
100
+ };
101
+ });
102
+ expect(measured).toEqual({
103
+ beforeBoxSizing: 'border-box',
104
+ afterBoxSizing: 'border-box',
105
+ beforeBorderStyle: 'solid',
106
+ beforeBorderWidth: '5px',
107
+ afterBorderWidth: '0px',
108
+ width: 100,
109
+ });
110
+ });
111
+
112
+ test('input and textarea placeholders take the reset color in both schemes', async ({
113
+ page,
114
+ }) => {
115
+ const readPlaceholders = async () =>
116
+ page.evaluate(() =>
117
+ ['name', 'notes'].map(id => {
118
+ const field = document.getElementById(id);
119
+ if (!field) {
120
+ throw new Error(`missing #${id}`);
121
+ }
122
+ const placeholder = getComputedStyle(field, '::placeholder');
123
+ return {color: placeholder.color, opacity: placeholder.opacity};
124
+ }),
125
+ );
126
+ for (const scheme of ['light', 'dark'] as const) {
127
+ await page.emulateMedia({colorScheme: scheme});
128
+ const color = PLACEHOLDER_FALLBACK[scheme];
129
+ expect(await readPlaceholders(), scheme).toEqual([
130
+ {color, opacity: '1'},
131
+ {color, opacity: '1'},
132
+ ]);
133
+ }
134
+ });
135
+
136
+ test('a search field has no cancel button to clear it', async ({page}) => {
137
+ // Chromium draws the cancel button at the inline end of a focused search
138
+ // field that has a value, and a click on it empties the field.
139
+ const query = page.locator('#query');
140
+ await query.focus();
141
+ const box = await query.boundingBox();
142
+ if (!box) {
143
+ throw new Error('missing search field box');
144
+ }
145
+ await page.mouse.click(box.x + box.width - 8, box.y + box.height / 2);
146
+ await expect(query).toHaveValue('query');
147
+ });
148
+
149
+ test('a later layer and an unlayered rule still override the reset', async ({
150
+ page,
151
+ }) => {
152
+ // Same 0,0,1 specificity as the reset's own selectors, so only the cascade
153
+ // layer decides.
154
+ await page.addStyleTag({
155
+ content: `
156
+ @layer reset, astryx-base;
157
+ @layer astryx-base {
158
+ :where(textarea)::placeholder { color: rgb(0, 128, 0); }
159
+ }
160
+ :where(#probe)::after { box-sizing: content-box; }
161
+ `,
162
+ });
163
+ const overridden = await page.evaluate(() => {
164
+ const probe = document.getElementById('probe');
165
+ const notes = document.getElementById('notes');
166
+ if (!probe || !notes) {
167
+ throw new Error('missing probe or textarea');
168
+ }
169
+ return {
170
+ placeholderColor: getComputedStyle(notes, '::placeholder').color,
171
+ afterBoxSizing: getComputedStyle(probe, '::after').boxSizing,
172
+ };
173
+ });
174
+ expect(overridden).toEqual({
175
+ placeholderColor: 'rgb(0, 128, 0)',
176
+ afterBoxSizing: 'content-box',
177
+ });
178
+ });
package/src/reset.css CHANGED
@@ -13,7 +13,7 @@
13
13
  * - Removes default margins, padding, and list styles
14
14
  * - Makes images block-level and responsive
15
15
  * - Resets form elements to inherit font styles
16
- * - Uses :where() for zero specificity (easy to override)
16
+ * - Uses :where() for zero specificity (pseudo-elements go outside it)
17
17
  *
18
18
  * Usage:
19
19
  *
@@ -46,7 +46,7 @@
46
46
  * Use border-box globally. Prevents padding/border from affecting
47
47
  * element dimensions — the single most impactful reset.
48
48
  */
49
- :where(*, *::before, *::after) {
49
+ :where(*), :where(*)::before, :where(*)::after {
50
50
  box-sizing: border-box;
51
51
  }
52
52
 
@@ -54,7 +54,7 @@
54
54
  * Reset borders to 0-width solid. This lets you add a border by just
55
55
  * setting border-width — no need to also specify border-style.
56
56
  */
57
- :where(*, *::before, *::after) {
57
+ :where(*), :where(*)::before, :where(*)::after {
58
58
  border-width: 0;
59
59
  border-style: solid;
60
60
  border-color: currentColor;
@@ -284,19 +284,19 @@
284
284
 
285
285
  /*
286
286
  * Normalize placeholder opacity (Firefox renders at reduced opacity)
287
- * and set a sensible default color using the Astryx text-placeholder token.
287
+ * and color it with the --color-text-secondary token, or its default.
288
288
  */
289
- :where(input::placeholder, textarea::placeholder) {
289
+ :where(input, textarea)::placeholder {
290
290
  opacity: 1;
291
- color: var(--color-text-secondary, #9ca3af);
291
+ color: var(--color-text-secondary, light-dark(#4E606F, #AAAFB5));
292
292
  }
293
293
 
294
294
  /*
295
295
  * Remove the inner padding and cancel buttons in search inputs
296
296
  * for Chrome and Safari.
297
297
  */
298
- :where([type='search']::-webkit-search-decoration,
299
- [type='search']::-webkit-search-cancel-button) {
298
+ :where([type='search'])::-webkit-search-decoration,
299
+ :where([type='search'])::-webkit-search-cancel-button {
300
300
  -webkit-appearance: none;
301
301
  }
302
302
 
package/src/reset.test.ts CHANGED
@@ -80,3 +80,115 @@ describe('reset.css accessibility invariants', () => {
80
80
  ).toBe(true);
81
81
  });
82
82
  });
83
+
84
+ /** The argument of every `:where()` and `:is()`, nested ones included. */
85
+ function forgivingListArguments(selector: string): string[] {
86
+ return [...selector.matchAll(/:(?:where|is)\(/g)].map(match => {
87
+ const start = match.index + match[0].length;
88
+ let depth = 1;
89
+ let end = start;
90
+ for (; end < selector.length && depth > 0; end++) {
91
+ if (selector[end] === '(') {
92
+ depth++;
93
+ } else if (selector[end] === ')') {
94
+ depth--;
95
+ }
96
+ }
97
+ return selector.slice(start, end - 1);
98
+ });
99
+ }
100
+
101
+ /** A selector list split on its top-level commas. */
102
+ function topLevelSelectors(list: string): string[] {
103
+ const selectors: string[] = [];
104
+ let depth = 0;
105
+ let start = 0;
106
+ for (let index = 0; index < list.length; index++) {
107
+ if (list[index] === '(') {
108
+ depth++;
109
+ } else if (list[index] === ')') {
110
+ depth--;
111
+ } else if (list[index] === ',' && depth === 0) {
112
+ selectors.push(list.slice(start, index).trim());
113
+ start = index + 1;
114
+ }
115
+ }
116
+ selectors.push(list.slice(start).trim());
117
+ return selectors;
118
+ }
119
+
120
+ /**
121
+ * A pseudo-element is not valid inside :where() or :is() (Selectors Level 4).
122
+ * Both take a forgiving selector list, so the browser drops that entry rather
123
+ * than the rule, and the declarations silently stop reaching the
124
+ * pseudo-element. It goes after the :where(), as in `:where(*)::before`.
125
+ * reset.a11y.chromium.spec.ts measures the result in Chromium.
126
+ */
127
+ describe('reset.css pseudo-element rules', () => {
128
+ let ruleBlocks: {selector: string; body: string}[];
129
+
130
+ beforeAll(() => {
131
+ const stripped = fs
132
+ .readFileSync(path.resolve(__dirname, './reset.css'), 'utf-8')
133
+ .replace(/\/\*[\s\S]*?\*\//g, '');
134
+ ruleBlocks = [...stripped.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
135
+ ([, selector, body]) => ({selector: selector.trim(), body}),
136
+ );
137
+ });
138
+
139
+ it('keeps every pseudo-element outside :where() and :is()', () => {
140
+ for (const {selector} of ruleBlocks) {
141
+ for (const argument of forgivingListArguments(selector)) {
142
+ expect
143
+ .soft(argument, `"${selector}" puts a pseudo-element in a list`)
144
+ .not.toMatch(/::|:(?:before|after|first-line|first-letter)\b/);
145
+ }
146
+ }
147
+ });
148
+
149
+ it('targets ::before, ::after, ::placeholder and the search field parts', () => {
150
+ const selectorsFor = (declaration: string) =>
151
+ ruleBlocks
152
+ .filter(({body}) => body.includes(declaration))
153
+ .flatMap(({selector}) => topLevelSelectors(selector));
154
+ const endsWith = (declaration: string, pseudoElement: string) =>
155
+ selectorsFor(declaration).some(selector =>
156
+ selector.endsWith(pseudoElement),
157
+ );
158
+
159
+ for (const declaration of [
160
+ 'box-sizing: border-box',
161
+ 'border-style: solid',
162
+ ]) {
163
+ expect(endsWith(declaration, '::before'), declaration).toBe(true);
164
+ expect(endsWith(declaration, '::after'), declaration).toBe(true);
165
+ }
166
+ expect(endsWith('opacity: 1', '::placeholder')).toBe(true);
167
+ expect(
168
+ endsWith('-webkit-appearance: none', '::-webkit-search-decoration'),
169
+ ).toBe(true);
170
+ expect(
171
+ endsWith('-webkit-appearance: none', '::-webkit-search-cancel-button'),
172
+ ).toBe(true);
173
+ });
174
+
175
+ it('falls back to the --color-text-secondary default for placeholders', () => {
176
+ // A page that loads only the reset never defines the token. The fallback
177
+ // repeats the token's own default, so that page shows the placeholder an
178
+ // Astryx page does, at the contrast the token was chosen for.
179
+ const tokens = fs.readFileSync(
180
+ path.resolve(__dirname, './theme/tokens.stylex.ts'),
181
+ 'utf-8',
182
+ );
183
+ const tokenDefault = tokens.match(
184
+ /'--color-text-secondary':\s*'([^']+)'/,
185
+ )?.[1];
186
+ expect(tokenDefault).toMatch(/^light-dark\(/);
187
+ const placeholder = ruleBlocks.find(({selector}) =>
188
+ selector.endsWith('::placeholder'),
189
+ );
190
+ expect(placeholder?.body).toContain(
191
+ `color: var(--color-text-secondary, ${tokenDefault});`,
192
+ );
193
+ });
194
+ });
@@ -0,0 +1,198 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file SpacingRootFontSize.a11y.chromium.spec.ts
5
+ * @input The checked-in Stack and TableRowExpansion stories, and Chromium
6
+ * @output Browser evidence that spacing follows the root font size as text does
7
+ * @position Theme token proof (WCAG 2.2 SC 1.4.4 Resize Text). A reader who
8
+ * raises the browser's font size gets larger text from the rem type scale;
9
+ * the gaps and padding between that text have to grow with it, and stay
10
+ * exactly as they were at the default 16px root.
11
+ */
12
+
13
+ import {expect, test, type Page} from '@playwright/test';
14
+ import {
15
+ DEFAULT_STORYBOOK_DIR,
16
+ serveStorybook,
17
+ type StaticServer,
18
+ } from '@astryxdesign/a11y-spec/storybook';
19
+
20
+ /** Each spacing token and the step it multiplies by the 4px base unit. */
21
+ const SPACING_STEPS: Readonly<Record<string, number>> = {
22
+ '--spacing-0': 0,
23
+ '--spacing-0-5': 0.5,
24
+ '--spacing-1': 1,
25
+ '--spacing-1-5': 1.5,
26
+ '--spacing-2': 2,
27
+ '--spacing-3': 3,
28
+ '--spacing-4': 4,
29
+ '--spacing-5': 5,
30
+ '--spacing-6': 6,
31
+ '--spacing-7': 7,
32
+ '--spacing-8': 8,
33
+ '--spacing-9': 9,
34
+ '--spacing-10': 10,
35
+ '--spacing-11': 11,
36
+ '--spacing-12': 12,
37
+ };
38
+
39
+ let storybook: StaticServer;
40
+
41
+ test.beforeAll(async () => {
42
+ storybook = await serveStorybook(
43
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
44
+ );
45
+ });
46
+
47
+ test.afterAll(async () => {
48
+ await storybook?.close();
49
+ });
50
+
51
+ async function openStory(
52
+ page: Page,
53
+ id: string,
54
+ rootFontSizePx: number,
55
+ globals = '',
56
+ ) {
57
+ const query = globals === '' ? '' : `&globals=${globals}`;
58
+ await page.goto(
59
+ `${storybook.origin}/iframe.html?id=${id}&viewMode=story${query}`,
60
+ {waitUntil: 'load'},
61
+ );
62
+ await page.locator('#storybook-root > *').first().waitFor();
63
+ // The browser's font-size setting changes the root font size; setting it
64
+ // on the root element is the same input, without restarting the browser.
65
+ await page.evaluate(size => {
66
+ document.documentElement.style.fontSize = `${size}px`;
67
+ }, rootFontSizePx);
68
+ }
69
+
70
+ /** Every spacing token's used length in px, read off a probe's width. */
71
+ async function spacingLengths(page: Page): Promise<Record<string, number>> {
72
+ return page.evaluate(names => {
73
+ const probe = document.createElement('div');
74
+ probe.style.position = 'absolute';
75
+ document.body.append(probe);
76
+ const lengths: Record<string, number> = {};
77
+ for (const name of names) {
78
+ probe.style.width = `var(${name})`;
79
+ lengths[name] = Number.parseFloat(getComputedStyle(probe).width);
80
+ }
81
+ probe.remove();
82
+ return lengths;
83
+ }, Object.keys(SPACING_STEPS));
84
+ }
85
+
86
+ /** The left edge of the first text node that reads exactly `text`. */
87
+ async function textLeft(page: Page, text: string): Promise<number> {
88
+ return page.evaluate(wanted => {
89
+ const walker = document.createTreeWalker(
90
+ document.body,
91
+ NodeFilter.SHOW_TEXT,
92
+ );
93
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
94
+ if (node.textContent?.trim() === wanted) {
95
+ const range = document.createRange();
96
+ range.selectNodeContents(node);
97
+ return range.getBoundingClientRect().left;
98
+ }
99
+ }
100
+ throw new Error(`no text node reads "${wanted}"`);
101
+ }, text);
102
+ }
103
+
104
+ for (const rootFontSizePx of [16, 20]) {
105
+ const scale = rootFontSizePx / 16;
106
+
107
+ test(`every spacing step is ${4 * scale}px × step at a ${rootFontSizePx}px root`, async ({
108
+ page,
109
+ }) => {
110
+ await openStory(page, 'core-stack--vertical', rootFontSizePx);
111
+ const lengths = await spacingLengths(page);
112
+ for (const [name, step] of Object.entries(SPACING_STEPS)) {
113
+ expect(lengths[name], name).toBeCloseTo(4 * scale * step, 3);
114
+ }
115
+ });
116
+
117
+ test(`a stack's gap grows with its text at a ${rootFontSizePx}px root`, async ({
118
+ page,
119
+ }) => {
120
+ await openStory(page, 'core-stack--vertical', rootFontSizePx);
121
+ const stack = page.getByText('Item 1', {exact: true}).locator('..');
122
+ const gap = await stack.evaluate(
123
+ element => getComputedStyle(element).rowGap,
124
+ );
125
+ // gap={4} is --spacing-4: 16px at the default root, 20px at 20px.
126
+ expect(gap).toBe(`${16 * scale}px`);
127
+ });
128
+ }
129
+
130
+ test('a detail panel keeps its place beside the first column at any root size', async ({
131
+ page,
132
+ }) => {
133
+ // The chevron column is a fixed pixel width, so the panel's indent spells
134
+ // those pixels while its padding half follows the spacing scale, as the
135
+ // cell's own padding does. A larger root font size must not slide the
136
+ // panel's content away from the first column's.
137
+ const offsets: number[] = [];
138
+ // 32px is text at 200%, where the column's own insets would outgrow it.
139
+ for (const rootFontSizePx of [16, 20, 32]) {
140
+ await openStory(
141
+ page,
142
+ 'core-tablerowexpansion--detail-panel',
143
+ rootFontSizePx,
144
+ );
145
+ await expect(page.getByText('Line items')).toBeVisible();
146
+ offsets.push(
147
+ (await textLeft(page, 'Line items')) -
148
+ (await textLeft(page, 'Ada Lovelace')),
149
+ );
150
+ }
151
+ expect(Math.abs(offsets[0])).toBeLessThanOrEqual(1);
152
+ for (const offset of offsets) {
153
+ expect(Math.abs(offset - offsets[0])).toBeLessThan(0.5);
154
+ }
155
+ });
156
+
157
+ test('the expansion chevron sits inside its column at any root size or spacing scale', async ({
158
+ page,
159
+ }) => {
160
+ // The chevron column is a fixed pixel width while the cell's insets follow
161
+ // the spacing scale, so the chevron centers itself in the column instead of
162
+ // laying out after those insets and running past the column's edge.
163
+ for (const [rootFontSizePx, globals] of [
164
+ [16, ''],
165
+ [20, ''],
166
+ [32, ''],
167
+ [16, 'astryxTheme:matcha'],
168
+ ] as const) {
169
+ await openStory(
170
+ page,
171
+ 'core-tablerowexpansion--detail-panel',
172
+ rootFontSizePx,
173
+ globals,
174
+ );
175
+ const chevron = page.locator('button[aria-expanded="true"]').first();
176
+ await expect(chevron).toBeVisible();
177
+ const {glyph, cell} = await chevron.evaluate(button => {
178
+ const box = (element: Element) => {
179
+ const rect = element.getBoundingClientRect();
180
+ return {left: rect.left, right: rect.right};
181
+ };
182
+ const svg = button.querySelector('svg');
183
+ const td = button.closest('td');
184
+ if (svg == null || td == null) {
185
+ throw new Error('missing chevron glyph or cell');
186
+ }
187
+ return {glyph: box(svg), cell: box(td)};
188
+ });
189
+ const where = `${rootFontSizePx}px root ${globals}`;
190
+ expect(cell.right - cell.left, where).toBeCloseTo(40, 0);
191
+ expect(glyph.left, where).toBeGreaterThanOrEqual(cell.left);
192
+ expect(glyph.right, where).toBeLessThanOrEqual(cell.right);
193
+ expect(
194
+ Math.abs((glyph.left + glyph.right) / 2 - (cell.left + cell.right) / 2),
195
+ where,
196
+ ).toBeLessThan(1);
197
+ }
198
+ });
@@ -151,22 +151,25 @@ export const colorVars = stylex.defineVars(colorDefaults);
151
151
  // Spacing Tokens
152
152
  // =============================================================================
153
153
 
154
+ // rem, like the type scale, so gaps and padding grow with the reader's root
155
+ // font size instead of tightening around larger text. At the default 16px
156
+ // root every step is still 4px × step: --spacing-2 is 8px, --spacing-4 16px.
154
157
  export const spacingDefaults = {
155
- '--spacing-0': '0px',
156
- '--spacing-0-5': '2px',
157
- '--spacing-1': '4px',
158
- '--spacing-1-5': '6px',
159
- '--spacing-2': '8px',
160
- '--spacing-3': '12px',
161
- '--spacing-4': '16px',
162
- '--spacing-5': '20px',
163
- '--spacing-6': '24px',
164
- '--spacing-7': '28px',
165
- '--spacing-8': '32px',
166
- '--spacing-9': '36px',
167
- '--spacing-10': '40px',
168
- '--spacing-11': '44px',
169
- '--spacing-12': '48px',
158
+ '--spacing-0': '0rem',
159
+ '--spacing-0-5': '0.125rem',
160
+ '--spacing-1': '0.25rem',
161
+ '--spacing-1-5': '0.375rem',
162
+ '--spacing-2': '0.5rem',
163
+ '--spacing-3': '0.75rem',
164
+ '--spacing-4': '1rem',
165
+ '--spacing-5': '1.25rem',
166
+ '--spacing-6': '1.5rem',
167
+ '--spacing-7': '1.75rem',
168
+ '--spacing-8': '2rem',
169
+ '--spacing-9': '2.25rem',
170
+ '--spacing-10': '2.5rem',
171
+ '--spacing-11': '2.75rem',
172
+ '--spacing-12': '3rem',
170
173
  } as const;
171
174
 
172
175
  export const spacingVars = stylex.defineVars(spacingDefaults);
@@ -39,7 +39,7 @@ describe('resolveThemeTokens', () => {
39
39
  it('resolves defaults when no theme is provided', () => {
40
40
  const tokens = resolveThemeTokens(null, {mode: 'light'});
41
41
  expect(tokens['--color-text-primary']).toBe('#0A1317');
42
- expect(tokens['--spacing-1']).toBe('4px');
42
+ expect(tokens['--spacing-1']).toBe('0.25rem');
43
43
  expect(tokens['--border-width']).toBe('1px');
44
44
  });
45
45
 
@@ -42,7 +42,7 @@ describe('useTheme', () => {
42
42
  expect(result.current.name).toBe('default');
43
43
  // Should resolve default light-dark() tokens for light mode (useMediaQuery mocked to false = light)
44
44
  expect(result.current.token('--color-text-primary')).toBe('#0A1317');
45
- expect(result.current.token('--spacing-1')).toBe('4px');
45
+ expect(result.current.token('--spacing-1')).toBe('0.25rem');
46
46
  expect(result.current.mode).toBe('light');
47
47
  });
48
48
 
@@ -78,8 +78,8 @@ describe('useTheme', () => {
78
78
  const {result} = renderHook(() => useTheme(), {
79
79
  wrapper: ({children}) => wrapper({children, mode: 'light'}),
80
80
  });
81
- // --spacing-1 is not overridden — should be the default '4px'
82
- expect(result.current.token('--spacing-1')).toBe('4px');
81
+ // --spacing-1 is not overridden — should be the default '0.25rem'
82
+ expect(result.current.token('--spacing-1')).toBe('0.25rem');
83
83
  expect(result.current.token('--border-width')).toBe('1px');
84
84
  });
85
85
 
@@ -139,7 +139,7 @@ describe('useTheme', () => {
139
139
  const tokens = result.current.tokens;
140
140
  expect(tokens['--color-accent']).toBe('#AA0000');
141
141
  expect(tokens['--spacing-4']).toBe('20px');
142
- expect(tokens['--spacing-1']).toBe('4px');
142
+ expect(tokens['--spacing-1']).toBe('0.25rem');
143
143
  });
144
144
 
145
145
  it('uses the same token resolution as resolveThemeTokens', () => {
@@ -81,7 +81,7 @@ export interface UseThemeReturn {
81
81
  * @example
82
82
  * ```
83
83
  * const accent = token('--color-accent'); // "#0064E0" in light mode
84
- * const spacing = token('--spacing-4'); // "16px"
84
+ * const spacing = token('--spacing-4'); // "1rem"
85
85
  * ```
86
86
  */
87
87
  token: (name: string) => string;