@astryxdesign/core 0.7.2-canary.22c592a → 0.7.2-canary.29fc5f9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.2-canary.22c592a",
3
+ "version": "0.7.2-canary.29fc5f9",
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.22c592a",
796
- "@astryxdesign/cli": "0.7.2-canary.22c592a",
795
+ "@astryxdesign/a11y-spec": "0.7.2-canary.29fc5f9",
796
+ "@astryxdesign/cli": "0.7.2-canary.29fc5f9",
797
797
  "@babel/cli": "^7.29.7",
798
798
  "@babel/core": "^7.29.7",
799
799
  "@babel/preset-react": "^7.29.7",
@@ -140,14 +140,6 @@ describe('Toolbar', () => {
140
140
  );
141
141
  });
142
142
 
143
- it('sets aria-orientation to vertical', () => {
144
- render(<Toolbar label="Actions" orientation="vertical" />);
145
- expect(screen.getByRole('toolbar')).toHaveAttribute(
146
- 'aria-orientation',
147
- 'vertical',
148
- );
149
- });
150
-
151
143
  it('reflects the size', () => {
152
144
  render(<Toolbar label="Actions" size="sm" />);
153
145
  const toolbar = screen.getByRole('toolbar');
@@ -0,0 +1,163 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Toolbar.a11y.chromium.spec.ts
5
+ * @input Built toolbar stories and TOOLBAR_PATTERN in real Chromium
6
+ * @output Browser evidence for every toolbar expectation across Toolbar's representative states, and a paint check that each state's controls sit on the axis and in the direction it declares
7
+ * @position Chromium lane of the Toolbar binding
8
+ */
9
+
10
+ import {
11
+ expect,
12
+ test,
13
+ type CDPSession,
14
+ type Locator,
15
+ type Page,
16
+ } from '@playwright/test';
17
+ import {
18
+ TOOLBAR_PATTERN,
19
+ blockingResults,
20
+ checkAccessibilitySpec,
21
+ formatFailures,
22
+ neverExercised,
23
+ summarize,
24
+ type BindingResult,
25
+ } from '@astryxdesign/a11y-spec';
26
+ import {
27
+ createChromiumHarness,
28
+ holdMotionStill,
29
+ } from '@astryxdesign/a11y-spec/chromium';
30
+ import {
31
+ DEFAULT_STORYBOOK_DIR,
32
+ serveStorybook,
33
+ type StaticServer,
34
+ } from '@astryxdesign/a11y-spec/storybook';
35
+ import {
36
+ TOOLBAR_BINDING_STATES,
37
+ type ToolbarBindingState,
38
+ } from './Toolbar.a11y.states';
39
+
40
+ let storybook: StaticServer;
41
+
42
+ test.beforeAll(async () => {
43
+ storybook = await serveStorybook(
44
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
45
+ );
46
+ });
47
+
48
+ test.afterAll(async () => {
49
+ await storybook?.close();
50
+ });
51
+
52
+ function storyUrl(storyId: string): string {
53
+ return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
54
+ }
55
+
56
+ async function mountState(
57
+ page: Page,
58
+ state: ToolbarBindingState,
59
+ ): Promise<{subject: Locator; related: Record<string, Locator>}> {
60
+ await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
61
+ await holdMotionStill(page);
62
+ const root = page.locator('#storybook-root');
63
+ const subject = root.getByRole('toolbar', {name: state.label, exact: true});
64
+ await expect(subject).toBeVisible();
65
+ const related: Record<string, Locator> = {
66
+ before: root.getByRole('button', {
67
+ name: 'Before the toolbar',
68
+ exact: true,
69
+ }),
70
+ after: root.getByRole('button', {name: 'After the toolbar', exact: true}),
71
+ };
72
+ for (const [relation, name] of Object.entries(state.controls)) {
73
+ related[relation] = subject.getByRole('button', {name, exact: true});
74
+ }
75
+ return {subject, related};
76
+ }
77
+
78
+ async function runState(
79
+ page: Page,
80
+ cdp: CDPSession,
81
+ state: ToolbarBindingState,
82
+ ): Promise<BindingResult> {
83
+ return checkAccessibilitySpec({
84
+ spec: TOOLBAR_PATTERN,
85
+ binding: 'Toolbar',
86
+ state: state.id,
87
+ facts: state.facts,
88
+ mount: async () => {
89
+ const {subject, related} = await mountState(page, state);
90
+ return createChromiumHarness({page, cdp, subject, related});
91
+ },
92
+ });
93
+ }
94
+
95
+ test('every toolbar expectation is exercised by a Toolbar state', async ({
96
+ page,
97
+ }) => {
98
+ const cdp = await page.context().newCDPSession(page);
99
+ const results: BindingResult[] = [];
100
+ for (const state of TOOLBAR_BINDING_STATES) {
101
+ results.push(await runState(page, cdp, state));
102
+ }
103
+ expect(neverExercised(results)).toEqual([]);
104
+ });
105
+
106
+ /**
107
+ * The contract reads `orientation` and `direction` as facts the binding
108
+ * declares. This checks the painted page agrees: the second control sits
109
+ * below the first in a vertical state, and after it in reading order — to
110
+ * its right, or its left under right-to-left — in a horizontal one.
111
+ */
112
+ for (const state of TOOLBAR_BINDING_STATES) {
113
+ test(`Toolbar [${state.id}] paints its controls on the declared axis and direction`, async ({
114
+ page,
115
+ }) => {
116
+ const {related} = await mountState(page, state);
117
+ const first = await related['first-control'].boundingBox();
118
+ const second = await related['second-control'].boundingBox();
119
+ if (first == null || second == null) {
120
+ throw new Error(`state "${state.id}" has an unpainted control`);
121
+ }
122
+ const firstMiddle = {
123
+ x: first.x + first.width / 2,
124
+ y: first.y + first.height / 2,
125
+ };
126
+ const secondMiddle = {
127
+ x: second.x + second.width / 2,
128
+ y: second.y + second.height / 2,
129
+ };
130
+ if (state.facts.orientation === 'vertical') {
131
+ expect(second.y, 'second control below the first').toBeGreaterThanOrEqual(
132
+ first.y + first.height,
133
+ );
134
+ } else {
135
+ expect(
136
+ Math.abs(secondMiddle.y - firstMiddle.y),
137
+ 'controls share a row',
138
+ ).toBeLessThan(first.height / 2);
139
+ if (state.facts.direction === 'rtl') {
140
+ expect(secondMiddle.x, 'second control left of the first').toBeLessThan(
141
+ firstMiddle.x,
142
+ );
143
+ } else {
144
+ expect(
145
+ secondMiddle.x,
146
+ 'second control right of the first',
147
+ ).toBeGreaterThan(firstMiddle.x);
148
+ }
149
+ }
150
+ });
151
+ }
152
+
153
+ for (const state of TOOLBAR_BINDING_STATES) {
154
+ test(`Toolbar [${state.id}] — ${state.summary}`, async ({page}) => {
155
+ const cdp = await page.context().newCDPSession(page);
156
+ const result = await runState(page, cdp, state);
157
+ const report = summarize(TOOLBAR_PATTERN, [result]);
158
+ // Required rows block (AST-020 FR9).
159
+ expect(formatFailures(blockingResults([result]))).toBe('');
160
+ expect(report.unrunLayers).toEqual([]);
161
+ expect(report.counts.unexpectedPass).toBe(0);
162
+ });
163
+ }
@@ -0,0 +1,63 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file Toolbar.a11y.renders.tsx
7
+ * @input One toolbar binding state from Toolbar.a11y.states.ts
8
+ * @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook): a page button before the Toolbar, the Toolbar, and a page button after it
9
+ * @position Shared fixture for the Toolbar binding
10
+ */
11
+
12
+ import type {ReactElement} from 'react';
13
+ import {Button} from '../../Button/Button';
14
+ import {VStack} from '../../VStack/VStack';
15
+ import {Toolbar} from '../Toolbar';
16
+ import type {ToolbarBindingState, ToolbarControl} from './Toolbar.a11y.states';
17
+
18
+ /**
19
+ * One slot's controls. A slot lays its children out in a row, so a vertical
20
+ * toolbar's caller stacks them, as a product toolbar would, to arrange the
21
+ * controls on the axis the state declares.
22
+ */
23
+ function controls(
24
+ list: ReadonlyArray<ToolbarControl>,
25
+ orientation: ToolbarBindingState['facts']['orientation'],
26
+ ): ReactElement | undefined {
27
+ if (list.length === 0) {
28
+ return undefined;
29
+ }
30
+ const buttons = list.map(control => (
31
+ <Button
32
+ key={control.label}
33
+ label={control.label}
34
+ variant="ghost"
35
+ isDisabled={control.isDisabled}
36
+ />
37
+ ));
38
+ return orientation === 'vertical' ? (
39
+ <VStack gap={1}>{buttons}</VStack>
40
+ ) : (
41
+ <>{buttons}</>
42
+ );
43
+ }
44
+
45
+ export function renderToolbarState(
46
+ state: Pick<
47
+ ToolbarBindingState,
48
+ 'label' | 'startControls' | 'endControls' | 'facts'
49
+ >,
50
+ ): ReactElement {
51
+ return (
52
+ <div dir={state.facts.direction} style={{padding: 24, maxWidth: 640}}>
53
+ <button type="button">Before the toolbar</button>
54
+ <Toolbar
55
+ label={state.label}
56
+ orientation={state.facts.orientation}
57
+ startContent={controls(state.startControls, state.facts.orientation)}
58
+ endContent={controls(state.endControls, state.facts.orientation)}
59
+ />
60
+ <button type="button">After the toolbar</button>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,142 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Toolbar.a11y.states.ts
5
+ * @input Toolbar's representative states and the facts TOOLBAR_PATTERN reads
6
+ * @output TOOLBAR_BINDING_STATES and the explicit ownership exclusions for this binding
7
+ * @position Binding inventory shared by the jsdom test and the Chromium spec (Toolbar under spec:AST-020 FR15)
8
+ */
9
+
10
+ import type {ToolbarStateFacts} from '@astryxdesign/a11y-spec';
11
+
12
+ export interface ToolbarControl {
13
+ readonly label: string;
14
+ readonly isDisabled?: boolean;
15
+ }
16
+
17
+ export interface ToolbarBindingState {
18
+ readonly id: string;
19
+ readonly summary: string;
20
+ readonly storyId: string;
21
+ /** The Toolbar `label`, which names the toolbar. */
22
+ readonly label: string;
23
+ /** Controls in the start slot, in order. */
24
+ readonly startControls: ReadonlyArray<ToolbarControl>;
25
+ /** Controls in the end slot, in order. */
26
+ readonly endControls: ReadonlyArray<ToolbarControl>;
27
+ /** Relation name → accessible name of the control it designates. */
28
+ readonly controls: Readonly<
29
+ Record<'first-control' | 'second-control' | 'last-control', string>
30
+ >;
31
+ readonly facts: ToolbarStateFacts;
32
+ }
33
+
34
+ const story = (id: string) => `a11y-toolbar--${id}`;
35
+
36
+ const FORMATTING = [
37
+ {label: 'Bold'},
38
+ {label: 'Italic'},
39
+ {label: 'Underline'},
40
+ ] as const;
41
+
42
+ const FORMATTING_CONTROLS = {
43
+ 'first-control': 'Bold',
44
+ 'second-control': 'Italic',
45
+ 'last-control': 'Settings',
46
+ } as const;
47
+
48
+ const HORIZONTAL_LTR: ToolbarStateFacts = {
49
+ orientation: 'horizontal',
50
+ direction: 'ltr',
51
+ wraps: true,
52
+ homeEnd: true,
53
+ };
54
+
55
+ export const TOOLBAR_BINDING_STATES = [
56
+ {
57
+ id: 'horizontal',
58
+ summary:
59
+ 'a named horizontal Toolbar with three formatting buttons in the start slot and Settings in the end slot',
60
+ storyId: story('horizontal'),
61
+ label: 'Text formatting',
62
+ startControls: FORMATTING,
63
+ endControls: [{label: 'Settings'}],
64
+ controls: FORMATTING_CONTROLS,
65
+ facts: HORIZONTAL_LTR,
66
+ },
67
+ {
68
+ id: 'horizontal-rtl',
69
+ summary:
70
+ 'the same Toolbar inside a right-to-left container, where ArrowLeft moves to the next control',
71
+ storyId: story('horizontal-rtl'),
72
+ label: 'Text formatting',
73
+ startControls: FORMATTING,
74
+ endControls: [{label: 'Settings'}],
75
+ controls: FORMATTING_CONTROLS,
76
+ facts: {...HORIZONTAL_LTR, direction: 'rtl'},
77
+ },
78
+ {
79
+ id: 'vertical',
80
+ summary:
81
+ 'a vertical Toolbar of drawing tools, moved through with ArrowDown and ArrowUp',
82
+ storyId: story('vertical'),
83
+ label: 'Drawing tools',
84
+ startControls: [{label: 'Select'}, {label: 'Pen'}, {label: 'Eraser'}],
85
+ endControls: [],
86
+ controls: {
87
+ 'first-control': 'Select',
88
+ 'second-control': 'Pen',
89
+ 'last-control': 'Eraser',
90
+ },
91
+ facts: {...HORIZONTAL_LTR, orientation: 'vertical'},
92
+ },
93
+ {
94
+ id: 'first-control-disabled',
95
+ summary:
96
+ 'a Toolbar whose first button is disabled, so Tab enters on the first enabled button and the arrows skip the disabled one',
97
+ storyId: story('first-control-disabled'),
98
+ label: 'Edit actions',
99
+ startControls: [
100
+ {label: 'Undo', isDisabled: true},
101
+ {label: 'Cut'},
102
+ {label: 'Copy'},
103
+ ],
104
+ endControls: [{label: 'Paste'}],
105
+ controls: {
106
+ 'first-control': 'Cut',
107
+ 'second-control': 'Copy',
108
+ 'last-control': 'Paste',
109
+ },
110
+ facts: HORIZONTAL_LTR,
111
+ },
112
+ ] as const satisfies ReadonlyArray<ToolbarBindingState>;
113
+
114
+ export const TOOLBAR_EXCLUSIONS = [
115
+ {
116
+ id: 'control-semantics',
117
+ owner:
118
+ 'the button, toggle-button and menu-button patterns and their bindings',
119
+ reason:
120
+ 'Each control placed in a Toolbar exposes its own role, name, state and activation through its own pattern; this binding renders plain Buttons only so the toolbar container and the movement between controls are what is checked.',
121
+ },
122
+ {
123
+ id: 'caret-guard-and-nested-popovers',
124
+ owner:
125
+ 'Toolbar.test.tsx (caret-guard, nested popover and onKeyDown composition suites)',
126
+ reason:
127
+ 'Leaving caret keys to a text field mid-line, leaving keys inside an open MoreMenu to that menu, and composing a caller onKeyDown are binding behaviour the APG leaves to the author, so no state here places a text field or popover in the toolbar.',
128
+ },
129
+ {
130
+ id: 'three-lane-layout',
131
+ owner:
132
+ 'Toolbar.test.tsx (centerContent and three-slot layout tests) and the repository visual gate',
133
+ reason:
134
+ 'Center content switches the row to a three-column grid, but the controls stay in DOM order inside the same toolbar element and the same roving tab stop walks them, so the role, name, Tab stop and arrow order this contract reads do not change; the grid itself is layout paint.',
135
+ },
136
+ {
137
+ id: 'outer-chrome',
138
+ owner: 'component:Section and the repository visual gate',
139
+ reason:
140
+ 'Variant, dividers, block padding and edge compensation are Section and layout paint around the toolbar row; they change no role, name, Tab stop or arrow movement, so every state uses the default transparent chrome.',
141
+ },
142
+ ] as const;
@@ -0,0 +1,126 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ /** @vitest-environment jsdom */
3
+
4
+ /**
5
+ * @file Toolbar.a11y.test.tsx
6
+ * @input TOOLBAR_PATTERN bound to Toolbar's representative states
7
+ * @output jsdom-lane proof of the DOM expectations and an honest "unrun" report for the browser layers
8
+ * @position Binding test for Toolbar (the Chromium spec covers the rest)
9
+ */
10
+
11
+ import {cleanup, render, screen} from '@testing-library/react';
12
+ import {describe, expect, it} from 'vitest';
13
+ import {
14
+ TOOLBAR_PATTERN,
15
+ checkAccessibilitySpec,
16
+ createJsdomHarness,
17
+ expectAccessibilitySpec,
18
+ summarize,
19
+ type BindingResult,
20
+ } from '@astryxdesign/a11y-spec';
21
+ import {renderToolbarState} from './Toolbar.a11y.renders';
22
+ import {
23
+ TOOLBAR_BINDING_STATES,
24
+ TOOLBAR_EXCLUSIONS,
25
+ type ToolbarBindingState,
26
+ } from './Toolbar.a11y.states';
27
+
28
+ function toolbarFor(state: ToolbarBindingState): HTMLElement {
29
+ return screen.getByRole('toolbar', {name: state.label});
30
+ }
31
+
32
+ function relatedFor(
33
+ state: ToolbarBindingState,
34
+ ): Readonly<Record<string, Element>> {
35
+ const related: Record<string, Element> = {
36
+ before: screen.getByRole('button', {name: 'Before the toolbar'}),
37
+ after: screen.getByRole('button', {name: 'After the toolbar'}),
38
+ };
39
+ for (const [relation, name] of Object.entries(state.controls)) {
40
+ related[relation] = screen.getByRole('button', {name});
41
+ }
42
+ return related;
43
+ }
44
+
45
+ async function checkState(state: ToolbarBindingState): Promise<BindingResult> {
46
+ return checkAccessibilitySpec({
47
+ spec: TOOLBAR_PATTERN,
48
+ binding: 'Toolbar',
49
+ state: state.id,
50
+ facts: state.facts,
51
+ mount: async () => {
52
+ render(renderToolbarState(state));
53
+ return createJsdomHarness({
54
+ subject: toolbarFor(state),
55
+ related: relatedFor(state),
56
+ });
57
+ },
58
+ unmount: cleanup,
59
+ });
60
+ }
61
+
62
+ describe('Toolbar — toolbar pattern, jsdom lane', () => {
63
+ it.each(
64
+ TOOLBAR_BINDING_STATES.map(
65
+ state => [state.id, state.summary, state] as const,
66
+ ),
67
+ )('%s — %s', async (_id, _summary, state) => {
68
+ await expectAccessibilitySpec({
69
+ spec: TOOLBAR_PATTERN,
70
+ binding: 'Toolbar',
71
+ state: state.id,
72
+ facts: state.facts,
73
+ render: () => {
74
+ render(renderToolbarState(state));
75
+ },
76
+ subject: () => toolbarFor(state),
77
+ related: () => relatedFor(state),
78
+ cleanup,
79
+ });
80
+ });
81
+
82
+ it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => {
83
+ const results: BindingResult[] = [];
84
+ for (const state of TOOLBAR_BINDING_STATES) {
85
+ results.push(await checkState(state));
86
+ }
87
+ const report = summarize(TOOLBAR_PATTERN, results);
88
+ expect(report.counts.pass).toBeGreaterThan(0);
89
+ expect(report.unrunLayers).toEqual(['accessibility-tree', 'real-browser']);
90
+ expect(report.counts.unexpectedPass).toBe(0);
91
+ });
92
+ });
93
+
94
+ describe('toolbar binding inventory', () => {
95
+ it('gives every representative state a distinct checked-in story', () => {
96
+ const stories = TOOLBAR_BINDING_STATES.map(state => state.storyId);
97
+ expect(new Set(stories).size).toBe(stories.length);
98
+ });
99
+
100
+ it('keeps control semantics, editing and popover keys, the three-lane layout, and outer chrome explicit', () => {
101
+ expect(TOOLBAR_EXCLUSIONS.map(row => row.id)).toEqual([
102
+ 'control-semantics',
103
+ 'caret-guard-and-nested-popovers',
104
+ 'three-lane-layout',
105
+ 'outer-chrome',
106
+ ]);
107
+ for (const exclusion of TOOLBAR_EXCLUSIONS) {
108
+ expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20);
109
+ expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80);
110
+ }
111
+ });
112
+
113
+ it('renders the toolbar and every control relation it names for every state', () => {
114
+ for (const state of TOOLBAR_BINDING_STATES) {
115
+ render(renderToolbarState(state));
116
+ const toolbar = toolbarFor(state);
117
+ for (const name of Object.values(state.controls)) {
118
+ expect(
119
+ toolbar.contains(screen.getByRole('button', {name})),
120
+ `${state.id}: ${name}`,
121
+ ).toBe(true);
122
+ }
123
+ cleanup();
124
+ }
125
+ });
126
+ });