@astryxdesign/core 0.1.2-canary.b753238 → 0.1.2-canary.bfcbf64
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 +1 -4
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +4 -2
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +14 -33
- package/dist/Calendar/hooks/index.d.ts +2 -0
- package/dist/Calendar/hooks/index.d.ts.map +1 -1
- package/dist/Calendar/hooks/index.js +2 -1
- package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts +57 -0
- package/dist/Calendar/hooks/useCalendarRovingTabindex.d.ts.map +1 -0
- package/dist/Calendar/hooks/useCalendarRovingTabindex.js +96 -0
- 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/ContextMenu/ContextMenu.d.ts +7 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +13 -7
- package/dist/DropdownMenu/DropdownMenu.d.ts +8 -1
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +9 -4
- 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 +4 -2
- package/dist/MoreMenu/MoreMenu.d.ts +7 -1
- package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
- package/dist/MoreMenu/MoreMenu.js +2 -0
- package/dist/MultiSelector/MultiSelector.d.ts +2 -25
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -31
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
- package/dist/ProgressBar/ProgressBar.js +4 -2
- 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 +61 -51
- package/dist/Selector/Selector.d.ts +1 -22
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +6 -31
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +4 -2
- package/dist/TabList/TabList.d.ts +2 -3
- package/dist/TabList/TabList.d.ts.map +1 -1
- package/dist/TabList/TabList.js +33 -55
- package/dist/Table/tableContextMenu.d.ts.map +1 -1
- package/dist/Table/tableContextMenu.js +3 -0
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +4 -2
- package/dist/Toolbar/Toolbar.d.ts +3 -3
- package/dist/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar/Toolbar.js +6 -46
- package/dist/TreeList/TreeList.d.ts.map +1 -1
- package/dist/TreeList/TreeList.js +22 -16
- package/dist/TreeList/TreeListItem.d.ts +2 -3
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/astryx.css +0 -3
- package/dist/astryx.umd.js +47 -47
- package/dist/astryx.umd.js.map +4 -4
- package/dist/hooks/index.d.ts +2 -4
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +0 -1
- package/dist/hooks/useGridFocus.d.ts +0 -56
- package/dist/hooks/useGridFocus.d.ts.map +1 -1
- package/dist/hooks/useGridFocus.js +24 -138
- package/dist/hooks/useTreeFocus.d.ts +3 -27
- package/dist/hooks/useTreeFocus.d.ts.map +1 -1
- package/dist/hooks/useTreeFocus.js +6 -69
- package/package.json +1 -1
- package/src/Breadcrumbs/BreadcrumbItem.tsx +2 -5
- package/src/Button/Button.tsx +16 -3
- package/src/Calendar/Calendar.tsx +22 -52
- package/src/Calendar/hooks/index.ts +6 -0
- package/src/Calendar/hooks/useCalendarRovingTabindex.ts +118 -0
- package/src/Calendar/index.ts +3 -0
- package/src/CheckboxList/CheckboxList.tsx +3 -3
- package/src/ContextMenu/ContextMenu.doc.mjs +6 -0
- package/src/ContextMenu/ContextMenu.test.tsx +6 -4
- package/src/ContextMenu/ContextMenu.tsx +19 -7
- package/src/DropdownMenu/DropdownMenu.doc.mjs +8 -1
- package/src/DropdownMenu/DropdownMenu.test.tsx +23 -0
- package/src/DropdownMenu/DropdownMenu.tsx +16 -4
- 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 +13 -2
- package/src/MoreMenu/MoreMenu.doc.mjs +17 -2
- package/src/MoreMenu/MoreMenu.tsx +9 -0
- package/src/MultiSelector/MultiSelector.doc.mjs +0 -25
- package/src/MultiSelector/MultiSelector.test.tsx +1 -150
- package/src/MultiSelector/MultiSelector.tsx +3 -55
- package/src/ProgressBar/ProgressBar.tsx +3 -2
- package/src/RadioList/RadioList.tsx +3 -3
- package/src/SegmentedControl/SegmentedControl.tsx +77 -51
- package/src/Selector/Selector.doc.mjs +0 -21
- package/src/Selector/Selector.test.tsx +1 -123
- package/src/Selector/Selector.tsx +3 -53
- package/src/Switch/Switch.tsx +16 -2
- package/src/TabList/TabList.test.tsx +0 -41
- package/src/TabList/TabList.tsx +39 -66
- package/src/Table/tableContextMenu.tsx +5 -1
- package/src/TextArea/TextArea.tsx +13 -3
- package/src/Toolbar/Toolbar.test.tsx +6 -64
- package/src/Toolbar/Toolbar.tsx +4 -55
- package/src/TreeList/TreeList.tsx +28 -17
- package/src/TreeList/TreeListItem.tsx +2 -3
- package/src/VisuallyHidden/VisuallyHidden.doc.mjs +7 -7
- package/src/hooks/index.ts +5 -12
- package/src/hooks/useGridFocus.doc.mjs +2 -40
- package/src/hooks/useGridFocus.ts +23 -179
- package/src/hooks/useTreeFocus.doc.mjs +1 -15
- package/src/hooks/useTreeFocus.ts +6 -101
- package/dist/hooks/useKeyboardHint.d.ts +0 -82
- package/dist/hooks/useKeyboardHint.d.ts.map +0 -1
- package/dist/hooks/useKeyboardHint.js +0 -220
- package/src/hooks/useGridFocus.test.tsx +0 -132
- package/src/hooks/useKeyboardHint.doc.mjs +0 -101
- package/src/hooks/useKeyboardHint.test.tsx +0 -70
- package/src/hooks/useKeyboardHint.tsx +0 -332
package/src/Button/Button.tsx
CHANGED
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
} from '../theme/tokens.stylex';
|
|
35
35
|
import {Tooltip} from '../Tooltip/Tooltip';
|
|
36
36
|
import {Spinner} from '../Spinner';
|
|
37
|
-
import {VisuallyHidden} from '../VisuallyHidden';
|
|
38
37
|
|
|
39
38
|
import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
|
|
40
39
|
import {useSize} from '../SizeContext/SizeContext';
|
|
@@ -125,6 +124,17 @@ const styles = stylex.create({
|
|
|
125
124
|
textOverflow: 'ellipsis',
|
|
126
125
|
minWidth: 0,
|
|
127
126
|
},
|
|
127
|
+
visuallyHidden: {
|
|
128
|
+
position: 'absolute',
|
|
129
|
+
width: '1px',
|
|
130
|
+
height: '1px',
|
|
131
|
+
padding: 0,
|
|
132
|
+
margin: '-1px',
|
|
133
|
+
overflow: 'hidden',
|
|
134
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
135
|
+
whiteSpace: 'nowrap',
|
|
136
|
+
borderWidth: 0,
|
|
137
|
+
},
|
|
128
138
|
link: {
|
|
129
139
|
textDecoration: 'none',
|
|
130
140
|
},
|
|
@@ -680,9 +690,12 @@ export function Button({
|
|
|
680
690
|
)}
|
|
681
691
|
</span>
|
|
682
692
|
{/* Live region for loading state announcements */}
|
|
683
|
-
<
|
|
693
|
+
<span
|
|
694
|
+
{...stylex.props(styles.visuallyHidden)}
|
|
695
|
+
role="status"
|
|
696
|
+
aria-live="polite">
|
|
684
697
|
{isLoadingState ? 'Loading' : ''}
|
|
685
|
-
</
|
|
698
|
+
</span>
|
|
686
699
|
</>
|
|
687
700
|
);
|
|
688
701
|
|
|
@@ -32,6 +32,7 @@ import {useGridFocus} from '../hooks';
|
|
|
32
32
|
import {
|
|
33
33
|
useCalendarDays,
|
|
34
34
|
useCalendarConstraints,
|
|
35
|
+
useCalendarRovingTabindex,
|
|
35
36
|
type CalendarDay,
|
|
36
37
|
} from './hooks';
|
|
37
38
|
import {
|
|
@@ -553,34 +554,14 @@ function MonthGrid({
|
|
|
553
554
|
return null;
|
|
554
555
|
}, [mode, value]);
|
|
555
556
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
selectedDateForTabindex.year === year &&
|
|
565
|
-
selectedDateForTabindex.month === month.month;
|
|
566
|
-
if (isSelectedInMonth && !isDateDisabled(selectedDateForTabindex)) {
|
|
567
|
-
return plainDateToISO(selectedDateForTabindex);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
const isTodayInMonth = today.year === year && today.month === month.month;
|
|
572
|
-
if (isTodayInMonth && !isDateDisabled(today)) {
|
|
573
|
-
return plainDateToISO(today);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
for (const day of days) {
|
|
577
|
-
if (!day.isOutside && !isDateDisabled(day.date)) {
|
|
578
|
-
return day.iso;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
return null;
|
|
583
|
-
}, [days, today, year, month.month, isDateDisabled, selectedDateForTabindex]);
|
|
557
|
+
const {isTabbable} = useCalendarRovingTabindex({
|
|
558
|
+
days,
|
|
559
|
+
today,
|
|
560
|
+
year,
|
|
561
|
+
month: month.month,
|
|
562
|
+
isDateDisabled,
|
|
563
|
+
selectedDate: selectedDateForTabindex,
|
|
564
|
+
});
|
|
584
565
|
|
|
585
566
|
// Helper to get the focused date from the currently focused element.
|
|
586
567
|
// Reads the machine-readable `data-date` (ISO) attribute rather than parsing
|
|
@@ -647,22 +628,18 @@ function MonthGrid({
|
|
|
647
628
|
// focus (the day button inside the cell). Arrow keys move to the target
|
|
648
629
|
// row/column and, if that cell is disabled, continue in the same direction to
|
|
649
630
|
// the next enabled cell.
|
|
650
|
-
const {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
onNavigateAfter: handleNavigateNext,
|
|
663
|
-
onPageUp: handlePageUp,
|
|
664
|
-
onPageDown: handlePageDown,
|
|
665
|
-
});
|
|
631
|
+
const {gridRef, handleKeyDown: handleGridKeyDown} =
|
|
632
|
+
useGridFocus<HTMLDivElement>({
|
|
633
|
+
columns: 7,
|
|
634
|
+
cellSelector: '[role="gridcell"]',
|
|
635
|
+
isCellFocusable: cell =>
|
|
636
|
+
cell.querySelector('button:not([disabled])') !== null,
|
|
637
|
+
getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
|
|
638
|
+
onNavigateBefore: handleNavigatePrevious,
|
|
639
|
+
onNavigateAfter: handleNavigateNext,
|
|
640
|
+
onPageUp: handlePageUp,
|
|
641
|
+
onPageDown: handlePageDown,
|
|
642
|
+
});
|
|
666
643
|
|
|
667
644
|
// Handle pending focus after month navigation
|
|
668
645
|
useEffect(() => {
|
|
@@ -742,7 +719,6 @@ function MonthGrid({
|
|
|
742
719
|
role="grid"
|
|
743
720
|
aria-label={monthLabel}
|
|
744
721
|
onKeyDown={handleGridKeyDown}
|
|
745
|
-
onFocus={handleGridFocus}
|
|
746
722
|
{...stylex.props(
|
|
747
723
|
monthGridStyles.daysGrid,
|
|
748
724
|
hasWeekNumbers && monthGridStyles.daysGridWithNumbers,
|
|
@@ -798,7 +774,7 @@ function MonthGrid({
|
|
|
798
774
|
today={today}
|
|
799
775
|
hasOutsideDays={hasOutsideDays}
|
|
800
776
|
isDisabled={isDateDisabled(day.date)}
|
|
801
|
-
isTabbable={day.iso
|
|
777
|
+
isTabbable={isTabbable(day.iso)}
|
|
802
778
|
onDayClick={onDayClick}
|
|
803
779
|
onDayHover={onDayHover}
|
|
804
780
|
/>
|
|
@@ -827,11 +803,6 @@ interface DayCellProps {
|
|
|
827
803
|
today: PlainDate;
|
|
828
804
|
hasOutsideDays: boolean;
|
|
829
805
|
isDisabled: boolean;
|
|
830
|
-
/**
|
|
831
|
-
* Whether this day seeds the initial roving tab stop. useGridFocus
|
|
832
|
-
* (`hasRovingTabIndex`) owns the live tab stop thereafter — it honors an
|
|
833
|
-
* existing `tabindex="0"` and repairs/moves it on navigation and focus.
|
|
834
|
-
*/
|
|
835
806
|
isTabbable: boolean;
|
|
836
807
|
onDayClick: (date: PlainDate) => void;
|
|
837
808
|
onDayHover: (date: PlainDate | null) => void;
|
|
@@ -923,7 +894,6 @@ function DayCell({
|
|
|
923
894
|
aria-selected={state.isSelected || state.isInRange || undefined}
|
|
924
895
|
aria-disabled={state.effectivelyDisabled || undefined}
|
|
925
896
|
disabled={isDisabled}
|
|
926
|
-
// Initial roving tab-stop seed; useGridFocus owns it after mount.
|
|
927
897
|
tabIndex={isTabbableDay ? 0 : -1}
|
|
928
898
|
onClick={() => !state.effectivelyDisabled && onDayClick(date)}
|
|
929
899
|
onMouseEnter={() => !state.effectivelyDisabled && onDayHover(date)}
|
|
@@ -27,3 +27,9 @@ export type {
|
|
|
27
27
|
UseCalendarNavigationOptions,
|
|
28
28
|
UseCalendarNavigationReturn,
|
|
29
29
|
} from './useCalendarNavigation';
|
|
30
|
+
|
|
31
|
+
export {useCalendarRovingTabindex} from './useCalendarRovingTabindex';
|
|
32
|
+
export type {
|
|
33
|
+
UseCalendarRovingTabindexOptions,
|
|
34
|
+
UseCalendarRovingTabindexReturn,
|
|
35
|
+
} from './useCalendarRovingTabindex';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file useCalendarRovingTabindex.ts
|
|
7
|
+
* @input Uses React useMemo, PlainDate utilities
|
|
8
|
+
* @output Exports useCalendarRovingTabindex hook for accessible grid navigation
|
|
9
|
+
* @position Calendar-specific hook; used by Calendar
|
|
10
|
+
*
|
|
11
|
+
* SYNC: When modified, update:
|
|
12
|
+
* - /packages/core/src/Calendar/hooks/index.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {useMemo} from 'react';
|
|
16
|
+
import type {ISODateString} from '../../utils/dateTypes';
|
|
17
|
+
import {type PlainDate, plainDateToISO} from '../../utils/plainDate';
|
|
18
|
+
import type {CalendarDay} from './useCalendarDays';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration for calendar roving tabindex
|
|
22
|
+
*/
|
|
23
|
+
export interface UseCalendarRovingTabindexOptions {
|
|
24
|
+
/** All days in the grid */
|
|
25
|
+
days: CalendarDay[];
|
|
26
|
+
/** Today's date as a PlainDate */
|
|
27
|
+
today: PlainDate;
|
|
28
|
+
/** The year being displayed */
|
|
29
|
+
year: number;
|
|
30
|
+
/** The month being displayed (1-based: 1 = January, 12 = December) */
|
|
31
|
+
month: number;
|
|
32
|
+
/** Function to check if a date is disabled */
|
|
33
|
+
isDateDisabled: (date: PlainDate) => boolean;
|
|
34
|
+
/** Currently selected date (if any) */
|
|
35
|
+
selectedDate?: PlainDate | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return type for useCalendarRovingTabindex hook
|
|
40
|
+
*/
|
|
41
|
+
export interface UseCalendarRovingTabindexReturn {
|
|
42
|
+
/** The ISO date string of the tabbable element, or null */
|
|
43
|
+
tabbableDate: ISODateString | null;
|
|
44
|
+
/** Check if a specific date is the tabbable one */
|
|
45
|
+
isTabbable: (iso: ISODateString) => boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook for managing roving tabindex in calendar grids.
|
|
50
|
+
*
|
|
51
|
+
* Implements the WAI-ARIA roving tabindex pattern where only one element
|
|
52
|
+
* in a group has tabindex="0" while others have tabindex="-1".
|
|
53
|
+
*
|
|
54
|
+
* Priority for tabbable element:
|
|
55
|
+
* 1. Today if visible in the current month and enabled
|
|
56
|
+
* 2. First enabled day in the month
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```
|
|
60
|
+
* const {tabbableDate, isTabbable} = useCalendarRovingTabindex({
|
|
61
|
+
* days,
|
|
62
|
+
* today: {year: 2026, month: 1, day: 20},
|
|
63
|
+
* year: 2026,
|
|
64
|
+
* month: 1, // January (1-based)
|
|
65
|
+
* isDateDisabled,
|
|
66
|
+
* });
|
|
67
|
+
*
|
|
68
|
+
* // In day rendering:
|
|
69
|
+
* <button tabIndex={isTabbable(day.iso) ? 0 : -1}>
|
|
70
|
+
* {day.dayNumber}
|
|
71
|
+
* </button>
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function useCalendarRovingTabindex(
|
|
75
|
+
options: UseCalendarRovingTabindexOptions,
|
|
76
|
+
): UseCalendarRovingTabindexReturn {
|
|
77
|
+
const {days, today, year, month, isDateDisabled, selectedDate} = options;
|
|
78
|
+
|
|
79
|
+
// Determine which day should be tabbable
|
|
80
|
+
const tabbableDate = useMemo(() => {
|
|
81
|
+
// Priority 1: Selected date if visible in this month and enabled
|
|
82
|
+
if (selectedDate) {
|
|
83
|
+
const isSelectedInMonth =
|
|
84
|
+
selectedDate.year === year && selectedDate.month === month;
|
|
85
|
+
if (isSelectedInMonth && !isDateDisabled(selectedDate)) {
|
|
86
|
+
return plainDateToISO(selectedDate);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Priority 2: Today if visible in this month and enabled
|
|
91
|
+
const isTodayInMonth = today.year === year && today.month === month;
|
|
92
|
+
|
|
93
|
+
if (isTodayInMonth && !isDateDisabled(today)) {
|
|
94
|
+
return plainDateToISO(today);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Priority 3: First enabled day in this month
|
|
98
|
+
for (const day of days) {
|
|
99
|
+
if (!day.isOutside && !isDateDisabled(day.date)) {
|
|
100
|
+
return day.iso;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}, [days, today, year, month, isDateDisabled, selectedDate]);
|
|
106
|
+
|
|
107
|
+
// Check if a specific date is tabbable
|
|
108
|
+
const isTabbable = useMemo(() => {
|
|
109
|
+
return (iso: ISODateString): boolean => {
|
|
110
|
+
return tabbableDate !== null && iso === tabbableDate;
|
|
111
|
+
};
|
|
112
|
+
}, [tabbableDate]);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
tabbableDate,
|
|
116
|
+
isTabbable,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/Calendar/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
useCalendarDays,
|
|
27
27
|
useCalendarConstraints,
|
|
28
28
|
useCalendarNavigation,
|
|
29
|
+
useCalendarRovingTabindex,
|
|
29
30
|
} from './hooks';
|
|
30
31
|
export type {
|
|
31
32
|
CalendarDay,
|
|
@@ -35,6 +36,8 @@ export type {
|
|
|
35
36
|
UseCalendarConstraintsReturn,
|
|
36
37
|
UseCalendarNavigationOptions,
|
|
37
38
|
UseCalendarNavigationReturn,
|
|
39
|
+
UseCalendarRovingTabindexOptions,
|
|
40
|
+
UseCalendarRovingTabindexReturn,
|
|
38
41
|
} from './hooks';
|
|
39
42
|
|
|
40
43
|
// Re-export calendar-specific utilities for advanced usage
|
|
@@ -151,7 +151,7 @@ export function CheckboxList({
|
|
|
151
151
|
'data-testid': dataTestId,
|
|
152
152
|
}: CheckboxListProps) {
|
|
153
153
|
const inputID = useId();
|
|
154
|
-
const
|
|
154
|
+
const labelElementID = useId();
|
|
155
155
|
const descriptionID = useId();
|
|
156
156
|
const statusMessageID = useId();
|
|
157
157
|
|
|
@@ -211,7 +211,7 @@ export function CheckboxList({
|
|
|
211
211
|
isLabelHidden={isLabelHidden}
|
|
212
212
|
description={description}
|
|
213
213
|
inputID={inputID}
|
|
214
|
-
|
|
214
|
+
labelElementID={labelElementID}
|
|
215
215
|
isGroupLabel
|
|
216
216
|
descriptionID={description ? descriptionID : undefined}
|
|
217
217
|
isDisabled={isDisabled}
|
|
@@ -231,7 +231,7 @@ export function CheckboxList({
|
|
|
231
231
|
<CheckboxListContext value={contextValue}>
|
|
232
232
|
<div
|
|
233
233
|
role="group"
|
|
234
|
-
aria-labelledby={
|
|
234
|
+
aria-labelledby={labelElementID}
|
|
235
235
|
aria-describedby={
|
|
236
236
|
[
|
|
237
237
|
description ? descriptionID : null,
|
|
@@ -58,6 +58,12 @@ export const docs = {
|
|
|
58
58
|
description: 'Accessible name for the menu surface, announced when it opens.',
|
|
59
59
|
default: "'Context menu'",
|
|
60
60
|
},
|
|
61
|
+
{
|
|
62
|
+
name: 'hasAutoFocus',
|
|
63
|
+
type: 'boolean',
|
|
64
|
+
description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases.',
|
|
65
|
+
default: 'true',
|
|
66
|
+
},
|
|
61
67
|
{
|
|
62
68
|
name: 'isDisabled',
|
|
63
69
|
type: 'boolean',
|
|
@@ -62,7 +62,9 @@ describe('ContextMenu', () => {
|
|
|
62
62
|
|
|
63
63
|
it('typeahead focuses the matching menu item (menus-11)', () => {
|
|
64
64
|
render(
|
|
65
|
-
<ContextMenu
|
|
65
|
+
<ContextMenu
|
|
66
|
+
items={[{label: 'Cut'}, {label: 'Copy'}, {label: 'Paste'}]}
|
|
67
|
+
hasAutoFocus={false}>
|
|
66
68
|
<div>Right-click me</div>
|
|
67
69
|
</ContextMenu>,
|
|
68
70
|
);
|
|
@@ -119,7 +121,7 @@ describe('ContextMenu', () => {
|
|
|
119
121
|
|
|
120
122
|
it('closes on Escape even when opened without auto-focus', () => {
|
|
121
123
|
render(
|
|
122
|
-
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
124
|
+
<ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
|
|
123
125
|
<div>Right-click me</div>
|
|
124
126
|
</ContextMenu>,
|
|
125
127
|
);
|
|
@@ -133,7 +135,7 @@ describe('ContextMenu', () => {
|
|
|
133
135
|
|
|
134
136
|
it('ignores Escape during IME composition', () => {
|
|
135
137
|
render(
|
|
136
|
-
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
138
|
+
<ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
|
|
137
139
|
<div>Right-click me</div>
|
|
138
140
|
</ContextMenu>,
|
|
139
141
|
);
|
|
@@ -145,7 +147,7 @@ describe('ContextMenu', () => {
|
|
|
145
147
|
|
|
146
148
|
it('restores focus to the trigger on close', () => {
|
|
147
149
|
render(
|
|
148
|
-
<ContextMenu items={[{label: 'Item 1'}]}>
|
|
150
|
+
<ContextMenu items={[{label: 'Item 1'}]} hasAutoFocus={false}>
|
|
149
151
|
<button type="button">Right-click me</button>
|
|
150
152
|
</ContextMenu>,
|
|
151
153
|
);
|
|
@@ -125,6 +125,12 @@ interface ContextMenuBaseProps extends BaseProps {
|
|
|
125
125
|
* @default 'Context menu'
|
|
126
126
|
*/
|
|
127
127
|
label?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Whether to auto-focus the first menu item when the menu opens.
|
|
130
|
+
* Set to `false` for inline showcases or documentation previews.
|
|
131
|
+
* @default true
|
|
132
|
+
*/
|
|
133
|
+
hasAutoFocus?: boolean;
|
|
128
134
|
/** When true, right-click shows the native browser context menu instead. */
|
|
129
135
|
isDisabled?: boolean;
|
|
130
136
|
/** Called when the menu opens or closes. */
|
|
@@ -178,6 +184,7 @@ export function ContextMenu({
|
|
|
178
184
|
menuWidth,
|
|
179
185
|
size = 'md',
|
|
180
186
|
label = 'Context menu',
|
|
187
|
+
hasAutoFocus = true,
|
|
181
188
|
isDisabled = false,
|
|
182
189
|
onOpenChange,
|
|
183
190
|
ref,
|
|
@@ -275,9 +282,10 @@ export function ContextMenu({
|
|
|
275
282
|
}, [isOpen, closeMenu, listRef]);
|
|
276
283
|
|
|
277
284
|
// Dismiss on Escape from anywhere while open. The menu div's own onKeyDown
|
|
278
|
-
// only fires when focus is inside the menu
|
|
279
|
-
//
|
|
280
|
-
//
|
|
285
|
+
// only fires when focus is inside the menu, which never happens when the
|
|
286
|
+
// menu is opened with hasAutoFocus={false} (e.g. table context menus), so a
|
|
287
|
+
// document-level listener is required for a reliable Escape path. Guards
|
|
288
|
+
// against IME composition-cancel.
|
|
281
289
|
useEffect(() => {
|
|
282
290
|
if (!isOpen) {
|
|
283
291
|
return;
|
|
@@ -342,9 +350,11 @@ export function ContextMenu({
|
|
|
342
350
|
? document.activeElement
|
|
343
351
|
: (e.currentTarget as HTMLElement);
|
|
344
352
|
layer.show();
|
|
345
|
-
|
|
353
|
+
if (hasAutoFocus) {
|
|
354
|
+
requestAnimationFrame(() => focusFirst());
|
|
355
|
+
}
|
|
346
356
|
},
|
|
347
|
-
[isDisabled, layer, focusFirst],
|
|
357
|
+
[isDisabled, layer, hasAutoFocus, focusFirst],
|
|
348
358
|
);
|
|
349
359
|
|
|
350
360
|
// Touch long-press invocation (menus-8). iOS Safari never synthesizes a
|
|
@@ -357,9 +367,11 @@ export function ContextMenu({
|
|
|
357
367
|
(point: {x: number; y: number}) => {
|
|
358
368
|
positionRef.current = {x: point.x, y: point.y};
|
|
359
369
|
layer.show();
|
|
360
|
-
|
|
370
|
+
if (hasAutoFocus) {
|
|
371
|
+
requestAnimationFrame(() => focusFirst());
|
|
372
|
+
}
|
|
361
373
|
},
|
|
362
|
-
[layer, focusFirst],
|
|
374
|
+
[layer, hasAutoFocus, focusFirst],
|
|
363
375
|
),
|
|
364
376
|
});
|
|
365
377
|
|
|
@@ -61,7 +61,14 @@ export const docs = {
|
|
|
61
61
|
type: 'boolean',
|
|
62
62
|
description: 'Whether to show a chevron icon on the trigger button. Set to false for icon-only triggers.',
|
|
63
63
|
default: 'true',
|
|
64
|
-
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'hasAutoFocus',
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description: 'Whether to auto-focus the first menu item when the menu opens. Set to false for inline showcases or documentation previews.',
|
|
69
|
+
default: 'true',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
65
72
|
name: 'children',
|
|
66
73
|
type: '(item: DropdownMenuItemData) => ReactNode',
|
|
67
74
|
description: 'Custom render function for each item in the list.',
|
|
@@ -249,6 +249,29 @@ describe('DropdownMenu controlled mode', () => {
|
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
describe('DropdownMenu hasAutoFocus', () => {
|
|
253
|
+
it('does not focus menu items when hasAutoFocus is false and isMenuOpen is true', () => {
|
|
254
|
+
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus');
|
|
255
|
+
render(
|
|
256
|
+
<DropdownMenu
|
|
257
|
+
button={{label: 'Actions'}}
|
|
258
|
+
items={[{label: 'Edit'}, {label: 'Delete'}]}
|
|
259
|
+
isMenuOpen={true}
|
|
260
|
+
hasAutoFocus={false}
|
|
261
|
+
onOpenChange={() => {}}
|
|
262
|
+
/>,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const menuItems = screen.getAllByRole('menuitem', {hidden: true});
|
|
266
|
+
const menuItemFocusCalls = focusSpy.mock.calls.filter((_, i) => {
|
|
267
|
+
const ctx = focusSpy.mock.contexts[i];
|
|
268
|
+
return menuItems.includes(ctx as HTMLElement);
|
|
269
|
+
});
|
|
270
|
+
expect(menuItemFocusCalls).toHaveLength(0);
|
|
271
|
+
focusSpy.mockRestore();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
252
275
|
describe('DropdownMenu items', () => {
|
|
253
276
|
it('renders items with labels', () => {
|
|
254
277
|
render(
|
|
@@ -135,6 +135,13 @@ interface DropdownMenuBaseProps extends BaseProps {
|
|
|
135
135
|
*/
|
|
136
136
|
placement?: LayerPlacement;
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Whether to auto-focus the first menu item when the menu opens.
|
|
140
|
+
* Set to `false` for inline showcases or documentation previews
|
|
141
|
+
* where stealing focus is undesirable.
|
|
142
|
+
* @default true
|
|
143
|
+
*/
|
|
144
|
+
hasAutoFocus?: boolean;
|
|
138
145
|
'data-testid'?: string;
|
|
139
146
|
}
|
|
140
147
|
|
|
@@ -186,6 +193,7 @@ export function DropdownMenu({
|
|
|
186
193
|
onClick,
|
|
187
194
|
hasChevron = true,
|
|
188
195
|
placement = 'below',
|
|
196
|
+
hasAutoFocus = true,
|
|
189
197
|
className,
|
|
190
198
|
style,
|
|
191
199
|
xstyle,
|
|
@@ -291,12 +299,14 @@ export function DropdownMenu({
|
|
|
291
299
|
if (isControlled) {
|
|
292
300
|
if (controlledIsOpen && !popover.isOpen) {
|
|
293
301
|
popover.show();
|
|
294
|
-
|
|
302
|
+
if (hasAutoFocus) {
|
|
303
|
+
requestAnimationFrame(() => focusFirst());
|
|
304
|
+
}
|
|
295
305
|
} else if (!controlledIsOpen && popover.isOpen) {
|
|
296
306
|
popover.hide();
|
|
297
307
|
}
|
|
298
308
|
}
|
|
299
|
-
}, [controlledIsOpen, isControlled, popover, focusFirst]);
|
|
309
|
+
}, [controlledIsOpen, isControlled, popover, hasAutoFocus, focusFirst]);
|
|
300
310
|
|
|
301
311
|
// Extend useListFocus with Enter/Space activation + typeahead
|
|
302
312
|
const listKeyDown = useCallback(
|
|
@@ -330,8 +340,10 @@ export function DropdownMenu({
|
|
|
330
340
|
|
|
331
341
|
const openAndFocus = useCallback(() => {
|
|
332
342
|
popover.show();
|
|
333
|
-
|
|
334
|
-
|
|
343
|
+
if (hasAutoFocus) {
|
|
344
|
+
requestAnimationFrame(() => focusFirst());
|
|
345
|
+
}
|
|
346
|
+
}, [popover, hasAutoFocus, focusFirst]);
|
|
335
347
|
|
|
336
348
|
const handleButtonClick = useCallback(() => {
|
|
337
349
|
// If the menu was just closed by light dismiss (e.g. iOS Safari fires
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -109,7 +109,7 @@ describe('Field', () => {
|
|
|
109
109
|
<Field
|
|
110
110
|
label="Plan"
|
|
111
111
|
inputID="plan-group"
|
|
112
|
-
|
|
112
|
+
labelElementID="plan-label"
|
|
113
113
|
isGroupLabel>
|
|
114
114
|
<div role="radiogroup" aria-labelledby="plan-label" />
|
|
115
115
|
</Field>,
|
|
@@ -119,7 +119,7 @@ describe('Field', () => {
|
|
|
119
119
|
expect(labelEl.tagName).toBe('SPAN');
|
|
120
120
|
expect(labelEl.closest('label')).toBeNull();
|
|
121
121
|
expect(labelEl).not.toHaveAttribute('for');
|
|
122
|
-
//
|
|
122
|
+
// labelElementID is applied to the label element and referenced by the group.
|
|
123
123
|
expect(labelEl).toHaveAttribute('id', 'plan-label');
|
|
124
124
|
const group = screen.getByRole('radiogroup', {name: 'Plan'});
|
|
125
125
|
expect(group.getAttribute('aria-labelledby')).toBe(labelEl.id);
|
package/src/Field/Field.tsx
CHANGED
|
@@ -101,7 +101,7 @@ export interface FieldProps extends Omit<
|
|
|
101
101
|
/**
|
|
102
102
|
* ID of the input element this label points AT (used as the label's
|
|
103
103
|
* `htmlFor`). This is the id of the *control*, not of the label element —
|
|
104
|
-
* see `
|
|
104
|
+
* see `labelElementID` for the latter.
|
|
105
105
|
*/
|
|
106
106
|
inputID: string;
|
|
107
107
|
/**
|
|
@@ -110,12 +110,12 @@ export interface FieldProps extends Omit<
|
|
|
110
110
|
* (radiogroup, checkbox group) references this via `aria-labelledby` to take
|
|
111
111
|
* the label as its accessible name. Pair with `isGroupLabel`.
|
|
112
112
|
*/
|
|
113
|
-
|
|
113
|
+
labelElementID?: string;
|
|
114
114
|
/**
|
|
115
115
|
* When the field wraps a group of controls rather than a single input, set
|
|
116
116
|
* this so the label renders as a non-`<label>` element (a `<span>`): a
|
|
117
117
|
* `<label>` semantically names one control and can't be associated with a
|
|
118
|
-
* group. Pair with `
|
|
118
|
+
* group. Pair with `labelElementID` + `aria-labelledby` on the group.
|
|
119
119
|
* @default false
|
|
120
120
|
*/
|
|
121
121
|
isGroupLabel?: boolean;
|
|
@@ -189,7 +189,7 @@ export function Field({
|
|
|
189
189
|
isLabelHidden = false,
|
|
190
190
|
description,
|
|
191
191
|
inputID,
|
|
192
|
-
|
|
192
|
+
labelElementID,
|
|
193
193
|
isGroupLabel = false,
|
|
194
194
|
descriptionID,
|
|
195
195
|
isOptional = false,
|
|
@@ -225,7 +225,7 @@ export function Field({
|
|
|
225
225
|
<FieldLabel
|
|
226
226
|
label={label}
|
|
227
227
|
inputID={inputID}
|
|
228
|
-
|
|
228
|
+
labelElementID={labelElementID}
|
|
229
229
|
isGroupLabel={isGroupLabel}
|
|
230
230
|
isLabelHidden={isLabelHidden}
|
|
231
231
|
isDisabled={isDisabled}
|
package/src/Field/FieldLabel.tsx
CHANGED
|
@@ -84,7 +84,7 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
|
|
|
84
84
|
/**
|
|
85
85
|
* ID of the input element this label points AT (rendered as `htmlFor` on the
|
|
86
86
|
* label). This is *not* the id of the label element itself — see
|
|
87
|
-
* `
|
|
87
|
+
* `labelElementID` for that.
|
|
88
88
|
*/
|
|
89
89
|
inputID: string;
|
|
90
90
|
/**
|
|
@@ -93,14 +93,14 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
|
|
|
93
93
|
* reference this via `aria-labelledby` to take the label as its accessible
|
|
94
94
|
* name.
|
|
95
95
|
*/
|
|
96
|
-
|
|
96
|
+
labelElementID?: string;
|
|
97
97
|
/**
|
|
98
98
|
* When true, the field wraps a *group* of controls (e.g. a radiogroup)
|
|
99
99
|
* rather than a single input. In that case the label is rendered as a
|
|
100
100
|
* `<span>` instead of a `<label>` — a `<label>` semantically names one form
|
|
101
101
|
* control and can't be associated with a group, so it must not be a literal
|
|
102
102
|
* label element. The group takes the label as its name via
|
|
103
|
-
* `
|
|
103
|
+
* `labelElementID` + `aria-labelledby`.
|
|
104
104
|
* @default false
|
|
105
105
|
*/
|
|
106
106
|
isGroupLabel?: boolean;
|
|
@@ -161,7 +161,7 @@ export interface FieldLabelProps extends BaseProps<HTMLLabelElement> {
|
|
|
161
161
|
export function FieldLabel({
|
|
162
162
|
label,
|
|
163
163
|
inputID,
|
|
164
|
-
|
|
164
|
+
labelElementID,
|
|
165
165
|
isGroupLabel = false,
|
|
166
166
|
isLabelHidden = false,
|
|
167
167
|
isDisabled = false,
|
|
@@ -203,7 +203,7 @@ export function FieldLabel({
|
|
|
203
203
|
<>
|
|
204
204
|
<LabelElement
|
|
205
205
|
ref={ref}
|
|
206
|
-
id={
|
|
206
|
+
id={labelElementID}
|
|
207
207
|
// `htmlFor` only applies to a real `<label>` associating with a single
|
|
208
208
|
// control; a group label (span) has no `htmlFor`.
|
|
209
209
|
htmlFor={isGroupLabel ? undefined : inputID}
|