@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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Selector.tsx
|
|
7
|
-
* @input Uses React, StyleX, usePopover, Icon
|
|
7
|
+
* @input Uses React, StyleX, usePopover, useTooltip, Icon
|
|
8
8
|
* @output Exports Selector 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 {
|
|
@@ -369,6 +370,28 @@ interface SelectorPropsBase<
|
|
|
369
370
|
*/
|
|
370
371
|
isDisabled?: boolean;
|
|
371
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Explains why the selector is disabled. When set together with
|
|
375
|
+
* `isDisabled`, the selector shows a tooltip with this text on hover and
|
|
376
|
+
* keyboard focus, and the trigger stays focusable (via `aria-disabled`)
|
|
377
|
+
* so the reason is discoverable by keyboard and assistive technology.
|
|
378
|
+
* Activation stays blocked.
|
|
379
|
+
*
|
|
380
|
+
* Use this instead of wrapping a disabled selector in `Tooltip` — disabled
|
|
381
|
+
* controls don't emit the pointer events an external tooltip needs.
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* ```
|
|
385
|
+
* <Selector
|
|
386
|
+
* label="Owner"
|
|
387
|
+
* options={owners}
|
|
388
|
+
* isDisabled
|
|
389
|
+
* disabledMessage="You need the Editor role to change this"
|
|
390
|
+
* />
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
disabledMessage?: string;
|
|
394
|
+
|
|
372
395
|
/**
|
|
373
396
|
* The options to display in the selector.
|
|
374
397
|
* Can be strings, objects, dividers, or sections.
|
|
@@ -525,6 +548,7 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
525
548
|
isOptional = false,
|
|
526
549
|
isRequired = false,
|
|
527
550
|
isDisabled = false,
|
|
551
|
+
disabledMessage,
|
|
528
552
|
options,
|
|
529
553
|
value,
|
|
530
554
|
onChange,
|
|
@@ -567,11 +591,26 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
567
591
|
const [optimisticValue, setOptimisticValue] = useOptimistic(normalizedValue);
|
|
568
592
|
const isBusy = isLoading || optimisticValue !== normalizedValue;
|
|
569
593
|
|
|
594
|
+
// Disabled-reason tooltip. Disabled controls swallow pointer events, so the
|
|
595
|
+
// tooltip listeners attach to the trigger container (which already exists)
|
|
596
|
+
// and the trigger button stays perceivable via aria-disabled instead of the
|
|
597
|
+
// disabled attribute. Activation is blocked by the isDisabled guards in
|
|
598
|
+
// useCombobox (onTriggerClick / onKeyDown).
|
|
599
|
+
const showsDisabledMessage = isDisabled && !!disabledMessage;
|
|
600
|
+
const disabledMessageTooltip = useTooltip({
|
|
601
|
+
placement: 'above',
|
|
602
|
+
// The container div is not naturally focusable; focusin bubbles up from
|
|
603
|
+
// the trigger button, so always attach focus listeners.
|
|
604
|
+
focusTrigger: 'always',
|
|
605
|
+
isEnabled: showsDisabledMessage,
|
|
606
|
+
});
|
|
607
|
+
|
|
570
608
|
// Build aria-describedby
|
|
571
609
|
const ariaDescribedBy =
|
|
572
610
|
[
|
|
573
611
|
description ? descriptionId : null,
|
|
574
612
|
status?.message ? statusMessageId : null,
|
|
613
|
+
showsDisabledMessage ? disabledMessageTooltip.describedBy : null,
|
|
575
614
|
]
|
|
576
615
|
.filter(Boolean)
|
|
577
616
|
.join(' ') || undefined;
|
|
@@ -902,6 +941,10 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
902
941
|
<div
|
|
903
942
|
ref={el => {
|
|
904
943
|
popover.triggerRef(el);
|
|
944
|
+
// Anchor + hover/focus listeners for the disabled-message tooltip.
|
|
945
|
+
// Handlers are gated internally by isEnabled, and anchor names
|
|
946
|
+
// compose, so attaching unconditionally is safe.
|
|
947
|
+
disabledMessageTooltip.ref(el);
|
|
905
948
|
}}
|
|
906
949
|
onClick={onTriggerClick}
|
|
907
950
|
data-testid={testId}
|
|
@@ -943,9 +986,13 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
943
986
|
aria-required={isRequired ? 'true' : undefined}
|
|
944
987
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
945
988
|
aria-busy={isBusy || undefined}
|
|
946
|
-
|
|
989
|
+
// With a disabledMessage the trigger keeps focusability via
|
|
990
|
+
// aria-disabled so the reason is focus-discoverable; activation is
|
|
991
|
+
// still blocked by the isDisabled guards in useCombobox.
|
|
992
|
+
disabled={isDisabled && !showsDisabledMessage}
|
|
993
|
+
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
947
994
|
onKeyDown={onKeyDown}
|
|
948
|
-
tabIndex={isDisabled ? -1 : 0}
|
|
995
|
+
tabIndex={isDisabled && !showsDisabledMessage ? -1 : 0}
|
|
949
996
|
{...stylex.props(styles.trigger)}>
|
|
950
997
|
<span {...stylex.props(styles.triggerLabel)}>
|
|
951
998
|
{selectedItem?.label ?? placeholder}
|
|
@@ -1012,6 +1059,9 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
1012
1059
|
style: popoverOffsetStyle,
|
|
1013
1060
|
},
|
|
1014
1061
|
)}
|
|
1062
|
+
|
|
1063
|
+
{showsDisabledMessage &&
|
|
1064
|
+
disabledMessageTooltip.renderTooltip(disabledMessage)}
|
|
1015
1065
|
</Field>
|
|
1016
1066
|
);
|
|
1017
1067
|
}
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -44,6 +44,7 @@ import {switchScope} from './switch.markers.stylex';
|
|
|
44
44
|
import type {BaseProps} from '../BaseProps';
|
|
45
45
|
import type {SizeValue} from '../utils/types';
|
|
46
46
|
import {themeProps} from '../utils/themeProps';
|
|
47
|
+
import {VisuallyHidden} from '../VisuallyHidden';
|
|
47
48
|
|
|
48
49
|
// Fixed dimensions: 40px width, 24px height, 16px thumb (off), 20px thumb (on)
|
|
49
50
|
const SWITCH_WIDTH = 40;
|
|
@@ -179,17 +180,6 @@ const styles = stylex.create({
|
|
|
179
180
|
fontSize: typeScaleVars['--text-supporting-size'],
|
|
180
181
|
color: colorVars['--color-text-secondary'],
|
|
181
182
|
},
|
|
182
|
-
srOnly: {
|
|
183
|
-
position: 'absolute',
|
|
184
|
-
width: 1,
|
|
185
|
-
height: 1,
|
|
186
|
-
padding: 0,
|
|
187
|
-
margin: -1,
|
|
188
|
-
overflow: 'hidden',
|
|
189
|
-
clip: 'rect(0, 0, 0, 0)',
|
|
190
|
-
whiteSpace: 'nowrap',
|
|
191
|
-
borderWidth: 0,
|
|
192
|
-
},
|
|
193
183
|
});
|
|
194
184
|
|
|
195
185
|
export type SwitchLabelPosition = 'start' | 'end';
|
|
@@ -416,11 +406,7 @@ export function Switch({
|
|
|
416
406
|
{isBusy && <Spinner size="sm" />}
|
|
417
407
|
</div>
|
|
418
408
|
</div>
|
|
419
|
-
{isBusy &&
|
|
420
|
-
<span {...stylex.props(styles.srOnly)} role="status">
|
|
421
|
-
Loading
|
|
422
|
-
</span>
|
|
423
|
-
)}
|
|
409
|
+
{isBusy && <VisuallyHidden role="status">Loading</VisuallyHidden>}
|
|
424
410
|
</div>
|
|
425
411
|
);
|
|
426
412
|
|
|
@@ -445,6 +445,47 @@ describe('TabList keyboard navigation (roving tabindex)', () => {
|
|
|
445
445
|
await user.keyboard('a');
|
|
446
446
|
expect(home).toHaveFocus();
|
|
447
447
|
});
|
|
448
|
+
|
|
449
|
+
it('composes consumer onKeyDown with internal arrow navigation', async () => {
|
|
450
|
+
const user = userEvent.setup();
|
|
451
|
+
const onKeyDown = vi.fn();
|
|
452
|
+
|
|
453
|
+
render(
|
|
454
|
+
<TabList value="home" onChange={() => {}} onKeyDown={onKeyDown}>
|
|
455
|
+
<Tab value="home" label="Home" />
|
|
456
|
+
<Tab value="settings" label="Settings" />
|
|
457
|
+
</TabList>,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const home = screen.getByRole('button', {name: 'Home'});
|
|
461
|
+
const settings = screen.getByRole('button', {name: 'Settings'});
|
|
462
|
+
|
|
463
|
+
home.focus();
|
|
464
|
+
await user.keyboard('{ArrowRight}');
|
|
465
|
+
|
|
466
|
+
expect(onKeyDown).toHaveBeenCalled();
|
|
467
|
+
expect(settings).toHaveFocus();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('respects preventDefault from consumer onKeyDown', async () => {
|
|
471
|
+
const user = userEvent.setup();
|
|
472
|
+
|
|
473
|
+
render(
|
|
474
|
+
<TabList
|
|
475
|
+
value="home"
|
|
476
|
+
onChange={() => {}}
|
|
477
|
+
onKeyDown={e => e.preventDefault()}>
|
|
478
|
+
<Tab value="home" label="Home" />
|
|
479
|
+
<Tab value="settings" label="Settings" />
|
|
480
|
+
</TabList>,
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const home = screen.getByRole('button', {name: 'Home'});
|
|
484
|
+
home.focus();
|
|
485
|
+
await user.keyboard('{ArrowRight}');
|
|
486
|
+
|
|
487
|
+
expect(home).toHaveFocus();
|
|
488
|
+
});
|
|
448
489
|
});
|
|
449
490
|
|
|
450
491
|
describe('Tab polymorphic link', () => {
|
package/src/TabList/TabList.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file TabList.tsx
|
|
7
|
-
* @input Uses React, StyleX, TabListContext, useListFocus
|
|
7
|
+
* @input Uses React, StyleX, TabListContext, useListFocus, useKeyboardHint
|
|
8
8
|
* @output Exports TabList component and TabListProps type
|
|
9
9
|
* @position Nav wrapper; provides TabListContext to Tab and TabMenu children.
|
|
10
10
|
* Owns roving-tabindex keyboard navigation (Arrow/Home/End) across the tab
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - /packages/cli/templates/blocks/components/TabList/ (showcase blocks)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import React, {useMemo, type ReactNode} from 'react';
|
|
20
|
+
import React, {useCallback, useMemo, type ReactNode} from 'react';
|
|
21
21
|
import * as stylex from '@stylexjs/stylex';
|
|
22
22
|
import {borderVars, colorVars, spacingVars} from '../theme/tokens.stylex';
|
|
23
23
|
import type {BaseProps} from '../BaseProps';
|
|
@@ -26,7 +26,7 @@ import type {TabListOrientation, TabListSize} from './TabListContext';
|
|
|
26
26
|
import {useSize} from '../SizeContext/SizeContext';
|
|
27
27
|
import {mergeProps, mergeRefs} from '../utils';
|
|
28
28
|
import {useListFocus} from '../hooks/useListFocus';
|
|
29
|
-
import {
|
|
29
|
+
import {useKeyboardHint} from '../hooks/useKeyboardHint';
|
|
30
30
|
import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
|
|
31
31
|
import {themeProps} from '../utils/themeProps';
|
|
32
32
|
|
|
@@ -37,13 +37,6 @@ import {themeProps} from '../utils/themeProps';
|
|
|
37
37
|
*/
|
|
38
38
|
const TAB_STOP_SELECTOR = '[data-tab-value],[data-tab-menu]';
|
|
39
39
|
|
|
40
|
-
function isDisabledStop(el: HTMLElement): boolean {
|
|
41
|
-
return (
|
|
42
|
-
el.getAttribute('aria-disabled') === 'true' ||
|
|
43
|
-
(el instanceof HTMLButtonElement && el.disabled)
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
40
|
export interface TabListProps extends Omit<BaseProps<HTMLElement>, 'onChange'> {
|
|
48
41
|
ref?: React.Ref<HTMLElement>;
|
|
49
42
|
/**
|
|
@@ -133,6 +126,12 @@ export function TabList({
|
|
|
133
126
|
className,
|
|
134
127
|
style,
|
|
135
128
|
children,
|
|
129
|
+
onKeyDown: onKeyDownProp,
|
|
130
|
+
onFocus: onFocusProp,
|
|
131
|
+
onBlur: onBlurProp,
|
|
132
|
+
'aria-label': ariaLabel = 'Tabs',
|
|
133
|
+
'aria-orientation': _ariaOrientation,
|
|
134
|
+
[EDGE_COMP_ATTR]: _edgeCompAttr,
|
|
136
135
|
...restProps
|
|
137
136
|
}: TabListProps) {
|
|
138
137
|
const size = useSize(sizeProp, 'md');
|
|
@@ -142,51 +141,78 @@ export function TabList({
|
|
|
142
141
|
// allowance for tab strips (ArrowRight/ArrowDown advance, ArrowLeft/ArrowUp
|
|
143
142
|
// retreat) regardless of the component's `orientation` prop, which only
|
|
144
143
|
// drives the reported `aria-orientation`.
|
|
145
|
-
|
|
144
|
+
//
|
|
145
|
+
// `hasRovingTabIndex` makes the hook own the single tab stop: it stamps
|
|
146
|
+
// tabindex 0/-1, repairs the stop on mount and as stops mount/unmount or
|
|
147
|
+
// toggle disabled, and — via `handleFocus` on the nav — keeps the stop in
|
|
148
|
+
// sync after clicks or programmatic focus. Individual Tabs still render
|
|
149
|
+
// `tabIndex={isSelected ? 0 : -1}` (see Tab.tsx) as the initial source of
|
|
150
|
+
// truth; the hook's repair preserves an existing tab stop and only promotes
|
|
151
|
+
// the first enabled stop when none is tabbable.
|
|
152
|
+
const {listRef, handleKeyDown, handleFocus} = useListFocus<HTMLElement>({
|
|
146
153
|
itemSelector: TAB_STOP_SELECTOR,
|
|
147
154
|
orientation: 'both',
|
|
155
|
+
hasRovingTabIndex: true,
|
|
148
156
|
});
|
|
149
157
|
|
|
158
|
+
const {
|
|
159
|
+
hintElement,
|
|
160
|
+
onKeyDown: onHintKeyDown,
|
|
161
|
+
onFocus: onHintFocus,
|
|
162
|
+
onBlur: onHintBlur,
|
|
163
|
+
} = useKeyboardHint({orientation});
|
|
164
|
+
|
|
150
165
|
const contextValue = useMemo(
|
|
151
166
|
() => ({value, onChange, size, layout}),
|
|
152
167
|
[value, onChange, size, layout],
|
|
153
168
|
);
|
|
154
169
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
170
|
+
const handleRootKeyDown = useCallback(
|
|
171
|
+
(e: React.KeyboardEvent<HTMLElement>) => {
|
|
172
|
+
onKeyDownProp?.(e);
|
|
173
|
+
if (e.defaultPrevented) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
onHintKeyDown(e);
|
|
177
|
+
handleKeyDown(e);
|
|
178
|
+
},
|
|
179
|
+
[onKeyDownProp, onHintKeyDown, handleKeyDown],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const handleRootFocus = useCallback(
|
|
183
|
+
(e: React.FocusEvent<HTMLElement>) => {
|
|
184
|
+
onFocusProp?.(e);
|
|
185
|
+
if (e.defaultPrevented) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
onHintFocus(e);
|
|
189
|
+
handleFocus(e);
|
|
190
|
+
},
|
|
191
|
+
[onFocusProp, onHintFocus, handleFocus],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const handleRootBlur = useCallback(
|
|
195
|
+
(e: React.FocusEvent<HTMLElement>) => {
|
|
196
|
+
onBlurProp?.(e);
|
|
197
|
+
if (e.defaultPrevented) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
onHintBlur(e);
|
|
201
|
+
},
|
|
202
|
+
[onBlurProp, onHintBlur],
|
|
203
|
+
);
|
|
180
204
|
|
|
181
205
|
return (
|
|
182
206
|
<TabListContext value={contextValue}>
|
|
183
207
|
<nav
|
|
184
208
|
ref={mergeRefs(ref, listRef)}
|
|
185
|
-
|
|
209
|
+
{...restProps}
|
|
210
|
+
aria-label={ariaLabel}
|
|
186
211
|
aria-orientation={orientation}
|
|
187
|
-
onKeyDown={
|
|
212
|
+
onKeyDown={handleRootKeyDown}
|
|
213
|
+
onFocus={handleRootFocus}
|
|
214
|
+
onBlur={handleRootBlur}
|
|
188
215
|
{...{[EDGE_COMP_ATTR]: ''}}
|
|
189
|
-
{...restProps}
|
|
190
216
|
{...mergeProps(
|
|
191
217
|
themeProps('tab-list', {size}),
|
|
192
218
|
stylex.props(
|
|
@@ -199,6 +225,7 @@ export function TabList({
|
|
|
199
225
|
style,
|
|
200
226
|
)}>
|
|
201
227
|
{children}
|
|
228
|
+
{hintElement}
|
|
202
229
|
</nav>
|
|
203
230
|
</TabListContext>
|
|
204
231
|
);
|
|
@@ -107,7 +107,6 @@ function LazyTableContextMenu({
|
|
|
107
107
|
return (
|
|
108
108
|
<ContextMenu
|
|
109
109
|
items={options ?? []}
|
|
110
|
-
hasAutoFocus={false}
|
|
111
110
|
onOpenChange={open => {
|
|
112
111
|
// Resolve actions when opening; clear on close so state derived later
|
|
113
112
|
// (e.g. current sort direction) is always fresh next open.
|
|
@@ -135,12 +134,9 @@ export function wrapInTableContextMenu(
|
|
|
135
134
|
if (!actions || actions.length === 0) {
|
|
136
135
|
return element;
|
|
137
136
|
}
|
|
138
|
-
// hasAutoFocus={false}: a right-click menu shouldn't pre-highlight the first
|
|
139
137
|
// item (it looked like "ascending" was always selected). Focus moves only
|
|
140
138
|
// when the user arrow-keys into the menu.
|
|
141
139
|
return (
|
|
142
|
-
<ContextMenu items={toContextMenuOptions(actions)}
|
|
143
|
-
{element}
|
|
144
|
-
</ContextMenu>
|
|
140
|
+
<ContextMenu items={toContextMenuOptions(actions)}>{element}</ContextMenu>
|
|
145
141
|
);
|
|
146
142
|
}
|
|
@@ -49,6 +49,7 @@ import type {SizeValue} from '../utils/types';
|
|
|
49
49
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
50
50
|
import {useSize} from '../SizeContext/SizeContext';
|
|
51
51
|
import {themeProps} from '../utils/themeProps';
|
|
52
|
+
import {VisuallyHidden} from '../VisuallyHidden';
|
|
52
53
|
|
|
53
54
|
const COUNTER_WARNING_THRESHOLD = 0.8;
|
|
54
55
|
|
|
@@ -93,17 +94,6 @@ const styles = stylex.create({
|
|
|
93
94
|
counterError: {
|
|
94
95
|
color: colorVars['--color-error'],
|
|
95
96
|
},
|
|
96
|
-
srOnly: {
|
|
97
|
-
position: 'absolute',
|
|
98
|
-
width: '1px',
|
|
99
|
-
height: '1px',
|
|
100
|
-
padding: 0,
|
|
101
|
-
margin: '-1px',
|
|
102
|
-
overflow: 'hidden',
|
|
103
|
-
clip: 'rect(0,0,0,0)',
|
|
104
|
-
whiteSpace: 'nowrap',
|
|
105
|
-
borderWidth: 0,
|
|
106
|
-
},
|
|
107
97
|
statusIcon: {
|
|
108
98
|
position: 'absolute',
|
|
109
99
|
top: spacingVars['--spacing-2'],
|
|
@@ -451,13 +441,13 @@ export function TextArea({
|
|
|
451
441
|
optimisticValue.length > maxLength && styles.counterError,
|
|
452
442
|
)}>
|
|
453
443
|
{optimisticValue.length}/{maxLength}
|
|
454
|
-
<
|
|
444
|
+
<VisuallyHidden aria-live="polite">
|
|
455
445
|
{optimisticValue.length >= maxLength * COUNTER_WARNING_THRESHOLD
|
|
456
446
|
? optimisticValue.length > maxLength
|
|
457
447
|
? `${optimisticValue.length - maxLength} characters over limit`
|
|
458
448
|
: `${maxLength - optimisticValue.length} characters remaining`
|
|
459
449
|
: ''}
|
|
460
|
-
</
|
|
450
|
+
</VisuallyHidden>
|
|
461
451
|
</div>
|
|
462
452
|
)}
|
|
463
453
|
</Field>
|
|
@@ -71,9 +71,12 @@ describe('Toolbar', () => {
|
|
|
71
71
|
expect(screen.getByTestId('center')).toBeInTheDocument();
|
|
72
72
|
expect(screen.getByTestId('end')).toBeInTheDocument();
|
|
73
73
|
|
|
74
|
-
// Three-slot layout produces 3 child divs
|
|
74
|
+
// Three-slot layout produces 3 child divs (plus the aria-hidden
|
|
75
|
+
// keyboard-hint popover, which is excluded here as an implementation detail)
|
|
75
76
|
const toolbar = screen.getByRole('toolbar');
|
|
76
|
-
expect(toolbar.
|
|
77
|
+
expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
|
|
78
|
+
3,
|
|
79
|
+
);
|
|
77
80
|
});
|
|
78
81
|
|
|
79
82
|
it('renders two-slot layout without centerContent', () => {
|
|
@@ -87,9 +90,12 @@ describe('Toolbar', () => {
|
|
|
87
90
|
expect(screen.getByTestId('start')).toBeInTheDocument();
|
|
88
91
|
expect(screen.getByTestId('end')).toBeInTheDocument();
|
|
89
92
|
|
|
90
|
-
// Two-slot layout produces 2 child divs
|
|
93
|
+
// Two-slot layout produces 2 child divs (plus the aria-hidden
|
|
94
|
+
// keyboard-hint popover, excluded here)
|
|
91
95
|
const toolbar = screen.getByRole('toolbar');
|
|
92
|
-
expect(toolbar.
|
|
96
|
+
expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
|
|
97
|
+
2,
|
|
98
|
+
);
|
|
93
99
|
});
|
|
94
100
|
|
|
95
101
|
it('renders start-only layout', () => {
|
|
@@ -101,7 +107,9 @@ describe('Toolbar', () => {
|
|
|
101
107
|
);
|
|
102
108
|
expect(screen.getByTestId('start')).toBeInTheDocument();
|
|
103
109
|
const toolbar = screen.getByRole('toolbar');
|
|
104
|
-
expect(toolbar.
|
|
110
|
+
expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
|
|
111
|
+
1,
|
|
112
|
+
);
|
|
105
113
|
});
|
|
106
114
|
|
|
107
115
|
it('renders end-only layout', () => {
|
|
@@ -113,7 +121,9 @@ describe('Toolbar', () => {
|
|
|
113
121
|
);
|
|
114
122
|
expect(screen.getByTestId('end')).toBeInTheDocument();
|
|
115
123
|
const toolbar = screen.getByRole('toolbar');
|
|
116
|
-
expect(toolbar.
|
|
124
|
+
expect(toolbar.querySelectorAll(':scope > :not([popover])')).toHaveLength(
|
|
125
|
+
1,
|
|
126
|
+
);
|
|
117
127
|
});
|
|
118
128
|
|
|
119
129
|
it('sets aria-orientation to horizontal by default', () => {
|
|
@@ -301,4 +311,52 @@ describe('Toolbar', () => {
|
|
|
301
311
|
// Caret movement stays in the input; focus is not stolen by the toolbar.
|
|
302
312
|
expect(document.activeElement).toBe(inputEl);
|
|
303
313
|
});
|
|
314
|
+
|
|
315
|
+
it('composes consumer onKeyDown with internal arrow navigation', async () => {
|
|
316
|
+
const user = userEvent.setup();
|
|
317
|
+
const onKeyDown = vi.fn();
|
|
318
|
+
|
|
319
|
+
render(
|
|
320
|
+
<Toolbar
|
|
321
|
+
label="Actions"
|
|
322
|
+
onKeyDown={onKeyDown}
|
|
323
|
+
startContent={
|
|
324
|
+
<>
|
|
325
|
+
<button type="button">Cut</button>
|
|
326
|
+
<button type="button">Copy</button>
|
|
327
|
+
</>
|
|
328
|
+
}
|
|
329
|
+
/>,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const buttons = screen.getAllByRole('button');
|
|
333
|
+
buttons[0].focus();
|
|
334
|
+
await user.keyboard('{ArrowRight}');
|
|
335
|
+
|
|
336
|
+
expect(onKeyDown).toHaveBeenCalled();
|
|
337
|
+
expect(buttons[1]).toHaveFocus();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('respects preventDefault from consumer onKeyDown', async () => {
|
|
341
|
+
const user = userEvent.setup();
|
|
342
|
+
|
|
343
|
+
render(
|
|
344
|
+
<Toolbar
|
|
345
|
+
label="Actions"
|
|
346
|
+
onKeyDown={e => e.preventDefault()}
|
|
347
|
+
startContent={
|
|
348
|
+
<>
|
|
349
|
+
<button type="button">Cut</button>
|
|
350
|
+
<button type="button">Copy</button>
|
|
351
|
+
</>
|
|
352
|
+
}
|
|
353
|
+
/>,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const buttons = screen.getAllByRole('button');
|
|
357
|
+
buttons[0].focus();
|
|
358
|
+
await user.keyboard('{ArrowRight}');
|
|
359
|
+
|
|
360
|
+
expect(buttons[0]).toHaveFocus();
|
|
361
|
+
});
|
|
304
362
|
});
|
package/src/Toolbar/Toolbar.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Toolbar.tsx
|
|
7
|
-
* @input Uses Section, SizeContext, useListFocus, StyleX, spacingVars, sizeVars
|
|
7
|
+
* @input Uses Section, SizeContext, useListFocus, useKeyboardHint, StyleX, spacingVars, sizeVars
|
|
8
8
|
* @output Exports Toolbar component and ToolbarProps
|
|
9
9
|
* @position Core implementation; consumed by index.ts
|
|
10
10
|
*
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - /packages/cli/templates/blocks/components/Toolbar/ (showcase blocks)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type
|
|
19
|
+
import {useCallback, type ReactNode} from 'react';
|
|
20
20
|
import type {BaseProps} from '../BaseProps';
|
|
21
21
|
import type {SectionVariant} from '../Section/Section';
|
|
22
22
|
import type {SpacingStep} from '../utils/types';
|
|
@@ -26,6 +26,7 @@ import {spacingVars, sizeVars} from '../theme/tokens.stylex';
|
|
|
26
26
|
import {mergeProps} from '../utils';
|
|
27
27
|
import {Section} from '../Section/Section';
|
|
28
28
|
import {useListFocus} from '../hooks/useListFocus';
|
|
29
|
+
import {useKeyboardHint} from '../hooks/useKeyboardHint';
|
|
29
30
|
import {SizeProvider} from '../SizeContext/SizeContext';
|
|
30
31
|
import {edgeCompSlot} from '../Layout/edgeCompensation.stylex';
|
|
31
32
|
import {themeProps} from '../utils/themeProps';
|
|
@@ -227,6 +228,12 @@ export function Toolbar({
|
|
|
227
228
|
className,
|
|
228
229
|
style,
|
|
229
230
|
ref,
|
|
231
|
+
onKeyDown: onKeyDownProp,
|
|
232
|
+
onFocus: onFocusProp,
|
|
233
|
+
onBlur: onBlurProp,
|
|
234
|
+
role: _role,
|
|
235
|
+
'aria-label': _ariaLabel,
|
|
236
|
+
'aria-orientation': _ariaOrientation,
|
|
230
237
|
...props
|
|
231
238
|
}: ToolbarProps) {
|
|
232
239
|
const hasCenterContent = centerContent != null;
|
|
@@ -242,6 +249,48 @@ export function Toolbar({
|
|
|
242
249
|
hasCaretGuard: true,
|
|
243
250
|
});
|
|
244
251
|
|
|
252
|
+
const {
|
|
253
|
+
hintElement,
|
|
254
|
+
onKeyDown: onHintKeyDown,
|
|
255
|
+
onFocus: onHintFocus,
|
|
256
|
+
onBlur: onHintBlur,
|
|
257
|
+
} = useKeyboardHint({orientation});
|
|
258
|
+
|
|
259
|
+
const handleToolbarKeyDown = useCallback(
|
|
260
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
261
|
+
onKeyDownProp?.(e);
|
|
262
|
+
if (e.defaultPrevented) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
onHintKeyDown(e);
|
|
266
|
+
handleKeyDown(e);
|
|
267
|
+
},
|
|
268
|
+
[onKeyDownProp, onHintKeyDown, handleKeyDown],
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const handleToolbarFocus = useCallback(
|
|
272
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
273
|
+
onFocusProp?.(e);
|
|
274
|
+
if (e.defaultPrevented) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
onHintFocus(e);
|
|
278
|
+
handleFocus(e);
|
|
279
|
+
},
|
|
280
|
+
[onFocusProp, onHintFocus, handleFocus],
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const handleToolbarBlur = useCallback(
|
|
284
|
+
(e: React.FocusEvent<HTMLDivElement>) => {
|
|
285
|
+
onBlurProp?.(e);
|
|
286
|
+
if (e.defaultPrevented) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
onHintBlur(e);
|
|
290
|
+
},
|
|
291
|
+
[onBlurProp, onHintBlur],
|
|
292
|
+
);
|
|
293
|
+
|
|
245
294
|
return (
|
|
246
295
|
<SizeProvider value={size}>
|
|
247
296
|
<Section
|
|
@@ -257,8 +306,9 @@ export function Toolbar({
|
|
|
257
306
|
role="toolbar"
|
|
258
307
|
aria-label={label}
|
|
259
308
|
aria-orientation={orientation}
|
|
260
|
-
onKeyDown={
|
|
261
|
-
onFocus={
|
|
309
|
+
onKeyDown={handleToolbarKeyDown}
|
|
310
|
+
onFocus={handleToolbarFocus}
|
|
311
|
+
onBlur={handleToolbarBlur}
|
|
262
312
|
{...mergeProps(
|
|
263
313
|
themeProps('toolbar', {size}),
|
|
264
314
|
stylex.props(
|
|
@@ -323,6 +373,7 @@ export function Toolbar({
|
|
|
323
373
|
)}
|
|
324
374
|
</>
|
|
325
375
|
)}
|
|
376
|
+
{hintElement}
|
|
326
377
|
</div>
|
|
327
378
|
</Section>
|
|
328
379
|
</SizeProvider>
|