@astryxdesign/lab 0.7.3-canary.fba04ed → 0.7.4-canary.3f6ea7d
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 +4 -4
- package/src/CircularProgress/__tests__/CircularProgress.a11y.chromium.spec.ts +114 -0
- package/src/CircularProgress/__tests__/CircularProgress.a11y.known-failures.ts +12 -0
- package/src/CircularProgress/__tests__/CircularProgress.a11y.renders.tsx +41 -0
- package/src/CircularProgress/__tests__/CircularProgress.a11y.states.ts +106 -0
- package/src/CircularProgress/__tests__/CircularProgress.a11y.test.tsx +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/lab",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4-canary.3f6ea7d",
|
|
4
4
|
"description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
|
|
5
5
|
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"@stylexjs/stylex": ">=0.10.0",
|
|
52
|
-
"@astryxdesign/core": "0.7.
|
|
52
|
+
"@astryxdesign/core": "0.7.4-canary.3f6ea7d",
|
|
53
53
|
"react": ">=19.0.0",
|
|
54
54
|
"react-dom": ">=19.0.0"
|
|
55
55
|
},
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"d3-shape": "^3.2.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@astryxdesign/a11y-spec": "0.7.
|
|
63
|
-
"@astryxdesign/cli": "0.7.
|
|
62
|
+
"@astryxdesign/a11y-spec": "0.7.4-canary.3f6ea7d",
|
|
63
|
+
"@astryxdesign/cli": "0.7.4-canary.3f6ea7d",
|
|
64
64
|
"@babel/cli": "^7.29.7",
|
|
65
65
|
"@babel/core": "^7.29.7",
|
|
66
66
|
"@babel/preset-react": "^7.29.7",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file CircularProgress.a11y.chromium.spec.ts
|
|
5
|
+
* @input Built CircularProgress stories and PROGRESSBAR_PATTERN in real Chromium
|
|
6
|
+
* @output Browser evidence for every progressbar expectation across the binding's states, reconciled against the exact known failures
|
|
7
|
+
* @position Chromium lane of the CircularProgress 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
|
+
PROGRESSBAR_PATTERN,
|
|
19
|
+
blockingResults,
|
|
20
|
+
checkAccessibilitySpec,
|
|
21
|
+
formatFailures,
|
|
22
|
+
neverExercised,
|
|
23
|
+
summarize,
|
|
24
|
+
unmatchedKnownFailures,
|
|
25
|
+
type BindingResult,
|
|
26
|
+
} from '@astryxdesign/a11y-spec';
|
|
27
|
+
import {
|
|
28
|
+
createChromiumHarness,
|
|
29
|
+
holdMotionStill,
|
|
30
|
+
} from '@astryxdesign/a11y-spec/chromium';
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_STORYBOOK_DIR,
|
|
33
|
+
serveStorybook,
|
|
34
|
+
type StaticServer,
|
|
35
|
+
} from '@astryxdesign/a11y-spec/storybook';
|
|
36
|
+
import {CIRCULAR_PROGRESS_KNOWN_FAILURES} from './CircularProgress.a11y.known-failures';
|
|
37
|
+
import {
|
|
38
|
+
CIRCULAR_PROGRESS_BINDING_STATES,
|
|
39
|
+
type CircularProgressBindingState,
|
|
40
|
+
} from './CircularProgress.a11y.states';
|
|
41
|
+
|
|
42
|
+
let storybook: StaticServer;
|
|
43
|
+
|
|
44
|
+
test.beforeAll(async () => {
|
|
45
|
+
storybook = await serveStorybook(
|
|
46
|
+
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test.afterAll(async () => {
|
|
51
|
+
await storybook?.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function storyUrl(storyId: string): string {
|
|
55
|
+
return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function mountState(
|
|
59
|
+
page: Page,
|
|
60
|
+
state: CircularProgressBindingState,
|
|
61
|
+
): Promise<{subject: Locator; related: Record<string, Locator>}> {
|
|
62
|
+
await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
|
|
63
|
+
await holdMotionStill(page);
|
|
64
|
+
const subject = page
|
|
65
|
+
.locator('#storybook-root')
|
|
66
|
+
.getByRole('progressbar', {name: state.facts.name});
|
|
67
|
+
await subject.waitFor({state: 'attached'});
|
|
68
|
+
return {subject, related: {}};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runState(
|
|
72
|
+
page: Page,
|
|
73
|
+
cdp: CDPSession,
|
|
74
|
+
state: CircularProgressBindingState,
|
|
75
|
+
): Promise<BindingResult> {
|
|
76
|
+
return checkAccessibilitySpec({
|
|
77
|
+
spec: PROGRESSBAR_PATTERN,
|
|
78
|
+
binding: 'CircularProgress',
|
|
79
|
+
state: state.id,
|
|
80
|
+
facts: state.facts,
|
|
81
|
+
knownFailures: CIRCULAR_PROGRESS_KNOWN_FAILURES,
|
|
82
|
+
mount: async () => {
|
|
83
|
+
const {subject, related} = await mountState(page, state);
|
|
84
|
+
return createChromiumHarness({page, cdp, subject, related});
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('every progressbar expectation is exercised by a CircularProgress state', async ({
|
|
90
|
+
page,
|
|
91
|
+
}) => {
|
|
92
|
+
const cdp = await page.context().newCDPSession(page);
|
|
93
|
+
const results: BindingResult[] = [];
|
|
94
|
+
for (const state of CIRCULAR_PROGRESS_BINDING_STATES) {
|
|
95
|
+
results.push(await runState(page, cdp, state));
|
|
96
|
+
}
|
|
97
|
+
expect(neverExercised(results)).toEqual([]);
|
|
98
|
+
expect(
|
|
99
|
+
unmatchedKnownFailures(CIRCULAR_PROGRESS_KNOWN_FAILURES, results),
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
for (const state of CIRCULAR_PROGRESS_BINDING_STATES) {
|
|
104
|
+
test(`CircularProgress [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
105
|
+
const cdp = await page.context().newCDPSession(page);
|
|
106
|
+
const result = await runState(page, cdp, state);
|
|
107
|
+
const report = summarize(PROGRESSBAR_PATTERN, [result]);
|
|
108
|
+
// Required rows block (AST-020 FR9); advisory rows and exact known
|
|
109
|
+
// failures report.
|
|
110
|
+
expect(formatFailures(blockingResults([result]))).toBe('');
|
|
111
|
+
expect(report.unrunLayers).toEqual([]);
|
|
112
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file CircularProgress.a11y.known-failures.ts
|
|
5
|
+
* @input The exact progressbar-contract failures the shipped CircularProgress produces today
|
|
6
|
+
* @output CIRCULAR_PROGRESS_KNOWN_FAILURES — records both lanes reconcile against, so a fix shows up as an unexpected pass and any other failure stays a failure
|
|
7
|
+
* @position Known-failure ledger for the CircularProgress binding
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {KnownFailure} from '@astryxdesign/a11y-spec';
|
|
11
|
+
|
|
12
|
+
export const CIRCULAR_PROGRESS_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file CircularProgress.a11y.renders.tsx
|
|
7
|
+
* @input One CircularProgress binding state
|
|
8
|
+
* @output The React tree every lane renders for that state (the jsdom test directly, the story through Storybook)
|
|
9
|
+
* @position Shared fixture for the CircularProgress binding
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {ReactElement} from 'react';
|
|
13
|
+
import {CircularProgress} from '../CircularProgress';
|
|
14
|
+
import {
|
|
15
|
+
formatSteps,
|
|
16
|
+
type CircularProgressBindingState,
|
|
17
|
+
} from './CircularProgress.a11y.states';
|
|
18
|
+
|
|
19
|
+
export function renderCircularProgressState(
|
|
20
|
+
state: CircularProgressBindingState,
|
|
21
|
+
): ReactElement {
|
|
22
|
+
return (
|
|
23
|
+
<div style={{padding: 24}}>
|
|
24
|
+
<CircularProgress
|
|
25
|
+
label={state.facts.name}
|
|
26
|
+
// An indeterminate state still passes a value, so the proof shows the
|
|
27
|
+
// ring withholds it rather than never having had one.
|
|
28
|
+
value={state.facts.value ?? 50}
|
|
29
|
+
max={state.facts.max}
|
|
30
|
+
isLabelHidden={state.isLabelHidden}
|
|
31
|
+
hasValueLabel={state.hasValueLabel}
|
|
32
|
+
formatValueLabel={state.formatsValue ? formatSteps : undefined}
|
|
33
|
+
isIndeterminate={state.isIndeterminate}
|
|
34
|
+
isDisabled={state.isDisabled}>
|
|
35
|
+
{state.centerLink != null ? (
|
|
36
|
+
<a href="#upload-details">{state.centerLink}</a>
|
|
37
|
+
) : null}
|
|
38
|
+
</CircularProgress>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file CircularProgress.a11y.states.ts
|
|
5
|
+
* @input CircularProgress's representative states and the facts PROGRESSBAR_PATTERN reads
|
|
6
|
+
* @output CIRCULAR_PROGRESS_BINDING_STATES and the explicit ownership exclusions for this binding
|
|
7
|
+
* @position Binding inventory shared by the jsdom test, the Chromium spec, and the story
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {ProgressbarStateFacts} from '@astryxdesign/a11y-spec';
|
|
11
|
+
|
|
12
|
+
export interface CircularProgressBindingState {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly summary: string;
|
|
15
|
+
readonly storyId: string;
|
|
16
|
+
readonly facts: ProgressbarStateFacts;
|
|
17
|
+
/** Props beyond label/value/max that shape the state. */
|
|
18
|
+
readonly isLabelHidden?: boolean;
|
|
19
|
+
readonly hasValueLabel?: boolean;
|
|
20
|
+
readonly formatsValue?: boolean;
|
|
21
|
+
readonly isIndeterminate?: boolean;
|
|
22
|
+
readonly isDisabled?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* A focusable link rendered as the ring's center content, which must sit
|
|
25
|
+
* beside the progress bar rather than inside its presentational subtree.
|
|
26
|
+
*/
|
|
27
|
+
readonly centerLink?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function facts(
|
|
31
|
+
overrides: Partial<ProgressbarStateFacts> = {},
|
|
32
|
+
): ProgressbarStateFacts {
|
|
33
|
+
return {
|
|
34
|
+
name: 'Upload',
|
|
35
|
+
value: 40,
|
|
36
|
+
min: 0,
|
|
37
|
+
max: 100,
|
|
38
|
+
valueText: '40%',
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const story = (id: string) => `a11y-progressbar--circular-progress-${id}`;
|
|
44
|
+
|
|
45
|
+
/** The custom formatter the `formatted` state passes, so its facts can quote the text. */
|
|
46
|
+
export const formatSteps = (value: number, max: number): string =>
|
|
47
|
+
`${value} of ${max}`;
|
|
48
|
+
|
|
49
|
+
export const CIRCULAR_PROGRESS_BINDING_STATES = [
|
|
50
|
+
{
|
|
51
|
+
id: 'determinate',
|
|
52
|
+
summary:
|
|
53
|
+
'a CircularProgress at 40 of 0–100 with its default hidden label and percentage',
|
|
54
|
+
storyId: story('determinate'),
|
|
55
|
+
facts: facts(),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'formatted',
|
|
59
|
+
summary:
|
|
60
|
+
'a CircularProgress with a custom formatter shown in the ring: 3 of 10 speaks "3 of 10"',
|
|
61
|
+
storyId: story('formatted'),
|
|
62
|
+
facts: facts({name: 'Steps', value: 3, max: 10, valueText: '3 of 10'}),
|
|
63
|
+
hasValueLabel: true,
|
|
64
|
+
formatsValue: true,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'label-visible',
|
|
68
|
+
summary: 'a CircularProgress whose label is shown names the ring with it',
|
|
69
|
+
storyId: story('label-visible'),
|
|
70
|
+
facts: facts({name: 'Storage used'}),
|
|
71
|
+
isLabelHidden: false,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'indeterminate',
|
|
75
|
+
summary:
|
|
76
|
+
'an indeterminate CircularProgress with hasValueLabel claims no value or text',
|
|
77
|
+
storyId: story('indeterminate'),
|
|
78
|
+
facts: facts({name: 'Loading data', value: null, valueText: null}),
|
|
79
|
+
isIndeterminate: true,
|
|
80
|
+
hasValueLabel: true,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'center-content',
|
|
84
|
+
summary:
|
|
85
|
+
'a disabled CircularProgress whose center content is a focusable link: the link sits beside the ring, not inside it',
|
|
86
|
+
storyId: story('center-content'),
|
|
87
|
+
facts: facts({value: 75, valueText: '75%'}),
|
|
88
|
+
isDisabled: true,
|
|
89
|
+
centerLink: 'Upload details',
|
|
90
|
+
},
|
|
91
|
+
] as const satisfies ReadonlyArray<CircularProgressBindingState>;
|
|
92
|
+
|
|
93
|
+
export const CIRCULAR_PROGRESS_EXCLUSIONS = [
|
|
94
|
+
{
|
|
95
|
+
id: 'value-clamping',
|
|
96
|
+
owner: 'CircularProgress.test.tsx clamping and non-finite value tests',
|
|
97
|
+
reason:
|
|
98
|
+
'Clamping an out-of-range or non-finite value before it reaches aria-valuenow is component input handling; this binding proves the value the ring exposes once it is in range.',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'ring-geometry',
|
|
102
|
+
owner: 'CircularProgress.test.tsx size and dash-offset tests',
|
|
103
|
+
reason:
|
|
104
|
+
'Ring size, stroke, and the dash offset that draws the completed arc are paint geometry; this binding proves the programmatic value they illustrate.',
|
|
105
|
+
},
|
|
106
|
+
] as const;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
/** @vitest-environment jsdom */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @file CircularProgress.a11y.test.tsx
|
|
6
|
+
* @input PROGRESSBAR_PATTERN bound to CircularProgress'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 the CircularProgress binding (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
|
+
PROGRESSBAR_PATTERN,
|
|
15
|
+
checkAccessibilitySpec,
|
|
16
|
+
createJsdomHarness,
|
|
17
|
+
expectAccessibilitySpec,
|
|
18
|
+
summarize,
|
|
19
|
+
type BindingResult,
|
|
20
|
+
} from '@astryxdesign/a11y-spec';
|
|
21
|
+
import {CIRCULAR_PROGRESS_KNOWN_FAILURES} from './CircularProgress.a11y.known-failures';
|
|
22
|
+
import {renderCircularProgressState} from './CircularProgress.a11y.renders';
|
|
23
|
+
import {
|
|
24
|
+
CIRCULAR_PROGRESS_BINDING_STATES,
|
|
25
|
+
CIRCULAR_PROGRESS_EXCLUSIONS,
|
|
26
|
+
type CircularProgressBindingState,
|
|
27
|
+
} from './CircularProgress.a11y.states';
|
|
28
|
+
|
|
29
|
+
function progressbar(state: CircularProgressBindingState): HTMLElement {
|
|
30
|
+
return screen.getByRole('progressbar', {name: state.facts.name});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function checkState(
|
|
34
|
+
state: CircularProgressBindingState,
|
|
35
|
+
): Promise<BindingResult> {
|
|
36
|
+
return checkAccessibilitySpec({
|
|
37
|
+
spec: PROGRESSBAR_PATTERN,
|
|
38
|
+
binding: 'CircularProgress',
|
|
39
|
+
state: state.id,
|
|
40
|
+
facts: state.facts,
|
|
41
|
+
knownFailures: CIRCULAR_PROGRESS_KNOWN_FAILURES,
|
|
42
|
+
mount: async () => {
|
|
43
|
+
render(renderCircularProgressState(state));
|
|
44
|
+
return createJsdomHarness({subject: progressbar(state), related: {}});
|
|
45
|
+
},
|
|
46
|
+
unmount: cleanup,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('CircularProgress — progressbar pattern, jsdom lane', () => {
|
|
51
|
+
it.each(
|
|
52
|
+
CIRCULAR_PROGRESS_BINDING_STATES.map(
|
|
53
|
+
state => [state.id, state.summary, state] as const,
|
|
54
|
+
),
|
|
55
|
+
)('%s — %s', async (_id, _summary, state) => {
|
|
56
|
+
await expectAccessibilitySpec({
|
|
57
|
+
spec: PROGRESSBAR_PATTERN,
|
|
58
|
+
binding: 'CircularProgress',
|
|
59
|
+
state: state.id,
|
|
60
|
+
facts: state.facts,
|
|
61
|
+
knownFailures: CIRCULAR_PROGRESS_KNOWN_FAILURES,
|
|
62
|
+
render: () => {
|
|
63
|
+
render(renderCircularProgressState(state));
|
|
64
|
+
},
|
|
65
|
+
subject: () => progressbar(state),
|
|
66
|
+
related: () => ({}),
|
|
67
|
+
cleanup,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('runs the DOM layer and reports the browser-owned outcomes as unrun', async () => {
|
|
72
|
+
const results: BindingResult[] = [];
|
|
73
|
+
for (const state of CIRCULAR_PROGRESS_BINDING_STATES) {
|
|
74
|
+
results.push(await checkState(state));
|
|
75
|
+
}
|
|
76
|
+
const report = summarize(PROGRESSBAR_PATTERN, results);
|
|
77
|
+
expect(report.counts.pass).toBeGreaterThan(0);
|
|
78
|
+
expect(report.unrunLayers).toEqual(['accessibility-tree']);
|
|
79
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('CircularProgress binding inventory', () => {
|
|
84
|
+
it('gives every representative state a distinct checked-in story', () => {
|
|
85
|
+
const stories = CIRCULAR_PROGRESS_BINDING_STATES.map(
|
|
86
|
+
state => state.storyId,
|
|
87
|
+
);
|
|
88
|
+
expect(new Set(stories).size).toBe(stories.length);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('keeps clamping and ring-geometry ownership explicit', () => {
|
|
92
|
+
expect(CIRCULAR_PROGRESS_EXCLUSIONS.map(exclusion => exclusion.id)).toEqual(
|
|
93
|
+
['value-clamping', 'ring-geometry'],
|
|
94
|
+
);
|
|
95
|
+
for (const exclusion of CIRCULAR_PROGRESS_EXCLUSIONS) {
|
|
96
|
+
expect(exclusion.owner.length, exclusion.id).toBeGreaterThan(20);
|
|
97
|
+
expect(exclusion.reason.length, exclusion.id).toBeGreaterThan(80);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('renders exactly one named progress bar for every state', () => {
|
|
102
|
+
for (const state of CIRCULAR_PROGRESS_BINDING_STATES) {
|
|
103
|
+
render(renderCircularProgressState(state));
|
|
104
|
+
expect(progressbar(state), state.id).toBeTruthy();
|
|
105
|
+
cleanup();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|