@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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
-
import {render, screen, waitFor} from '@testing-library/react';
|
|
13
|
+
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
15
|
import {MultiSelector} from './MultiSelector';
|
|
16
16
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
@@ -893,4 +893,153 @@ describe('MultiSelector', () => {
|
|
|
893
893
|
});
|
|
894
894
|
});
|
|
895
895
|
});
|
|
896
|
+
|
|
897
|
+
describe('disabledMessage', () => {
|
|
898
|
+
it('shows the reason tooltip on hover when disabled with a reason', async () => {
|
|
899
|
+
render(
|
|
900
|
+
<MultiSelector
|
|
901
|
+
label="Fruit"
|
|
902
|
+
options={defaultOptions}
|
|
903
|
+
value={[]}
|
|
904
|
+
onChange={() => {}}
|
|
905
|
+
isDisabled
|
|
906
|
+
disabledMessage="Select a table first"
|
|
907
|
+
data-testid="fruit-multi-selector"
|
|
908
|
+
/>,
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
const container = screen.getByTestId('fruit-multi-selector');
|
|
912
|
+
const tooltip = screen.getByRole('tooltip', h);
|
|
913
|
+
expect(tooltip).toHaveTextContent('Select a table first');
|
|
914
|
+
|
|
915
|
+
fireEvent.mouseEnter(container);
|
|
916
|
+
await waitFor(() => {
|
|
917
|
+
expect(tooltip).toHaveAttribute('popover-open');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
fireEvent.mouseLeave(container);
|
|
921
|
+
await waitFor(() => {
|
|
922
|
+
expect(tooltip).not.toHaveAttribute('popover-open');
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('shows the reason tooltip on keyboard focus', async () => {
|
|
927
|
+
const user = userEvent.setup();
|
|
928
|
+
render(
|
|
929
|
+
<MultiSelector
|
|
930
|
+
label="Fruit"
|
|
931
|
+
options={defaultOptions}
|
|
932
|
+
value={[]}
|
|
933
|
+
onChange={() => {}}
|
|
934
|
+
isDisabled
|
|
935
|
+
disabledMessage="Select a table first"
|
|
936
|
+
/>,
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
const tooltip = screen.getByRole('tooltip', h);
|
|
940
|
+
await user.tab();
|
|
941
|
+
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
942
|
+
await waitFor(() => {
|
|
943
|
+
expect(tooltip).toHaveAttribute('popover-open');
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it('does not render a tooltip when not disabled', () => {
|
|
948
|
+
render(
|
|
949
|
+
<MultiSelector
|
|
950
|
+
label="Fruit"
|
|
951
|
+
options={defaultOptions}
|
|
952
|
+
value={[]}
|
|
953
|
+
onChange={() => {}}
|
|
954
|
+
disabledMessage="Select a table first"
|
|
955
|
+
/>,
|
|
956
|
+
);
|
|
957
|
+
expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('does not render a tooltip when disabled without a reason', () => {
|
|
961
|
+
render(
|
|
962
|
+
<MultiSelector
|
|
963
|
+
label="Fruit"
|
|
964
|
+
options={defaultOptions}
|
|
965
|
+
value={[]}
|
|
966
|
+
onChange={() => {}}
|
|
967
|
+
isDisabled
|
|
968
|
+
/>,
|
|
969
|
+
);
|
|
970
|
+
expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('keeps the trigger focusable via aria-disabled when a reason is provided', () => {
|
|
974
|
+
render(
|
|
975
|
+
<MultiSelector
|
|
976
|
+
label="Fruit"
|
|
977
|
+
options={defaultOptions}
|
|
978
|
+
value={[]}
|
|
979
|
+
onChange={() => {}}
|
|
980
|
+
isDisabled
|
|
981
|
+
disabledMessage="Select a table first"
|
|
982
|
+
/>,
|
|
983
|
+
);
|
|
984
|
+
const trigger = screen.getByRole('combobox');
|
|
985
|
+
expect(trigger).not.toBeDisabled();
|
|
986
|
+
expect(trigger).toHaveAttribute('aria-disabled', 'true');
|
|
987
|
+
expect(trigger).toHaveAttribute('tabIndex', '0');
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('links the reason tooltip from the trigger via aria-describedby', () => {
|
|
991
|
+
render(
|
|
992
|
+
<MultiSelector
|
|
993
|
+
label="Fruit"
|
|
994
|
+
options={defaultOptions}
|
|
995
|
+
value={[]}
|
|
996
|
+
onChange={() => {}}
|
|
997
|
+
isDisabled
|
|
998
|
+
disabledMessage="Select a table first"
|
|
999
|
+
/>,
|
|
1000
|
+
);
|
|
1001
|
+
const trigger = screen.getByRole('combobox');
|
|
1002
|
+
const tooltip = screen.getByRole('tooltip', h);
|
|
1003
|
+
expect(trigger.getAttribute('aria-describedby')).toContain(tooltip.id);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('blocks activation while focusable-disabled', async () => {
|
|
1007
|
+
const user = userEvent.setup();
|
|
1008
|
+
const onChange = vi.fn();
|
|
1009
|
+
render(
|
|
1010
|
+
<MultiSelector
|
|
1011
|
+
label="Fruit"
|
|
1012
|
+
options={defaultOptions}
|
|
1013
|
+
value={[]}
|
|
1014
|
+
onChange={onChange}
|
|
1015
|
+
isDisabled
|
|
1016
|
+
disabledMessage="Select a table first"
|
|
1017
|
+
/>,
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
const trigger = screen.getByRole('combobox');
|
|
1021
|
+
await user.click(trigger);
|
|
1022
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1023
|
+
|
|
1024
|
+
await user.keyboard('{Enter}');
|
|
1025
|
+
await user.keyboard('{ArrowDown}');
|
|
1026
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
1027
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it('remains non-focusable when disabled without a reason', () => {
|
|
1031
|
+
render(
|
|
1032
|
+
<MultiSelector
|
|
1033
|
+
label="Fruit"
|
|
1034
|
+
options={defaultOptions}
|
|
1035
|
+
value={[]}
|
|
1036
|
+
onChange={() => {}}
|
|
1037
|
+
isDisabled
|
|
1038
|
+
/>,
|
|
1039
|
+
);
|
|
1040
|
+
const trigger = screen.getByRole('combobox');
|
|
1041
|
+
expect(trigger).toBeDisabled();
|
|
1042
|
+
expect(trigger).toHaveAttribute('tabIndex', '-1');
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
896
1045
|
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file MultiSelector.tsx
|
|
7
|
-
* @input Uses React, StyleX, usePopover, CheckboxInput, Field, Badge, Icon
|
|
7
|
+
* @input Uses React, StyleX, usePopover, useTooltip, CheckboxInput, Field, Badge, Icon
|
|
8
8
|
* @output Exports MultiSelector component
|
|
9
9
|
* @position Core implementation; consumed by index.ts
|
|
10
10
|
*
|
|
@@ -27,6 +27,7 @@ import React, {
|
|
|
27
27
|
} from 'react';
|
|
28
28
|
import * as stylex from '@stylexjs/stylex';
|
|
29
29
|
import {usePopover} from '../Popover/usePopover';
|
|
30
|
+
import {useTooltip} from '../Tooltip';
|
|
30
31
|
import {Icon, renderIconSlot, type IconType} from '../Icon';
|
|
31
32
|
import type {IconName} from '../Icon';
|
|
32
33
|
import {
|
|
@@ -398,6 +399,30 @@ export interface MultiSelectorProps<
|
|
|
398
399
|
*/
|
|
399
400
|
isDisabled?: boolean;
|
|
400
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Explains why the selector is disabled. When set together with
|
|
404
|
+
* `isDisabled`, the selector shows a tooltip with this text on hover and
|
|
405
|
+
* keyboard focus, and the trigger stays focusable (via `aria-disabled`)
|
|
406
|
+
* so the reason is discoverable by keyboard and assistive technology.
|
|
407
|
+
* Activation stays blocked.
|
|
408
|
+
*
|
|
409
|
+
* Use this instead of wrapping a disabled selector in `Tooltip` — disabled
|
|
410
|
+
* controls don't emit the pointer events an external tooltip needs.
|
|
411
|
+
*
|
|
412
|
+
* @example
|
|
413
|
+
* ```
|
|
414
|
+
* <MultiSelector
|
|
415
|
+
* label="Columns"
|
|
416
|
+
* options={columns}
|
|
417
|
+
* value={selected}
|
|
418
|
+
* onChange={setSelected}
|
|
419
|
+
* isDisabled
|
|
420
|
+
* disabledMessage="Select a table first"
|
|
421
|
+
* />
|
|
422
|
+
* ```
|
|
423
|
+
*/
|
|
424
|
+
disabledMessage?: string;
|
|
425
|
+
|
|
401
426
|
/**
|
|
402
427
|
* The options to display in the selector.
|
|
403
428
|
* Can be strings, objects, dividers, or sections.
|
|
@@ -546,6 +571,7 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
546
571
|
isOptional = false,
|
|
547
572
|
isRequired = false,
|
|
548
573
|
isDisabled = false,
|
|
574
|
+
disabledMessage,
|
|
549
575
|
options,
|
|
550
576
|
value,
|
|
551
577
|
onChange,
|
|
@@ -593,11 +619,26 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
593
619
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
594
620
|
const isBusy = isLoading || optimisticValue !== value;
|
|
595
621
|
|
|
622
|
+
// Disabled-reason tooltip. Disabled controls swallow pointer events, so the
|
|
623
|
+
// tooltip listeners attach to the trigger container (which already exists)
|
|
624
|
+
// and the trigger button stays perceivable via aria-disabled instead of the
|
|
625
|
+
// disabled attribute. Activation is blocked by the isDisabled guards in
|
|
626
|
+
// useMultiCombobox (onTriggerClick / onKeyDown).
|
|
627
|
+
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
628
|
+
const disabledMessageTooltip = useTooltip({
|
|
629
|
+
placement: 'above',
|
|
630
|
+
// The container div is not naturally focusable; focusin bubbles up from
|
|
631
|
+
// the trigger button, so always attach focus listeners.
|
|
632
|
+
focusTrigger: 'always',
|
|
633
|
+
isEnabled: showsDisabledMessage,
|
|
634
|
+
});
|
|
635
|
+
|
|
596
636
|
// Build aria-describedby
|
|
597
637
|
const ariaDescribedBy =
|
|
598
638
|
[
|
|
599
639
|
description ? descriptionId : null,
|
|
600
640
|
status?.message ? statusMessageId : null,
|
|
641
|
+
showsDisabledMessage ? disabledMessageTooltip.describedBy : null,
|
|
601
642
|
]
|
|
602
643
|
.filter(Boolean)
|
|
603
644
|
.join(' ') || undefined;
|
|
@@ -1190,6 +1231,10 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1190
1231
|
<div
|
|
1191
1232
|
ref={el => {
|
|
1192
1233
|
popover.triggerRef(el);
|
|
1234
|
+
// Anchor + hover/focus listeners for the disabled-message tooltip.
|
|
1235
|
+
// Handlers are gated internally by isEnabled, and anchor names
|
|
1236
|
+
// compose, so attaching unconditionally is safe.
|
|
1237
|
+
disabledMessageTooltip.ref(el);
|
|
1193
1238
|
}}
|
|
1194
1239
|
onClick={onTriggerClick}
|
|
1195
1240
|
data-testid={testId}
|
|
@@ -1230,9 +1275,13 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1230
1275
|
aria-required={isRequired ? 'true' : undefined}
|
|
1231
1276
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
1232
1277
|
aria-busy={isBusy || undefined}
|
|
1233
|
-
|
|
1278
|
+
// With a disabledMessage the trigger keeps focusability via
|
|
1279
|
+
// aria-disabled so the reason is focus-discoverable; activation is
|
|
1280
|
+
// still blocked by the isDisabled guards in useMultiCombobox.
|
|
1281
|
+
disabled={isDisabled && !showsDisabledMessage}
|
|
1282
|
+
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
1234
1283
|
onKeyDown={onKeyDown}
|
|
1235
|
-
tabIndex={isDisabled ? -1 : 0}
|
|
1284
|
+
tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
|
|
1236
1285
|
{...stylex.props(styles.trigger)}>
|
|
1237
1286
|
<span {...stylex.props(styles.triggerContent)}>
|
|
1238
1287
|
{renderTriggerContent()}
|
|
@@ -1283,6 +1332,9 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1283
1332
|
xstyle: styles.popover,
|
|
1284
1333
|
},
|
|
1285
1334
|
)}
|
|
1335
|
+
|
|
1336
|
+
{showsDisabledMessage &&
|
|
1337
|
+
disabledMessageTooltip.renderTooltip(disabledMessage)}
|
|
1286
1338
|
</Field>
|
|
1287
1339
|
);
|
|
1288
1340
|
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import {mergeProps} from '../utils';
|
|
32
32
|
import type {BaseProps} from '../BaseProps';
|
|
33
33
|
import {themeProps} from '../utils/themeProps';
|
|
34
|
+
import {VisuallyHidden} from '../VisuallyHidden';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Extensible variant map for ProgressBar.
|
|
@@ -313,9 +314,7 @@ export function ProgressBar({
|
|
|
313
314
|
)}
|
|
314
315
|
</div>
|
|
315
316
|
) : (
|
|
316
|
-
<
|
|
317
|
-
{label}
|
|
318
|
-
</span>
|
|
317
|
+
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
|
|
319
318
|
)}
|
|
320
319
|
|
|
321
320
|
{/* Progress track */}
|
|
@@ -183,7 +183,7 @@ export function RadioList({
|
|
|
183
183
|
}: RadioListProps) {
|
|
184
184
|
const name = useId();
|
|
185
185
|
const inputID = useId();
|
|
186
|
-
const
|
|
186
|
+
const labelID = useId();
|
|
187
187
|
const descriptionID = useId();
|
|
188
188
|
const statusMessageID = useId();
|
|
189
189
|
|
|
@@ -284,7 +284,7 @@ export function RadioList({
|
|
|
284
284
|
isLabelHidden={isLabelHidden}
|
|
285
285
|
description={description}
|
|
286
286
|
inputID={inputID}
|
|
287
|
-
|
|
287
|
+
labelID={labelID}
|
|
288
288
|
isGroupLabel
|
|
289
289
|
descriptionID={description ? descriptionID : undefined}
|
|
290
290
|
isOptional={isOptional}
|
|
@@ -308,7 +308,7 @@ export function RadioList({
|
|
|
308
308
|
<div
|
|
309
309
|
ref={groupRef}
|
|
310
310
|
role="radiogroup"
|
|
311
|
-
aria-labelledby={
|
|
311
|
+
aria-labelledby={labelID}
|
|
312
312
|
onFocus={handleFocus}
|
|
313
313
|
aria-describedby={
|
|
314
314
|
[
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file SegmentedControl.tsx
|
|
7
|
-
* @input Uses React, StyleX, SegmentedControlContext
|
|
7
|
+
* @input Uses React, StyleX, SegmentedControlContext, useListFocus, useKeyboardHint
|
|
8
8
|
* @output Exports SegmentedControl component and SegmentedControlProps type
|
|
9
9
|
* @position Container wrapper; provides context to SegmentedControlItem children
|
|
10
10
|
*
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
* - /packages/cli/templates/blocks/components/SegmentedControl/ (showcase blocks)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import React, {useMemo,
|
|
18
|
+
import React, {useMemo, useCallback, type ReactNode} from 'react';
|
|
19
19
|
import * as stylex from '@stylexjs/stylex';
|
|
20
20
|
import {colorVars, spacingVars, radiusVars} from '../theme/tokens.stylex';
|
|
21
21
|
import {SegmentedControlContext} from './SegmentedControlContext';
|
|
22
|
-
import {
|
|
22
|
+
import {useListFocus} from '../hooks/useListFocus';
|
|
23
|
+
import {useKeyboardHint} from '../hooks/useKeyboardHint';
|
|
23
24
|
import type {
|
|
24
25
|
SegmentedControlSize,
|
|
25
26
|
SegmentedControlLayout,
|
|
@@ -134,63 +135,58 @@ export function SegmentedControl({
|
|
|
134
135
|
style,
|
|
135
136
|
}: SegmentedControlProps) {
|
|
136
137
|
const size = useSize(sizeProp, 'md');
|
|
137
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
// Roving tabindex + arrow/Home/End navigation is owned by the shared
|
|
140
|
+
// useListFocus primitive: it stamps a single tab stop (tabIndex 0/-1) across
|
|
141
|
+
// the radios, skips disabled ones, wraps at the ends, handles Home/End, and
|
|
142
|
+
// repairs the tab stop on mount and whenever items mount/disable — replacing
|
|
143
|
+
// the component's former inline keyboard handler and tab-stop repair effect.
|
|
144
|
+
const {listRef, handleKeyDown, handleFocus} = useListFocus<HTMLDivElement>({
|
|
145
|
+
itemSelector: '[role="radio"]:not([aria-disabled="true"])',
|
|
146
|
+
hasRovingTabIndex: true,
|
|
147
|
+
wrap: true,
|
|
148
|
+
orientation: 'horizontal',
|
|
149
|
+
});
|
|
144
150
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
const hint = useKeyboardHint({
|
|
152
|
+
orientation: 'horizontal',
|
|
153
|
+
isEnabled: !isDisabled,
|
|
154
|
+
});
|
|
149
155
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
const handleContainerKeyDown = useCallback(
|
|
157
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
158
|
+
hint.onKeyDown(e);
|
|
159
|
+
handleKeyDown(e);
|
|
160
|
+
},
|
|
161
|
+
[hint, handleKeyDown],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Selection-follows-focus (APG radiogroup): useListFocus only *moves* focus,
|
|
165
|
+
// so whenever it lands focus on a new radio (arrow/Home/End, or a click) we
|
|
166
|
+
// select that radio's value. Reading the focused element's data-value here
|
|
167
|
+
// keeps selection in lockstep with focus without duplicating the navigation
|
|
168
|
+
// logic. Disabled radios are ignored (they should never become the value),
|
|
169
|
+
// and the already-selected value is skipped so an initial Tab-in (or a click
|
|
170
|
+
// on the current segment) is a no-op, matching click behavior.
|
|
171
|
+
const handleContainerFocus = useCallback(
|
|
172
|
+
(e: React.FocusEvent) => {
|
|
173
|
+
hint.onFocus(e);
|
|
174
|
+
handleFocus(e);
|
|
175
|
+
if (isDisabled) {
|
|
156
176
|
return;
|
|
157
177
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
item => item === document.activeElement,
|
|
178
|
+
const focused = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
|
179
|
+
'[role="radio"][data-value]',
|
|
161
180
|
);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
switch (e.key) {
|
|
165
|
-
case 'ArrowRight':
|
|
166
|
-
nextIndex =
|
|
167
|
-
currentIndex === -1 ? 0 : (currentIndex + 1) % items.length;
|
|
168
|
-
break;
|
|
169
|
-
case 'ArrowLeft':
|
|
170
|
-
nextIndex =
|
|
171
|
-
currentIndex === -1
|
|
172
|
-
? items.length - 1
|
|
173
|
-
: (currentIndex - 1 + items.length) % items.length;
|
|
174
|
-
break;
|
|
175
|
-
case 'Home':
|
|
176
|
-
nextIndex = 0;
|
|
177
|
-
break;
|
|
178
|
-
case 'End':
|
|
179
|
-
nextIndex = items.length - 1;
|
|
180
|
-
break;
|
|
181
|
-
default:
|
|
182
|
-
return;
|
|
181
|
+
if (!focused || focused.getAttribute('aria-disabled') === 'true') {
|
|
182
|
+
return;
|
|
183
183
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const nextItem = items[nextIndex];
|
|
187
|
-
nextItem.focus();
|
|
188
|
-
const nextValue = nextItem.dataset.value;
|
|
189
|
-
if (nextValue != null) {
|
|
184
|
+
const nextValue = focused.dataset.value;
|
|
185
|
+
if (nextValue != null && nextValue !== value) {
|
|
190
186
|
onChange(nextValue);
|
|
191
187
|
}
|
|
192
188
|
},
|
|
193
|
-
[isDisabled, onChange],
|
|
189
|
+
[hint, handleFocus, isDisabled, onChange, value],
|
|
194
190
|
);
|
|
195
191
|
|
|
196
192
|
const contextValue = useMemo(
|
|
@@ -198,39 +194,16 @@ export function SegmentedControl({
|
|
|
198
194
|
[value, onChange, size, layout, isDisabled],
|
|
199
195
|
);
|
|
200
196
|
|
|
201
|
-
// Tab-stop repair (navigation-6): each item sets tabIndex=0 only when its
|
|
202
|
-
// value matches the group value, so a stale/unmatched `value` (or a disabled
|
|
203
|
-
// selected item) can leave every segment at tabIndex=-1 — making the whole
|
|
204
|
-
// radiogroup unreachable by Tab. After render, if no enabled radio is
|
|
205
|
-
// tabbable, promote the first enabled radio to tabIndex=0 so the group always
|
|
206
|
-
// has exactly one tab stop. Mirrors Base UI's Composite tab-stop repair.
|
|
207
|
-
useIsomorphicLayoutEffect(() => {
|
|
208
|
-
const container = containerRef.current;
|
|
209
|
-
if (!container) {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const enabled = Array.from(
|
|
213
|
-
container.querySelectorAll<HTMLButtonElement>(
|
|
214
|
-
'[role="radio"]:not([aria-disabled="true"])',
|
|
215
|
-
),
|
|
216
|
-
);
|
|
217
|
-
if (enabled.length === 0) {
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
const hasTabStop = enabled.some(el => el.tabIndex === 0);
|
|
221
|
-
if (!hasTabStop) {
|
|
222
|
-
enabled[0].tabIndex = 0;
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
|
|
226
197
|
return (
|
|
227
198
|
<SegmentedControlContext value={contextValue}>
|
|
228
199
|
<div
|
|
229
|
-
ref={mergeRefs(ref,
|
|
200
|
+
ref={mergeRefs(ref, listRef)}
|
|
230
201
|
role="radiogroup"
|
|
231
202
|
aria-label={label}
|
|
232
203
|
aria-disabled={isDisabled || undefined}
|
|
233
|
-
onKeyDown={
|
|
204
|
+
onKeyDown={handleContainerKeyDown}
|
|
205
|
+
onFocus={handleContainerFocus}
|
|
206
|
+
onBlur={hint.onBlur}
|
|
234
207
|
{...mergeProps(
|
|
235
208
|
themeProps('segmented-control', {size}),
|
|
236
209
|
stylex.props(
|
|
@@ -244,6 +217,7 @@ export function SegmentedControl({
|
|
|
244
217
|
style,
|
|
245
218
|
)}>
|
|
246
219
|
{children}
|
|
220
|
+
{hint.hintElement}
|
|
247
221
|
</div>
|
|
248
222
|
</SegmentedControlContext>
|
|
249
223
|
);
|
|
@@ -86,6 +86,12 @@ export const docs = {
|
|
|
86
86
|
description: 'Disables the selector.',
|
|
87
87
|
default: 'false',
|
|
88
88
|
},
|
|
89
|
+
{
|
|
90
|
+
name: 'disabledMessage',
|
|
91
|
+
type: 'string',
|
|
92
|
+
description:
|
|
93
|
+
'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 Selector in Tooltip — disabled controls swallow the hover events an external Tooltip needs.',
|
|
94
|
+
},
|
|
89
95
|
{
|
|
90
96
|
name: 'isLabelHidden',
|
|
91
97
|
type: 'boolean',
|
|
@@ -176,6 +182,11 @@ export const docs = {
|
|
|
176
182
|
description:
|
|
177
183
|
'Put more than ~20 options without sections; consider Typeahead for large lists.',
|
|
178
184
|
},
|
|
185
|
+
{
|
|
186
|
+
guidance: false,
|
|
187
|
+
description:
|
|
188
|
+
'Wrap a disabled Selector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
|
|
189
|
+
},
|
|
179
190
|
],
|
|
180
191
|
anatomy: [
|
|
181
192
|
{
|
|
@@ -263,6 +274,11 @@ export const docsZh = {
|
|
|
263
274
|
description:
|
|
264
275
|
'Put more than ~20 options without sections; consider Typeahead for large lists.',
|
|
265
276
|
},
|
|
277
|
+
{
|
|
278
|
+
guidance: false,
|
|
279
|
+
description:
|
|
280
|
+
'Wrap a disabled Selector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
|
|
281
|
+
},
|
|
266
282
|
],
|
|
267
283
|
anatomy: [
|
|
268
284
|
{
|
|
@@ -350,6 +366,11 @@ export const docsDense = {
|
|
|
350
366
|
description:
|
|
351
367
|
'Put more than ~20 options without sections; consider Typeahead for large lists.',
|
|
352
368
|
},
|
|
369
|
+
{
|
|
370
|
+
guidance: false,
|
|
371
|
+
description:
|
|
372
|
+
'Wrap a disabled Selector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
|
|
373
|
+
},
|
|
353
374
|
],
|
|
354
375
|
anatomy: [
|
|
355
376
|
{
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
13
|
-
import {render, screen, waitFor} from '@testing-library/react';
|
|
13
|
+
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
15
|
import {Selector} from './Selector';
|
|
16
16
|
import {SelectorOption} from './SelectorOption';
|
|
@@ -652,4 +652,126 @@ describe('Selector', () => {
|
|
|
652
652
|
}
|
|
653
653
|
});
|
|
654
654
|
});
|
|
655
|
+
|
|
656
|
+
describe('disabledMessage', () => {
|
|
657
|
+
it('shows the reason tooltip on hover when disabled with a reason', async () => {
|
|
658
|
+
render(
|
|
659
|
+
<Selector
|
|
660
|
+
label="Fruit"
|
|
661
|
+
options={OPTIONS}
|
|
662
|
+
isDisabled
|
|
663
|
+
disabledMessage="You need the Editor role"
|
|
664
|
+
data-testid="fruit-selector"
|
|
665
|
+
/>,
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const container = screen.getByTestId('fruit-selector');
|
|
669
|
+
const tooltip = screen.getByRole('tooltip', h);
|
|
670
|
+
expect(tooltip).toHaveTextContent('You need the Editor role');
|
|
671
|
+
|
|
672
|
+
fireEvent.mouseEnter(container);
|
|
673
|
+
await waitFor(() => {
|
|
674
|
+
expect(tooltip).toHaveAttribute('popover-open');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
fireEvent.mouseLeave(container);
|
|
678
|
+
await waitFor(() => {
|
|
679
|
+
expect(tooltip).not.toHaveAttribute('popover-open');
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('shows the reason tooltip on keyboard focus', async () => {
|
|
684
|
+
const user = userEvent.setup();
|
|
685
|
+
render(
|
|
686
|
+
<Selector
|
|
687
|
+
label="Fruit"
|
|
688
|
+
options={OPTIONS}
|
|
689
|
+
isDisabled
|
|
690
|
+
disabledMessage="You need the Editor role"
|
|
691
|
+
/>,
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
const tooltip = screen.getByRole('tooltip', h);
|
|
695
|
+
await user.tab();
|
|
696
|
+
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
697
|
+
await waitFor(() => {
|
|
698
|
+
expect(tooltip).toHaveAttribute('popover-open');
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('does not render a tooltip when not disabled', () => {
|
|
703
|
+
render(
|
|
704
|
+
<Selector
|
|
705
|
+
label="Fruit"
|
|
706
|
+
options={OPTIONS}
|
|
707
|
+
disabledMessage="You need the Editor role"
|
|
708
|
+
/>,
|
|
709
|
+
);
|
|
710
|
+
expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('does not render a tooltip when disabled without a reason', () => {
|
|
714
|
+
render(<Selector label="Fruit" options={OPTIONS} isDisabled />);
|
|
715
|
+
expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('keeps the trigger focusable via aria-disabled when a reason is provided', () => {
|
|
719
|
+
render(
|
|
720
|
+
<Selector
|
|
721
|
+
label="Fruit"
|
|
722
|
+
options={OPTIONS}
|
|
723
|
+
isDisabled
|
|
724
|
+
disabledMessage="You need the Editor role"
|
|
725
|
+
/>,
|
|
726
|
+
);
|
|
727
|
+
const trigger = screen.getByRole('combobox');
|
|
728
|
+
expect(trigger).not.toBeDisabled();
|
|
729
|
+
expect(trigger).toHaveAttribute('aria-disabled', 'true');
|
|
730
|
+
expect(trigger).toHaveAttribute('tabIndex', '0');
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('links the reason tooltip from the trigger via aria-describedby', () => {
|
|
734
|
+
render(
|
|
735
|
+
<Selector
|
|
736
|
+
label="Fruit"
|
|
737
|
+
options={OPTIONS}
|
|
738
|
+
isDisabled
|
|
739
|
+
disabledMessage="You need the Editor role"
|
|
740
|
+
/>,
|
|
741
|
+
);
|
|
742
|
+
const trigger = screen.getByRole('combobox');
|
|
743
|
+
const tooltip = screen.getByRole('tooltip', h);
|
|
744
|
+
expect(trigger.getAttribute('aria-describedby')).toContain(tooltip.id);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('blocks activation while focusable-disabled', async () => {
|
|
748
|
+
const user = userEvent.setup();
|
|
749
|
+
const onChange = vi.fn();
|
|
750
|
+
render(
|
|
751
|
+
<Selector
|
|
752
|
+
label="Fruit"
|
|
753
|
+
options={OPTIONS}
|
|
754
|
+
onChange={onChange}
|
|
755
|
+
isDisabled
|
|
756
|
+
disabledMessage="You need the Editor role"
|
|
757
|
+
/>,
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
const trigger = screen.getByRole('combobox');
|
|
761
|
+
await user.click(trigger);
|
|
762
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
763
|
+
|
|
764
|
+
await user.keyboard('{Enter}');
|
|
765
|
+
await user.keyboard('{ArrowDown}');
|
|
766
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
767
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('remains non-focusable when disabled without a reason', () => {
|
|
771
|
+
render(<Selector label="Fruit" options={OPTIONS} isDisabled />);
|
|
772
|
+
const trigger = screen.getByRole('combobox');
|
|
773
|
+
expect(trigger).toBeDisabled();
|
|
774
|
+
expect(trigger).toHaveAttribute('tabIndex', '-1');
|
|
775
|
+
});
|
|
776
|
+
});
|
|
655
777
|
});
|