@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.
- package/CHANGELOG.md +186 -0
- package/dist/BottomSheet/BottomSheet.d.ts +5 -4
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +1 -1
- package/dist/Calendar/styles.d.ts +6 -6
- package/dist/Chat/ChatComposerInput.d.ts +0 -11
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +0 -11
- package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
- package/dist/Chat/useTriggerMenu.js +1 -1
- package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
- package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
- package/dist/CommandPalette/CommandPaletteList.js +3 -2
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +4 -1
- package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
- package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
- package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +173 -11
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
- package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
- package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
- package/dist/DropdownMenu/menuRegionsContext.js +17 -0
- package/dist/Field/inputStyles.stylex.d.ts +3 -3
- package/dist/Layout/container.stylex.d.ts +18 -20
- package/dist/Layout/container.stylex.d.ts.map +1 -1
- package/dist/Layout/container.stylex.js +15 -25
- package/dist/Layout/padding.stylex.d.ts +231 -231
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +15 -6
- package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
- package/dist/Markdown/plugins/protocol.js +33 -8
- package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +21 -1
- package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
- package/dist/Stack/stack.stylex.d.ts +22 -22
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
- package/dist/Table/table.stylex.d.ts +6 -6
- package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
- package/dist/TimeInput/TouchTimeField.js +4 -1
- package/dist/Tokenizer/Tokenizer.d.ts +1 -12
- package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
- package/dist/Tokenizer/Tokenizer.js +1 -13
- package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +1 -9
- package/dist/Typeahead/Typeahead.d.ts +1 -12
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +1 -13
- package/dist/astryx.css +17 -12
- package/dist/hooks/useFocusTrap.d.ts +8 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +5 -1
- package/dist/hooks/useRenamedProp.d.ts +6 -6
- package/dist/hooks/useRenamedProp.js +6 -6
- package/dist/reset.a11y.chromium.spec.d.ts +2 -0
- package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
- package/dist/reset.a11y.chromium.spec.js +176 -0
- package/dist/theme/tokens.stylex.d.ts +30 -30
- package/dist/theme/tokens.stylex.d.ts.map +1 -1
- package/dist/theme/tokens.stylex.js +18 -15
- package/dist/theme/useTheme.d.ts +1 -1
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
- package/src/BottomSheet/BottomSheet.tsx +5 -4
- package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
- package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
- package/src/Chat/ChatComposerInput.doc.mjs +3 -3
- package/src/Chat/ChatComposerInput.test.tsx +7 -55
- package/src/Chat/ChatComposerInput.tsx +0 -36
- package/src/Chat/useTriggerMenu.tsx +1 -4
- package/src/CommandPalette/CommandPalette.spec.md +2 -0
- package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
- package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
- package/src/CommandPalette/CommandPaletteList.tsx +3 -2
- package/src/DateInput/TouchDateField.tsx +3 -1
- package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
- package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
- package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
- package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
- package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
- package/src/DropdownMenu/DropdownMenu.tsx +278 -49
- package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
- package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
- package/src/DropdownMenu/menuRegionsContext.ts +17 -0
- package/src/Layout/container.stylex.ts +11 -21
- package/src/Lightbox/Lightbox.test.tsx +58 -0
- package/src/Lightbox/Lightbox.tsx +12 -6
- package/src/Markdown/plugins/protocol.ts +45 -12
- package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
- package/src/PowerSearch/PowerSearch.tsx +28 -1
- package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
- package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
- package/src/TimeInput/TouchTimeField.tsx +3 -1
- package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
- package/src/Tokenizer/Tokenizer.test.tsx +12 -63
- package/src/Tokenizer/Tokenizer.tsx +1 -25
- package/src/Toolbar/Toolbar.test.tsx +29 -0
- package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
- package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +1 -21
- package/src/Typeahead/Typeahead.doc.mjs +0 -7
- package/src/Typeahead/Typeahead.test.tsx +24 -42
- package/src/Typeahead/Typeahead.tsx +1 -25
- package/src/hooks/useFocusTrap.ts +5 -1
- package/src/hooks/useRenamedProp.ts +6 -6
- package/src/reset.a11y.chromium.spec.ts +178 -0
- package/src/reset.css +8 -8
- package/src/reset.test.ts +112 -0
- package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
- package/src/theme/tokens.stylex.ts +18 -15
- package/src/theme/tokens.test.ts +1 -1
- package/src/theme/useTheme.test.tsx +4 -4
- 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 (
|
|
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(
|
|
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(
|
|
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
|
|
287
|
+
* and color it with the --color-text-secondary token, or its default.
|
|
288
288
|
*/
|
|
289
|
-
:where(input
|
|
289
|
+
:where(input, textarea)::placeholder {
|
|
290
290
|
opacity: 1;
|
|
291
|
-
color: var(--color-text-secondary, #
|
|
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
|
-
|
|
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': '
|
|
156
|
-
'--spacing-0-5': '
|
|
157
|
-
'--spacing-1': '
|
|
158
|
-
'--spacing-1-5': '
|
|
159
|
-
'--spacing-2': '
|
|
160
|
-
'--spacing-3': '
|
|
161
|
-
'--spacing-4': '
|
|
162
|
-
'--spacing-5': '
|
|
163
|
-
'--spacing-6': '
|
|
164
|
-
'--spacing-7': '
|
|
165
|
-
'--spacing-8': '
|
|
166
|
-
'--spacing-9': '
|
|
167
|
-
'--spacing-10': '
|
|
168
|
-
'--spacing-11': '
|
|
169
|
-
'--spacing-12': '
|
|
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);
|
package/src/theme/tokens.test.ts
CHANGED
|
@@ -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('
|
|
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('
|
|
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 '
|
|
82
|
-
expect(result.current.token('--spacing-1')).toBe('
|
|
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('
|
|
142
|
+
expect(tokens['--spacing-1']).toBe('0.25rem');
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
it('uses the same token resolution as resolveThemeTokens', () => {
|
package/src/theme/useTheme.ts
CHANGED
|
@@ -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'); // "
|
|
84
|
+
* const spacing = token('--spacing-4'); // "1rem"
|
|
85
85
|
* ```
|
|
86
86
|
*/
|
|
87
87
|
token: (name: string) => string;
|