@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
|
@@ -372,6 +372,67 @@ describe('FieldStatus', () => {
|
|
|
372
372
|
});
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
+
describe('field-status-icon theme target', () => {
|
|
376
|
+
// The stable theme target lands on the detached message box's leading glyph
|
|
377
|
+
// itself, so a theme can restyle (e.g. resize) just this icon via
|
|
378
|
+
// `defineTheme`. It reflects the status type as a data attribute so themes
|
|
379
|
+
// can target per status, mirroring the parent astryx-field-status.
|
|
380
|
+
const getStatusIcon = (root: HTMLElement): HTMLElement => {
|
|
381
|
+
const icon = root.querySelector('.astryx-field-status-icon');
|
|
382
|
+
if (icon == null) {
|
|
383
|
+
throw new Error('status icon not found');
|
|
384
|
+
}
|
|
385
|
+
return icon as HTMLElement;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
it('renders the target on the detached leading icon', () => {
|
|
389
|
+
render(
|
|
390
|
+
<FieldStatus
|
|
391
|
+
type="error"
|
|
392
|
+
message="msg"
|
|
393
|
+
variant="detached"
|
|
394
|
+
data-testid="fs"
|
|
395
|
+
/>,
|
|
396
|
+
);
|
|
397
|
+
const icon = getStatusIcon(screen.getByTestId('fs'));
|
|
398
|
+
expect(icon).toHaveClass('astryx-field-status-icon');
|
|
399
|
+
expect(icon).toHaveClass('astryx-icon');
|
|
400
|
+
expect(icon).toHaveAttribute('data-type', 'error');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('reflects the status type per status', () => {
|
|
404
|
+
for (const type of ['error', 'warning', 'success'] as const) {
|
|
405
|
+
const {unmount} = render(
|
|
406
|
+
<FieldStatus
|
|
407
|
+
type={type}
|
|
408
|
+
message="msg"
|
|
409
|
+
variant="detached"
|
|
410
|
+
data-testid="fs"
|
|
411
|
+
/>,
|
|
412
|
+
);
|
|
413
|
+
expect(getStatusIcon(screen.getByTestId('fs'))).toHaveAttribute(
|
|
414
|
+
'data-type',
|
|
415
|
+
type,
|
|
416
|
+
);
|
|
417
|
+
unmount();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('does not render the target for the attached variant', () => {
|
|
422
|
+
render(
|
|
423
|
+
<FieldStatus
|
|
424
|
+
type="error"
|
|
425
|
+
message="msg"
|
|
426
|
+
variant="attached"
|
|
427
|
+
data-testid="fs"
|
|
428
|
+
/>,
|
|
429
|
+
);
|
|
430
|
+
expect(
|
|
431
|
+
screen.getByTestId('fs').querySelector('.astryx-field-status-icon'),
|
|
432
|
+
).toBeNull();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
375
436
|
describe('edge cases', () => {
|
|
376
437
|
it('renders an empty message without crashing', () => {
|
|
377
438
|
render(<FieldStatus type="error" message="" data-testid="fs" />);
|
|
@@ -56,8 +56,8 @@ const styles = stylex.create({
|
|
|
56
56
|
paddingBlockStart: `calc(${spacingVars['--spacing-1-5']} + ${spacingVars['--spacing-2']})`,
|
|
57
57
|
paddingBlockEnd: spacingVars['--spacing-2'],
|
|
58
58
|
paddingInline: spacingVars['--spacing-2'],
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
borderEndStartRadius: radiusVars['--radius-element'],
|
|
60
|
+
borderEndEndRadius: radiusVars['--radius-element'],
|
|
61
61
|
},
|
|
62
62
|
detached: {
|
|
63
63
|
marginTop: spacingVars['--spacing-1'],
|
|
@@ -216,7 +216,18 @@ export function FieldStatus({
|
|
|
216
216
|
{variant === 'detached' ? (
|
|
217
217
|
<span {...stylex.props(styles.detachedContent)}>
|
|
218
218
|
<span {...stylex.props(styles.detachedIcon)}>
|
|
219
|
-
<Icon
|
|
219
|
+
<Icon
|
|
220
|
+
icon={statusIconMap[type]}
|
|
221
|
+
size="sm"
|
|
222
|
+
color="inherit"
|
|
223
|
+
// Stable theme target on the detached message box's leading glyph
|
|
224
|
+
// itself, so a theme can restyle just this icon (color, size) —
|
|
225
|
+
// and each status — via `defineTheme`. Same-element rules in
|
|
226
|
+
// @layer astryx-theme win over the icon's own base
|
|
227
|
+
// width/height/fontSize, which a field-level target could not
|
|
228
|
+
// reach.
|
|
229
|
+
{...themeProps('field-status-icon', {type})}
|
|
230
|
+
/>
|
|
220
231
|
</span>
|
|
221
232
|
<span>{message}</span>
|
|
222
233
|
</span>
|
package/src/Item/Item.doc.mjs
CHANGED
|
@@ -37,6 +37,7 @@ export const docs = {
|
|
|
37
37
|
{name: 'labelLines', type: 'number', description: 'Max lines before label truncates with ellipsis.'},
|
|
38
38
|
{name: 'descriptionLines', type: 'number', description: 'Max lines before description truncates with ellipsis.'},
|
|
39
39
|
{name: 'onClick', type: '(event: MouseEvent) => void', description: 'Click handler. Makes the item clickable with button semantics.'},
|
|
40
|
+
{name: 'interactiveRef', type: 'RefObject<HTMLElement | null>', description: 'Ref to a nested control (e.g. a checkbox in startContent) that owns the item\'s keyboard access and action. The row becomes an enlarged click/tap target that delegates surface clicks to it (useClickableContainer) and renders no invisible button/anchor, so the row adds no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href — those are ignored when set.'},
|
|
40
41
|
{name: 'href', type: 'string', description: 'Link URL. Makes the item a link via an invisible anchor element.'},
|
|
41
42
|
{name: 'target', type: "'_blank' | '_self'", description: 'Link target. Only used with href. target="_blank" automatically adds noopener noreferrer.'},
|
|
42
43
|
{name: 'rel', type: 'string', description: 'Link relationship tokens. noopener noreferrer are merged automatically for target="_blank".'},
|
|
@@ -90,6 +91,8 @@ export const docsZh = {
|
|
|
90
91
|
labelLines: '标签截断前的最大行数。',
|
|
91
92
|
descriptionLines: '描述截断前的最大行数。',
|
|
92
93
|
onClick: '点击处理函数。使项目可点击,具有按钮语义。',
|
|
94
|
+
interactiveRef:
|
|
95
|
+
'指向嵌套控件(如 startContent 中的复选框)的 ref,该控件承载项目的键盘访问和操作。行成为更大的点击/触摸目标,将表面点击委托给该控件(useClickableContainer),且不渲染不可见按钮/锚点,因此行不会增加第二个 Tab 停留点(WCAG 4.1.2)。与 onClick/href 互斥——设置后二者将被忽略。',
|
|
93
96
|
href: '链接 URL。通过不可见锚点元素使项目成为链接。',
|
|
94
97
|
target: '链接目标。仅与 href 一起使用。target="_blank" 会自动添加 noopener noreferrer。',
|
|
95
98
|
rel: '链接关系标记。target="_blank" 会自动合并 noopener noreferrer。',
|
|
@@ -157,6 +160,8 @@ export const docsDense = {
|
|
|
157
160
|
labelLines: 'Max label lines before truncation.',
|
|
158
161
|
descriptionLines: 'Max description lines before truncation.',
|
|
159
162
|
onClick: 'Click handler; enables button semantics.',
|
|
163
|
+
interactiveRef:
|
|
164
|
+
'Ref to a nested control that owns the item\'s keyboard access/action; row delegates surface clicks to it (useClickableContainer), no invisible button/anchor, no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href.',
|
|
160
165
|
href: 'Link URL; enables anchor semantics.',
|
|
161
166
|
target:
|
|
162
167
|
'Link target, only with href. target="_blank" auto-adds noopener noreferrer.',
|
package/src/Item/Item.test.tsx
CHANGED
|
@@ -9,11 +9,35 @@
|
|
|
9
9
|
* SYNC: When Item component changes, update tests to match new behavior
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import {useRef} from 'react';
|
|
12
13
|
import {describe, it, expect, vi} from 'vitest';
|
|
13
14
|
import {render, screen} from '@testing-library/react';
|
|
14
15
|
import userEvent from '@testing-library/user-event';
|
|
15
16
|
import {Item} from './Item';
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Item in delegation mode: `interactiveRef` points at a nested control that
|
|
20
|
+
* owns the row's keyboard access and action. The row is an enlarged tap target
|
|
21
|
+
* that forwards surface clicks to that control (useClickableContainer).
|
|
22
|
+
*/
|
|
23
|
+
function DelegatingItem({onToggle}: {onToggle?: () => void}) {
|
|
24
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
25
|
+
return (
|
|
26
|
+
<Item
|
|
27
|
+
label="Row"
|
|
28
|
+
interactiveRef={ref}
|
|
29
|
+
startContent={
|
|
30
|
+
<input
|
|
31
|
+
ref={ref}
|
|
32
|
+
type="checkbox"
|
|
33
|
+
aria-label="Pick row"
|
|
34
|
+
onChange={onToggle}
|
|
35
|
+
/>
|
|
36
|
+
}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
17
41
|
describe('Item', () => {
|
|
18
42
|
// ===========================================================================
|
|
19
43
|
// Basic rendering
|
|
@@ -190,6 +214,70 @@ describe('Item', () => {
|
|
|
190
214
|
expect(buttons).toHaveLength(1);
|
|
191
215
|
});
|
|
192
216
|
|
|
217
|
+
// ===========================================================================
|
|
218
|
+
// Interactive — interactiveRef (delegation to a nested control)
|
|
219
|
+
// ===========================================================================
|
|
220
|
+
|
|
221
|
+
it('renders no invisible button in interactiveRef (delegation) mode', () => {
|
|
222
|
+
const {container} = render(<DelegatingItem />);
|
|
223
|
+
// The nested control provides keyboard access — the row must not add a
|
|
224
|
+
// second focusable control for the same action (WCAG 4.1.2).
|
|
225
|
+
expect(container.querySelector('button')).not.toBeInTheDocument();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('keeps the nested control as the only tab stop in interactiveRef mode', async () => {
|
|
229
|
+
const user = userEvent.setup();
|
|
230
|
+
render(<DelegatingItem />);
|
|
231
|
+
await user.tab();
|
|
232
|
+
expect(screen.getByRole('checkbox')).toHaveFocus();
|
|
233
|
+
// Next tab leaves the item entirely — the row itself is not focusable.
|
|
234
|
+
await user.tab();
|
|
235
|
+
expect(screen.getByRole('checkbox')).not.toHaveFocus();
|
|
236
|
+
expect(document.body).toHaveFocus();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('delegates a row-surface click to the interactive control', async () => {
|
|
240
|
+
const user = userEvent.setup();
|
|
241
|
+
const onToggle = vi.fn();
|
|
242
|
+
render(<DelegatingItem onToggle={onToggle} />);
|
|
243
|
+
// Clicking the label (row surface) is forwarded to the checkbox.
|
|
244
|
+
await user.click(screen.getByText('Row'));
|
|
245
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('does not double-fire when the interactive control itself is clicked', async () => {
|
|
249
|
+
const user = userEvent.setup();
|
|
250
|
+
const onToggle = vi.fn();
|
|
251
|
+
render(<DelegatingItem onToggle={onToggle} />);
|
|
252
|
+
await user.click(screen.getByRole('checkbox'));
|
|
253
|
+
// The row must not re-forward the control's own click back to it.
|
|
254
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('ignores onClick when interactiveRef is set (delegation wins, single tab stop)', async () => {
|
|
258
|
+
const user = userEvent.setup();
|
|
259
|
+
const onClick = vi.fn();
|
|
260
|
+
function ItemWithBoth() {
|
|
261
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
262
|
+
return (
|
|
263
|
+
<Item
|
|
264
|
+
label="Row"
|
|
265
|
+
onClick={onClick}
|
|
266
|
+
interactiveRef={ref}
|
|
267
|
+
startContent={
|
|
268
|
+
<input ref={ref} type="checkbox" aria-label="Pick row" />
|
|
269
|
+
}
|
|
270
|
+
/>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const {container} = render(<ItemWithBoth />);
|
|
274
|
+
// No invisible button (onClick is ignored in delegation mode)...
|
|
275
|
+
expect(container.querySelector('button')).not.toBeInTheDocument();
|
|
276
|
+
// ...and the checkbox is still the sole tab stop.
|
|
277
|
+
await user.tab();
|
|
278
|
+
expect(screen.getByRole('checkbox')).toHaveFocus();
|
|
279
|
+
});
|
|
280
|
+
|
|
193
281
|
// ===========================================================================
|
|
194
282
|
// Interactive — href (invisible anchor pattern)
|
|
195
283
|
// ===========================================================================
|
package/src/Item/Item.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Item.tsx
|
|
7
|
-
* @input Uses React, ReactNode, StyleXStyles, theme tokens
|
|
7
|
+
* @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer
|
|
8
8
|
* @output Exports Item component, ItemProps type
|
|
9
9
|
* @position Core layout primitive; consumed by index.ts, tested by Item.test.tsx
|
|
10
10
|
*
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - /packages/cli/templates/blocks/components/Item/ (showcase blocks)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type
|
|
19
|
+
import {useRef, type ReactNode} from 'react';
|
|
20
20
|
import * as stylex from '@stylexjs/stylex';
|
|
21
21
|
import {
|
|
22
22
|
colorVars,
|
|
@@ -27,9 +27,11 @@ import {
|
|
|
27
27
|
typeScaleVars,
|
|
28
28
|
} from '../theme/tokens.stylex';
|
|
29
29
|
import type {BaseProps} from '../BaseProps';
|
|
30
|
-
import {mergeProps} from '../utils';
|
|
30
|
+
import {mergeProps, mergeRefs} from '../utils';
|
|
31
31
|
import {computeTargetAndRel} from '../Link/computeTargetAndRel';
|
|
32
32
|
import {useLinkComponent} from '../Link/useLinkComponent';
|
|
33
|
+
import {useClickableContainer} from '../hooks/useClickableContainer';
|
|
34
|
+
import {useDevWarning} from '../hooks/useDevWarning';
|
|
33
35
|
import {themeProps} from '../utils/themeProps';
|
|
34
36
|
|
|
35
37
|
// =============================================================================
|
|
@@ -108,6 +110,20 @@ export interface ItemProps extends BaseProps<HTMLElement> {
|
|
|
108
110
|
*/
|
|
109
111
|
onClick?: (event: React.MouseEvent) => void;
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Ref to a nested control inside the item (e.g. a checkbox in
|
|
115
|
+
* `startContent`) that already provides the item's keyboard access and
|
|
116
|
+
* action. When set, the item becomes an enlarged click/tap target that
|
|
117
|
+
* delegates surface clicks to that control via the `useClickableContainer`
|
|
118
|
+
* pattern: it renders no invisible button/anchor, so the row adds no second
|
|
119
|
+
* tab stop (WCAG 4.1.2 — one focusable control per option). Clicks on the
|
|
120
|
+
* control itself, and on any other nested interactive element, are left to
|
|
121
|
+
* that element. Mutually exclusive with `onClick`/`href` — when
|
|
122
|
+
* `interactiveRef` is set those are ignored (the nested control is the sole
|
|
123
|
+
* action).
|
|
124
|
+
*/
|
|
125
|
+
interactiveRef?: React.RefObject<HTMLElement | null>;
|
|
126
|
+
|
|
111
127
|
/**
|
|
112
128
|
* Link URL. Makes the item a link via an invisible anchor element.
|
|
113
129
|
*/
|
|
@@ -349,6 +365,7 @@ export function Item({
|
|
|
349
365
|
labelLines,
|
|
350
366
|
descriptionLines,
|
|
351
367
|
onClick,
|
|
368
|
+
interactiveRef,
|
|
352
369
|
href,
|
|
353
370
|
target: targetFromProps,
|
|
354
371
|
rel: relFromProps,
|
|
@@ -363,7 +380,29 @@ export function Item({
|
|
|
363
380
|
...restProps
|
|
364
381
|
}: ItemProps) {
|
|
365
382
|
const LinkComponent = useLinkComponent();
|
|
366
|
-
|
|
383
|
+
|
|
384
|
+
// Delegation mode: the row is an enlarged click/tap target for a nested
|
|
385
|
+
// control (e.g. a checkbox) that owns the keyboard access and action. The
|
|
386
|
+
// control is the row's only tab stop; the row proxies surface clicks to it.
|
|
387
|
+
const isDelegate = interactiveRef != null;
|
|
388
|
+
const containerRef = useRef<HTMLElement | null>(null);
|
|
389
|
+
// Only onClick is needed: onMouseUp handles middle-click href navigation,
|
|
390
|
+
// which delegation mode never has (href is ignored here).
|
|
391
|
+
const {onClick: delegatedOnClick} = useClickableContainer({
|
|
392
|
+
containerRef,
|
|
393
|
+
interactiveRef: interactiveRef ?? undefined,
|
|
394
|
+
disabled: isDisabled,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
useDevWarning(
|
|
398
|
+
'Item',
|
|
399
|
+
'`interactiveRef` is mutually exclusive with `onClick`/`href`. In ' +
|
|
400
|
+
'delegation mode the row only forwards clicks to the referenced control, ' +
|
|
401
|
+
'so `onClick`/`href` are ignored. Drop one of them.',
|
|
402
|
+
isDelegate && (onClick != null || href != null),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const isInteractive = onClick != null || href != null || isDelegate;
|
|
367
406
|
const {target, rel} = computeTargetAndRel(targetFromProps, relFromProps);
|
|
368
407
|
// When a semantic role is provided (e.g. "menuitem"), a parent component
|
|
369
408
|
// handles keyboard access. Skip the invisible button/anchor and put
|
|
@@ -441,7 +480,10 @@ export function Item({
|
|
|
441
480
|
<span {...stylex.props(styles.startContent)}>{startContent}</span>
|
|
442
481
|
)}
|
|
443
482
|
|
|
444
|
-
{hasParentRole ? (
|
|
483
|
+
{hasParentRole || isDelegate ? (
|
|
484
|
+
// Delegation mode (and parent-role mode) put the label in a plain span:
|
|
485
|
+
// keyboard access lives on the nested control, so no invisible
|
|
486
|
+
// button/anchor is rendered and the row adds no second tab stop.
|
|
445
487
|
<span
|
|
446
488
|
{...stylex.props(
|
|
447
489
|
styles.content,
|
|
@@ -497,7 +539,9 @@ export function Item({
|
|
|
497
539
|
|
|
498
540
|
return (
|
|
499
541
|
<Component
|
|
500
|
-
ref={
|
|
542
|
+
ref={
|
|
543
|
+
(isDelegate ? mergeRefs(ref, containerRef) : ref) as React.Ref<never>
|
|
544
|
+
}
|
|
501
545
|
{...restProps}
|
|
502
546
|
aria-selected={(allowsAriaSelected && isSelected) || undefined}
|
|
503
547
|
// aria-selected is invalid on roles that don't permit it (listitem, a
|
|
@@ -527,11 +571,13 @@ export function Item({
|
|
|
527
571
|
)}
|
|
528
572
|
role={role}
|
|
529
573
|
onClick={
|
|
530
|
-
|
|
531
|
-
?
|
|
532
|
-
:
|
|
533
|
-
?
|
|
534
|
-
:
|
|
574
|
+
isDelegate
|
|
575
|
+
? delegatedOnClick
|
|
576
|
+
: hasParentRole
|
|
577
|
+
? onClick
|
|
578
|
+
: isInteractive
|
|
579
|
+
? handleContainerClick
|
|
580
|
+
: undefined
|
|
535
581
|
}>
|
|
536
582
|
{innerContent}
|
|
537
583
|
</Component>
|
|
@@ -59,6 +59,11 @@ export const docs = {
|
|
|
59
59
|
type: '(e: MouseEvent) => void',
|
|
60
60
|
description: 'Click handler; enables the invisible button pattern.',
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
name: 'interactiveRef',
|
|
64
|
+
type: 'RefObject<HTMLElement | null>',
|
|
65
|
+
description: 'Ref to a nested control (e.g. a checkbox in startContent) that owns the item\'s keyboard access and action. The row becomes an enlarged click/tap target that delegates surface clicks to it (useClickableContainer) and renders no invisible button/anchor, so the row adds no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href — those are ignored when set.',
|
|
66
|
+
},
|
|
62
67
|
{
|
|
63
68
|
name: 'href',
|
|
64
69
|
type: 'string',
|
|
@@ -121,6 +126,11 @@ export const docsZh = {
|
|
|
121
126
|
type: '(e: MouseEvent) => void',
|
|
122
127
|
description: '点击处理函数;启用隐形按钮模式。',
|
|
123
128
|
},
|
|
129
|
+
{
|
|
130
|
+
name: 'interactiveRef',
|
|
131
|
+
type: 'RefObject<HTMLElement | null>',
|
|
132
|
+
description: '指向嵌套控件(如 startContent 中的复选框)的 ref,该控件承载项目的键盘访问和操作。行成为更大的点击/触摸目标,将表面点击委托给该控件(useClickableContainer),且不渲染不可见按钮/锚点,因此行不会增加第二个 Tab 停留点(WCAG 4.1.2)。与 onClick/href 互斥——设置后二者将被忽略。',
|
|
133
|
+
},
|
|
124
134
|
{
|
|
125
135
|
name: 'href',
|
|
126
136
|
type: 'string',
|
|
@@ -162,6 +172,8 @@ export const docsDense = {
|
|
|
162
172
|
startContent: 'Content before label area (e.g. icon, avatar).',
|
|
163
173
|
endContent: 'Content after label area (e.g. badge, chevron).',
|
|
164
174
|
onClick: 'Click handler; enables invisible button pattern.',
|
|
175
|
+
interactiveRef:
|
|
176
|
+
'Ref to a nested control that owns the item\'s keyboard access/action; row delegates surface clicks to it (useClickableContainer), no invisible button/anchor, no second tab stop (WCAG 4.1.2). Mutually exclusive with onClick/href.',
|
|
165
177
|
href: 'Link URL; enables invisible anchor pattern.',
|
|
166
178
|
target: 'Link target attribute, only when href provided. target="_blank" auto-adds noopener noreferrer.',
|
|
167
179
|
rel: 'Link relationship tokens. noopener noreferrer are merged for target="_blank".',
|
package/src/List/ListItem.tsx
CHANGED
|
@@ -75,6 +75,17 @@ export interface ListItemProps extends BaseProps<HTMLLIElement> {
|
|
|
75
75
|
*/
|
|
76
76
|
onClick?: (e: React.MouseEvent) => void;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Ref to a nested control inside the item (e.g. a checkbox in
|
|
80
|
+
* `startContent`) that already provides the item's keyboard access and
|
|
81
|
+
* action. When set, the item becomes an enlarged click/tap target that
|
|
82
|
+
* delegates surface clicks to that control via the `useClickableContainer`
|
|
83
|
+
* pattern: it renders no invisible button/anchor, so the row adds no second
|
|
84
|
+
* tab stop (WCAG 4.1.2 — one focusable control per option). Mutually
|
|
85
|
+
* exclusive with `onClick`/`href` — when set those are ignored.
|
|
86
|
+
*/
|
|
87
|
+
interactiveRef?: React.RefObject<HTMLElement | null>;
|
|
88
|
+
|
|
78
89
|
/**
|
|
79
90
|
* URL for link items. Renders an invisible anchor element.
|
|
80
91
|
* Automatically enables hover/press styles when provided.
|
|
@@ -199,6 +210,7 @@ export function ListItem({
|
|
|
199
210
|
startContent,
|
|
200
211
|
endContent,
|
|
201
212
|
onClick,
|
|
213
|
+
interactiveRef,
|
|
202
214
|
href,
|
|
203
215
|
target,
|
|
204
216
|
rel,
|
|
@@ -239,6 +251,7 @@ export function ListItem({
|
|
|
239
251
|
description={description}
|
|
240
252
|
endContent={endContent}
|
|
241
253
|
onClick={onClick}
|
|
254
|
+
interactiveRef={interactiveRef}
|
|
242
255
|
href={href}
|
|
243
256
|
target={target as '_blank' | '_self'}
|
|
244
257
|
rel={rel}
|
|
@@ -5,6 +5,7 @@ import {render, screen} from '@testing-library/react';
|
|
|
5
5
|
import userEvent from '@testing-library/user-event';
|
|
6
6
|
import {MetadataList} from './MetadataList';
|
|
7
7
|
import {MetadataListItem} from './MetadataListItem';
|
|
8
|
+
import {InternationalizationProvider} from '../i18n';
|
|
8
9
|
|
|
9
10
|
describe('MetadataList', () => {
|
|
10
11
|
it('renders a description list with items', () => {
|
|
@@ -87,6 +88,29 @@ describe('MetadataList', () => {
|
|
|
87
88
|
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
it('localizes the show more / show less labels through the i18n catalog', async () => {
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<InternationalizationProvider
|
|
96
|
+
locale="fr"
|
|
97
|
+
overrides={{
|
|
98
|
+
fr: {
|
|
99
|
+
'@astryx.metadataList.showMore': 'Afficher plus',
|
|
100
|
+
'@astryx.metadataList.showLess': 'Afficher moins',
|
|
101
|
+
},
|
|
102
|
+
}}>
|
|
103
|
+
<MetadataList maxNumOfItems={1}>
|
|
104
|
+
<MetadataListItem label="A">1</MetadataListItem>
|
|
105
|
+
<MetadataListItem label="B">2</MetadataListItem>
|
|
106
|
+
</MetadataList>
|
|
107
|
+
</InternationalizationProvider>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await user.click(screen.getByText('Afficher plus'));
|
|
111
|
+
expect(screen.getByText('Afficher moins')).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
|
|
90
114
|
it('does not show toggle in horizontal mode even with maxNumOfItems', () => {
|
|
91
115
|
render(
|
|
92
116
|
<MetadataList orientation="horizontal" maxNumOfItems={1}>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file MetadataList.tsx
|
|
7
|
-
* @input Uses React, ReactNode, StyleXStyles, theme tokens, MetadataListContext
|
|
7
|
+
* @input Uses React, ReactNode, StyleXStyles, theme tokens, MetadataListContext, i18n (useTranslator)
|
|
8
8
|
* @output Exports MetadataList component, MetadataListProps, MetadataListColumns types
|
|
9
9
|
* @position Core implementation; consumed by index.ts, tested by MetadataList.test.tsx
|
|
10
10
|
*
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import type {BaseProps} from '../BaseProps';
|
|
32
32
|
import {mergeProps} from '../utils';
|
|
33
33
|
import {themeProps} from '../utils/themeProps';
|
|
34
|
+
import {useTranslator} from '../i18n';
|
|
34
35
|
|
|
35
36
|
// =============================================================================
|
|
36
37
|
// Types
|
|
@@ -197,6 +198,7 @@ export function MetadataList({
|
|
|
197
198
|
const labelConfig = label ?? (isMultiColumn ? LABEL_TOP : LABEL_START);
|
|
198
199
|
const [isShowAll, setIsShowAll] = useState(false);
|
|
199
200
|
const contentId = useId();
|
|
201
|
+
const t = useTranslator();
|
|
200
202
|
|
|
201
203
|
const contextValue = useMemo(
|
|
202
204
|
() => ({
|
|
@@ -283,7 +285,9 @@ export function MetadataList({
|
|
|
283
285
|
aria-expanded={isShowAll}
|
|
284
286
|
onClick={() => setIsShowAll(prev => !prev)}
|
|
285
287
|
{...stylex.props(styles.toggleButton)}>
|
|
286
|
-
{isShowAll
|
|
288
|
+
{isShowAll
|
|
289
|
+
? t('@astryx.metadataList.showLess')
|
|
290
|
+
: t('@astryx.metadataList.showMore')}
|
|
287
291
|
</button>
|
|
288
292
|
)}
|
|
289
293
|
</div>
|
|
@@ -510,6 +510,59 @@ describe('MultiSelector', () => {
|
|
|
510
510
|
expect(options[0]).toHaveTextContent('Select all');
|
|
511
511
|
});
|
|
512
512
|
|
|
513
|
+
it('select-all accessible name reflects none/partial/all selection', async () => {
|
|
514
|
+
const user = userEvent.setup();
|
|
515
|
+
const options = [
|
|
516
|
+
{value: 'apple', label: 'Apple'},
|
|
517
|
+
{value: 'banana', label: 'Banana'},
|
|
518
|
+
];
|
|
519
|
+
const {rerender} = render(
|
|
520
|
+
<MultiSelector
|
|
521
|
+
label="Fruit"
|
|
522
|
+
options={options}
|
|
523
|
+
value={[]}
|
|
524
|
+
onChange={() => {}}
|
|
525
|
+
hasSelectAll
|
|
526
|
+
/>,
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
await user.click(screen.getByRole('combobox'));
|
|
530
|
+
|
|
531
|
+
// None selected: plain name, not selected
|
|
532
|
+
let selectAll = screen.getAllByRole('option', h)[0];
|
|
533
|
+
expect(selectAll).not.toHaveAccessibleName(/partially selected/);
|
|
534
|
+
expect(selectAll).toHaveAttribute('aria-selected', 'false');
|
|
535
|
+
|
|
536
|
+
// Partial: aria-selected="mixed" is invalid on role="option", so the
|
|
537
|
+
// indeterminate state must be conveyed through the accessible name.
|
|
538
|
+
rerender(
|
|
539
|
+
<MultiSelector
|
|
540
|
+
label="Fruit"
|
|
541
|
+
options={options}
|
|
542
|
+
value={['apple']}
|
|
543
|
+
onChange={() => {}}
|
|
544
|
+
hasSelectAll
|
|
545
|
+
/>,
|
|
546
|
+
);
|
|
547
|
+
selectAll = screen.getAllByRole('option', h)[0];
|
|
548
|
+
expect(selectAll).toHaveAccessibleName('Select all, partially selected');
|
|
549
|
+
expect(selectAll).toHaveAttribute('aria-selected', 'false');
|
|
550
|
+
|
|
551
|
+
// All selected: plain name again, selected
|
|
552
|
+
rerender(
|
|
553
|
+
<MultiSelector
|
|
554
|
+
label="Fruit"
|
|
555
|
+
options={options}
|
|
556
|
+
value={['apple', 'banana']}
|
|
557
|
+
onChange={() => {}}
|
|
558
|
+
hasSelectAll
|
|
559
|
+
/>,
|
|
560
|
+
);
|
|
561
|
+
selectAll = screen.getAllByRole('option', h)[0];
|
|
562
|
+
expect(selectAll).not.toHaveAccessibleName(/partially selected/);
|
|
563
|
+
expect(selectAll).toHaveAttribute('aria-selected', 'true');
|
|
564
|
+
});
|
|
565
|
+
|
|
513
566
|
it('select-all toggles via keyboard Enter', async () => {
|
|
514
567
|
const user = userEvent.setup();
|
|
515
568
|
const onChange = vi.fn();
|
|
@@ -714,6 +767,29 @@ describe('MultiSelector', () => {
|
|
|
714
767
|
).toBeInTheDocument();
|
|
715
768
|
});
|
|
716
769
|
|
|
770
|
+
it('empty-state message is not exposed as a listbox child', async () => {
|
|
771
|
+
const user = userEvent.setup();
|
|
772
|
+
render(
|
|
773
|
+
<MultiSelector
|
|
774
|
+
label="Fruit"
|
|
775
|
+
options={defaultOptions}
|
|
776
|
+
value={[]}
|
|
777
|
+
onChange={() => {}}
|
|
778
|
+
hasSearch
|
|
779
|
+
/>,
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
783
|
+
await user.type(screen.getByRole('combobox', h), 'xyz');
|
|
784
|
+
|
|
785
|
+
// role="listbox" only permits option/group children — the visual
|
|
786
|
+
// empty-state message must be presentational (it is announced through
|
|
787
|
+
// the result-count live region instead).
|
|
788
|
+
const listbox = screen.getByRole('listbox', h);
|
|
789
|
+
const empty = within(listbox).getByText('No results found');
|
|
790
|
+
expect(empty).toHaveAttribute('role', 'presentation');
|
|
791
|
+
});
|
|
792
|
+
|
|
717
793
|
describe('result announcements', () => {
|
|
718
794
|
it('announces the match count politely while searching', async () => {
|
|
719
795
|
const user = userEvent.setup();
|
|
@@ -119,11 +119,12 @@ const styles = stylex.create({
|
|
|
119
119
|
lineHeight: 'inherit',
|
|
120
120
|
color: 'inherit',
|
|
121
121
|
cursor: 'pointer',
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
// The wrapper (inputWrapperStyles.base) renders the focus ring via
|
|
123
|
+
// :focus-within when this button is focused, matching
|
|
124
|
+
// TextInput/NumberInput/Selector. The button must not draw its own
|
|
125
|
+
// :focus-visible outline or the two stack into a doubled ring over the
|
|
126
|
+
// trigger.
|
|
127
|
+
outline: 'none',
|
|
127
128
|
borderRadius: radiusVars['--radius-element'],
|
|
128
129
|
},
|
|
129
130
|
triggerPlaceholder: {
|
|
@@ -1129,6 +1130,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1129
1130
|
? allEnabledSelected
|
|
1130
1131
|
: optimisticValue.includes(item.value);
|
|
1131
1132
|
const checkboxValue = isSelectAll ? selectAllState : isSelected;
|
|
1133
|
+
// aria-selected="mixed" is invalid on role="option", and the tri-state
|
|
1134
|
+
// checkbox is inert/decorative, so the indeterminate state must be
|
|
1135
|
+
// conveyed through the option's accessible name (WCAG 4.1.2).
|
|
1136
|
+
const isPartiallySelected =
|
|
1137
|
+
isSelectAll && selectAllState === 'indeterminate';
|
|
1132
1138
|
|
|
1133
1139
|
return (
|
|
1134
1140
|
<div
|
|
@@ -1136,6 +1142,13 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1136
1142
|
id={getItemId(flatIndex)}
|
|
1137
1143
|
role="option"
|
|
1138
1144
|
aria-selected={isSelected}
|
|
1145
|
+
aria-label={
|
|
1146
|
+
isPartiallySelected
|
|
1147
|
+
? t('@astryx.multiSelector.selectAllPartiallySelected', {
|
|
1148
|
+
label: selectAllLabel,
|
|
1149
|
+
})
|
|
1150
|
+
: undefined
|
|
1151
|
+
}
|
|
1139
1152
|
aria-disabled={item.disabled}
|
|
1140
1153
|
onClick={() => {
|
|
1141
1154
|
if (!item.disabled) {
|
|
@@ -1180,10 +1193,12 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1180
1193
|
optimisticValue,
|
|
1181
1194
|
allEnabledSelected,
|
|
1182
1195
|
selectAllState,
|
|
1196
|
+
selectAllLabel,
|
|
1183
1197
|
getItemId,
|
|
1184
1198
|
handleNavigableToggle,
|
|
1185
1199
|
onItemMouseEnter,
|
|
1186
1200
|
size,
|
|
1201
|
+
t,
|
|
1187
1202
|
],
|
|
1188
1203
|
);
|
|
1189
1204
|
|
|
@@ -1211,10 +1226,16 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1211
1226
|
cursor = 1;
|
|
1212
1227
|
}
|
|
1213
1228
|
|
|
1214
|
-
// Empty state — no real items to show
|
|
1229
|
+
// Empty state — no real items to show. role="presentation" keeps the
|
|
1230
|
+
// message out of the listbox's accessibility tree (role="listbox" only
|
|
1231
|
+
// permits option/group children); the no-results outcome is announced
|
|
1232
|
+
// via the result-count live region instead.
|
|
1215
1233
|
if (realItemCount === 0) {
|
|
1216
1234
|
elements.push(
|
|
1217
|
-
<div
|
|
1235
|
+
<div
|
|
1236
|
+
key="empty"
|
|
1237
|
+
role="presentation"
|
|
1238
|
+
{...stylex.props(styles.emptyState)}>
|
|
1218
1239
|
No results found
|
|
1219
1240
|
</div>,
|
|
1220
1241
|
);
|