@astryxdesign/core 0.7.2-canary.7ae39ea → 0.7.2-canary.7c0f850
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DropdownMenu/DropdownMenuGroup.d.ts +6 -4
- package/dist/DropdownMenu/DropdownMenuGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuGroup.js +4 -3
- package/package.json +3 -3
- package/src/DropdownMenu/DropdownMenu.test.tsx +3 -4
- package/src/DropdownMenu/DropdownMenuGroup.doc.mjs +169 -10
- package/src/DropdownMenu/DropdownMenuGroup.tsx +6 -4
- package/src/DropdownMenu/__tests__/DropdownMenuGroupAudit.a11y.chromium.spec.ts +334 -0
- package/src/Field/FieldLabel.doc.mjs +133 -26
- package/src/Field/FieldLabel.spec.md +172 -0
- package/src/Field/__tests__/FieldLabelAudit.a11y.chromium.spec.ts +296 -0
- package/src/SideNav/SideNav.spec.md +227 -0
- package/src/Toolbar/Toolbar.test.tsx +0 -8
- package/src/Toolbar/__tests__/Toolbar.a11y.chromium.spec.ts +163 -0
- package/src/Toolbar/__tests__/Toolbar.a11y.renders.tsx +63 -0
- package/src/Toolbar/__tests__/Toolbar.a11y.states.ts +142 -0
- package/src/Toolbar/__tests__/Toolbar.a11y.test.tsx +126 -0
|
@@ -23,7 +23,8 @@ import type { BaseProps } from '../BaseProps';
|
|
|
23
23
|
export interface DropdownMenuGroupProps extends Pick<BaseProps, 'xstyle' | 'className' | 'style'> {
|
|
24
24
|
/**
|
|
25
25
|
* Heading shown above the group's rows and used as the group's accessible
|
|
26
|
-
* name (`aria-labelledby`). Omit it for an unnamed group.
|
|
26
|
+
* name (`aria-labelledby`). Omit it for an unnamed group. Keep this content
|
|
27
|
+
* non-interactive; controls belong in DropdownMenu's `header` or `footer`.
|
|
27
28
|
*/
|
|
28
29
|
title?: ReactNode;
|
|
29
30
|
/** The rows of the group: menu items, selectable items, sub-menus. */
|
|
@@ -34,9 +35,10 @@ export interface DropdownMenuGroupProps extends Pick<BaseProps, 'xstyle' | 'clas
|
|
|
34
35
|
/**
|
|
35
36
|
* A titled group of menu rows.
|
|
36
37
|
*
|
|
37
|
-
* Renders `role="group"` named by its heading. The heading is
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Renders `role="group"` named by its heading. The heading is not a
|
|
39
|
+
* `menuitem`, so arrow-key navigation and typeahead skip it. Keep heading
|
|
40
|
+
* content non-interactive; place controls in DropdownMenu's `header` or
|
|
41
|
+
* `footer`. The compound peer of
|
|
40
42
|
* `{type: 'section', title, items}` in the `items` data API.
|
|
41
43
|
*
|
|
42
44
|
* @example
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DropdownMenuGroup.d.ts","sourceRoot":"","sources":["../../src/DropdownMenu/DropdownMenuGroup.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAQ,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAQ5C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAgB5C,MAAM,WAAW,sBAAuB,SAAQ,IAAI,CAClD,SAAS,EACT,QAAQ,GAAG,WAAW,GAAG,OAAO,CACjC;IACC
|
|
1
|
+
{"version":3,"file":"DropdownMenuGroup.d.ts","sourceRoot":"","sources":["../../src/DropdownMenu/DropdownMenuGroup.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAQ,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAQ5C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,cAAc,CAAC;AAgB5C,MAAM,WAAW,sBAAuB,SAAQ,IAAI,CAClD,SAAS,EACT,QAAQ,GAAG,WAAW,GAAG,OAAO,CACjC;IACC;;;;OAIG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,mDAAmD;IACnD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,QAAQ,EACR,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,GACJ,EAAE,sBAAsB,+BAuBxB;yBA9Be,iBAAiB"}
|
|
@@ -32,9 +32,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
32
32
|
/**
|
|
33
33
|
* A titled group of menu rows.
|
|
34
34
|
*
|
|
35
|
-
* Renders `role="group"` named by its heading. The heading is
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Renders `role="group"` named by its heading. The heading is not a
|
|
36
|
+
* `menuitem`, so arrow-key navigation and typeahead skip it. Keep heading
|
|
37
|
+
* content non-interactive; place controls in DropdownMenu's `header` or
|
|
38
|
+
* `footer`. The compound peer of
|
|
38
39
|
* `{type: 'section', title, items}` in the `items` data API.
|
|
39
40
|
*
|
|
40
41
|
* @example
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/core",
|
|
3
|
-
"version": "0.7.2-canary.
|
|
3
|
+
"version": "0.7.2-canary.7c0f850",
|
|
4
4
|
"displayName": "Astryx Core",
|
|
5
5
|
"description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -792,8 +792,8 @@
|
|
|
792
792
|
"react-dom": ">=19.0.0"
|
|
793
793
|
},
|
|
794
794
|
"devDependencies": {
|
|
795
|
-
"@astryxdesign/a11y-spec": "0.7.2-canary.
|
|
796
|
-
"@astryxdesign/cli": "0.7.2-canary.
|
|
795
|
+
"@astryxdesign/a11y-spec": "0.7.2-canary.7c0f850",
|
|
796
|
+
"@astryxdesign/cli": "0.7.2-canary.7c0f850",
|
|
797
797
|
"@babel/cli": "^7.29.7",
|
|
798
798
|
"@babel/core": "^7.29.7",
|
|
799
799
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -1567,10 +1567,9 @@ describe('DropdownMenuItem host attributes', () => {
|
|
|
1567
1567
|
|
|
1568
1568
|
describe('DropdownMenuGroup (compound mode)', () => {
|
|
1569
1569
|
it('a focusable node in the heading is not reachable by the arrow keys', async () => {
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
// pinned rather than discovered.
|
|
1570
|
+
// The public ReactNode type permits a focusable descendant, but the menu's
|
|
1571
|
+
// roving focus model cannot reach it. Pin that shipped gap while the public
|
|
1572
|
+
// title contract remains unsettled.
|
|
1574
1573
|
const user = userEvent.setup();
|
|
1575
1574
|
render(
|
|
1576
1575
|
<DropdownMenu button={{label: 'Actions'}}>
|
|
@@ -13,25 +13,86 @@ export const docs = {
|
|
|
13
13
|
defaults: {title: 'Version history'},
|
|
14
14
|
},
|
|
15
15
|
description:
|
|
16
|
-
'A
|
|
16
|
+
'A group of rows in a compound menu. A visible title names role="group" through aria-labelledby; an omitted title leaves the group unnamed. The heading is not a menuitem, so arrow keys and typeahead skip it. Keep title content non-interactive and place controls in DropdownMenu header or footer. The data-driven equivalent is the `{type: "section", title, items}` entry in `items`; both forms share the heading treatment and theme target.',
|
|
17
|
+
usage: {
|
|
18
|
+
description:
|
|
19
|
+
'Use DropdownMenuGroup to give related compound-menu rows one visible and programmatic name. Use an untitled group only when a separate name would not help people understand the rows.',
|
|
20
|
+
bestPractices: [
|
|
21
|
+
{
|
|
22
|
+
guidance: true,
|
|
23
|
+
description:
|
|
24
|
+
'Use short, descriptive title content that explains what the grouped rows have in common.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
guidance: true,
|
|
28
|
+
description:
|
|
29
|
+
'Keep the title non-interactive so every available action remains in the menu arrow-key order.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
guidance: false,
|
|
33
|
+
description:
|
|
34
|
+
'Place buttons, links, fields, or other controls in title. Put them in DropdownMenu header or footer instead.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
guidance: false,
|
|
38
|
+
description:
|
|
39
|
+
'Wrap rows that do not form a meaningful set; a group adds structure only when its relationship helps users.',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
anatomy: [
|
|
43
|
+
{
|
|
44
|
+
name: 'Group',
|
|
45
|
+
required: true,
|
|
46
|
+
description:
|
|
47
|
+
'The role="group" wrapper containing the heading and menu rows.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'Heading',
|
|
51
|
+
required: false,
|
|
52
|
+
description:
|
|
53
|
+
'Visible non-interactive title content that names the group and carries the dropdown-menu-section-heading theme target.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'Rows',
|
|
57
|
+
required: false,
|
|
58
|
+
description:
|
|
59
|
+
'Menu items, selectable items, radio groups, or submenus owned by DropdownMenu.',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
17
63
|
props: [
|
|
18
64
|
{
|
|
19
65
|
name: 'title',
|
|
20
66
|
type: 'ReactNode',
|
|
21
67
|
description:
|
|
22
|
-
"
|
|
68
|
+
"Non-interactive heading content shown above the rows and used as the group's accessible name. Omit for an unnamed group; place controls in DropdownMenu header or footer.",
|
|
23
69
|
},
|
|
24
70
|
{
|
|
25
71
|
name: 'children',
|
|
26
72
|
type: 'ReactNode',
|
|
27
73
|
description:
|
|
28
|
-
'The rows
|
|
74
|
+
'The grouped rows: DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, or DropdownMenuSubMenu.',
|
|
29
75
|
},
|
|
30
76
|
{
|
|
31
77
|
name: 'xstyle',
|
|
32
78
|
type: 'StyleXStyles',
|
|
33
79
|
description:
|
|
34
|
-
'StyleX styles applied to the group wrapper. Must be a stylex.create() value
|
|
80
|
+
'StyleX styles applied to the role="group" wrapper. Must be a stylex.create() value, not an inline style object.',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'className',
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'CSS class name applied to the role="group" wrapper.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'style',
|
|
89
|
+
type: 'React.CSSProperties',
|
|
90
|
+
description: 'Inline styles applied to the role="group" wrapper.',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'ref',
|
|
94
|
+
type: 'React.Ref<HTMLDivElement>',
|
|
95
|
+
description: 'Ref forwarded to the role="group" wrapper.',
|
|
35
96
|
},
|
|
36
97
|
],
|
|
37
98
|
theming: {
|
|
@@ -39,15 +100,113 @@ export const docs = {
|
|
|
39
100
|
},
|
|
40
101
|
};
|
|
41
102
|
|
|
42
|
-
|
|
103
|
+
/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
|
|
104
|
+
export const docsZh = {
|
|
43
105
|
name: 'DropdownMenuGroup',
|
|
44
|
-
|
|
106
|
+
subComponentOf: 'DropdownMenu',
|
|
45
107
|
displayName: 'Dropdown Menu Group',
|
|
108
|
+
isHiddenFromOverview: true,
|
|
109
|
+
description:
|
|
110
|
+
'复合菜单中的一组菜单行。可见标题通过 aria-labelledby 为 role="group" 命名;省略标题时该组没有名称。标题不是 menuitem,因此方向键和预输入会跳过它。标题内容应保持非交互;控件应放在 DropdownMenu 的 header 或 footer 中。',
|
|
111
|
+
usage: {
|
|
112
|
+
description:
|
|
113
|
+
'使用 DropdownMenuGroup 为相关的复合菜单行提供同一个可见名称和无障碍名称。只有当名称能帮助理解这些菜单行时才使用分组。',
|
|
114
|
+
bestPractices: [
|
|
115
|
+
{guidance: true, description: '使用简短且能说明菜单行关系的标题。'},
|
|
116
|
+
{
|
|
117
|
+
guidance: true,
|
|
118
|
+
description: '保持标题非交互,让所有操作都位于菜单的方向键顺序中。',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
guidance: false,
|
|
122
|
+
description:
|
|
123
|
+
'在标题中放置按钮、链接或输入控件;应改用 DropdownMenu 的 header 或 footer。',
|
|
124
|
+
},
|
|
125
|
+
{guidance: false, description: '对没有共同含义的菜单行进行分组。'},
|
|
126
|
+
],
|
|
127
|
+
anatomy: [
|
|
128
|
+
{
|
|
129
|
+
name: '组',
|
|
130
|
+
required: true,
|
|
131
|
+
description: '包含标题和菜单行的 role="group" 包装元素。',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: '标题',
|
|
135
|
+
required: false,
|
|
136
|
+
description: '用于命名分组的可见非交互标题内容。',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: '菜单行',
|
|
140
|
+
required: false,
|
|
141
|
+
description: '由 DropdownMenu 持有的菜单项、可选项、单选组或子菜单。',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
props: [
|
|
146
|
+
{
|
|
147
|
+
name: 'title',
|
|
148
|
+
type: 'ReactNode',
|
|
149
|
+
description:
|
|
150
|
+
'显示在菜单行上方并作为分组无障碍名称的非交互标题内容。省略时分组无名称;控件应放在 DropdownMenu 的 header 或 footer 中。',
|
|
151
|
+
},
|
|
152
|
+
{name: 'children', type: 'ReactNode', description: '该分组中的菜单行。'},
|
|
153
|
+
{
|
|
154
|
+
name: 'xstyle',
|
|
155
|
+
type: 'StyleXStyles',
|
|
156
|
+
description: '应用于 role="group" 包装元素的 StyleX 样式。',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'className',
|
|
160
|
+
type: 'string',
|
|
161
|
+
description: '应用于 role="group" 包装元素的 CSS 类名。',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'style',
|
|
165
|
+
type: 'React.CSSProperties',
|
|
166
|
+
description: '应用于 role="group" 包装元素的内联样式。',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'ref',
|
|
170
|
+
type: 'React.Ref<HTMLDivElement>',
|
|
171
|
+
description: '转发到 role="group" 包装元素的 ref。',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
theming: {
|
|
175
|
+
targets: [{className: 'astryx-dropdown-menu-section-heading'}],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
|
|
180
|
+
export const docsDense = {
|
|
46
181
|
description:
|
|
47
|
-
'
|
|
182
|
+
'named role="group" of compound-menu rows; compound peer of the data API\'s {type: "section"}',
|
|
183
|
+
usage: {
|
|
184
|
+
description:
|
|
185
|
+
'Use to give related compound-menu rows one visible and programmatic name.',
|
|
186
|
+
bestPractices: [
|
|
187
|
+
{
|
|
188
|
+
guidance: true,
|
|
189
|
+
description: "Short title that explains the rows' relationship.",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
guidance: true,
|
|
193
|
+
description:
|
|
194
|
+
'Non-interactive title keeps every action in menu arrow navigation.',
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
guidance: false,
|
|
198
|
+
description:
|
|
199
|
+
'Controls in title. Put them in DropdownMenu header or footer instead.',
|
|
200
|
+
},
|
|
201
|
+
{guidance: false, description: 'Group unrelated rows.'},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
48
204
|
propDescriptions: {
|
|
49
|
-
title: 'heading above the rows; names the group',
|
|
50
|
-
children: 'the
|
|
51
|
-
xstyle: 'StyleX styles
|
|
205
|
+
title: 'non-interactive heading above the rows; names the group',
|
|
206
|
+
children: 'the grouped menu rows',
|
|
207
|
+
xstyle: 'StyleX styles on the role="group" wrapper',
|
|
208
|
+
className: 'CSS class on the role="group" wrapper',
|
|
209
|
+
style: 'inline styles on the role="group" wrapper',
|
|
210
|
+
ref: 'ref forwarded to the role="group" wrapper',
|
|
52
211
|
},
|
|
53
212
|
};
|
|
@@ -53,7 +53,8 @@ export interface DropdownMenuGroupProps extends Pick<
|
|
|
53
53
|
> {
|
|
54
54
|
/**
|
|
55
55
|
* Heading shown above the group's rows and used as the group's accessible
|
|
56
|
-
* name (`aria-labelledby`). Omit it for an unnamed group.
|
|
56
|
+
* name (`aria-labelledby`). Omit it for an unnamed group. Keep this content
|
|
57
|
+
* non-interactive; controls belong in DropdownMenu's `header` or `footer`.
|
|
57
58
|
*/
|
|
58
59
|
title?: ReactNode;
|
|
59
60
|
/** The rows of the group: menu items, selectable items, sub-menus. */
|
|
@@ -65,9 +66,10 @@ export interface DropdownMenuGroupProps extends Pick<
|
|
|
65
66
|
/**
|
|
66
67
|
* A titled group of menu rows.
|
|
67
68
|
*
|
|
68
|
-
* Renders `role="group"` named by its heading. The heading is
|
|
69
|
-
*
|
|
70
|
-
*
|
|
69
|
+
* Renders `role="group"` named by its heading. The heading is not a
|
|
70
|
+
* `menuitem`, so arrow-key navigation and typeahead skip it. Keep heading
|
|
71
|
+
* content non-interactive; place controls in DropdownMenu's `header` or
|
|
72
|
+
* `footer`. The compound peer of
|
|
71
73
|
* `{type: 'section', title, items}` in the `items` data API.
|
|
72
74
|
*
|
|
73
75
|
* @example
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file DropdownMenuGroupAudit.a11y.chromium.spec.ts
|
|
5
|
+
* @input Exact-head DropdownMenuGroup story across themes, color modes, direction, and viewport widths
|
|
6
|
+
* @output Screenshots and fail-closed sensor receipts for the DropdownMenuGroup component audit
|
|
7
|
+
* @position Component-owned Night Watch browser evidence; CI runs it only for a DropdownMenuGroup 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-group-audit-evidence');
|
|
23
|
+
const STORY_ID = 'core-dropdownmenugroup--named-and-unnamed-groups';
|
|
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 menu = page.getByRole('menu').last();
|
|
157
|
+
await expect(menu).toBeVisible();
|
|
158
|
+
const groups = menu.getByRole('group');
|
|
159
|
+
await expect(groups).toHaveCount(2);
|
|
160
|
+
const namedGroup = menu.getByRole('group', {name: 'Version history'});
|
|
161
|
+
const unnamedGroup = groups.nth(1);
|
|
162
|
+
await expect(namedGroup).toBeVisible();
|
|
163
|
+
await expect(unnamedGroup).not.toHaveAttribute('aria-labelledby');
|
|
164
|
+
const heading = namedGroup.getByText('Version history', {exact: true});
|
|
165
|
+
await expect(heading).toBeVisible();
|
|
166
|
+
const headingId = await heading.getAttribute('id');
|
|
167
|
+
if (headingId == null) {
|
|
168
|
+
throw new Error('named group heading has no id');
|
|
169
|
+
}
|
|
170
|
+
await expect(namedGroup).toHaveAttribute('aria-labelledby', headingId);
|
|
171
|
+
await settle(page);
|
|
172
|
+
|
|
173
|
+
const menuItems = menu.getByRole('menuitem');
|
|
174
|
+
await expect(menuItems).toHaveCount(3);
|
|
175
|
+
await menu.focus();
|
|
176
|
+
await page.keyboard.press('ArrowDown');
|
|
177
|
+
await expect(menuItems.first()).toBeFocused();
|
|
178
|
+
await page.keyboard.press('ArrowDown');
|
|
179
|
+
await expect(menuItems.nth(1)).toBeFocused();
|
|
180
|
+
await page.keyboard.press('ArrowDown');
|
|
181
|
+
await expect(menuItems.last()).toBeFocused();
|
|
182
|
+
await page.keyboard.press('v');
|
|
183
|
+
await expect(menuItems.last()).toBeFocused();
|
|
184
|
+
await expect(namedGroup).not.toBeFocused();
|
|
185
|
+
await expect(heading).not.toBeFocused();
|
|
186
|
+
|
|
187
|
+
const observed = await namedGroup.evaluate(element => {
|
|
188
|
+
const headingId = element.getAttribute('aria-labelledby');
|
|
189
|
+
const currentHeading = headingId
|
|
190
|
+
? document.getElementById(headingId)
|
|
191
|
+
: null;
|
|
192
|
+
const groupBox = element.getBoundingClientRect();
|
|
193
|
+
const headingBox = currentHeading?.getBoundingClientRect() ?? null;
|
|
194
|
+
const groupStyle = getComputedStyle(element);
|
|
195
|
+
const headingStyle = currentHeading
|
|
196
|
+
? getComputedStyle(currentHeading)
|
|
197
|
+
: null;
|
|
198
|
+
const currentMenu = element.closest('[role="menu"]');
|
|
199
|
+
const currentGroups = currentMenu?.querySelectorAll('[role="group"]');
|
|
200
|
+
const currentUnnamed = currentGroups?.item(1) ?? null;
|
|
201
|
+
return {
|
|
202
|
+
theme: document
|
|
203
|
+
.querySelector('[data-astryx-theme]')
|
|
204
|
+
?.getAttribute('data-astryx-theme'),
|
|
205
|
+
mode: document.documentElement.getAttribute('data-theme'),
|
|
206
|
+
direction: groupStyle.direction,
|
|
207
|
+
viewport: {width: innerWidth, height: innerHeight},
|
|
208
|
+
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
209
|
+
horizontalOverflow:
|
|
210
|
+
document.documentElement.scrollWidth > innerWidth + 1,
|
|
211
|
+
fonts: document.fonts.status,
|
|
212
|
+
named: {
|
|
213
|
+
role: element.getAttribute('role'),
|
|
214
|
+
labelledBy: headingId,
|
|
215
|
+
accessibleText: currentHeading?.textContent?.trim() ?? null,
|
|
216
|
+
headingRole: currentHeading?.getAttribute('role') ?? null,
|
|
217
|
+
headingTabIndex: currentHeading?.getAttribute('tabindex') ?? null,
|
|
218
|
+
headingTarget:
|
|
219
|
+
currentHeading?.classList.contains(
|
|
220
|
+
'astryx-dropdown-menu-section-heading',
|
|
221
|
+
) ?? false,
|
|
222
|
+
},
|
|
223
|
+
unnamed: {
|
|
224
|
+
role: currentUnnamed?.getAttribute('role') ?? null,
|
|
225
|
+
labelledBy: currentUnnamed?.getAttribute('aria-labelledby') ?? null,
|
|
226
|
+
ariaLabel: currentUnnamed?.getAttribute('aria-label') ?? null,
|
|
227
|
+
},
|
|
228
|
+
geometry: {
|
|
229
|
+
group: {
|
|
230
|
+
x: groupBox.x,
|
|
231
|
+
y: groupBox.y,
|
|
232
|
+
width: groupBox.width,
|
|
233
|
+
height: groupBox.height,
|
|
234
|
+
},
|
|
235
|
+
heading:
|
|
236
|
+
headingBox === null
|
|
237
|
+
? null
|
|
238
|
+
: {
|
|
239
|
+
x: headingBox.x,
|
|
240
|
+
y: headingBox.y,
|
|
241
|
+
width: headingBox.width,
|
|
242
|
+
height: headingBox.height,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
headingPaint:
|
|
246
|
+
headingStyle === null
|
|
247
|
+
? null
|
|
248
|
+
: {
|
|
249
|
+
color: headingStyle.color,
|
|
250
|
+
fontFamily: headingStyle.fontFamily,
|
|
251
|
+
fontSize: headingStyle.fontSize,
|
|
252
|
+
lineHeight: headingStyle.lineHeight,
|
|
253
|
+
paddingInlineStart: headingStyle.paddingInlineStart,
|
|
254
|
+
paddingInlineEnd: headingStyle.paddingInlineEnd,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const semanticsPass =
|
|
260
|
+
observed.named.role === 'group' &&
|
|
261
|
+
observed.named.labelledBy !== null &&
|
|
262
|
+
observed.named.accessibleText === 'Version history' &&
|
|
263
|
+
observed.named.headingRole === null &&
|
|
264
|
+
observed.named.headingTabIndex === null &&
|
|
265
|
+
observed.named.headingTarget &&
|
|
266
|
+
observed.unnamed.role === 'group' &&
|
|
267
|
+
observed.unnamed.labelledBy === null &&
|
|
268
|
+
observed.unnamed.ariaLabel === null;
|
|
269
|
+
const geometryPass =
|
|
270
|
+
observed.geometry.group.width > 0 &&
|
|
271
|
+
observed.geometry.group.height > 0 &&
|
|
272
|
+
observed.geometry.heading !== null &&
|
|
273
|
+
observed.geometry.heading.width > 0 &&
|
|
274
|
+
observed.geometry.heading.height > 0;
|
|
275
|
+
const paintPass =
|
|
276
|
+
observed.headingPaint !== null &&
|
|
277
|
+
observed.headingPaint.color !== 'rgba(0, 0, 0, 0)' &&
|
|
278
|
+
observed.headingPaint.fontSize !== '0px' &&
|
|
279
|
+
observed.headingPaint.lineHeight !== '0px' &&
|
|
280
|
+
observed.headingPaint.paddingInlineStart ===
|
|
281
|
+
observed.headingPaint.paddingInlineEnd;
|
|
282
|
+
|
|
283
|
+
const png = await page.screenshot({animations: 'disabled', fullPage: true});
|
|
284
|
+
const image = `${item.key}.png`;
|
|
285
|
+
const receipt = {
|
|
286
|
+
schemaVersion: 1,
|
|
287
|
+
expected: {
|
|
288
|
+
build: checkoutSha,
|
|
289
|
+
storyId: STORY_ID,
|
|
290
|
+
mode: item.mode,
|
|
291
|
+
direction: item.direction,
|
|
292
|
+
theme: item.theme,
|
|
293
|
+
viewport: item.viewport,
|
|
294
|
+
},
|
|
295
|
+
observed: {
|
|
296
|
+
build: storybookSha,
|
|
297
|
+
...observed,
|
|
298
|
+
groupCount: await groups.count(),
|
|
299
|
+
menuItemCount: await menuItems.count(),
|
|
300
|
+
pageErrors,
|
|
301
|
+
},
|
|
302
|
+
image: {
|
|
303
|
+
file: image,
|
|
304
|
+
sha256: createHash('sha256').update(png).digest('hex'),
|
|
305
|
+
width: png.readUInt32BE(16),
|
|
306
|
+
height: png.readUInt32BE(20),
|
|
307
|
+
},
|
|
308
|
+
checks: {semanticsPass, geometryPass, paintPass},
|
|
309
|
+
passed:
|
|
310
|
+
storybookSha === checkoutSha &&
|
|
311
|
+
pageErrors.length === 0 &&
|
|
312
|
+
observed.theme === item.theme &&
|
|
313
|
+
observed.mode === item.mode &&
|
|
314
|
+
observed.direction === item.direction &&
|
|
315
|
+
observed.viewport.width === item.viewport.width &&
|
|
316
|
+
observed.viewport.height === item.viewport.height &&
|
|
317
|
+
observed.reducedMotion &&
|
|
318
|
+
!observed.horizontalOverflow &&
|
|
319
|
+
observed.fonts === 'loaded' &&
|
|
320
|
+
(await groups.count()) === 2 &&
|
|
321
|
+
(await menuItems.count()) === 3 &&
|
|
322
|
+
semanticsPass &&
|
|
323
|
+
geometryPass &&
|
|
324
|
+
paintPass,
|
|
325
|
+
};
|
|
326
|
+
fs.writeFileSync(path.join(OUTPUT, image), png);
|
|
327
|
+
fs.writeFileSync(
|
|
328
|
+
path.join(OUTPUT, `${item.key}.sensors.json`),
|
|
329
|
+
`${JSON.stringify(receipt, null, 2)}\n`,
|
|
330
|
+
);
|
|
331
|
+
expect(receipt.passed).toBe(true);
|
|
332
|
+
await context.close();
|
|
333
|
+
});
|
|
334
|
+
}
|