@astryxdesign/core 0.2.0-canary.f306f13 → 0.2.0-canary.fc53ff7
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/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +6 -2
- package/dist/Button/Button.d.ts +1 -1
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +4 -2
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +2 -2
- package/dist/CheckboxList/CheckboxListItem.d.ts.map +1 -1
- package/dist/CheckboxList/CheckboxListItem.js +19 -4
- package/dist/CodeBlock/CodeBlock.js +2 -2
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +10 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +8 -11
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +3 -1
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +1 -1
- package/dist/DropdownMenu/DropdownMenuRadioItem.js +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuSubMenu.js +9 -1
- package/dist/DropdownMenu/menuItemHover.d.ts +1 -0
- package/dist/DropdownMenu/menuItemHover.d.ts.map +1 -1
- package/dist/DropdownMenu/menuItemHover.js +1 -0
- package/dist/Field/FieldLabel.d.ts +2 -1
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +7 -3
- package/dist/FieldStatus/FieldStatus.d.ts.map +1 -1
- package/dist/FieldStatus/FieldStatus.js +12 -2
- package/dist/Item/Item.d.ts +16 -3
- package/dist/Item/Item.d.ts.map +1 -1
- package/dist/Item/Item.js +31 -6
- package/dist/List/ListItem.d.ts +11 -1
- package/dist/List/ListItem.d.ts.map +1 -1
- package/dist/List/ListItem.js +2 -0
- package/dist/MetadataList/MetadataList.d.ts +1 -1
- package/dist/MetadataList/MetadataList.d.ts.map +1 -1
- package/dist/MetadataList/MetadataList.js +4 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +14 -3
- package/dist/RadioList/RadioListItem.d.ts.map +1 -1
- package/dist/RadioList/RadioListItem.js +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControlItem.js +3 -2
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +5 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -1
- package/dist/Skeleton/Skeleton.js +2 -2
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +15 -4
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +1 -1
- package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
- package/dist/Thumbnail/Thumbnail.js +42 -43
- package/dist/Toast/ToastViewport.d.ts.map +1 -1
- package/dist/Toast/ToastViewport.js +61 -9
- package/dist/ToggleButton/ToggleButton.d.ts.map +1 -1
- package/dist/ToggleButton/ToggleButton.js +3 -1
- package/dist/Token/Token.d.ts +4 -1
- package/dist/Token/Token.d.ts.map +1 -1
- package/dist/Token/Token.js +68 -43
- package/dist/Token/TokenLink.d.ts +62 -0
- package/dist/Token/TokenLink.d.ts.map +1 -0
- package/dist/Token/TokenLink.js +76 -0
- package/dist/TopNav/TopNavMegaMenu.d.ts.map +1 -1
- package/dist/TopNav/TopNavMegaMenu.js +9 -3
- package/dist/Typeahead/BaseTypeahead.d.ts +9 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +2 -0
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +8 -1
- package/dist/astryx.css +51 -8
- package/dist/astryx.umd.js +50 -50
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/containerReveal.pool.stylex.d.ts +281 -0
- package/dist/hooks/containerReveal.pool.stylex.d.ts.map +1 -0
- package/dist/hooks/containerReveal.pool.stylex.js +281 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +2 -1
- package/dist/hooks/useContainerReveal.d.ts +83 -0
- package/dist/hooks/useContainerReveal.d.ts.map +1 -0
- package/dist/hooks/useContainerReveal.js +123 -0
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +11 -0
- package/dist/hooks/useKeyboardHint.d.ts +1 -1
- package/dist/hooks/useKeyboardHint.d.ts.map +1 -1
- package/dist/hooks/useKeyboardHint.js +4 -2
- package/locales/en.json +32 -0
- package/locales/pseudo.json +24 -0
- package/package.json +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +21 -1
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +4 -2
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +1 -1
- package/src/Button/Button.test.tsx +16 -0
- package/src/Button/Button.tsx +4 -2
- package/src/CheckboxInput/CheckboxInput.test.tsx +23 -0
- package/src/CheckboxInput/CheckboxInput.tsx +17 -2
- package/src/CheckboxList/CheckboxList.test.tsx +94 -3
- package/src/CheckboxList/CheckboxListItem.tsx +17 -10
- package/src/CodeBlock/CodeBlock.test.tsx +17 -0
- package/src/CodeBlock/CodeBlock.tsx +2 -2
- package/src/ContextMenu/ContextMenu.test.tsx +17 -2
- package/src/ContextMenu/ContextMenu.tsx +10 -1
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +4 -12
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +9 -10
- package/src/DropdownMenu/DropdownMenuRadioItem.tsx +1 -1
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +20 -2
- package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +29 -1
- package/src/DropdownMenu/DropdownMenuSubMenu.tsx +13 -5
- package/src/DropdownMenu/menuItemHover.ts +1 -0
- package/src/Field/Field.doc.mjs +4 -0
- package/src/Field/FieldLabel.test.tsx +25 -0
- package/src/Field/FieldLabel.tsx +11 -2
- package/src/FieldStatus/FieldStatus.doc.mjs +1 -0
- package/src/FieldStatus/FieldStatus.test.tsx +61 -0
- package/src/FieldStatus/FieldStatus.tsx +14 -3
- package/src/Item/Item.doc.mjs +5 -0
- package/src/Item/Item.test.tsx +88 -0
- package/src/Item/Item.tsx +57 -11
- package/src/List/ListItem.doc.mjs +12 -0
- package/src/List/ListItem.tsx +13 -0
- package/src/MetadataList/MetadataList.test.tsx +24 -0
- package/src/MetadataList/MetadataList.tsx +6 -2
- package/src/MultiSelector/MultiSelector.test.tsx +76 -0
- package/src/MultiSelector/MultiSelector.tsx +28 -7
- package/src/RadioList/RadioList.test.tsx +26 -4
- package/src/RadioList/RadioListItem.tsx +8 -1
- package/src/SegmentedControl/SegmentedControl.test.tsx +28 -0
- package/src/SegmentedControl/SegmentedControlItem.tsx +20 -2
- package/src/Selector/Selector.test.tsx +22 -0
- package/src/Selector/Selector.tsx +8 -1
- package/src/Skeleton/Skeleton.test.tsx +16 -0
- package/src/Skeleton/Skeleton.tsx +13 -1
- package/src/Switch/Switch.test.tsx +37 -0
- package/src/Switch/Switch.tsx +52 -3
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +19 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +3 -1
- package/src/Thumbnail/Thumbnail.tsx +18 -32
- package/src/Toast/ToastViewport.test.tsx +218 -8
- package/src/Toast/ToastViewport.tsx +71 -12
- package/src/ToggleButton/ToggleButton.test.tsx +24 -0
- package/src/ToggleButton/ToggleButton.tsx +19 -1
- package/src/Token/Token.test.tsx +145 -0
- package/src/Token/Token.tsx +73 -33
- package/src/Token/TokenLink.tsx +107 -0
- package/src/TopNav/TopNavMegaMenu.tsx +26 -2
- package/src/Typeahead/BaseTypeahead.tsx +12 -0
- package/src/Typeahead/Typeahead.test.tsx +62 -0
- package/src/Typeahead/Typeahead.tsx +6 -0
- package/src/__tests__/forcedColors.ts +60 -0
- package/src/hooks/containerReveal.pool.stylex.ts +464 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useContainerReveal.test.tsx +116 -0
- package/src/hooks/useContainerReveal.ts +180 -0
- package/src/hooks/useInputStatusIcon.test.tsx +59 -0
- package/src/hooks/useInputStatusIcon.tsx +12 -1
- package/src/hooks/useKeyboardHint.test.tsx +22 -0
- package/src/hooks/useKeyboardHint.tsx +6 -2
- package/dist/Thumbnail/thumbnail.markers.stylex.d.ts +0 -9
- package/dist/Thumbnail/thumbnail.markers.stylex.d.ts.map +0 -1
- package/dist/Thumbnail/thumbnail.markers.stylex.js +0 -14
- package/src/Thumbnail/thumbnail.markers.stylex.ts +0 -12
|
@@ -154,14 +154,105 @@ describe('CheckboxList', () => {
|
|
|
154
154
|
</CheckboxList>,
|
|
155
155
|
);
|
|
156
156
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
157
|
+
// The row is a pointer-only click surface (no invisible row button — the
|
|
158
|
+
// checkbox is the option's only tab stop). Click the row's label area,
|
|
159
|
+
// where the composed onClick fires — not the inner checkbox.
|
|
160
|
+
expect(
|
|
161
|
+
screen.queryByRole('button', {name: 'Option B'}),
|
|
162
|
+
).not.toBeInTheDocument();
|
|
163
|
+
// The text also exists in the checkbox's visually hidden <label>; target
|
|
164
|
+
// the row's label <span> to click the row surface.
|
|
165
|
+
await user.click(screen.getByText('Option B', {selector: 'span'}));
|
|
160
166
|
|
|
161
167
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
162
168
|
expect(handleChange).toHaveBeenCalledWith(['a', 'b']);
|
|
163
169
|
});
|
|
164
170
|
|
|
171
|
+
describe('single tab stop per option (WCAG 4.1.2 / APG checkbox pattern)', () => {
|
|
172
|
+
it('renders exactly one focusable control per option — the checkbox, no row button', () => {
|
|
173
|
+
render(
|
|
174
|
+
<CheckboxList label="Preferences" value={['a']} onChange={() => {}}>
|
|
175
|
+
<CheckboxListItem label="Option A" value="a" />
|
|
176
|
+
<CheckboxListItem label="Option B" value="b" />
|
|
177
|
+
</CheckboxList>,
|
|
178
|
+
);
|
|
179
|
+
// No invisible whole-row button duplicating the checkbox's action.
|
|
180
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
181
|
+
for (const item of screen.getAllByRole('listitem')) {
|
|
182
|
+
const focusables = item.querySelectorAll(
|
|
183
|
+
'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
184
|
+
);
|
|
185
|
+
expect(focusables).toHaveLength(1);
|
|
186
|
+
expect(focusables[0]).toBe(within(item).getByRole('checkbox'));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('Tab moves directly from one option checkbox to the next', async () => {
|
|
191
|
+
const user = userEvent.setup();
|
|
192
|
+
render(
|
|
193
|
+
<CheckboxList label="Preferences" value={[]} onChange={() => {}}>
|
|
194
|
+
<CheckboxListItem label="Option A" value="a" />
|
|
195
|
+
<CheckboxListItem label="Option B" value="b" />
|
|
196
|
+
</CheckboxList>,
|
|
197
|
+
);
|
|
198
|
+
await user.tab();
|
|
199
|
+
expect(screen.getByRole('checkbox', {name: 'Option A'})).toHaveFocus();
|
|
200
|
+
await user.tab();
|
|
201
|
+
expect(screen.getByRole('checkbox', {name: 'Option B'})).toHaveFocus();
|
|
202
|
+
// No further tab stop inside the list (the row itself is not focusable).
|
|
203
|
+
await user.tab();
|
|
204
|
+
expect(document.body).toHaveFocus();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('clicking the row surface (outside the checkbox) still toggles', async () => {
|
|
208
|
+
const user = userEvent.setup();
|
|
209
|
+
const handleChange = vi.fn();
|
|
210
|
+
render(
|
|
211
|
+
<CheckboxList label="Preferences" value={['a']} onChange={handleChange}>
|
|
212
|
+
<CheckboxListItem label="Option A" value="a" />
|
|
213
|
+
<CheckboxListItem label="Option B" value="b" />
|
|
214
|
+
</CheckboxList>,
|
|
215
|
+
);
|
|
216
|
+
// The text also exists in the checkbox's visually hidden <label>;
|
|
217
|
+
// target the row's label <span> to click the row surface.
|
|
218
|
+
await user.click(screen.getByText('Option B', {selector: 'span'}));
|
|
219
|
+
expect(handleChange).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(handleChange).toHaveBeenCalledWith(['a', 'b']);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('toggles with Space on the focused checkbox, which reports checked state', async () => {
|
|
224
|
+
const user = userEvent.setup();
|
|
225
|
+
const handleChange = vi.fn();
|
|
226
|
+
render(
|
|
227
|
+
<CheckboxList label="Preferences" value={['a']} onChange={handleChange}>
|
|
228
|
+
<CheckboxListItem label="Option A" value="a" />
|
|
229
|
+
<CheckboxListItem label="Option B" value="b" />
|
|
230
|
+
</CheckboxList>,
|
|
231
|
+
);
|
|
232
|
+
await user.tab();
|
|
233
|
+
// The focused control is the checkbox itself, so its accessible name
|
|
234
|
+
// and checked state are what assistive tech announces on focus.
|
|
235
|
+
const checkboxA = screen.getByRole('checkbox', {name: 'Option A'});
|
|
236
|
+
expect(checkboxA).toHaveFocus();
|
|
237
|
+
expect(checkboxA).toBeChecked();
|
|
238
|
+
await user.keyboard(' ');
|
|
239
|
+
expect(handleChange).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(handleChange).toHaveBeenCalledWith([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('the row itself is not focusable', () => {
|
|
244
|
+
render(
|
|
245
|
+
<CheckboxList label="Preferences" value={[]} onChange={() => {}}>
|
|
246
|
+
<CheckboxListItem label="Option A" value="a" />
|
|
247
|
+
</CheckboxList>,
|
|
248
|
+
);
|
|
249
|
+
const item = screen.getByRole('listitem');
|
|
250
|
+
expect(item).not.toHaveAttribute('tabindex');
|
|
251
|
+
item.focus();
|
|
252
|
+
expect(item).not.toHaveFocus();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
165
256
|
it('disables all checkboxes when group isDisabled is true', () => {
|
|
166
257
|
render(
|
|
167
258
|
<CheckboxList
|
|
@@ -16,12 +16,11 @@
|
|
|
16
16
|
* - /packages/cli/templates/blocks/components/CheckboxList/ (showcase blocks)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {use,
|
|
19
|
+
import {use, useRef, type ReactNode} from 'react';
|
|
20
20
|
import * as stylex from '@stylexjs/stylex';
|
|
21
21
|
import type {StyleXStyles} from '@stylexjs/stylex';
|
|
22
22
|
import {colorVars} from '../theme/tokens.stylex';
|
|
23
23
|
import type {BaseProps} from '../BaseProps';
|
|
24
|
-
import {composeEventHandlers} from '../utils';
|
|
25
24
|
import {useDevWarning} from '../hooks/useDevWarning';
|
|
26
25
|
import {CheckboxInput} from '../CheckboxInput/CheckboxInput';
|
|
27
26
|
import {ListItem} from '../List/ListItem';
|
|
@@ -212,6 +211,14 @@ export function CheckboxListItem({
|
|
|
212
211
|
// Whether this item is interactive (has a toggle handler)
|
|
213
212
|
const isInteractive = !effectiveReadOnly && (ctx != null || onCheck != null);
|
|
214
213
|
|
|
214
|
+
// The checkbox is the row's single keyboard control and action. The row is
|
|
215
|
+
// an enlarged click/tap target that delegates surface clicks to it via
|
|
216
|
+
// ListItem's `interactiveRef` (useClickableContainer), so each option is
|
|
217
|
+
// exactly one tab stop. Delegate whenever the row should respond to clicks:
|
|
218
|
+
// a toggleable item, or one carrying a consumer `onClick`.
|
|
219
|
+
const checkboxRef = useRef<HTMLInputElement | null>(null);
|
|
220
|
+
const hasRowInteraction = isInteractive || onClickProp != null;
|
|
221
|
+
|
|
215
222
|
const handleToggle = () => {
|
|
216
223
|
if (effectiveDisabled || effectiveReadOnly || isBusy) {
|
|
217
224
|
return;
|
|
@@ -244,14 +251,12 @@ export function CheckboxListItem({
|
|
|
244
251
|
description={description}
|
|
245
252
|
endContent={endContent}
|
|
246
253
|
isDisabled={effectiveDisabled}
|
|
247
|
-
onClick
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
: undefined
|
|
254
|
-
}
|
|
254
|
+
// Delegate row clicks to the checkbox instead of wiring onClick (which
|
|
255
|
+
// would add an invisible row button = a second tab stop). The checkbox
|
|
256
|
+
// stays the option's sole focusable control (WCAG 4.1.2 / APG checkbox
|
|
257
|
+
// pattern). A consumer onClick rides on the checkbox itself (below), so
|
|
258
|
+
// it still fires for both direct and delegated (row-surface) clicks.
|
|
259
|
+
interactiveRef={hasRowInteraction ? checkboxRef : undefined}
|
|
255
260
|
aria-busy={isBusy || undefined}
|
|
256
261
|
xstyle={
|
|
257
262
|
[
|
|
@@ -266,10 +271,12 @@ export function CheckboxListItem({
|
|
|
266
271
|
style={style}
|
|
267
272
|
startContent={
|
|
268
273
|
<CheckboxInput
|
|
274
|
+
ref={checkboxRef}
|
|
269
275
|
label={checkboxLabel}
|
|
270
276
|
isLabelHidden
|
|
271
277
|
value={resolvedChecked}
|
|
272
278
|
onChange={() => handleToggle()}
|
|
279
|
+
onClick={onClickProp}
|
|
273
280
|
isDisabled={effectiveDisabled}
|
|
274
281
|
isReadOnly={effectiveReadOnly}
|
|
275
282
|
isLoading={isBusy}
|
|
@@ -14,6 +14,7 @@ import {act, render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
|
14
14
|
import {CodeBlock} from './CodeBlock';
|
|
15
15
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
16
16
|
import {dracula} from '../theme/syntax';
|
|
17
|
+
import {InternationalizationProvider} from '../i18n';
|
|
17
18
|
|
|
18
19
|
function politeRegion(): HTMLElement | null {
|
|
19
20
|
return document.querySelector('[data-astryx-live-region="polite"]');
|
|
@@ -72,6 +73,22 @@ describe('CodeBlock', () => {
|
|
|
72
73
|
});
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
it('localizes the copy announcement through the i18n catalog', async () => {
|
|
77
|
+
render(
|
|
78
|
+
<InternationalizationProvider
|
|
79
|
+
locale="fr"
|
|
80
|
+
overrides={{fr: {'@astryx.codeBlock.copied': 'Copié'}}}>
|
|
81
|
+
<CodeBlock code="const x = 1;" language="javascript" />
|
|
82
|
+
</InternationalizationProvider>,
|
|
83
|
+
);
|
|
84
|
+
// The button label and the live-region announcement share the same key.
|
|
85
|
+
fireEvent.click(screen.getByRole('button', {name: 'Copy code'}));
|
|
86
|
+
await waitFor(() => {
|
|
87
|
+
expect(politeRegion()).toHaveTextContent('Copié');
|
|
88
|
+
});
|
|
89
|
+
expect(screen.getByRole('button', {name: 'Copié'})).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
75
92
|
it('keeps the copied indicator a full 2s after a rapid re-copy', async () => {
|
|
76
93
|
vi.useFakeTimers();
|
|
77
94
|
try {
|
|
@@ -786,7 +786,7 @@ export function CodeBlock({
|
|
|
786
786
|
setCopied(true);
|
|
787
787
|
// Swapping the button's aria-label alone isn't reliably announced by
|
|
788
788
|
// screen readers, so confirm the copy via a polite live region.
|
|
789
|
-
announce('
|
|
789
|
+
announce(t('@astryx.codeBlock.copied'));
|
|
790
790
|
onCopy?.();
|
|
791
791
|
// Restart the reset timer on every copy — otherwise a rapid re-copy
|
|
792
792
|
// is reverted early by the previous click's timer.
|
|
@@ -800,7 +800,7 @@ export function CodeBlock({
|
|
|
800
800
|
} catch {
|
|
801
801
|
// Clipboard failures leave the copied state unchanged.
|
|
802
802
|
}
|
|
803
|
-
}, [code, onCopy, announce]);
|
|
803
|
+
}, [code, onCopy, announce, t]);
|
|
804
804
|
|
|
805
805
|
const sizeStyle = size === 'sm' ? styles.sizeSm : styles.sizeMd;
|
|
806
806
|
// Digits in the largest line number — sizes the gutter column width.
|
|
@@ -136,6 +136,21 @@ describe('ContextMenu', () => {
|
|
|
136
136
|
expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled();
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
it('closes the menu when Tab is pressed inside it (APG menu pattern)', () => {
|
|
140
|
+
render(
|
|
141
|
+
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
142
|
+
<div>Right-click me</div>
|
|
143
|
+
</ContextMenu>,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
fireEvent.contextMenu(screen.getByText('Right-click me'));
|
|
147
|
+
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
|
|
148
|
+
|
|
149
|
+
const menu = screen.getByRole('menu', {hidden: true});
|
|
150
|
+
fireEvent.keyDown(menu, {key: 'Tab'});
|
|
151
|
+
expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
139
154
|
it('ignores Escape during IME composition', () => {
|
|
140
155
|
render(
|
|
141
156
|
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
@@ -552,7 +567,7 @@ describe('ContextMenu selectable items', () => {
|
|
|
552
567
|
<ContextMenuRadioGroup
|
|
553
568
|
value="name"
|
|
554
569
|
onChange={() => {}}
|
|
555
|
-
|
|
570
|
+
label="Sort by">
|
|
556
571
|
<ContextMenuRadioItem value="name" label="Sort by name" />
|
|
557
572
|
<ContextMenuRadioItem value="date" label="Sort by date" />
|
|
558
573
|
</ContextMenuRadioGroup>
|
|
@@ -587,7 +602,7 @@ describe('ContextMenu selectable items', () => {
|
|
|
587
602
|
<ContextMenuRadioGroup
|
|
588
603
|
value="name"
|
|
589
604
|
onChange={onSort}
|
|
590
|
-
|
|
605
|
+
label="Sort by">
|
|
591
606
|
<ContextMenuRadioItem value="name" label="Sort by name" />
|
|
592
607
|
<ContextMenuRadioItem value="date" label="Sort by date" />
|
|
593
608
|
</ContextMenuRadioGroup>
|
|
@@ -353,13 +353,22 @@ export function ContextMenu({
|
|
|
353
353
|
}
|
|
354
354
|
return;
|
|
355
355
|
}
|
|
356
|
+
// APG menu pattern: Tab closes the menu. Menu items are tabIndex={-1}
|
|
357
|
+
// so Tab would otherwise leak focus into the page while the menu stayed
|
|
358
|
+
// open (menus-5). Do NOT preventDefault — closing restores focus to the
|
|
359
|
+
// previously focused element, and the browser's default Tab then
|
|
360
|
+
// continues from there to the next element.
|
|
361
|
+
if (e.key === 'Tab') {
|
|
362
|
+
closeMenu();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
356
365
|
if (typeahead.onKeyDown(e)) {
|
|
357
366
|
e.preventDefault();
|
|
358
367
|
return;
|
|
359
368
|
}
|
|
360
369
|
listNavKeyDown(e);
|
|
361
370
|
},
|
|
362
|
-
[listNavKeyDown, typeahead, ownsEvent],
|
|
371
|
+
[listNavKeyDown, closeMenu, typeahead, ownsEvent],
|
|
363
372
|
);
|
|
364
373
|
|
|
365
374
|
// Place the zero-size cursor anchor at a point in the trigger's local
|
|
@@ -22,16 +22,10 @@ export const docs = {
|
|
|
22
22
|
description: 'Callback fired when the selected value changes.',
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
|
-
name: '
|
|
25
|
+
name: 'label',
|
|
26
26
|
type: 'string',
|
|
27
27
|
description:
|
|
28
|
-
'Accessible
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
name: 'aria-labelledby',
|
|
32
|
-
type: 'string',
|
|
33
|
-
description:
|
|
34
|
-
'The id of an element that labels the group, as an alternative to aria-label.',
|
|
28
|
+
'Accessible name for the group, applied as aria-label so screen readers announce the radios as a named set, e.g. "Sort by". Required. Pass aria-labelledby (via base props) instead when the name already exists as a visible element.',
|
|
35
29
|
},
|
|
36
30
|
{
|
|
37
31
|
name: 'hasCloseOnSelect',
|
|
@@ -57,8 +51,7 @@ export const docsZh = {
|
|
|
57
51
|
propDescriptions: {
|
|
58
52
|
value: '组中当前选中的值。尚无选中项时传 undefined。',
|
|
59
53
|
onChange: '所选值变化时触发的回调。',
|
|
60
|
-
'aria-label
|
|
61
|
-
'aria-labelledby': '标记该组的元素 id,作为 aria-label 的替代。',
|
|
54
|
+
label: '组的无障碍名称,作为 aria-label 应用,以便屏幕阅读器将单选项作为命名集合朗读,例如“Sort by”。必需。当名称已作为可见元素存在时,可改用 aria-labelledby(通过基础属性)。',
|
|
62
55
|
hasCloseOnSelect: '选择某值是否关闭菜单。默认关闭。',
|
|
63
56
|
children: '组成该组的 DropdownMenuRadioItem。',
|
|
64
57
|
},
|
|
@@ -72,8 +65,7 @@ export const docsDense = {
|
|
|
72
65
|
propDescriptions: {
|
|
73
66
|
value: 'selected value (undefined = none)',
|
|
74
67
|
onChange: 'fired when selected value changes',
|
|
75
|
-
|
|
76
|
-
'aria-labelledby': 'id of labelling element (alt to aria-label)',
|
|
68
|
+
label: 'accessible group name (applied as aria-label); required',
|
|
77
69
|
hasCloseOnSelect: 'close menu on select (default true)',
|
|
78
70
|
children: 'the DropdownMenuRadioItems',
|
|
79
71
|
},
|
|
@@ -49,16 +49,13 @@ export interface DropdownMenuRadioGroupProps extends Omit<
|
|
|
49
49
|
*/
|
|
50
50
|
onChange: (value: string) => void;
|
|
51
51
|
/**
|
|
52
|
-
* Accessible
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Accessible name for the group, announced by screen readers so the radios
|
|
53
|
+
* read as a named set (e.g. "Sort by"). Applied as the group's `aria-label`.
|
|
54
|
+
* Required -- an unnamed radio group is an accessibility defect. Pass
|
|
55
|
+
* `aria-labelledby` (via base props) instead if the name already exists as a
|
|
56
|
+
* visible element on the page.
|
|
55
57
|
*/
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The id of an element that labels the group, as an alternative to
|
|
59
|
-
* `aria-label`.
|
|
60
|
-
*/
|
|
61
|
-
'aria-labelledby'?: string;
|
|
58
|
+
label: string;
|
|
62
59
|
/**
|
|
63
60
|
* Whether selecting a value closes the menu. Radio items default to closing
|
|
64
61
|
* on selection (a single-choice commit), unlike checkbox items which stay
|
|
@@ -82,7 +79,7 @@ export interface DropdownMenuRadioGroupProps extends Omit<
|
|
|
82
79
|
* DropdownMenuRadioItem,
|
|
83
80
|
* } from '@astryxdesign/core/DropdownMenu';
|
|
84
81
|
* <DropdownMenu button={{label: 'Sort'}}>
|
|
85
|
-
* <DropdownMenuRadioGroup value={sort} onChange={setSort}
|
|
82
|
+
* <DropdownMenuRadioGroup value={sort} onChange={setSort} label="Sort by">
|
|
86
83
|
* <DropdownMenuRadioItem value="newest" label="Newest" />
|
|
87
84
|
* <DropdownMenuRadioItem value="oldest" label="Oldest" />
|
|
88
85
|
* </DropdownMenuRadioGroup>
|
|
@@ -92,6 +89,7 @@ export interface DropdownMenuRadioGroupProps extends Omit<
|
|
|
92
89
|
export function DropdownMenuRadioGroup({
|
|
93
90
|
value,
|
|
94
91
|
onChange,
|
|
92
|
+
label,
|
|
95
93
|
hasCloseOnSelect = true,
|
|
96
94
|
children,
|
|
97
95
|
className,
|
|
@@ -107,6 +105,7 @@ export function DropdownMenuRadioGroup({
|
|
|
107
105
|
<div
|
|
108
106
|
{...rest}
|
|
109
107
|
role="group"
|
|
108
|
+
aria-label={label}
|
|
110
109
|
{...mergeProps(stylex.props(styles.group), {className, style})}>
|
|
111
110
|
<DropdownMenuRadioGroupContext value={contextValue}>
|
|
112
111
|
{children}
|
|
@@ -145,7 +145,7 @@ export interface DropdownMenuRadioItemProps extends Omit<
|
|
|
145
145
|
*
|
|
146
146
|
* @example
|
|
147
147
|
* ```
|
|
148
|
-
* <DropdownMenuRadioGroup value={sort} onChange={setSort}
|
|
148
|
+
* <DropdownMenuRadioGroup value={sort} onChange={setSort} label="Sort by">
|
|
149
149
|
* <DropdownMenuRadioItem value="newest" label="Newest" />
|
|
150
150
|
* <DropdownMenuRadioItem value="oldest" label="Oldest" icon="clock" />
|
|
151
151
|
* </DropdownMenuRadioGroup>
|
|
@@ -110,7 +110,7 @@ describe('DropdownMenuRadioGroup / RadioItem', () => {
|
|
|
110
110
|
<DropdownMenuRadioGroup
|
|
111
111
|
value="newest"
|
|
112
112
|
onChange={() => {}}
|
|
113
|
-
|
|
113
|
+
label="Sort by">
|
|
114
114
|
<DropdownMenuRadioItem value="newest" label="Newest" />
|
|
115
115
|
<DropdownMenuRadioItem value="oldest" label="Oldest" />
|
|
116
116
|
</DropdownMenuRadioGroup>
|
|
@@ -136,7 +136,7 @@ describe('DropdownMenuRadioGroup / RadioItem', () => {
|
|
|
136
136
|
<DropdownMenuRadioGroup
|
|
137
137
|
value="newest"
|
|
138
138
|
onChange={onChange}
|
|
139
|
-
|
|
139
|
+
label="Sort by">
|
|
140
140
|
<DropdownMenuRadioItem value="newest" label="Newest" />
|
|
141
141
|
<DropdownMenuRadioItem value="oldest" label="Oldest" />
|
|
142
142
|
</DropdownMenuRadioGroup>
|
|
@@ -149,6 +149,24 @@ describe('DropdownMenuRadioGroup / RadioItem', () => {
|
|
|
149
149
|
expect(onChange).toHaveBeenCalledWith('oldest');
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
it('names the group from the required label prop', async () => {
|
|
153
|
+
const user = userEvent.setup();
|
|
154
|
+
render(
|
|
155
|
+
<DropdownMenu button={{label: 'Sort'}}>
|
|
156
|
+
<DropdownMenuRadioGroup
|
|
157
|
+
value="newest"
|
|
158
|
+
onChange={() => {}}
|
|
159
|
+
label="Sort by">
|
|
160
|
+
<DropdownMenuRadioItem value="newest" label="Newest" />
|
|
161
|
+
</DropdownMenuRadioGroup>
|
|
162
|
+
</DropdownMenu>,
|
|
163
|
+
);
|
|
164
|
+
await user.click(screen.getByRole('button', {name: /Sort/}));
|
|
165
|
+
expect(
|
|
166
|
+
screen.getByRole('group', {name: 'Sort by', hidden: true}),
|
|
167
|
+
).toBeInTheDocument();
|
|
168
|
+
});
|
|
169
|
+
|
|
152
170
|
it('throws when a radio item is used outside a group', () => {
|
|
153
171
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
154
172
|
expect(() =>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
10
10
|
import {useState} from 'react';
|
|
11
|
-
import {render, screen, waitFor} from '@testing-library/react';
|
|
11
|
+
import {render, screen, waitFor, fireEvent} from '@testing-library/react';
|
|
12
12
|
import userEvent from '@testing-library/user-event';
|
|
13
13
|
import {DropdownMenu} from './DropdownMenu';
|
|
14
14
|
import {DropdownMenuItem} from './DropdownMenuItem';
|
|
@@ -510,6 +510,34 @@ describe('DropdownMenuSubMenu accessibility (WCAG 2.2 / APG)', () => {
|
|
|
510
510
|
expect(onPick).toHaveBeenCalledWith('data');
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
// Regression: hovering the submenu trigger while a sibling item still holds
|
|
514
|
+
// focus must move the single focus-driven highlight onto the trigger — not
|
|
515
|
+
// leave two items highlighted at once (the trigger via :hover, the sibling
|
|
516
|
+
// via :focus). Mirrors DropdownMenuItem's hover-focus behavior.
|
|
517
|
+
it('moves focus to the submenu trigger on hover, keeping a single highlight', async () => {
|
|
518
|
+
const user = userEvent.setup();
|
|
519
|
+
render(<MoveMenu />);
|
|
520
|
+
await user.click(screen.getByRole('button', {name: /Actions/}));
|
|
521
|
+
|
|
522
|
+
const rename = screen.getByRole('menuitem', {
|
|
523
|
+
name: 'Rename',
|
|
524
|
+
hidden: true,
|
|
525
|
+
});
|
|
526
|
+
const trigger = screen.getByRole('menuitem', {
|
|
527
|
+
name: /Move to/,
|
|
528
|
+
hidden: true,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
rename.focus();
|
|
532
|
+
expect(rename).toHaveFocus();
|
|
533
|
+
|
|
534
|
+
// A mouse hover over the submenu trigger moves focus onto it, so the single
|
|
535
|
+
// focus-driven highlight follows the pointer instead of leaving two.
|
|
536
|
+
fireEvent.pointerMove(trigger, {pointerType: 'mouse'});
|
|
537
|
+
expect(trigger).toHaveFocus();
|
|
538
|
+
expect(rename).not.toHaveFocus();
|
|
539
|
+
});
|
|
540
|
+
|
|
513
541
|
// 1.4.13 Content on Hover or Focus: the flyout stays open while the pointer
|
|
514
542
|
// is over it (moving onto the flyout must not dismiss it).
|
|
515
543
|
it('keeps the flyout open while the pointer is over it', async () => {
|
|
@@ -44,6 +44,7 @@ import React, {
|
|
|
44
44
|
useMemo,
|
|
45
45
|
useRef,
|
|
46
46
|
useState,
|
|
47
|
+
type PointerEvent,
|
|
47
48
|
type ReactNode,
|
|
48
49
|
} from 'react';
|
|
49
50
|
import * as stylex from '@stylexjs/stylex';
|
|
@@ -78,6 +79,7 @@ import {
|
|
|
78
79
|
useDropdownMenuContext,
|
|
79
80
|
type DropdownMenuContextValue,
|
|
80
81
|
} from './DropdownMenuContext';
|
|
82
|
+
import {focusMenuItemOnHover} from './menuItemHover';
|
|
81
83
|
|
|
82
84
|
const triggerStyles = stylex.create({
|
|
83
85
|
root: {
|
|
@@ -93,11 +95,6 @@ const triggerStyles = stylex.create({
|
|
|
93
95
|
default: 'transparent',
|
|
94
96
|
':focus': colorVars['--color-overlay-hover'],
|
|
95
97
|
},
|
|
96
|
-
':hover': {
|
|
97
|
-
'@media (hover: hover)': {
|
|
98
|
-
backgroundColor: colorVars['--color-overlay-hover'],
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
98
|
border: 'none',
|
|
102
99
|
cursor: 'pointer',
|
|
103
100
|
textAlign: 'start',
|
|
@@ -393,6 +390,16 @@ export function DropdownMenuSubMenu(
|
|
|
393
390
|
[isDisabled, open],
|
|
394
391
|
);
|
|
395
392
|
|
|
393
|
+
// Move the single focus-driven highlight onto the trigger as the pointer
|
|
394
|
+
// enters it, so a sibling item that still holds focus doesn't stay
|
|
395
|
+
// highlighted alongside the hovered trigger. This is separate from the
|
|
396
|
+
// hover-open intent (onMouseEnter/onMouseLeave) — the flyout still opens on
|
|
397
|
+
// the hover delay; this only keeps the highlight single.
|
|
398
|
+
const handlePointerMove = useCallback(
|
|
399
|
+
(e: PointerEvent<HTMLElement>) => focusMenuItemOnHover(e, isDisabled),
|
|
400
|
+
[isDisabled],
|
|
401
|
+
);
|
|
402
|
+
|
|
396
403
|
// Enter/Space activate the focused row; typeahead jumps by first character;
|
|
397
404
|
// the close key (Left, or Right in RTL) returns focus to the trigger;
|
|
398
405
|
// arrows/Home/End defer to useListFocus (RTL-aware).
|
|
@@ -494,6 +501,7 @@ export function DropdownMenuSubMenu(
|
|
|
494
501
|
data-testid={testId}
|
|
495
502
|
onMouseEnter={triggerProps.onMouseEnter}
|
|
496
503
|
onMouseLeave={triggerProps.onMouseLeave}
|
|
504
|
+
onPointerMove={handlePointerMove}
|
|
497
505
|
startContent={
|
|
498
506
|
icon
|
|
499
507
|
? renderIconSlot(icon, {size: 'sm', color: 'secondary'})
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - /packages/core/src/DropdownMenu/DropdownMenuItem.tsx
|
|
17
17
|
* - /packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx
|
|
18
18
|
* - /packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx
|
|
19
|
+
* - /packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import type {PointerEvent} from 'react';
|
package/src/Field/Field.doc.mjs
CHANGED
|
@@ -30,6 +30,10 @@ export const docs = {
|
|
|
30
30
|
{className: 'astryx-field', visualProps: ['layout']},
|
|
31
31
|
{className: 'astryx-field-label'},
|
|
32
32
|
{className: 'astryx-field-status', visualProps: ['type', 'variant']},
|
|
33
|
+
{
|
|
34
|
+
className: 'astryx-input-status-icon',
|
|
35
|
+
visualProps: ['size', 'status'],
|
|
36
|
+
},
|
|
33
37
|
],
|
|
34
38
|
vars: [
|
|
35
39
|
{name: '--_field-radius', description: 'Border radius of input fields', default: 'var(--radius-element)', private: true},
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {describe, it, expect, vi} from 'vitest';
|
|
13
13
|
import {render, screen} from '@testing-library/react';
|
|
14
14
|
import {TestIcon} from '../__tests__/TestIcon';
|
|
15
|
+
import {InternationalizationProvider} from '../i18n';
|
|
15
16
|
import {FieldLabel} from './FieldLabel';
|
|
16
17
|
|
|
17
18
|
describe('FieldLabel', () => {
|
|
@@ -36,6 +37,30 @@ describe('FieldLabel', () => {
|
|
|
36
37
|
expect(screen.getByText(/Required/)).toBeInTheDocument();
|
|
37
38
|
});
|
|
38
39
|
|
|
40
|
+
it('localizes the required indicator via the i18n provider', () => {
|
|
41
|
+
render(
|
|
42
|
+
<InternationalizationProvider
|
|
43
|
+
locale="fr"
|
|
44
|
+
overrides={{fr: {'@astryx.field.required': 'Obligatoire'}}}>
|
|
45
|
+
<FieldLabel label="Nom" inputID="name-input" isRequired />
|
|
46
|
+
</InternationalizationProvider>,
|
|
47
|
+
);
|
|
48
|
+
expect(screen.getByText(/Obligatoire/)).toBeInTheDocument();
|
|
49
|
+
expect(screen.queryByText(/Required/)).not.toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('localizes the optional indicator via the i18n provider', () => {
|
|
53
|
+
render(
|
|
54
|
+
<InternationalizationProvider
|
|
55
|
+
locale="fr"
|
|
56
|
+
overrides={{fr: {'@astryx.field.optional': 'Facultatif'}}}>
|
|
57
|
+
<FieldLabel label="Nom" inputID="name-input" isOptional />
|
|
58
|
+
</InternationalizationProvider>,
|
|
59
|
+
);
|
|
60
|
+
expect(screen.getByText(/Facultatif/)).toBeInTheDocument();
|
|
61
|
+
expect(screen.queryByText(/Optional/)).not.toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
39
64
|
it('shows Optional when both isOptional and isRequired are true', () => {
|
|
40
65
|
render(
|
|
41
66
|
<FieldLabel label="Name" inputID="name-input" isOptional isRequired />,
|
package/src/Field/FieldLabel.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* @file FieldLabel.tsx
|
|
5
|
-
* @input Uses React, Icon, IconType
|
|
7
|
+
* @input Uses React, Icon, IconType, useTranslator
|
|
6
8
|
* @output Exports FieldLabel component, FieldLabelProps
|
|
7
9
|
* @position Core label implementation; used by Field, CheckboxInput, Switch
|
|
8
10
|
*
|
|
@@ -10,6 +12,7 @@
|
|
|
10
12
|
* - /packages/core/src/Field/Field.doc.mjs (props table, features, implementation notes)
|
|
11
13
|
* - /packages/core/src/Field/index.ts (exports if types change)
|
|
12
14
|
* - /packages/cli/templates/blocks/components/Field/ (showcase blocks)
|
|
15
|
+
* - /packages/core/locales/en.json (@astryx.field.required / @astryx.field.optional)
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import type {ReactNode} from 'react';
|
|
@@ -26,6 +29,7 @@ import {
|
|
|
26
29
|
} from '../theme/tokens.stylex';
|
|
27
30
|
import {Icon, renderIconSlot, type IconType} from '../Icon';
|
|
28
31
|
import {Tooltip} from '../Tooltip';
|
|
32
|
+
import {useTranslator} from '../i18n';
|
|
29
33
|
import {themeProps} from '../utils/themeProps';
|
|
30
34
|
|
|
31
35
|
const styles = stylex.create({
|
|
@@ -177,7 +181,12 @@ export function FieldLabel({
|
|
|
177
181
|
ref,
|
|
178
182
|
...rest
|
|
179
183
|
}: FieldLabelProps) {
|
|
180
|
-
const
|
|
184
|
+
const t = useTranslator();
|
|
185
|
+
const statusText = isOptional
|
|
186
|
+
? t('@astryx.field.optional')
|
|
187
|
+
: isRequired
|
|
188
|
+
? t('@astryx.field.required')
|
|
189
|
+
: null;
|
|
181
190
|
|
|
182
191
|
// A group label (e.g. for a radiogroup) must not be a literal `<label>`
|
|
183
192
|
// element: a `<label>` semantically names a single form control and can't be
|