@astryxdesign/core 0.7.3-canary.b74cf21 → 0.7.3-canary.cb489ff
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/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +19 -24
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +52 -10
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
- package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
- package/dist/DropdownMenu/menuRegionStops.js +2 -2
- package/dist/FileInput/FileInput.d.ts.map +1 -1
- package/dist/FileInput/FileInput.js +14 -12
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +7 -14
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +12 -14
- package/dist/Switch/switch.markers.stylex.d.ts +2 -0
- package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
- package/dist/Switch/switch.markers.stylex.js +6 -0
- package/dist/TimeInput/TimeInput.d.ts +2 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +36 -14
- package/dist/TopNav/TopNav.d.ts.map +1 -1
- package/dist/TopNav/TopNav.js +2 -2
- package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
- package/dist/TopNav/TopNavHeading.js +1 -0
- package/dist/astryx.css +3 -2
- package/dist/hooks/useMenuPress.d.ts +11 -1
- package/dist/hooks/useMenuPress.d.ts.map +1 -1
- package/dist/hooks/useMenuPress.js +43 -5
- package/dist/utils/timeParser.d.ts +4 -0
- package/dist/utils/timeParser.d.ts.map +1 -1
- package/dist/utils/timeParser.js +9 -2
- package/package.json +3 -3
- package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
- package/src/CheckboxInput/CheckboxInput.tsx +16 -26
- package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
- package/src/CheckboxList/CheckboxList.test.tsx +9 -6
- package/src/ContextMenu/ContextMenu.test.tsx +41 -0
- package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
- package/src/DropdownMenu/DropdownMenu.tsx +79 -8
- package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
- package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
- package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
- package/src/DropdownMenu/menuRegionStops.ts +2 -2
- package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
- package/src/FileInput/FileInput.test.tsx +30 -7
- package/src/FileInput/FileInput.tsx +21 -11
- package/src/SideNav/SideNav.test.tsx +35 -0
- package/src/Slider/Slider.test.tsx +3 -3
- package/src/Slider/Slider.tsx +8 -15
- package/src/Switch/Switch.test.tsx +29 -8
- package/src/Switch/Switch.tsx +13 -14
- package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
- package/src/Switch/switch.markers.stylex.ts +4 -0
- package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
- package/src/TimeInput/TimeInput.doc.mjs +5 -3
- package/src/TimeInput/TimeInput.test.tsx +20 -0
- package/src/TimeInput/TimeInput.tsx +2 -1
- package/src/Tooltip/Tooltip.test.tsx +51 -6
- package/src/Tooltip/Tooltip.tsx +41 -18
- package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
- package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
- package/src/TopNav/TopNav.tsx +11 -1
- package/src/TopNav/TopNavHeading.tsx +1 -0
- package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
- package/src/hooks/useMenuPress.ts +45 -7
- package/src/utils/timeParser.test.ts +11 -0
- package/src/utils/timeParser.ts +9 -2
package/src/Switch/Switch.tsx
CHANGED
|
@@ -43,7 +43,7 @@ import type {InputStatus} from '../Field/types';
|
|
|
43
43
|
import {Spinner} from '../Spinner';
|
|
44
44
|
import {useTooltip} from '../Tooltip';
|
|
45
45
|
import {mergeProps, mergeRefs, rtlStyles} from '../utils';
|
|
46
|
-
import {switchScope} from './switch.markers.stylex';
|
|
46
|
+
import {switchFocusScope, switchScope} from './switch.markers.stylex';
|
|
47
47
|
import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
|
|
48
48
|
import type {BaseProps} from '../BaseProps';
|
|
49
49
|
import type {SizeValue} from '../utils/types';
|
|
@@ -265,12 +265,12 @@ const styles = stylex.create({
|
|
|
265
265
|
trackFocus: {
|
|
266
266
|
outline: {
|
|
267
267
|
default: 'none',
|
|
268
|
-
[stylex.when.ancestor(':has(:focus-visible)',
|
|
268
|
+
[stylex.when.ancestor(':has(:focus-visible)', switchFocusScope)]:
|
|
269
269
|
`${focusVars['--focus-outline-width']} ${focusVars['--focus-outline-style']} ${focusVars['--focus-outline-color']}`,
|
|
270
270
|
},
|
|
271
271
|
outlineOffset: {
|
|
272
272
|
default: null,
|
|
273
|
-
[stylex.when.ancestor(':has(:focus-visible)',
|
|
273
|
+
[stylex.when.ancestor(':has(:focus-visible)', switchFocusScope)]:
|
|
274
274
|
focusVars['--focus-outline-offset'],
|
|
275
275
|
},
|
|
276
276
|
},
|
|
@@ -637,17 +637,13 @@ export function Switch({
|
|
|
637
637
|
id={id}
|
|
638
638
|
type="checkbox"
|
|
639
639
|
role="switch"
|
|
640
|
-
// Withhold the name while disabled:
|
|
641
|
-
//
|
|
642
|
-
// control must not submit.
|
|
640
|
+
// Withhold the name while disabled: the input stays focusable (not
|
|
641
|
+
// natively disabled), and a disabled control must not submit.
|
|
643
642
|
name={isDisabled ? undefined : htmlName}
|
|
644
643
|
checked={isOn}
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
disabled={isDisabled && !showsDisabledMessage}
|
|
649
|
-
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
650
|
-
form={showsDisabledMessage ? '' : undefined}
|
|
644
|
+
// Focusable but detached from the form while disabled (spec:AST-085).
|
|
645
|
+
aria-disabled={isDisabled ? 'true' : undefined}
|
|
646
|
+
form={isDisabled ? '' : undefined}
|
|
651
647
|
required={isRequired}
|
|
652
648
|
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
653
649
|
onChange={e => {
|
|
@@ -693,7 +689,7 @@ export function Switch({
|
|
|
693
689
|
styles.track,
|
|
694
690
|
trackSizeStyles[size],
|
|
695
691
|
isOn ? styles.trackOn : styles.trackOff,
|
|
696
|
-
|
|
692
|
+
styles.trackFocus,
|
|
697
693
|
isDisabled && styles.trackDisabled,
|
|
698
694
|
isDisabled && !isOn && styles.trackDisabledOff,
|
|
699
695
|
),
|
|
@@ -747,7 +743,9 @@ export function Switch({
|
|
|
747
743
|
className,
|
|
748
744
|
style,
|
|
749
745
|
)}
|
|
750
|
-
{...rest}
|
|
746
|
+
{...rest}
|
|
747
|
+
// Native `disabled` suppressed clicks on the switch (spec:AST-085 FR2).
|
|
748
|
+
onClick={isDisabled ? undefined : rest.onClick}>
|
|
751
749
|
<div
|
|
752
750
|
ref={el => {
|
|
753
751
|
// Interaction (hover/focus) listeners for the disabled-message
|
|
@@ -764,6 +762,7 @@ export function Switch({
|
|
|
764
762
|
isLabelHidden && styles.containerLabelHidden,
|
|
765
763
|
labelSpacing === 'spread' && styles.containerSpread,
|
|
766
764
|
!isDisabled && switchScope,
|
|
765
|
+
switchFocusScope,
|
|
767
766
|
// The touch press's strength and release live on the row the
|
|
768
767
|
// controller writes to; the track and thumb inherit and paint it.
|
|
769
768
|
!isDisabled && interactionOverlayStyles.pressedAlpha,
|
|
@@ -147,7 +147,7 @@ export const SWITCH_BINDING_STATES: ReadonlyArray<SwitchBindingState> = [
|
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
id: 'disabled',
|
|
150
|
-
summary: 'a
|
|
150
|
+
summary: 'a disabled switch that keeps its tab stop',
|
|
151
151
|
storyId: 'core-switch--disabled',
|
|
152
152
|
visibleLabel: 'Premium feature',
|
|
153
153
|
props: {
|
|
@@ -159,7 +159,7 @@ export const SWITCH_BINDING_STATES: ReadonlyArray<SwitchBindingState> = [
|
|
|
159
159
|
facts: {
|
|
160
160
|
checked: false,
|
|
161
161
|
operable: false,
|
|
162
|
-
focusable:
|
|
162
|
+
focusable: true,
|
|
163
163
|
disabled: true,
|
|
164
164
|
described: true,
|
|
165
165
|
required: false,
|
|
@@ -8,3 +8,7 @@ import * as stylex from '@stylexjs/stylex';
|
|
|
8
8
|
*/
|
|
9
9
|
export const switchScope: ReturnType<typeof stylex.defineMarker> =
|
|
10
10
|
stylex.defineMarker();
|
|
11
|
+
|
|
12
|
+
/** Focus-ring marker; unlike `switchScope`, it stays on while disabled. */
|
|
13
|
+
export const switchFocusScope: ReturnType<typeof stylex.defineMarker> =
|
|
14
|
+
stylex.defineMarker();
|
|
@@ -195,9 +195,11 @@ describe('useTableSelection', () => {
|
|
|
195
195
|
it('disables checkbox for disabled rows', () => {
|
|
196
196
|
render(<SelectionTable getIsItemEnabled={item => !item.isLocked} />);
|
|
197
197
|
const rowCheckboxes = screen.getAllByLabelText('Select row');
|
|
198
|
-
expect(rowCheckboxes[0]).not.
|
|
199
|
-
expect(rowCheckboxes[1]).not.
|
|
200
|
-
|
|
198
|
+
expect(rowCheckboxes[0]).not.toHaveAttribute('aria-disabled');
|
|
199
|
+
expect(rowCheckboxes[1]).not.toHaveAttribute('aria-disabled');
|
|
200
|
+
// Focusable disabled (spec:AST-085 FR1): aria-disabled, no native disabled.
|
|
201
|
+
expect(rowCheckboxes[2]).toHaveAttribute('aria-disabled', 'true');
|
|
202
|
+
expect(rowCheckboxes[2]).not.toHaveAttribute('disabled');
|
|
201
203
|
});
|
|
202
204
|
|
|
203
205
|
it('prepends selection <td> to each body row', () => {
|
|
@@ -212,7 +212,7 @@ export const docs = {
|
|
|
212
212
|
name: 'increment',
|
|
213
213
|
type: 'number',
|
|
214
214
|
description:
|
|
215
|
-
'Number of minutes to add or subtract when the user presses the up or down arrow key.',
|
|
215
|
+
'Number of minutes to add or subtract when the user presses the up or down arrow key. Steps are whole minutes; a fractional value leaves the time unchanged.',
|
|
216
216
|
default: '1',
|
|
217
217
|
},
|
|
218
218
|
{
|
|
@@ -392,7 +392,8 @@ export const docsZh = {
|
|
|
392
392
|
{
|
|
393
393
|
name: 'increment',
|
|
394
394
|
type: 'number',
|
|
395
|
-
description:
|
|
395
|
+
description:
|
|
396
|
+
'用户按上或下方向键时增加或减少的分钟数。步长为整分钟;传入小数时时间保持不变。',
|
|
396
397
|
default: '1',
|
|
397
398
|
},
|
|
398
399
|
{
|
|
@@ -623,7 +624,8 @@ export const docsDense = {
|
|
|
623
624
|
hasClear: 'Shows clear button when value set+not disabled.',
|
|
624
625
|
hourFormat:
|
|
625
626
|
"Display format. '12h' shows AM/PM; '24h' uses 24-hour notation.",
|
|
626
|
-
increment:
|
|
627
|
+
increment:
|
|
628
|
+
'Minutes to add/subtract on arrow up/down. Whole minutes only; a fractional value leaves the time unchanged.',
|
|
627
629
|
presentation:
|
|
628
630
|
"picker surface: 'adaptive-native' (default) = typed field fine / browser/OS coarse (hasSeconds or increment!=1 retains typed); 'native' = browser/OS always, no fallback; 'adaptive-bottom-sheet' = typed fine / Astryx wheel sheet coarse; 'bottom-sheet' = sheet always; 'text-input'/'popover' = typed field only.",
|
|
629
631
|
nativePicker:
|
|
@@ -65,6 +65,26 @@ describe('TimeInput', () => {
|
|
|
65
65
|
expect(onChange).toHaveBeenCalledWith('14:31');
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it('does not step into a fractional minute when increment is fractional', () => {
|
|
69
|
+
const onChange = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<TimeInput
|
|
72
|
+
label="Time"
|
|
73
|
+
value={'14:30' as ISOTimeString}
|
|
74
|
+
increment={0.5}
|
|
75
|
+
onChange={onChange}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
const input = screen.getByLabelText('Time');
|
|
79
|
+
|
|
80
|
+
// `increment` is documented as minutes and typed `number`. A fractional one
|
|
81
|
+
// used to reach formatISOTime unrounded, which pads "30.5" to two
|
|
82
|
+
// characters and leaves it, so the arrow key handed the consumer a value
|
|
83
|
+
// that is no longer a time at all.
|
|
84
|
+
fireEvent.keyDown(input, {key: 'ArrowUp'});
|
|
85
|
+
expect(onChange).toHaveBeenCalledWith('14:30');
|
|
86
|
+
});
|
|
87
|
+
|
|
68
88
|
it('displays formatted time in 12h format', () => {
|
|
69
89
|
render(
|
|
70
90
|
<TimeInput
|
|
@@ -286,7 +286,8 @@ export interface TimeInputProps extends Omit<
|
|
|
286
286
|
hourFormat?: TimeInputHourFormat;
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
|
-
* Increment in minutes when using arrow keys.
|
|
289
|
+
* Increment in minutes when using arrow keys. Steps are whole minutes: a
|
|
290
|
+
* fractional value leaves the time unchanged.
|
|
290
291
|
* @default 1
|
|
291
292
|
*/
|
|
292
293
|
increment?: number;
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
fireEvent,
|
|
26
26
|
waitFor,
|
|
27
27
|
} from '@testing-library/react';
|
|
28
|
+
import {useRef} from 'react';
|
|
28
29
|
import {Tooltip} from './Tooltip';
|
|
29
30
|
import {useTooltip} from './useTooltip';
|
|
30
31
|
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
@@ -84,17 +85,15 @@ describe('Tooltip', () => {
|
|
|
84
85
|
expect(screen.getByRole('button', {name: 'Trigger'})).toBeInTheDocument();
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
it('
|
|
88
|
+
it('renders the content prop inside the tooltip layer', () => {
|
|
88
89
|
render(
|
|
89
90
|
<Tooltip content="Tooltip text">
|
|
90
91
|
<button type="button">Trigger</button>
|
|
91
92
|
</Tooltip>,
|
|
92
93
|
);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const trigger = screen.getByRole('button', {name: 'Trigger'});
|
|
97
|
-
expect(trigger.getAttribute('aria-describedby')).toBe(layer.id);
|
|
94
|
+
expect(screen.getByRole('tooltip', {hidden: true})).toHaveTextContent(
|
|
95
|
+
'Tooltip text',
|
|
96
|
+
);
|
|
98
97
|
});
|
|
99
98
|
|
|
100
99
|
it('calls onOpenChange(true) when shown via hover', async () => {
|
|
@@ -604,3 +603,49 @@ describe('Tooltip', () => {
|
|
|
604
603
|
});
|
|
605
604
|
});
|
|
606
605
|
});
|
|
606
|
+
|
|
607
|
+
describe('Tooltip sibling mode (anchorRef)', () => {
|
|
608
|
+
// SideNavItem's collapsed rail tooltip renders the Tooltip inside the very
|
|
609
|
+
// element anchorRef points at. React attaches an ancestor's ref after its
|
|
610
|
+
// descendants' layout effects, so on mount the tooltip's effect used to see
|
|
611
|
+
// a null anchor and never retried (#7692).
|
|
612
|
+
it('attaches to an anchor it is rendered inside of', async () => {
|
|
613
|
+
const onOpenChange = vi.fn();
|
|
614
|
+
function AnchorInside() {
|
|
615
|
+
const anchorRef = useRef<HTMLDivElement>(null);
|
|
616
|
+
return (
|
|
617
|
+
<div ref={anchorRef}>
|
|
618
|
+
<button type="button">Inner</button>
|
|
619
|
+
<Tooltip
|
|
620
|
+
content="Sibling tooltip"
|
|
621
|
+
anchorRef={anchorRef}
|
|
622
|
+
delay={0}
|
|
623
|
+
onOpenChange={onOpenChange}
|
|
624
|
+
/>
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const {unmount} = render(<AnchorInside />);
|
|
630
|
+
const anchor = screen.getByRole('button', {name: 'Inner'})
|
|
631
|
+
.parentElement as HTMLElement;
|
|
632
|
+
|
|
633
|
+
// The anchor is described by the tooltip once the commit has attached refs.
|
|
634
|
+
await waitFor(() => {
|
|
635
|
+
expect(anchor.getAttribute('aria-describedby')).not.toBe('');
|
|
636
|
+
});
|
|
637
|
+
const layer = screen.getByRole('tooltip', {hidden: true});
|
|
638
|
+
expect(anchor.getAttribute('aria-describedby')).toBe(layer.id);
|
|
639
|
+
|
|
640
|
+
// Hovering the anchor opens the tooltip: the interaction listeners were
|
|
641
|
+
// attached to the late anchor too.
|
|
642
|
+
fireEvent.mouseEnter(anchor);
|
|
643
|
+
await waitFor(() => {
|
|
644
|
+
expect(onOpenChange).toHaveBeenCalledWith(true);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Unmounting detaches: the aria-describedby the tooltip added is removed.
|
|
648
|
+
unmount();
|
|
649
|
+
expect(anchor.getAttribute('aria-describedby')).toBe(null);
|
|
650
|
+
});
|
|
651
|
+
});
|
package/src/Tooltip/Tooltip.tsx
CHANGED
|
@@ -242,28 +242,51 @@ export function Tooltip({
|
|
|
242
242
|
return;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
const attach = (el: HTMLElement) => {
|
|
246
|
+
// Use combined ref for position + interaction
|
|
247
|
+
tooltip.ref(el);
|
|
248
|
+
|
|
249
|
+
// Set aria-describedby, merging with existing
|
|
250
|
+
const existingDescribedBy = el.getAttribute('aria-describedby');
|
|
251
|
+
el.setAttribute(
|
|
252
|
+
'aria-describedby',
|
|
253
|
+
mergeIds(existingDescribedBy, tooltip.describedBy) ?? '',
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
tooltip.ref(null);
|
|
258
|
+
if (existingDescribedBy) {
|
|
259
|
+
el.setAttribute('aria-describedby', existingDescribedBy);
|
|
260
|
+
} else {
|
|
261
|
+
el.removeAttribute('aria-describedby');
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
245
266
|
const el = anchorRef.current;
|
|
246
|
-
if (
|
|
247
|
-
return;
|
|
267
|
+
if (el) {
|
|
268
|
+
return attach(el);
|
|
248
269
|
}
|
|
249
270
|
|
|
250
|
-
//
|
|
251
|
-
tooltip
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return () => {
|
|
261
|
-
tooltip.ref(null);
|
|
262
|
-
if (existingDescribedBy) {
|
|
263
|
-
el.setAttribute('aria-describedby', existingDescribedBy);
|
|
264
|
-
} else {
|
|
265
|
-
el.removeAttribute('aria-describedby');
|
|
271
|
+
// React attaches an ancestor's ref after its descendants' layout effects,
|
|
272
|
+
// so a tooltip rendered inside its own anchor (SideNavItem's collapsed
|
|
273
|
+
// rail tooltip) still sees null here on mount, and this effect's deps
|
|
274
|
+
// never change to re-run it. Re-check once the commit has attached all
|
|
275
|
+
// refs; if the anchor is still gone, this tooltip has no anchor, as before.
|
|
276
|
+
let detach: (() => void) | undefined;
|
|
277
|
+
let cancelled = false;
|
|
278
|
+
queueMicrotask(() => {
|
|
279
|
+
if (cancelled) {
|
|
280
|
+
return;
|
|
266
281
|
}
|
|
282
|
+
const el = anchorRef.current;
|
|
283
|
+
if (el) {
|
|
284
|
+
detach = attach(el);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return () => {
|
|
288
|
+
cancelled = true;
|
|
289
|
+
detach?.();
|
|
267
290
|
};
|
|
268
291
|
}, [anchorRef, tooltip.ref, tooltip.describedBy]);
|
|
269
292
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Tooltip.a11y.chromium.spec.ts
|
|
5
|
+
* @input Built tooltip stories and TOOLTIP_PATTERN in real Chromium
|
|
6
|
+
* @output Browser evidence for every tooltip expectation across Tooltip's and useTooltip's representative states
|
|
7
|
+
* @position Chromium lane of the Tooltip 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
|
+
TOOLTIP_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
|
+
TOOLTIP_BINDING_STATES,
|
|
37
|
+
type TooltipBindingState,
|
|
38
|
+
} from './Tooltip.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: TooltipBindingState,
|
|
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 =
|
|
64
|
+
state.triggerRole == null
|
|
65
|
+
? root.getByText(state.triggerText, {exact: true})
|
|
66
|
+
: root.getByRole(state.triggerRole, {
|
|
67
|
+
name: state.triggerText,
|
|
68
|
+
exact: true,
|
|
69
|
+
});
|
|
70
|
+
await expect(subject).toBeVisible();
|
|
71
|
+
return {
|
|
72
|
+
subject,
|
|
73
|
+
related: {
|
|
74
|
+
// The layer may mount the tooltip outside the story root, and it stays
|
|
75
|
+
// in the page while hidden, so it is found page-wide, hidden included.
|
|
76
|
+
tooltip: page.getByRole('tooltip', {includeHidden: true}),
|
|
77
|
+
before: root.getByRole('button', {
|
|
78
|
+
name: 'Before the trigger',
|
|
79
|
+
exact: true,
|
|
80
|
+
}),
|
|
81
|
+
after: root.getByRole('button', {
|
|
82
|
+
name: 'After the trigger',
|
|
83
|
+
exact: true,
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runState(
|
|
90
|
+
page: Page,
|
|
91
|
+
cdp: CDPSession,
|
|
92
|
+
state: TooltipBindingState,
|
|
93
|
+
): Promise<BindingResult> {
|
|
94
|
+
return checkAccessibilitySpec({
|
|
95
|
+
spec: TOOLTIP_PATTERN,
|
|
96
|
+
binding: 'Tooltip',
|
|
97
|
+
state: state.id,
|
|
98
|
+
facts: state.facts,
|
|
99
|
+
mount: async () => {
|
|
100
|
+
const {subject, related} = await mountState(page, state);
|
|
101
|
+
return createChromiumHarness({page, cdp, subject, related});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('every tooltip expectation is exercised by a Tooltip state', async ({
|
|
107
|
+
page,
|
|
108
|
+
}) => {
|
|
109
|
+
test.setTimeout(3 * 60 * 1000);
|
|
110
|
+
const cdp = await page.context().newCDPSession(page);
|
|
111
|
+
const results: BindingResult[] = [];
|
|
112
|
+
for (const state of TOOLTIP_BINDING_STATES) {
|
|
113
|
+
results.push(await runState(page, cdp, state));
|
|
114
|
+
}
|
|
115
|
+
expect(neverExercised(results)).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
for (const state of TOOLTIP_BINDING_STATES) {
|
|
119
|
+
test(`Tooltip [${state.id}] — ${state.summary}`, async ({page}) => {
|
|
120
|
+
test.setTimeout(2 * 60 * 1000);
|
|
121
|
+
const cdp = await page.context().newCDPSession(page);
|
|
122
|
+
const result = await runState(page, cdp, state);
|
|
123
|
+
const report = summarize(TOOLTIP_PATTERN, [result]);
|
|
124
|
+
// Required rows block (AST-020 FR9); advisory rows report.
|
|
125
|
+
expect(formatFailures(blockingResults([result]))).toBe('');
|
|
126
|
+
expect(report.unrunLayers).toEqual([]);
|
|
127
|
+
expect(report.counts.unexpectedPass).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file Tooltip.a11y.renders.tsx
|
|
7
|
+
* @input One tooltip binding state from Tooltip.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 trigger, the trigger and its tooltip, and a page button after it
|
|
9
|
+
* @position Shared fixture for the Tooltip and useTooltip binding
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {useRef, type ReactElement} from 'react';
|
|
13
|
+
import {Button} from '../../Button/Button';
|
|
14
|
+
import {isRenderable} from '../../utils/isRenderable';
|
|
15
|
+
import {Tooltip} from '../Tooltip';
|
|
16
|
+
import {useTooltip} from '../useTooltip';
|
|
17
|
+
import type {TooltipBindingState} from './Tooltip.a11y.states';
|
|
18
|
+
|
|
19
|
+
const HINT_ID = 'tooltip-a11y-hint';
|
|
20
|
+
|
|
21
|
+
type TriggerState = Pick<
|
|
22
|
+
TooltipBindingState,
|
|
23
|
+
'attachment' | 'triggerText' | 'content' | 'hint'
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
/** A caller button the tooltip attaches to through `anchorRef`. */
|
|
27
|
+
function AnchorRefTrigger({state}: {state: TriggerState}): ReactElement {
|
|
28
|
+
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<button
|
|
32
|
+
ref={anchorRef}
|
|
33
|
+
type="button"
|
|
34
|
+
aria-describedby={state.hint == null ? undefined : HINT_ID}>
|
|
35
|
+
{state.triggerText}
|
|
36
|
+
</button>
|
|
37
|
+
<Tooltip anchorRef={anchorRef} content={state.content} />
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A caller button wired by hand through `useTooltip`. */
|
|
43
|
+
function HookTrigger({state}: {state: TriggerState}): ReactElement {
|
|
44
|
+
const tooltip = useTooltip();
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
<button
|
|
48
|
+
ref={tooltip.ref}
|
|
49
|
+
type="button"
|
|
50
|
+
aria-describedby={tooltip.describedBy}>
|
|
51
|
+
{state.triggerText}
|
|
52
|
+
</button>
|
|
53
|
+
{tooltip.renderTooltip(state.content)}
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Trigger({state}: {state: TriggerState}): ReactElement {
|
|
59
|
+
switch (state.attachment) {
|
|
60
|
+
case 'wrapped-element':
|
|
61
|
+
return (
|
|
62
|
+
<Tooltip content={state.content}>
|
|
63
|
+
<Button label={state.triggerText} />
|
|
64
|
+
</Tooltip>
|
|
65
|
+
);
|
|
66
|
+
case 'text':
|
|
67
|
+
return <Tooltip content={state.content}>{state.triggerText}</Tooltip>;
|
|
68
|
+
case 'anchor-ref':
|
|
69
|
+
return <AnchorRefTrigger state={state} />;
|
|
70
|
+
case 'hook':
|
|
71
|
+
return <HookTrigger state={state} />;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderTooltipState(state: TriggerState): ReactElement {
|
|
76
|
+
return (
|
|
77
|
+
<div style={{padding: 64}}>
|
|
78
|
+
<button type="button">Before the trigger</button>{' '}
|
|
79
|
+
<Trigger state={state} />
|
|
80
|
+
{isRenderable(state.hint) ? (
|
|
81
|
+
<span id={HINT_ID}> {state.hint}</span>
|
|
82
|
+
) : null}{' '}
|
|
83
|
+
<button type="button">After the trigger</button>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|