@astryxdesign/core 0.7.3-canary.f94e962 → 0.7.3

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 (133) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +8 -4
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  6. package/dist/Button/Button.d.ts.map +1 -1
  7. package/dist/Button/Button.js +15 -2
  8. package/dist/Calendar/Calendar.js +3 -2
  9. package/dist/Chat/ChatComposer.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposer.js +25 -2
  11. package/dist/Chat/ChatSystemMessage.d.ts +4 -0
  12. package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
  13. package/dist/Chat/ChatSystemMessage.js +4 -2
  14. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  15. package/dist/CodeBlock/CodeBlock.js +11 -1
  16. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  17. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  18. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  19. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  20. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  21. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  22. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  23. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  24. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  25. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  26. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  27. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  28. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  29. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  30. package/dist/Dialog/Dialog.d.ts.map +1 -1
  31. package/dist/Dialog/Dialog.js +9 -4
  32. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
  33. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  34. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
  35. package/dist/Markdown/Markdown.d.ts.map +1 -1
  36. package/dist/Markdown/Markdown.js +48 -15
  37. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  38. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  39. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  40. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  41. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  42. package/dist/MultiSelector/MultiSelector.js +25 -18
  43. package/dist/Pagination/Pagination.d.ts.map +1 -1
  44. package/dist/Pagination/Pagination.js +5 -2
  45. package/dist/Switch/Switch.d.ts.map +1 -1
  46. package/dist/Switch/Switch.js +13 -3
  47. package/dist/TimeInput/TimeInput.d.ts +5 -5
  48. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  49. package/dist/TimeInput/TimeInput.js +37 -20
  50. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  51. package/dist/TimeInput/TouchTimeField.js +17 -6
  52. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  53. package/dist/Timestamp/Timestamp.js +41 -8
  54. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  55. package/dist/TreeList/TreeListItem.js +8 -3
  56. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  57. package/dist/hooks/useGridFocus.js +10 -1
  58. package/package.json +4 -4
  59. package/scripts/agent-doc-state.mjs +24 -2
  60. package/src/BottomSheet/BottomSheet.spec.md +26 -53
  61. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  62. package/src/BottomSheet/BottomSheet.tsx +8 -4
  63. package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
  64. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  65. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  66. package/src/Button/Button.test.tsx +13 -2
  67. package/src/Button/Button.tsx +23 -2
  68. package/src/Button/__tests__/Button.a11y.states.ts +4 -3
  69. package/src/Calendar/Calendar.test.tsx +68 -24
  70. package/src/Calendar/Calendar.tsx +2 -1
  71. package/src/Carousel/Carousel.spec.md +146 -0
  72. package/src/Chat/ChatComposer.spec.md +26 -26
  73. package/src/Chat/ChatComposer.test.tsx +58 -2
  74. package/src/Chat/ChatComposer.tsx +25 -1
  75. package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
  76. package/src/Chat/ChatSystemMessage.test.tsx +25 -3
  77. package/src/Chat/ChatSystemMessage.tsx +6 -4
  78. package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
  79. package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
  80. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  81. package/src/CodeBlock/CodeBlock.tsx +11 -1
  82. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  83. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  84. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  85. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  86. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  87. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  88. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  89. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  90. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  91. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  92. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  93. package/src/Dialog/Dialog.test.tsx +39 -0
  94. package/src/Dialog/Dialog.tsx +9 -4
  95. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
  96. package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
  97. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
  98. package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
  99. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
  100. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
  101. package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
  102. package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
  103. package/src/FieldStatus/FieldStatus.spec.md +4 -4
  104. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
  105. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
  106. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
  107. package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
  108. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  109. package/src/Markdown/Markdown.tsx +89 -35
  110. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  111. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  112. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  113. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  114. package/src/MultiSelector/MultiSelector.tsx +53 -29
  115. package/src/Pagination/Pagination.test.tsx +5 -1
  116. package/src/Pagination/Pagination.tsx +3 -1
  117. package/src/Switch/Switch.test.tsx +56 -0
  118. package/src/Switch/Switch.tsx +13 -2
  119. package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
  120. package/src/Table/plugins/rowStatus/useTableRowStatus.spec.md +4 -3
  121. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  122. package/src/TimeInput/Presentation.test.tsx +23 -0
  123. package/src/TimeInput/TimeInput.doc.mjs +8 -7
  124. package/src/TimeInput/TimeInput.test.tsx +119 -7
  125. package/src/TimeInput/TimeInput.tsx +51 -23
  126. package/src/TimeInput/TouchTimeField.tsx +27 -5
  127. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  128. package/src/Timestamp/Timestamp.test.tsx +168 -0
  129. package/src/Timestamp/Timestamp.tsx +62 -11
  130. package/src/TreeList/TreeList.test.tsx +28 -0
  131. package/src/TreeList/TreeListItem.tsx +6 -2
  132. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  133. package/src/hooks/useGridFocus.ts +16 -1
@@ -0,0 +1,366 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenuItemAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head DropdownMenuItem story across states, themes, direction, and viewport widths
6
+ * @output Screenshots and fail-closed sensor receipts for the DropdownMenuItem component audit
7
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a DropdownMenuItem audit pull request
8
+ */
9
+
10
+ import {createHash} from 'node:crypto';
11
+ import {execFileSync} from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import {expect, test, type Page} from '@playwright/test';
15
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
+ import {
17
+ DEFAULT_STORYBOOK_DIR,
18
+ serveStorybook,
19
+ type StaticServer,
20
+ } from '@astryxdesign/a11y-spec/storybook';
21
+
22
+ const OUTPUT = path.resolve('dropdown-menu-item-audit-evidence');
23
+ const STORY_ID = 'core-dropdownmenuitem--item-states';
24
+ const WIDE = {width: 1024, height: 768};
25
+ const NARROW = {width: 320, height: 720};
26
+
27
+ type Mode = 'light' | 'dark';
28
+ type Direction = 'ltr' | 'rtl';
29
+ type Theme = 'neutral' | 'butter';
30
+ type Interaction = 'rest' | 'focus' | 'hover';
31
+ type AuditCase = {
32
+ key: string;
33
+ mode: Mode;
34
+ direction: Direction;
35
+ theme: Theme;
36
+ interaction: Interaction;
37
+ viewport: {width: number; height: number};
38
+ };
39
+
40
+ const cases: AuditCase[] = [
41
+ {
42
+ key: 'neutral-light',
43
+ mode: 'light',
44
+ direction: 'ltr',
45
+ theme: 'neutral',
46
+ interaction: 'rest',
47
+ viewport: WIDE,
48
+ },
49
+ {
50
+ key: 'neutral-dark',
51
+ mode: 'dark',
52
+ direction: 'ltr',
53
+ theme: 'neutral',
54
+ interaction: 'rest',
55
+ viewport: WIDE,
56
+ },
57
+ {
58
+ key: 'focus-light',
59
+ mode: 'light',
60
+ direction: 'ltr',
61
+ theme: 'neutral',
62
+ interaction: 'focus',
63
+ viewport: WIDE,
64
+ },
65
+ {
66
+ key: 'focus-dark',
67
+ mode: 'dark',
68
+ direction: 'ltr',
69
+ theme: 'neutral',
70
+ interaction: 'focus',
71
+ viewport: WIDE,
72
+ },
73
+ {
74
+ key: 'hover-light',
75
+ mode: 'light',
76
+ direction: 'ltr',
77
+ theme: 'neutral',
78
+ interaction: 'hover',
79
+ viewport: WIDE,
80
+ },
81
+ {
82
+ key: 'hover-dark',
83
+ mode: 'dark',
84
+ direction: 'ltr',
85
+ theme: 'neutral',
86
+ interaction: 'hover',
87
+ viewport: WIDE,
88
+ },
89
+ {
90
+ key: 'butter-light',
91
+ mode: 'light',
92
+ direction: 'ltr',
93
+ theme: 'butter',
94
+ interaction: 'rest',
95
+ viewport: WIDE,
96
+ },
97
+ {
98
+ key: 'butter-dark',
99
+ mode: 'dark',
100
+ direction: 'ltr',
101
+ theme: 'butter',
102
+ interaction: 'rest',
103
+ viewport: WIDE,
104
+ },
105
+ {
106
+ key: 'rtl-light',
107
+ mode: 'light',
108
+ direction: 'rtl',
109
+ theme: 'neutral',
110
+ interaction: 'rest',
111
+ viewport: WIDE,
112
+ },
113
+ {
114
+ key: 'rtl-dark',
115
+ mode: 'dark',
116
+ direction: 'rtl',
117
+ theme: 'neutral',
118
+ interaction: 'rest',
119
+ viewport: WIDE,
120
+ },
121
+ {
122
+ key: 'narrow-light',
123
+ mode: 'light',
124
+ direction: 'ltr',
125
+ theme: 'neutral',
126
+ interaction: 'rest',
127
+ viewport: NARROW,
128
+ },
129
+ {
130
+ key: 'narrow-dark',
131
+ mode: 'dark',
132
+ direction: 'ltr',
133
+ theme: 'neutral',
134
+ interaction: 'rest',
135
+ viewport: NARROW,
136
+ },
137
+ ];
138
+
139
+ test.describe.configure({mode: 'serial', retries: 0});
140
+ let server: StaticServer;
141
+ let checkoutSha: string;
142
+ let storybookSha: string;
143
+
144
+ function storyUrl(item: AuditCase) {
145
+ return `${server.origin}/iframe.html?id=${STORY_ID}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
146
+ }
147
+
148
+ async function settle(page: Page) {
149
+ await holdMotionStill(page);
150
+ await page.evaluate(async () => document.fonts.ready);
151
+ await page.waitForTimeout(40);
152
+ }
153
+
154
+ test.beforeAll(async () => {
155
+ fs.rmSync(OUTPUT, {recursive: true, force: true});
156
+ fs.mkdirSync(OUTPUT, {recursive: true});
157
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
158
+ encoding: 'utf8',
159
+ }).trim();
160
+ const expected = process.env.ASTRYX_HEAD_SHA;
161
+ if (expected && expected !== checkoutSha) {
162
+ throw new Error(
163
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
164
+ );
165
+ }
166
+ server = await serveStorybook(
167
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
168
+ );
169
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
170
+ if (!response.ok) {
171
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
172
+ }
173
+ storybookSha = (await response.text()).trim();
174
+ if (storybookSha !== checkoutSha) {
175
+ throw new Error(
176
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
177
+ );
178
+ }
179
+ });
180
+
181
+ test.afterAll(async () => {
182
+ await server?.close();
183
+ });
184
+
185
+ for (const item of cases) {
186
+ test(item.key, async ({browser}) => {
187
+ const context = await browser.newContext({
188
+ viewport: item.viewport,
189
+ colorScheme: item.mode,
190
+ reducedMotion: 'reduce',
191
+ });
192
+ const page = await context.newPage();
193
+ const pageErrors: string[] = [];
194
+ page.on('pageerror', error => pageErrors.push(String(error)));
195
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
196
+ await page.locator('#storybook-root').waitFor();
197
+
198
+ const menu = page.getByRole('menu').last();
199
+ await expect(menu).toBeVisible();
200
+ const rows = menu.getByRole('menuitem');
201
+ await expect(rows).toHaveCount(5);
202
+ const defaultRow = page.getByTestId('default-row');
203
+ const supportingRow = page.getByTestId('supporting-row');
204
+ const destructiveRow = page.getByTestId('destructive-row');
205
+ const disabledRow = page.getByTestId('disabled-row');
206
+ const linkRow = page.getByTestId('link-row');
207
+ await expect(disabledRow).toHaveAttribute('aria-disabled', 'true');
208
+ await expect(destructiveRow).toHaveAttribute('data-variant', 'destructive');
209
+ await expect(linkRow).toHaveAttribute('href', '/settings');
210
+ await expect(linkRow).toHaveAttribute('role', 'menuitem');
211
+ await expect(menu.locator('button, [role="button"]')).toHaveCount(0);
212
+
213
+ if (item.interaction === 'focus') {
214
+ await defaultRow.focus();
215
+ await expect(defaultRow).toBeFocused();
216
+ } else if (item.interaction === 'hover') {
217
+ await supportingRow.hover();
218
+ }
219
+ await settle(page);
220
+
221
+ const observed = await menu.evaluate(element => {
222
+ const currentRows = Array.from(
223
+ element.querySelectorAll<HTMLElement>('[role="menuitem"]'),
224
+ );
225
+ const describe = (row: HTMLElement | undefined) => {
226
+ if (!row) {
227
+ return null;
228
+ }
229
+ const style = getComputedStyle(row);
230
+ const box = row.getBoundingClientRect();
231
+ return {
232
+ tagName: row.tagName,
233
+ classTarget: row.classList.contains('astryx-dropdown-menu-item'),
234
+ role: row.getAttribute('role'),
235
+ disabled: row.getAttribute('aria-disabled'),
236
+ variant: row.getAttribute('data-variant'),
237
+ size: row.getAttribute('data-size'),
238
+ href: row.getAttribute('href'),
239
+ tabIndex: row.getAttribute('tabindex'),
240
+ backgroundColor: style.backgroundColor,
241
+ color: style.color,
242
+ opacity: style.opacity,
243
+ outlineStyle: style.outlineStyle,
244
+ outlineColor: style.outlineColor,
245
+ geometry: {x: box.x, y: box.y, width: box.width, height: box.height},
246
+ };
247
+ };
248
+ return {
249
+ theme: document
250
+ .querySelector('[data-astryx-theme]')
251
+ ?.getAttribute('data-astryx-theme'),
252
+ mode: document.documentElement.getAttribute('data-theme'),
253
+ direction: getComputedStyle(currentRows[0]).direction,
254
+ viewport: {width: innerWidth, height: innerHeight},
255
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
256
+ horizontalOverflow:
257
+ document.documentElement.scrollWidth > innerWidth + 1,
258
+ fonts: document.fonts.status,
259
+ activeText: document.activeElement?.textContent
260
+ ?.replace(/\s+/g, ' ')
261
+ .trim(),
262
+ rows: currentRows.map(describe),
263
+ };
264
+ });
265
+
266
+ const semanticPass =
267
+ observed.rows.length === 5 &&
268
+ observed.rows.every(row => row?.role === 'menuitem' && row.classTarget) &&
269
+ observed.rows[2]?.variant === 'destructive' &&
270
+ observed.rows[3]?.disabled === 'true' &&
271
+ observed.rows[4]?.tagName === 'A' &&
272
+ observed.rows[4]?.href === '/settings' &&
273
+ observed.rows.every(
274
+ row =>
275
+ row !== null && row.geometry.width > 0 && row.geometry.height > 0,
276
+ );
277
+ const paintPass =
278
+ observed.rows[2]?.color !== observed.rows[0]?.color &&
279
+ Number(observed.rows[3]?.opacity ?? 1) < 1;
280
+ const interactionPaintPass =
281
+ item.interaction === 'rest' ||
282
+ (item.interaction === 'focus' &&
283
+ observed.rows[0]?.backgroundColor !==
284
+ observed.rows[1]?.backgroundColor) ||
285
+ (item.interaction === 'hover' &&
286
+ observed.rows[1]?.backgroundColor !==
287
+ observed.rows[0]?.backgroundColor);
288
+ const interactionPass =
289
+ (item.interaction !== 'focus' ||
290
+ observed.activeText?.includes('Edit project')) &&
291
+ interactionPaintPass;
292
+
293
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
294
+ const image = `${item.key}.png`;
295
+ const receipt = {
296
+ schemaVersion: 1,
297
+ expected: {
298
+ build: checkoutSha,
299
+ storyId: STORY_ID,
300
+ mode: item.mode,
301
+ direction: item.direction,
302
+ theme: item.theme,
303
+ interaction: item.interaction,
304
+ viewport: item.viewport,
305
+ },
306
+ observed: {build: storybookSha, ...observed, pageErrors},
307
+ image: {
308
+ file: image,
309
+ sha256: createHash('sha256').update(png).digest('hex'),
310
+ width: png.readUInt32BE(16),
311
+ height: png.readUInt32BE(20),
312
+ },
313
+ checks: {
314
+ semanticPass,
315
+ paintPass,
316
+ interactionPaintPass,
317
+ interactionPass,
318
+ },
319
+ passed:
320
+ storybookSha === checkoutSha &&
321
+ pageErrors.length === 0 &&
322
+ observed.theme === item.theme &&
323
+ observed.mode === item.mode &&
324
+ observed.direction === item.direction &&
325
+ observed.viewport.width === item.viewport.width &&
326
+ observed.viewport.height === item.viewport.height &&
327
+ observed.reducedMotion &&
328
+ !observed.horizontalOverflow &&
329
+ observed.fonts === 'loaded' &&
330
+ semanticPass &&
331
+ paintPass &&
332
+ interactionPass,
333
+ };
334
+ fs.writeFileSync(path.join(OUTPUT, image), png);
335
+ fs.writeFileSync(
336
+ path.join(OUTPUT, `${item.key}.sensors.json`),
337
+ `${JSON.stringify(receipt, null, 2)}\n`,
338
+ );
339
+ expect(receipt.passed).toBe(true);
340
+ await context.close();
341
+ });
342
+ }
343
+
344
+ test('manifest is complete', () => {
345
+ const receipts = fs
346
+ .readdirSync(OUTPUT)
347
+ .filter(file => file.endsWith('.sensors.json'));
348
+ fs.writeFileSync(
349
+ path.join(OUTPUT, 'manifest.json'),
350
+ `${JSON.stringify(
351
+ {
352
+ schemaVersion: 1,
353
+ component: 'core/DropdownMenuItem',
354
+ build: checkoutSha,
355
+ frames: cases.map(item => ({
356
+ key: item.key,
357
+ image: `${item.key}.png`,
358
+ receipt: `${item.key}.sensors.json`,
359
+ })),
360
+ },
361
+ null,
362
+ 2,
363
+ )}\n`,
364
+ );
365
+ expect(receipts).toHaveLength(cases.length);
366
+ });
@@ -0,0 +1,327 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts
5
+ * @input Exact-head DropdownMenuRadioGroup story across themes, modes, direction, and viewport widths
6
+ * @output Screenshots and fail-closed sensor receipts for the DropdownMenuRadioGroup component audit
7
+ * @position Component-owned Night Watch browser evidence; CI runs it only for a DropdownMenuRadioGroup audit pull request
8
+ */
9
+
10
+ import {createHash} from 'node:crypto';
11
+ import {execFileSync} from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import {expect, test, type Page} from '@playwright/test';
15
+ import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium';
16
+ import {
17
+ DEFAULT_STORYBOOK_DIR,
18
+ serveStorybook,
19
+ type StaticServer,
20
+ } from '@astryxdesign/a11y-spec/storybook';
21
+
22
+ const OUTPUT = path.resolve('dropdown-menu-radio-group-audit-evidence');
23
+ const STORY_ID = 'core-dropdownmenuradiogroup--controlled-selection';
24
+ const WIDE = {width: 1024, height: 768};
25
+ const NARROW = {width: 320, height: 720};
26
+
27
+ type Mode = 'light' | 'dark';
28
+ type Direction = 'ltr' | 'rtl';
29
+ type Theme = 'neutral' | 'butter';
30
+ type AuditCase = {
31
+ key: string;
32
+ mode: Mode;
33
+ direction: Direction;
34
+ theme: Theme;
35
+ viewport: {width: number; height: number};
36
+ };
37
+
38
+ const cases: AuditCase[] = [
39
+ {
40
+ key: 'neutral-light',
41
+ mode: 'light',
42
+ direction: 'ltr',
43
+ theme: 'neutral',
44
+ viewport: WIDE,
45
+ },
46
+ {
47
+ key: 'neutral-dark',
48
+ mode: 'dark',
49
+ direction: 'ltr',
50
+ theme: 'neutral',
51
+ viewport: WIDE,
52
+ },
53
+ {
54
+ key: 'butter-light',
55
+ mode: 'light',
56
+ direction: 'ltr',
57
+ theme: 'butter',
58
+ viewport: WIDE,
59
+ },
60
+ {
61
+ key: 'butter-dark',
62
+ mode: 'dark',
63
+ direction: 'ltr',
64
+ theme: 'butter',
65
+ viewport: WIDE,
66
+ },
67
+ {
68
+ key: 'rtl-light',
69
+ mode: 'light',
70
+ direction: 'rtl',
71
+ theme: 'neutral',
72
+ viewport: WIDE,
73
+ },
74
+ {
75
+ key: 'rtl-dark',
76
+ mode: 'dark',
77
+ direction: 'rtl',
78
+ theme: 'neutral',
79
+ viewport: WIDE,
80
+ },
81
+ {
82
+ key: 'narrow-light',
83
+ mode: 'light',
84
+ direction: 'ltr',
85
+ theme: 'neutral',
86
+ viewport: NARROW,
87
+ },
88
+ {
89
+ key: 'narrow-dark',
90
+ mode: 'dark',
91
+ direction: 'ltr',
92
+ theme: 'neutral',
93
+ viewport: NARROW,
94
+ },
95
+ ];
96
+
97
+ test.describe.configure({mode: 'serial', retries: 0});
98
+ let server: StaticServer;
99
+ let checkoutSha: string;
100
+ let storybookSha: string;
101
+
102
+ function storyUrl(item: AuditCase) {
103
+ return `${server.origin}/iframe.html?id=${STORY_ID}&viewMode=story&globals=colorMode:${item.mode};astryxTheme:${item.theme};direction:${item.direction}`;
104
+ }
105
+
106
+ async function settle(page: Page) {
107
+ await holdMotionStill(page);
108
+ await page.evaluate(async () => document.fonts.ready);
109
+ await page.waitForTimeout(40);
110
+ }
111
+
112
+ test.beforeAll(async () => {
113
+ fs.rmSync(OUTPUT, {recursive: true, force: true});
114
+ fs.mkdirSync(OUTPUT, {recursive: true});
115
+ checkoutSha = execFileSync('git', ['rev-parse', 'HEAD'], {
116
+ encoding: 'utf8',
117
+ }).trim();
118
+ const expected = process.env.ASTRYX_HEAD_SHA;
119
+ if (expected && expected !== checkoutSha) {
120
+ throw new Error(
121
+ `checkout ${checkoutSha} does not match expected head ${expected}`,
122
+ );
123
+ }
124
+ server = await serveStorybook(
125
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
126
+ );
127
+ const response = await fetch(`${server.origin}/astryx-build-sha.txt`);
128
+ if (!response.ok) {
129
+ throw new Error(`Storybook build has no SHA stamp (${response.status})`);
130
+ }
131
+ storybookSha = (await response.text()).trim();
132
+ if (storybookSha !== checkoutSha) {
133
+ throw new Error(
134
+ `Storybook ${storybookSha} does not match checkout ${checkoutSha}`,
135
+ );
136
+ }
137
+ });
138
+
139
+ test.afterAll(async () => {
140
+ await server?.close();
141
+ });
142
+
143
+ for (const item of cases) {
144
+ test(item.key, async ({browser}) => {
145
+ const context = await browser.newContext({
146
+ viewport: item.viewport,
147
+ colorScheme: item.mode,
148
+ reducedMotion: 'reduce',
149
+ });
150
+ const page = await context.newPage();
151
+ const pageErrors: string[] = [];
152
+ page.on('pageerror', error => pageErrors.push(String(error)));
153
+ await page.goto(storyUrl(item), {waitUntil: 'load'});
154
+ await page.locator('#storybook-root').waitFor();
155
+
156
+ const fixture = page.getByTestId('dropdown-menu-radio-group-audit');
157
+ const menu = page.getByRole('menu').last();
158
+ const group = menu.getByRole('group', {name: 'Sort options'});
159
+ await expect(group).toBeVisible();
160
+ const radios = group.getByRole('menuitemradio');
161
+ await expect(radios).toHaveCount(3);
162
+ await expect(fixture).toHaveAttribute('data-close-request-count', '0');
163
+ await expect(radios.first()).toHaveAttribute('aria-checked', 'true');
164
+ await radios.nth(1).click();
165
+ await expect(radios.nth(1)).toHaveAttribute('aria-checked', 'true');
166
+ await expect(fixture).toHaveAttribute('data-close-request-count', '0');
167
+ await expect(menu).toBeVisible();
168
+ await settle(page);
169
+
170
+ const observed = await group.evaluate(element => {
171
+ const currentRadios = Array.from(
172
+ element.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
173
+ );
174
+ const groupStyle = getComputedStyle(element);
175
+ const groupBox = element.getBoundingClientRect();
176
+ const currentMenu = element.closest<HTMLElement>('[role="menu"]');
177
+ const rows = currentRadios.map(row => {
178
+ const box = row.getBoundingClientRect();
179
+ const style = getComputedStyle(row);
180
+ const indicator = row.querySelector<HTMLElement>(
181
+ '.astryx-dropdown-menu-radio',
182
+ );
183
+ const indicatorBox = indicator?.getBoundingClientRect() ?? null;
184
+ return {
185
+ role: row.getAttribute('role'),
186
+ checked: row.getAttribute('aria-checked'),
187
+ text: row.textContent?.replace(/\s+/g, ' ').trim() ?? null,
188
+ color: style.color,
189
+ geometry: {x: box.x, y: box.y, width: box.width, height: box.height},
190
+ indicator:
191
+ indicatorBox === null
192
+ ? null
193
+ : {width: indicatorBox.width, height: indicatorBox.height},
194
+ };
195
+ });
196
+ return {
197
+ theme: document
198
+ .querySelector('[data-astryx-theme]')
199
+ ?.getAttribute('data-astryx-theme'),
200
+ mode: document.documentElement.getAttribute('data-theme'),
201
+ direction: groupStyle.direction,
202
+ viewport: {width: innerWidth, height: innerHeight},
203
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
204
+ closeRequestCount:
205
+ element
206
+ .closest('[data-close-request-count]')
207
+ ?.getAttribute('data-close-request-count') ?? null,
208
+ horizontalOverflow:
209
+ document.documentElement.scrollWidth > innerWidth + 1,
210
+ fonts: document.fonts.status,
211
+ group: {
212
+ role: element.getAttribute('role'),
213
+ ariaLabel: element.getAttribute('aria-label'),
214
+ display: groupStyle.display,
215
+ flexDirection: groupStyle.flexDirection,
216
+ rowGap: groupStyle.rowGap,
217
+ menuRowGap:
218
+ currentMenu === null ? null : getComputedStyle(currentMenu).rowGap,
219
+ geometry: {
220
+ x: groupBox.x,
221
+ y: groupBox.y,
222
+ width: groupBox.width,
223
+ height: groupBox.height,
224
+ },
225
+ },
226
+ rows,
227
+ };
228
+ });
229
+
230
+ const semanticsPass =
231
+ observed.group.role === 'group' &&
232
+ observed.group.ariaLabel === 'Sort options' &&
233
+ observed.rows.length === 3 &&
234
+ observed.rows.every(row => row.role === 'menuitemradio') &&
235
+ observed.rows.filter(row => row.checked === 'true').length === 1 &&
236
+ observed.rows[1]?.checked === 'true' &&
237
+ observed.rows[1]?.text?.includes('Oldest first') === true &&
238
+ observed.closeRequestCount === '0';
239
+ const layoutPass =
240
+ observed.group.display === 'flex' &&
241
+ observed.group.flexDirection === 'column' &&
242
+ observed.group.menuRowGap !== null &&
243
+ observed.group.rowGap === observed.group.menuRowGap &&
244
+ Number.parseFloat(observed.group.rowGap) > 0 &&
245
+ observed.group.geometry.width > 0 &&
246
+ observed.group.geometry.height > 0 &&
247
+ observed.rows.every(
248
+ row => row.geometry.width > 0 && row.geometry.height > 0,
249
+ ) &&
250
+ (observed.rows[1]?.geometry.y ?? 0) >
251
+ (observed.rows[0]?.geometry.y ?? Number.POSITIVE_INFINITY);
252
+ const paintPass = observed.rows.every(
253
+ row =>
254
+ row.color !== 'rgba(0, 0, 0, 0)' &&
255
+ row.indicator !== null &&
256
+ row.indicator.width > 0 &&
257
+ row.indicator.height > 0,
258
+ );
259
+
260
+ const png = await page.screenshot({animations: 'disabled', fullPage: true});
261
+ const image = `${item.key}.png`;
262
+ const receipt = {
263
+ schemaVersion: 1,
264
+ expected: {
265
+ build: checkoutSha,
266
+ storyId: STORY_ID,
267
+ mode: item.mode,
268
+ direction: item.direction,
269
+ theme: item.theme,
270
+ viewport: item.viewport,
271
+ },
272
+ observed: {build: storybookSha, ...observed, pageErrors},
273
+ image: {
274
+ file: image,
275
+ sha256: createHash('sha256').update(png).digest('hex'),
276
+ width: png.readUInt32BE(16),
277
+ height: png.readUInt32BE(20),
278
+ },
279
+ checks: {semanticsPass, layoutPass, paintPass},
280
+ passed:
281
+ storybookSha === checkoutSha &&
282
+ pageErrors.length === 0 &&
283
+ observed.theme === item.theme &&
284
+ observed.mode === item.mode &&
285
+ observed.direction === item.direction &&
286
+ observed.viewport.width === item.viewport.width &&
287
+ observed.viewport.height === item.viewport.height &&
288
+ observed.reducedMotion &&
289
+ !observed.horizontalOverflow &&
290
+ observed.fonts === 'loaded' &&
291
+ semanticsPass &&
292
+ layoutPass &&
293
+ paintPass,
294
+ };
295
+ fs.writeFileSync(path.join(OUTPUT, image), png);
296
+ fs.writeFileSync(
297
+ path.join(OUTPUT, `${item.key}.sensors.json`),
298
+ `${JSON.stringify(receipt, null, 2)}\n`,
299
+ );
300
+ expect(receipt.passed).toBe(true);
301
+ await context.close();
302
+ });
303
+ }
304
+
305
+ test('manifest is complete', () => {
306
+ const receipts = fs
307
+ .readdirSync(OUTPUT)
308
+ .filter(file => file.endsWith('.sensors.json'));
309
+ fs.writeFileSync(
310
+ path.join(OUTPUT, 'manifest.json'),
311
+ `${JSON.stringify(
312
+ {
313
+ schemaVersion: 1,
314
+ component: 'core/DropdownMenuRadioGroup',
315
+ build: checkoutSha,
316
+ frames: cases.map(item => ({
317
+ key: item.key,
318
+ image: `${item.key}.png`,
319
+ receipt: `${item.key}.sensors.json`,
320
+ })),
321
+ },
322
+ null,
323
+ 2,
324
+ )}\n`,
325
+ );
326
+ expect(receipts).toHaveLength(cases.length);
327
+ });