@astryxdesign/core 0.1.2-canary.bfcbf64 → 0.1.2-canary.c395fca
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/Breadcrumbs/BreadcrumbItem.d.ts +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
- package/dist/Breadcrumbs/BreadcrumbItem.js +4 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +2 -4
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +33 -14
- package/dist/Calendar/hooks/index.d.ts +0 -2
- package/dist/Calendar/hooks/index.d.ts.map +1 -1
- package/dist/Calendar/hooks/index.js +1 -2
- package/dist/Calendar/index.d.ts +2 -2
- package/dist/Calendar/index.d.ts.map +1 -1
- package/dist/Calendar/index.js +1 -1
- package/dist/CheckboxList/CheckboxList.js +3 -3
- package/dist/Citation/Citation.d.ts.map +1 -1
- package/dist/Citation/Citation.js +3 -3
- package/dist/ContextMenu/ContextMenu.d.ts +1 -7
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +7 -13
- package/dist/DropdownMenu/DropdownMenu.d.ts +1 -8
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +4 -9
- package/dist/Field/Field.d.ts +4 -4
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +2 -2
- package/dist/Field/FieldLabel.d.ts +4 -4
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +2 -2
- package/dist/InputGroup/InputGroup.js +3 -3
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +2 -4
- package/dist/MoreMenu/MoreMenu.d.ts +1 -7
- package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
- package/dist/MoreMenu/MoreMenu.js +0 -2
- package/dist/MultiSelector/MultiSelector.d.ts +25 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +31 -6
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +2 -4
- package/dist/RadioList/RadioList.js +3 -3
- package/dist/SegmentedControl/SegmentedControl.d.ts +1 -1
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControl.js +51 -61
- package/dist/Selector/Selector.d.ts +22 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +31 -6
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +2 -4
- package/dist/TabList/TabList.d.ts +3 -2
- package/dist/TabList/TabList.d.ts.map +1 -1
- package/dist/TabList/TabList.js +55 -33
- package/dist/Table/tableContextMenu.d.ts.map +1 -1
- package/dist/Table/tableContextMenu.js +0 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +2 -4
- package/dist/Toolbar/Toolbar.d.ts +3 -3
- package/dist/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar/Toolbar.js +46 -6
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +16 -22
- package/dist/TreeList/TreeListItem.d.ts +3 -2
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/astryx.css +3 -0
- package/dist/astryx.umd.js +47 -47
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/index.d.ts +4 -2
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useGridFocus.d.ts +56 -0
- package/dist/hooks/useGridFocus.d.ts.map +1 -1
- package/dist/hooks/useGridFocus.js +138 -24
- package/dist/hooks/useKeyboardHint.d.ts +82 -0
- package/dist/hooks/useKeyboardHint.d.ts.map +1 -0
- package/dist/hooks/useKeyboardHint.js +220 -0
- package/dist/hooks/useTreeFocus.d.ts +27 -3
- package/dist/hooks/useTreeFocus.d.ts.map +1 -1
- package/dist/hooks/useTreeFocus.js +69 -6
- package/package.json +1 -1
- package/src/Breadcrumbs/BreadcrumbItem.tsx +5 -2
- package/src/Button/Button.tsx +3 -16
- package/src/Calendar/Calendar.tsx +52 -22
- package/src/Calendar/hooks/index.ts +0 -6
- package/src/Calendar/index.ts +0 -3
- package/src/CheckboxList/CheckboxList.tsx +3 -3
- package/src/Citation/Citation.doc.mjs +10 -0
- package/src/Citation/Citation.test.tsx +117 -0
- package/src/Citation/Citation.tsx +9 -1
- package/src/ContextMenu/ContextMenu.doc.mjs +0 -6
- package/src/ContextMenu/ContextMenu.test.tsx +4 -6
- package/src/ContextMenu/ContextMenu.tsx +7 -19
- package/src/DropdownMenu/DropdownMenu.doc.mjs +1 -8
- package/src/DropdownMenu/DropdownMenu.test.tsx +0 -23
- package/src/DropdownMenu/DropdownMenu.tsx +4 -16
- package/src/Field/Field.test.tsx +2 -2
- package/src/Field/Field.tsx +5 -5
- package/src/Field/FieldLabel.tsx +5 -5
- package/src/InputGroup/InputGroup.tsx +3 -3
- package/src/Link/Link.tsx +2 -13
- package/src/MoreMenu/MoreMenu.doc.mjs +2 -17
- package/src/MoreMenu/MoreMenu.tsx +0 -9
- package/src/MultiSelector/MultiSelector.doc.mjs +25 -0
- package/src/MultiSelector/MultiSelector.test.tsx +150 -1
- package/src/MultiSelector/MultiSelector.tsx +55 -3
- package/src/ProgressBar/ProgressBar.tsx +2 -3
- package/src/RadioList/RadioList.tsx +3 -3
- package/src/SegmentedControl/SegmentedControl.tsx +51 -77
- package/src/Selector/Selector.doc.mjs +21 -0
- package/src/Selector/Selector.test.tsx +123 -1
- package/src/Selector/Selector.tsx +53 -3
- package/src/Switch/Switch.tsx +2 -16
- package/src/TabList/TabList.test.tsx +41 -0
- package/src/TabList/TabList.tsx +66 -39
- package/src/Table/tableContextMenu.tsx +1 -5
- package/src/TextArea/TextArea.tsx +3 -13
- package/src/Toolbar/Toolbar.test.tsx +64 -6
- package/src/Toolbar/Toolbar.tsx +55 -4
- package/src/TreeList/TreeList.tsx +17 -28
- package/src/TreeList/TreeListItem.tsx +3 -2
- package/src/VisuallyHidden/VisuallyHidden.doc.mjs +7 -7
- package/src/hooks/index.ts +12 -5
- package/src/hooks/useGridFocus.doc.mjs +40 -2
- package/src/hooks/useGridFocus.test.tsx +132 -0
- package/src/hooks/useGridFocus.ts +179 -23
- package/src/hooks/useKeyboardHint.doc.mjs +101 -0
- package/src/hooks/useKeyboardHint.test.tsx +70 -0
- package/src/hooks/useKeyboardHint.tsx +332 -0
- package/src/hooks/useTreeFocus.doc.mjs +15 -1
- package/src/hooks/useTreeFocus.ts +101 -6
- package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts +0 -57
- package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts.map +0 -1
- package/dist/Calendar/hooks/useCalendarRovingTabindex.js +0 -96
- package/src/Calendar/hooks/useCalendarRovingTabindex.ts +0 -118
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Citation.test.tsx
|
|
5
|
+
* @input Uses React Testing Library, Citation, theme tokens
|
|
6
|
+
* @output Tests for Citation component
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {render, screen} from '@testing-library/react';
|
|
10
|
+
import {describe, it, expect} from 'vitest';
|
|
11
|
+
import * as stylex from '@stylexjs/stylex';
|
|
12
|
+
import {colorVars} from '../theme/tokens.stylex';
|
|
13
|
+
import {Citation} from './Citation';
|
|
14
|
+
|
|
15
|
+
// StyleX emits one deterministic atomic class per property/value pair, so an
|
|
16
|
+
// element carries a probe's class exactly when it has the same declaration.
|
|
17
|
+
// The dev-mode debug class (contains "__") varies by source location and is
|
|
18
|
+
// excluded from the comparison.
|
|
19
|
+
const probe = stylex.create({
|
|
20
|
+
secondaryText: {color: colorVars['--color-text-secondary']},
|
|
21
|
+
accentText: {color: colorVars['--color-text-accent']},
|
|
22
|
+
badgeBackground: {backgroundColor: colorVars['--color-accent-muted']},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function atomicClasses(style: (typeof probe)[keyof typeof probe]): string[] {
|
|
26
|
+
const {className = ''} = stylex.props(style);
|
|
27
|
+
return className.split(' ').filter(c => c !== '' && !c.includes('__'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Citation', () => {
|
|
31
|
+
const source = {title: 'Example Source', url: 'https://example.com'};
|
|
32
|
+
|
|
33
|
+
it('renders the source title as a link in the label variant', () => {
|
|
34
|
+
render(<Citation source={source} number={1} data-testid="citation" />);
|
|
35
|
+
const el = screen.getByTestId('citation');
|
|
36
|
+
expect(el.tagName).toBe('A');
|
|
37
|
+
expect(el).toHaveAttribute('href', 'https://example.com');
|
|
38
|
+
expect(el).toHaveTextContent('Example Source');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders the index as a badge in the number variant', () => {
|
|
42
|
+
render(
|
|
43
|
+
<Citation
|
|
44
|
+
source={source}
|
|
45
|
+
number={3}
|
|
46
|
+
variant="number"
|
|
47
|
+
data-testid="citation"
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
const el = screen.getByTestId('citation');
|
|
51
|
+
expect(el).toHaveTextContent('3');
|
|
52
|
+
expect(el).toHaveAttribute('role', 'doc-noteref');
|
|
53
|
+
expect(el).toHaveAttribute('aria-label', 'Citation 3: Example Source');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders as a span when the source has no url', () => {
|
|
57
|
+
render(
|
|
58
|
+
<Citation
|
|
59
|
+
source={{title: 'No link'}}
|
|
60
|
+
number={1}
|
|
61
|
+
data-testid="citation"
|
|
62
|
+
/>,
|
|
63
|
+
);
|
|
64
|
+
expect(screen.getByTestId('citation').tagName).toBe('SPAN');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders astryx-* class names for theme targeting', () => {
|
|
68
|
+
render(<Citation source={source} number={1} data-testid="citation" />);
|
|
69
|
+
expect(screen.getByTestId('citation').className).toContain(
|
|
70
|
+
'astryx-citation',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('uses the secondary text color in the label variant', () => {
|
|
75
|
+
render(<Citation source={source} number={1} data-testid="citation" />);
|
|
76
|
+
const el = screen.getByTestId('citation');
|
|
77
|
+
for (const cls of atomicClasses(probe.secondaryText)) {
|
|
78
|
+
expect(el.classList.contains(cls)).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses the secondary text color, not accent, in the number variant', () => {
|
|
83
|
+
render(
|
|
84
|
+
<Citation
|
|
85
|
+
source={source}
|
|
86
|
+
number={1}
|
|
87
|
+
variant="number"
|
|
88
|
+
data-testid="citation"
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
const el = screen.getByTestId('citation');
|
|
92
|
+
for (const cls of atomicClasses(probe.secondaryText)) {
|
|
93
|
+
expect(el.classList.contains(cls)).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
for (const cls of atomicClasses(probe.accentText)) {
|
|
96
|
+
expect(el.classList.contains(cls)).toBe(false);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps the accent-muted badge background when the source has a url', () => {
|
|
101
|
+
// `numberHover` must not clobber the base background: a hover-only
|
|
102
|
+
// conditional without a default replaces the whole property on merge,
|
|
103
|
+
// leaving linked badges with a transparent pill.
|
|
104
|
+
render(
|
|
105
|
+
<Citation
|
|
106
|
+
source={source}
|
|
107
|
+
number={1}
|
|
108
|
+
variant="number"
|
|
109
|
+
data-testid="citation"
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
const el = screen.getByTestId('citation');
|
|
113
|
+
for (const cls of atomicClasses(probe.badgeBackground)) {
|
|
114
|
+
expect(el.classList.contains(cls)).toBe(true);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -83,6 +83,10 @@ const styles = stylex.create({
|
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
85
|
color: {
|
|
86
|
+
// Explicit default: without it, this hover-only conditional replaces
|
|
87
|
+
// the base secondary color from `label` (last-wins property merge),
|
|
88
|
+
// leaving linked citations to inherit the surrounding text color.
|
|
89
|
+
default: colorVars['--color-text-secondary'],
|
|
86
90
|
':hover': {
|
|
87
91
|
'@media (hover: hover)': colorVars['--color-text-primary'],
|
|
88
92
|
},
|
|
@@ -96,7 +100,7 @@ const styles = stylex.create({
|
|
|
96
100
|
fontSize: typeScaleVars['--text-supporting-size'],
|
|
97
101
|
fontWeight: fontWeightVars['--font-weight-semibold'],
|
|
98
102
|
lineHeight: typeScaleVars['--text-supporting-leading'],
|
|
99
|
-
color: colorVars['--color-text-
|
|
103
|
+
color: colorVars['--color-text-secondary'],
|
|
100
104
|
backgroundColor: colorVars['--color-accent-muted'],
|
|
101
105
|
borderRadius: radiusVars['--radius-full'],
|
|
102
106
|
minWidth: spacingVars['--spacing-5'],
|
|
@@ -110,6 +114,10 @@ const styles = stylex.create({
|
|
|
110
114
|
},
|
|
111
115
|
numberHover: {
|
|
112
116
|
backgroundColor: {
|
|
117
|
+
// Explicit default for the same reason as labelHover's color: a
|
|
118
|
+
// hover-only conditional replaces the base accent-muted pill from
|
|
119
|
+
// `number` on merge, leaving linked badges transparent.
|
|
120
|
+
default: colorVars['--color-accent-muted'],
|
|
113
121
|
':hover': {
|
|
114
122
|
'@media (hover: hover)': colorVars['--color-overlay-hover'],
|
|
115
123
|
},
|
|
@@ -58,12 +58,6 @@ export const docs = {
|
|
|
58
58
|
description: 'Accessible name for the menu surface, announced when it opens.',
|
|
59
59
|
default: "'Context menu'",
|
|
60
60
|
},
|
|
61
|
-
{
|
|
62
|
-
name: 'hasAutoFocus',
|
|
63
|
-
type: 'boolean',
|
|
64
|
-
description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases.',
|
|
65
|
-
default: 'true',
|
|
66
|
-
},
|
|
67
61
|
{
|
|
68
62
|
name: 'isDisabled',
|
|
69
63
|
type: 'boolean',
|
|
@@ -62,9 +62,7 @@ describe('ContextMenu', () => {
|
|
|
62
62
|
|
|
63
63
|
it('typeahead focuses the matching menu item (menus-11)', () => {
|
|
64
64
|
render(
|
|
65
|
-
<ContextMenu
|
|
66
|
-
items={[{label: 'Cut'}, {label: 'Copy'}, {label: 'Paste'}]}
|
|
67
|
-
hasAutoFocus={false}>
|
|
65
|
+
<ContextMenu items={[{label: 'Cut'}, {label: 'Copy'}, {label: 'Paste'}]}>
|
|
68
66
|
<div>Right-click me</div>
|
|
69
67
|
</ContextMenu>,
|
|
70
68
|
);
|
|
@@ -121,7 +119,7 @@ describe('ContextMenu', () => {
|
|
|
121
119
|
|
|
122
120
|
it('closes on Escape even when opened without auto-focus', () => {
|
|
123
121
|
render(
|
|
124
|
-
<ContextMenu items={[{label: 'Item 1'}]}
|
|
122
|
+
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
125
123
|
<div>Right-click me</div>
|
|
126
124
|
</ContextMenu>,
|
|
127
125
|
);
|
|
@@ -135,7 +133,7 @@ describe('ContextMenu', () => {
|
|
|
135
133
|
|
|
136
134
|
it('ignores Escape during IME composition', () => {
|
|
137
135
|
render(
|
|
138
|
-
<ContextMenu items={[{label: 'Item 1'}]}
|
|
136
|
+
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
139
137
|
<div>Right-click me</div>
|
|
140
138
|
</ContextMenu>,
|
|
141
139
|
);
|
|
@@ -147,7 +145,7 @@ describe('ContextMenu', () => {
|
|
|
147
145
|
|
|
148
146
|
it('restores focus to the trigger on close', () => {
|
|
149
147
|
render(
|
|
150
|
-
<ContextMenu items={[{label: 'Item 1'}]}
|
|
148
|
+
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
151
149
|
<button type="button">Right-click me</button>
|
|
152
150
|
</ContextMenu>,
|
|
153
151
|
);
|
|
@@ -125,12 +125,6 @@ interface ContextMenuBaseProps extends BaseProps {
|
|
|
125
125
|
* @default 'Context menu'
|
|
126
126
|
*/
|
|
127
127
|
label?: string;
|
|
128
|
-
/**
|
|
129
|
-
* Whether to auto-focus the first menu item when the menu opens.
|
|
130
|
-
* Set to `false` for inline showcases or documentation previews.
|
|
131
|
-
* @default true
|
|
132
|
-
*/
|
|
133
|
-
hasAutoFocus?: boolean;
|
|
134
128
|
/** When true, right-click shows the native browser context menu instead. */
|
|
135
129
|
isDisabled?: boolean;
|
|
136
130
|
/** Called when the menu opens or closes. */
|
|
@@ -184,7 +178,6 @@ export function ContextMenu({
|
|
|
184
178
|
menuWidth,
|
|
185
179
|
size = 'md',
|
|
186
180
|
label = 'Context menu',
|
|
187
|
-
hasAutoFocus = true,
|
|
188
181
|
isDisabled = false,
|
|
189
182
|
onOpenChange,
|
|
190
183
|
ref,
|
|
@@ -282,10 +275,9 @@ export function ContextMenu({
|
|
|
282
275
|
}, [isOpen, closeMenu, listRef]);
|
|
283
276
|
|
|
284
277
|
// Dismiss on Escape from anywhere while open. The menu div's own onKeyDown
|
|
285
|
-
// only fires when focus is inside the menu
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// against IME composition-cancel.
|
|
278
|
+
// only fires when focus is inside the menu; a document-level listener is
|
|
279
|
+
// kept as a reliable fallback Escape path (e.g. if focus has moved out of
|
|
280
|
+
// the menu). Guards against IME composition-cancel.
|
|
289
281
|
useEffect(() => {
|
|
290
282
|
if (!isOpen) {
|
|
291
283
|
return;
|
|
@@ -350,11 +342,9 @@ export function ContextMenu({
|
|
|
350
342
|
? document.activeElement
|
|
351
343
|
: (e.currentTarget as HTMLElement);
|
|
352
344
|
layer.show();
|
|
353
|
-
|
|
354
|
-
requestAnimationFrame(() => focusFirst());
|
|
355
|
-
}
|
|
345
|
+
requestAnimationFrame(() => focusFirst());
|
|
356
346
|
},
|
|
357
|
-
[isDisabled, layer,
|
|
347
|
+
[isDisabled, layer, focusFirst],
|
|
358
348
|
);
|
|
359
349
|
|
|
360
350
|
// Touch long-press invocation (menus-8). iOS Safari never synthesizes a
|
|
@@ -367,11 +357,9 @@ export function ContextMenu({
|
|
|
367
357
|
(point: {x: number; y: number}) => {
|
|
368
358
|
positionRef.current = {x: point.x, y: point.y};
|
|
369
359
|
layer.show();
|
|
370
|
-
|
|
371
|
-
requestAnimationFrame(() => focusFirst());
|
|
372
|
-
}
|
|
360
|
+
requestAnimationFrame(() => focusFirst());
|
|
373
361
|
},
|
|
374
|
-
[layer,
|
|
362
|
+
[layer, focusFirst],
|
|
375
363
|
),
|
|
376
364
|
});
|
|
377
365
|
|
|
@@ -61,14 +61,7 @@ export const docs = {
|
|
|
61
61
|
type: 'boolean',
|
|
62
62
|
description: 'Whether to show a chevron icon on the trigger button. Set to false for icon-only triggers.',
|
|
63
63
|
default: 'true',
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: 'hasAutoFocus',
|
|
67
|
-
type: 'boolean',
|
|
68
|
-
description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases or documentation previews.',
|
|
69
|
-
default: 'true',
|
|
70
|
-
},
|
|
71
|
-
{
|
|
64
|
+
}, {
|
|
72
65
|
name: 'children',
|
|
73
66
|
type: '(item: DropdownMenuItemData) => ReactNode',
|
|
74
67
|
description: 'Custom render function for each item in the list.',
|
|
@@ -249,29 +249,6 @@ describe('DropdownMenu controlled mode', () => {
|
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
-
describe('DropdownMenu hasAutoFocus', () => {
|
|
253
|
-
it('does not focus menu items when hasAutoFocus is false and isMenuOpen is true', () => {
|
|
254
|
-
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus');
|
|
255
|
-
render(
|
|
256
|
-
<DropdownMenu
|
|
257
|
-
button={{label: 'Actions'}}
|
|
258
|
-
items={[{label: 'Edit'}, {label: 'Delete'}]}
|
|
259
|
-
isMenuOpen={true}
|
|
260
|
-
hasAutoFocus={false}
|
|
261
|
-
onOpenChange={() => {}}
|
|
262
|
-
/>,
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
const menuItems = screen.getAllByRole('menuitem', {hidden: true});
|
|
266
|
-
const menuItemFocusCalls = focusSpy.mock.calls.filter((_, i) => {
|
|
267
|
-
const ctx = focusSpy.mock.contexts[i];
|
|
268
|
-
return menuItems.includes(ctx as HTMLElement);
|
|
269
|
-
});
|
|
270
|
-
expect(menuItemFocusCalls).toHaveLength(0);
|
|
271
|
-
focusSpy.mockRestore();
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
275
252
|
describe('DropdownMenu items', () => {
|
|
276
253
|
it('renders items with labels', () => {
|
|
277
254
|
render(
|
|
@@ -135,13 +135,6 @@ interface DropdownMenuBaseProps extends BaseProps {
|
|
|
135
135
|
*/
|
|
136
136
|
placement?: LayerPlacement;
|
|
137
137
|
|
|
138
|
-
/**
|
|
139
|
-
* Whether to auto-focus the first menu item when the menu opens.
|
|
140
|
-
* Set to `false` for inline showcases or documentation previews
|
|
141
|
-
* where stealing focus is undesirable.
|
|
142
|
-
* @default true
|
|
143
|
-
*/
|
|
144
|
-
hasAutoFocus?: boolean;
|
|
145
138
|
'data-testid'?: string;
|
|
146
139
|
}
|
|
147
140
|
|
|
@@ -193,7 +186,6 @@ export function DropdownMenu({
|
|
|
193
186
|
onClick,
|
|
194
187
|
hasChevron = true,
|
|
195
188
|
placement = 'below',
|
|
196
|
-
hasAutoFocus = true,
|
|
197
189
|
className,
|
|
198
190
|
style,
|
|
199
191
|
xstyle,
|
|
@@ -299,14 +291,12 @@ export function DropdownMenu({
|
|
|
299
291
|
if (isControlled) {
|
|
300
292
|
if (controlledIsOpen && !popover.isOpen) {
|
|
301
293
|
popover.show();
|
|
302
|
-
|
|
303
|
-
requestAnimationFrame(() => focusFirst());
|
|
304
|
-
}
|
|
294
|
+
requestAnimationFrame(() => focusFirst());
|
|
305
295
|
} else if (!controlledIsOpen && popover.isOpen) {
|
|
306
296
|
popover.hide();
|
|
307
297
|
}
|
|
308
298
|
}
|
|
309
|
-
}, [controlledIsOpen, isControlled, popover,
|
|
299
|
+
}, [controlledIsOpen, isControlled, popover, focusFirst]);
|
|
310
300
|
|
|
311
301
|
// Extend useListFocus with Enter/Space activation + typeahead
|
|
312
302
|
const listKeyDown = useCallback(
|
|
@@ -340,10 +330,8 @@ export function DropdownMenu({
|
|
|
340
330
|
|
|
341
331
|
const openAndFocus = useCallback(() => {
|
|
342
332
|
popover.show();
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
}, [popover, hasAutoFocus, focusFirst]);
|
|
333
|
+
requestAnimationFrame(() => focusFirst());
|
|
334
|
+
}, [popover, focusFirst]);
|
|
347
335
|
|
|
348
336
|
const handleButtonClick = useCallback(() => {
|
|
349
337
|
// If the menu was just closed by light dismiss (e.g. iOS Safari fires
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -109,7 +109,7 @@ describe('Field', () => {
|
|
|
109
109
|
<Field
|
|
110
110
|
label="Plan"
|
|
111
111
|
inputID="plan-group"
|
|
112
|
-
|
|
112
|
+
labelID="plan-label"
|
|
113
113
|
isGroupLabel>
|
|
114
114
|
<div role="radiogroup" aria-labelledby="plan-label" />
|
|
115
115
|
</Field>,
|
|
@@ -119,7 +119,7 @@ describe('Field', () => {
|
|
|
119
119
|
expect(labelEl.tagName).toBe('SPAN');
|
|
120
120
|
expect(labelEl.closest('label')).toBeNull();
|
|
121
121
|
expect(labelEl).not.toHaveAttribute('for');
|
|
122
|
-
//
|
|
122
|
+
// labelID is applied to the label element and referenced by the group.
|
|
123
123
|
expect(labelEl).toHaveAttribute('id', 'plan-label');
|
|
124
124
|
const group = screen.getByRole('radiogroup', {name: 'Plan'});
|
|
125
125
|
expect(group.getAttribute('aria-labelledby')).toBe(labelEl.id);
|
package/src/Field/Field.tsx
CHANGED
|
@@ -101,7 +101,7 @@ export interface FieldProps extends Omit<
|
|
|
101
101
|
/**
|
|
102
102
|
* ID of the input element this label points AT (used as the label's
|
|
103
103
|
* `htmlFor`). This is the id of the *control*, not of the label element —
|
|
104
|
-
* see `
|
|
104
|
+
* see `labelID` for the latter.
|
|
105
105
|
*/
|
|
106
106
|
inputID: string;
|
|
107
107
|
/**
|
|
@@ -110,12 +110,12 @@ export interface FieldProps extends Omit<
|
|
|
110
110
|
* (radiogroup, checkbox group) references this via `aria-labelledby` to take
|
|
111
111
|
* the label as its accessible name. Pair with `isGroupLabel`.
|
|
112
112
|
*/
|
|
113
|
-
|
|
113
|
+
labelID?: string;
|
|
114
114
|
/**
|
|
115
115
|
* When the field wraps a group of controls rather than a single input, set
|
|
116
116
|
* this so the label renders as a non-`<label>` element (a `<span>`): a
|
|
117
117
|
* `<label>` semantically names one control and can't be associated with a
|
|
118
|
-
* group. Pair with `
|
|
118
|
+
* group. Pair with `labelID` + `aria-labelledby` on the group.
|
|
119
119
|
* @default false
|
|
120
120
|
*/
|
|
121
121
|
isGroupLabel?: boolean;
|
|
@@ -189,7 +189,7 @@ export function Field({
|
|
|
189
189
|
isLabelHidden = false,
|
|
190
190
|
description,
|
|
191
191
|
inputID,
|
|
192
|
-
|
|
192
|
+
labelID,
|
|
193
193
|
isGroupLabel = false,
|
|
194
194
|
descriptionID,
|
|
195
195
|
isOptional = false,
|
|
@@ -225,7 +225,7 @@ export function Field({
|
|
|
225
225
|
<FieldLabel
|
|
226
226
|
label={label}
|
|
227
227
|
inputID={inputID}
|
|
228
|
-
|
|
228
|
+
labelID={labelID}
|
|
229
229
|
isGroupLabel={isGroupLabel}
|
|
230
230
|
isLabelHidden={isLabelHidden}
|
|
231
231
|
isDisabled={isDisabled}
|
package/src/Field/FieldLabel.tsx
CHANGED
|
@@ -84,7 +84,7 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
|
|
|
84
84
|
/**
|
|
85
85
|
* ID of the input element this label points AT (rendered as `htmlFor` on the
|
|
86
86
|
* label). This is *not* the id of the label element itself — see
|
|
87
|
-
* `
|
|
87
|
+
* `labelID` for that.
|
|
88
88
|
*/
|
|
89
89
|
inputID: string;
|
|
90
90
|
/**
|
|
@@ -93,14 +93,14 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
|
|
|
93
93
|
* reference this via `aria-labelledby` to take the label as its accessible
|
|
94
94
|
* name.
|
|
95
95
|
*/
|
|
96
|
-
|
|
96
|
+
labelID?: string;
|
|
97
97
|
/**
|
|
98
98
|
* When true, the field wraps a *group* of controls (e.g. a radiogroup)
|
|
99
99
|
* rather than a single input. In that case the label is rendered as a
|
|
100
100
|
* `<span>` instead of a `<label>` — a `<label>` semantically names one form
|
|
101
101
|
* control and can't be associated with a group, so it must not be a literal
|
|
102
102
|
* label element. The group takes the label as its name via
|
|
103
|
-
* `
|
|
103
|
+
* `labelID` + `aria-labelledby`.
|
|
104
104
|
* @default false
|
|
105
105
|
*/
|
|
106
106
|
isGroupLabel?: boolean;
|
|
@@ -161,7 +161,7 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
|
|
|
161
161
|
export function FieldLabel({
|
|
162
162
|
label,
|
|
163
163
|
inputID,
|
|
164
|
-
|
|
164
|
+
labelID,
|
|
165
165
|
isGroupLabel = false,
|
|
166
166
|
isLabelHidden = false,
|
|
167
167
|
isDisabled = false,
|
|
@@ -203,7 +203,7 @@ export function FieldLabel({
|
|
|
203
203
|
<>
|
|
204
204
|
<LabelElement
|
|
205
205
|
ref={ref}
|
|
206
|
-
id={
|
|
206
|
+
id={labelID}
|
|
207
207
|
// `htmlFor` only applies to a real `<label>` associating with a single
|
|
208
208
|
// control; a group label (span) has no `htmlFor`.
|
|
209
209
|
htmlFor={isGroupLabel ? undefined : inputID}
|
|
@@ -156,7 +156,7 @@ export function InputGroup({
|
|
|
156
156
|
}: InputGroupProps) {
|
|
157
157
|
const size = useSize(sizeProp, 'md');
|
|
158
158
|
const inputId = useId();
|
|
159
|
-
const
|
|
159
|
+
const labelID = useId();
|
|
160
160
|
const statusMessageId = useId();
|
|
161
161
|
|
|
162
162
|
const contextValue = useMemo(() => ({isInGroup: true as const}), []);
|
|
@@ -169,7 +169,7 @@ export function InputGroup({
|
|
|
169
169
|
isLabelHidden={isLabelHidden}
|
|
170
170
|
description={description}
|
|
171
171
|
inputID={inputId}
|
|
172
|
-
|
|
172
|
+
labelID={labelID}
|
|
173
173
|
isGroupLabel
|
|
174
174
|
isOptional={isOptional}
|
|
175
175
|
isRequired={isRequired}
|
|
@@ -188,7 +188,7 @@ export function InputGroup({
|
|
|
188
188
|
<div
|
|
189
189
|
ref={ref}
|
|
190
190
|
role="group"
|
|
191
|
-
aria-labelledby={
|
|
191
|
+
aria-labelledby={labelID}
|
|
192
192
|
data-testid={testId}
|
|
193
193
|
{...rest}
|
|
194
194
|
{...mergeProps(
|
package/src/Link/Link.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import {Icon} from '../Icon';
|
|
31
31
|
import {Tooltip} from '../Tooltip';
|
|
32
32
|
import {Text} from '../Text';
|
|
33
|
+
import {VisuallyHidden} from '../VisuallyHidden';
|
|
33
34
|
import type {
|
|
34
35
|
TextType,
|
|
35
36
|
TextSize,
|
|
@@ -98,18 +99,6 @@ const styles = stylex.create({
|
|
|
98
99
|
fontSize: typeScaleVars['--text-body-size'],
|
|
99
100
|
lineHeight: typeScaleVars['--text-body-leading'],
|
|
100
101
|
},
|
|
101
|
-
// Screen-reader-only text (announces the new-tab context change).
|
|
102
|
-
visuallyHidden: {
|
|
103
|
-
position: 'absolute',
|
|
104
|
-
width: 1,
|
|
105
|
-
height: 1,
|
|
106
|
-
margin: -1,
|
|
107
|
-
padding: 0,
|
|
108
|
-
overflow: 'hidden',
|
|
109
|
-
clip: 'rect(0, 0, 0, 0)',
|
|
110
|
-
whiteSpace: 'nowrap',
|
|
111
|
-
borderStyle: 'none',
|
|
112
|
-
},
|
|
113
102
|
});
|
|
114
103
|
|
|
115
104
|
/**
|
|
@@ -345,7 +334,7 @@ export function Link({
|
|
|
345
334
|
{isExternalLink && !renderAsButton && (
|
|
346
335
|
<>
|
|
347
336
|
<Icon icon="externalLink" size="xsm" color="inherit" />
|
|
348
|
-
<
|
|
337
|
+
<VisuallyHidden>{newTabLabel}</VisuallyHidden>
|
|
349
338
|
</>
|
|
350
339
|
)}
|
|
351
340
|
</>
|
|
@@ -46,14 +46,7 @@ export const docs = {
|
|
|
46
46
|
type: 'boolean',
|
|
47
47
|
description: 'Whether the menu trigger is disabled.',
|
|
48
48
|
default: 'false',
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
name: 'hasAutoFocus',
|
|
52
|
-
type: 'boolean',
|
|
53
|
-
description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases or documentation previews.',
|
|
54
|
-
default: 'true',
|
|
55
|
-
},
|
|
56
|
-
{
|
|
49
|
+
}, {
|
|
57
50
|
name: 'xstyle',
|
|
58
51
|
type: 'StyleXStyles',
|
|
59
52
|
description:
|
|
@@ -126,14 +119,7 @@ export const docsZh = {
|
|
|
126
119
|
type: 'boolean',
|
|
127
120
|
description: '菜单触发器是否禁用。',
|
|
128
121
|
default: 'false',
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
name: 'hasAutoFocus',
|
|
132
|
-
type: 'boolean',
|
|
133
|
-
description: '菜单打开时是否自动聚焦第一个菜单项。内联展示或文档预览设为 false。',
|
|
134
|
-
default: 'true',
|
|
135
|
-
},
|
|
136
|
-
{
|
|
122
|
+
}, {
|
|
137
123
|
name: 'xstyle',
|
|
138
124
|
type: 'StyleXStyles',
|
|
139
125
|
description:
|
|
@@ -176,7 +162,6 @@ export const docsDense = {
|
|
|
176
162
|
size: 'Trigger button size.',
|
|
177
163
|
icon: 'Override default three-dot icon. Accepts any ReactNode.',
|
|
178
164
|
isDisabled: 'Whether menu trigger disabled.',
|
|
179
|
-
hasAutoFocus: 'Auto-focus first item on open; false for showcases.',
|
|
180
165
|
xstyle:
|
|
181
166
|
'StyleX styles for layout customization (margins, positioning, sizing). Must be stylex.create() value.',
|
|
182
167
|
},
|
|
@@ -81,13 +81,6 @@ export interface MoreMenuProps extends Pick<
|
|
|
81
81
|
*/
|
|
82
82
|
onOpenChange?: (isOpen: boolean) => void;
|
|
83
83
|
|
|
84
|
-
/**
|
|
85
|
-
* Whether to auto-focus the first menu item when the menu opens.
|
|
86
|
-
* Set to `false` for inline showcases or documentation previews.
|
|
87
|
-
* @default true
|
|
88
|
-
*/
|
|
89
|
-
hasAutoFocus?: boolean;
|
|
90
|
-
|
|
91
84
|
/** Test ID for testing frameworks. */
|
|
92
85
|
'data-testid'?: string;
|
|
93
86
|
}
|
|
@@ -116,7 +109,6 @@ export function MoreMenu({
|
|
|
116
109
|
isDisabled = false,
|
|
117
110
|
isMenuOpen,
|
|
118
111
|
onOpenChange,
|
|
119
|
-
hasAutoFocus,
|
|
120
112
|
xstyle,
|
|
121
113
|
className: classNameProp,
|
|
122
114
|
style,
|
|
@@ -149,7 +141,6 @@ export function MoreMenu({
|
|
|
149
141
|
}}
|
|
150
142
|
items={items}
|
|
151
143
|
hasChevron={false}
|
|
152
|
-
hasAutoFocus={hasAutoFocus}
|
|
153
144
|
data-testid={testId}
|
|
154
145
|
/>
|
|
155
146
|
);
|
|
@@ -112,6 +112,12 @@ export const docs = {
|
|
|
112
112
|
type: 'boolean',
|
|
113
113
|
description: 'Disables the selector.',
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
name: 'disabledMessage',
|
|
117
|
+
type: 'string',
|
|
118
|
+
description:
|
|
119
|
+
'Explains why the selector is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the trigger focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled MultiSelector in Tooltip — disabled controls swallow the hover events an external Tooltip needs.',
|
|
120
|
+
},
|
|
115
121
|
{
|
|
116
122
|
name: 'isLabelHidden',
|
|
117
123
|
type: 'boolean',
|
|
@@ -189,6 +195,11 @@ export const docs = {
|
|
|
189
195
|
guidance: false,
|
|
190
196
|
description: 'Show more than ~20 options without enabling search.',
|
|
191
197
|
},
|
|
198
|
+
{
|
|
199
|
+
guidance: false,
|
|
200
|
+
description:
|
|
201
|
+
'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
|
|
202
|
+
},
|
|
192
203
|
],
|
|
193
204
|
},
|
|
194
205
|
};
|
|
@@ -217,6 +228,8 @@ export const docsZh = {
|
|
|
217
228
|
hasSearch: '是否显示用于过滤选项的搜索输入。',
|
|
218
229
|
searchPlaceholder: '搜索输入的占位文本。',
|
|
219
230
|
isDisabled: '禁用选择器。',
|
|
231
|
+
disabledMessage:
|
|
232
|
+
'解释选择器被禁用的原因。与 isDisabled 一起使用时,悬停/键盘聚焦时显示工具提示,并通过 aria-disabled 保持触发器可聚焦(仍无法激活)。请使用此属性,而不是用 Tooltip 包裹被禁用的选择器。',
|
|
220
233
|
isLabelHidden: '视觉上隐藏标签同时保持其可访问性。',
|
|
221
234
|
description: '标签下方显示的辅助文本。',
|
|
222
235
|
isOptional: '将字段标记为可选。',
|
|
@@ -261,6 +274,11 @@ export const docsZh = {
|
|
|
261
274
|
guidance: false,
|
|
262
275
|
description: 'Show more than ~20 options without enabling search.',
|
|
263
276
|
},
|
|
277
|
+
{
|
|
278
|
+
guidance: false,
|
|
279
|
+
description:
|
|
280
|
+
'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
|
|
281
|
+
},
|
|
264
282
|
],
|
|
265
283
|
},
|
|
266
284
|
};
|
|
@@ -301,6 +319,11 @@ export const docsDense = {
|
|
|
301
319
|
guidance: false,
|
|
302
320
|
description: 'Show more than ~20 options without enabling search.',
|
|
303
321
|
},
|
|
322
|
+
{
|
|
323
|
+
guidance: false,
|
|
324
|
+
description:
|
|
325
|
+
'Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
|
|
326
|
+
},
|
|
304
327
|
],
|
|
305
328
|
},
|
|
306
329
|
components: [
|
|
@@ -324,6 +347,8 @@ export const docsDense = {
|
|
|
324
347
|
hasSearch: 'show search input',
|
|
325
348
|
searchPlaceholder: 'search placeholder',
|
|
326
349
|
isDisabled: 'disables selector',
|
|
350
|
+
disabledMessage:
|
|
351
|
+
'why disabled; w/ isDisabled shows tooltip on hover/focus, trigger stays focusable via aria-disabled; use instead of Tooltip wrapper',
|
|
327
352
|
isLabelHidden: 'visually hides label',
|
|
328
353
|
description: 'helper text below label',
|
|
329
354
|
isOptional: 'marks optional',
|