Skip to content

Commit 2b4abbc

Browse files
Michael Jordandevongovettdannify
authored
fix(#3118): DatePicker calendar cannot be opened by keyboard (#3145)
* fix(#3118): DatePicker calendar cannot be opened by keyboard 1. Include DatePicker/DateRangePicker in tab order. 2. Fix focus management so that ArrowLeft/ArrowRight moves focus to and from the Calendar button. 3. Fix focus ring so that the focus state on the button is rendered independent of the focus state for the DatePicker input group. * fix(#3118): update tests * fix(#3118): DatePicker Enter on last segment should not focus Calendar Button TODO: With DateRangePicker, Enter should still advance from last segment of start date to first segment of end date. * Fix date range picker navigation * Fix * fix(#3118): Calendar add stopPropagation on keyboard navigation Per #3145 (comment) Co-authored-by: Devon Govett <devongovett@gmail.com> Co-authored-by: Danni <drobinson@livefyre.com>
1 parent 67d6429 commit 2b4abbc

File tree

10 files changed

+95
-51
lines changed

10 files changed

+95
-51
lines changed

packages/@react-aria/calendar/src/useCalendarGrid.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,27 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta
6363
break;
6464
case 'PageUp':
6565
e.preventDefault();
66+
e.stopPropagation();
6667
state.focusPreviousSection(e.shiftKey);
6768
break;
6869
case 'PageDown':
6970
e.preventDefault();
71+
e.stopPropagation();
7072
state.focusNextSection(e.shiftKey);
7173
break;
7274
case 'End':
7375
e.preventDefault();
76+
e.stopPropagation();
7477
state.focusSectionEnd();
7578
break;
7679
case 'Home':
7780
e.preventDefault();
81+
e.stopPropagation();
7882
state.focusSectionStart();
7983
break;
8084
case 'ArrowLeft':
8185
e.preventDefault();
86+
e.stopPropagation();
8287
if (direction === 'rtl') {
8388
state.focusNextDay();
8489
} else {
@@ -87,10 +92,12 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta
8792
break;
8893
case 'ArrowUp':
8994
e.preventDefault();
95+
e.stopPropagation();
9096
state.focusPreviousRow();
9197
break;
9298
case 'ArrowRight':
9399
e.preventDefault();
100+
e.stopPropagation();
94101
if (direction === 'rtl') {
95102
state.focusPreviousDay();
96103
} else {
@@ -99,6 +106,7 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta
99106
break;
100107
case 'ArrowDown':
101108
e.preventDefault();
109+
e.stopPropagation();
102110
state.focusNextRow();
103111
break;
104112
case 'Escape':

packages/@react-aria/datepicker/src/useDateField.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,6 @@ export function useDateField<T extends DateValue>(props: AriaDateFieldProps<T>,
6262
labelElementType: 'span'
6363
});
6464

65-
let groupProps = useDatePickerGroup(state, ref);
66-
6765
let {focusWithinProps} = useFocusWithin({
6866
onBlurWithin() {
6967
state.confirmPlaceholder();
@@ -83,6 +81,7 @@ export function useDateField<T extends DateValue>(props: AriaDateFieldProps<T>,
8381
: [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
8482
let propsFocusManager = props[focusManagerSymbol];
8583
let focusManager = useMemo(() => propsFocusManager || createFocusManager(ref), [propsFocusManager, ref]);
84+
let groupProps = useDatePickerGroup(state, ref, props[roleSymbol] === 'presentation');
8685

8786
// Pass labels and other information to segments.
8887
hookData.set(state, {

packages/@react-aria/datepicker/src/useDatePicker.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {CalendarProps} from '@react-types/calendar';
1717
import {createFocusManager} from '@react-aria/focus';
1818
import {DatePickerState} from '@react-stately/datepicker';
1919
import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils';
20-
import {HTMLAttributes, RefObject} from 'react';
20+
import {HTMLAttributes, RefObject, useMemo} from 'react';
2121
// @ts-ignore
2222
import intlMessages from '../intl/*.json';
2323
import {roleSymbol} from './useDateField';
@@ -68,6 +68,7 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
6868
let descProps = useDescription(description);
6969
let ariaDescribedBy = [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
7070
let domProps = filterDOMProps(props);
71+
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
7172

7273
return {
7374
groupProps: mergeProps(domProps, groupProps, fieldProps, descProps, {
@@ -79,7 +80,6 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
7980
labelProps: {
8081
...labelProps,
8182
onClick: () => {
82-
let focusManager = createFocusManager(ref);
8383
focusManager.focusFirst();
8484
}
8585
},
@@ -106,7 +106,6 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
106106
buttonProps: {
107107
...descProps,
108108
id: buttonId,
109-
excludeFromTabOrder: true,
110109
'aria-haspopup': 'dialog',
111110
'aria-label': formatMessage('calendar'),
112111
'aria-labelledby': `${labelledBy} ${buttonId}`,

packages/@react-aria/datepicker/src/useDatePickerGroup.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,47 @@
1+
import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus';
12
import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker';
2-
import {getFocusableTreeWalker} from '@react-aria/focus';
33
import {KeyboardEvent} from '@react-types/shared';
44
import {mergeProps} from '@react-aria/utils';
5-
import {RefObject} from 'react';
5+
import {RefObject, useMemo} from 'react';
6+
import {useLocale} from '@react-aria/i18n';
67
import {usePress} from '@react-aria/interactions';
78

8-
export function useDatePickerGroup(state: DatePickerState | DateRangePickerState | DateFieldState, ref: RefObject<HTMLElement>) {
9+
export function useDatePickerGroup(state: DatePickerState | DateRangePickerState | DateFieldState, ref: RefObject<HTMLElement>, disableArrowNavigation?: boolean) {
10+
let {direction} = useLocale();
11+
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
12+
913
// Open the popover on alt + arrow down
1014
let onKeyDown = (e: KeyboardEvent) => {
1115
if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && 'setOpen' in state) {
1216
e.preventDefault();
1317
e.stopPropagation();
1418
state.setOpen(true);
1519
}
20+
21+
if (disableArrowNavigation) {
22+
return;
23+
}
24+
25+
switch (e.key) {
26+
case 'ArrowLeft':
27+
e.preventDefault();
28+
e.stopPropagation();
29+
if (direction === 'rtl') {
30+
focusManager.focusNext();
31+
} else {
32+
focusManager.focusPrevious();
33+
}
34+
break;
35+
case 'ArrowRight':
36+
e.preventDefault();
37+
e.stopPropagation();
38+
if (direction === 'rtl') {
39+
focusManager.focusPrevious();
40+
} else {
41+
focusManager.focusNext();
42+
}
43+
break;
44+
}
1645
};
1746

1847
// Focus the first placeholder segment from the end on mouse down/touch up in the field.

packages/@react-aria/datepicker/src/useDateRangePicker.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
8787
});
8888

8989
let ariaDescribedBy = [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
90-
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
90+
let focusManager = useMemo(() => createFocusManager(ref, {
91+
// Exclude the button from the focus manager.
92+
accept: element => element.id !== buttonId
93+
}), [ref, buttonId]);
94+
9195
let commonFieldProps = {
9296
[focusManagerSymbol]: focusManager,
9397
[roleSymbol]: 'presentation',
@@ -121,7 +125,6 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
121125
buttonProps: {
122126
...descProps,
123127
id: buttonId,
124-
excludeFromTabOrder: true,
125128
'aria-haspopup': 'dialog',
126129
'aria-label': formatMessage('calendar'),
127130
'aria-labelledby': `${labelledBy} ${buttonId}`,

packages/@react-aria/datepicker/src/useDateSegment.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface DateSegmentAria {
3131
*/
3232
export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: RefObject<HTMLElement>): DateSegmentAria {
3333
let enteredKeys = useRef('');
34-
let {locale, direction} = useLocale();
34+
let {locale} = useLocale();
3535
let displayNames = useDisplayNames();
3636
let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state);
3737

@@ -114,31 +114,13 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
114114
}
115115

116116
switch (e.key) {
117-
case 'ArrowLeft':
118-
e.preventDefault();
119-
e.stopPropagation();
120-
if (direction === 'rtl') {
121-
focusManager.focusNext({tabbable: true});
122-
} else {
123-
focusManager.focusPrevious({tabbable: true});
124-
}
125-
break;
126-
case 'ArrowRight':
127-
e.preventDefault();
128-
e.stopPropagation();
129-
if (direction === 'rtl') {
130-
focusManager.focusPrevious({tabbable: true});
131-
} else {
132-
focusManager.focusNext({tabbable: true});
133-
}
134-
break;
135117
case 'Enter':
136118
e.preventDefault();
137119
e.stopPropagation();
138120
if (segment.isPlaceholder && !state.isReadOnly) {
139121
state.confirmPlaceholder(segment.type);
140122
}
141-
focusManager.focusNext({tabbable: true});
123+
focusManager.focusNext();
142124
break;
143125
case 'Tab':
144126
break;
@@ -184,7 +166,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
184166
} else {
185167
break;
186168
}
187-
focusManager.focusNext({tabbable: true});
169+
focusManager.focusNext();
188170
break;
189171
case 'day':
190172
case 'hour':
@@ -233,7 +215,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
233215
if (Number(numberValue + '0') > segment.maxValue || newValue.length >= String(segment.maxValue).length) {
234216
enteredKeys.current = '';
235217
if (shouldSetValue) {
236-
focusManager.focusNext({tabbable: true});
218+
focusManager.focusNext();
237219
}
238220
} else {
239221
enteredKeys.current = newValue;

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ interface FocusManagerOptions {
4444
/** Whether to only include tabbable elements, or all focusable elements. */
4545
tabbable?: boolean,
4646
/** Whether focus should wrap around when it reaches the end of the scope. */
47-
wrap?: boolean
47+
wrap?: boolean,
48+
/** A callback that determines whether the given element is focused. */
49+
accept?: (node: Element) => boolean
4850
}
4951

5052
export interface FocusManager {
@@ -487,7 +489,9 @@ export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOpt
487489

488490
if ((node as HTMLElement).matches(selector)
489491
&& isElementVisible(node as HTMLElement)
490-
&& (!scope || isElementInScope(node as HTMLElement, scope))) {
492+
&& (!scope || isElementInScope(node as HTMLElement, scope))
493+
&& (!opts?.accept || opts.accept(node as Element))
494+
) {
491495
return NodeFilter.FILTER_ACCEPT;
492496
}
493497

@@ -506,13 +510,13 @@ export function getFocusableTreeWalker(root: HTMLElement, opts?: FocusManagerOpt
506510
/**
507511
* Creates a FocusManager object that can be used to move focus within an element.
508512
*/
509-
export function createFocusManager(ref: RefObject<HTMLElement>): FocusManager {
513+
export function createFocusManager(ref: RefObject<HTMLElement>, defaultOptions: FocusManagerOptions = {}): FocusManager {
510514
return {
511515
focusNext(opts: FocusManagerOptions = {}) {
512516
let root = ref.current;
513-
let {from, tabbable, wrap} = opts;
517+
let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
514518
let node = from || document.activeElement;
515-
let walker = getFocusableTreeWalker(root, {tabbable});
519+
let walker = getFocusableTreeWalker(root, {tabbable, accept});
516520
if (root.contains(node)) {
517521
walker.currentNode = node;
518522
}
@@ -526,11 +530,11 @@ export function createFocusManager(ref: RefObject<HTMLElement>): FocusManager {
526530
}
527531
return nextNode;
528532
},
529-
focusPrevious(opts: FocusManagerOptions = {}) {
533+
focusPrevious(opts: FocusManagerOptions = defaultOptions) {
530534
let root = ref.current;
531-
let {from, tabbable, wrap} = opts;
535+
let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
532536
let node = from || document.activeElement;
533-
let walker = getFocusableTreeWalker(root, {tabbable});
537+
let walker = getFocusableTreeWalker(root, {tabbable, accept});
534538
if (root.contains(node)) {
535539
walker.currentNode = node;
536540
} else {
@@ -550,20 +554,20 @@ export function createFocusManager(ref: RefObject<HTMLElement>): FocusManager {
550554
}
551555
return previousNode;
552556
},
553-
focusFirst(opts = {}) {
557+
focusFirst(opts = defaultOptions) {
554558
let root = ref.current;
555-
let {tabbable} = opts;
556-
let walker = getFocusableTreeWalker(root, {tabbable});
559+
let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts;
560+
let walker = getFocusableTreeWalker(root, {tabbable, accept});
557561
let nextNode = walker.nextNode() as HTMLElement;
558562
if (nextNode) {
559563
focusElement(nextNode, true);
560564
}
561565
return nextNode;
562566
},
563-
focusLast(opts = {}) {
567+
focusLast(opts = defaultOptions) {
564568
let root = ref.current;
565-
let {tabbable} = opts;
566-
let walker = getFocusableTreeWalker(root, {tabbable});
569+
let {tabbable = defaultOptions.tabbable, accept = defaultOptions.accept} = opts;
570+
let walker = getFocusableTreeWalker(root, {tabbable, accept});
567571
let next = last(walker);
568572
if (next) {
569573
focusElement(next, true);

packages/@react-spectrum/datepicker/src/DatePicker.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ function DatePicker<T extends DateValue>(props: SpectrumDatePickerProps<T>, ref:
6262
autoFocus
6363
});
6464

65+
let {isFocused: isFocusedButton, focusProps: focusPropsButton} = useFocusRing({
66+
within: false,
67+
isTextInput: false,
68+
autoFocus
69+
});
70+
6571
let className = classNames(
6672
styles,
6773
'spectrum-InputGroup',
@@ -71,7 +77,7 @@ function DatePicker<T extends DateValue>(props: SpectrumDatePickerProps<T>, ref:
7177
'is-disabled': isDisabled,
7278
'is-hovered': isHovered,
7379
'is-focused': isFocused,
74-
'focus-ring': isFocusVisible
80+
'focus-ring': isFocusVisible && !isFocusedButton
7581
}
7682
);
7783

@@ -136,7 +142,7 @@ function DatePicker<T extends DateValue>(props: SpectrumDatePickerProps<T>, ref:
136142
onOpenChange={setOpen}
137143
shouldFlip={props.shouldFlip}>
138144
<FieldButton
139-
{...buttonProps}
145+
{...mergeProps(buttonProps, focusPropsButton)}
140146
UNSAFE_className={classNames(styles, 'spectrum-FieldButton')}
141147
isQuiet={isQuiet}
142148
validationState={state.validationState}

packages/@react-spectrum/datepicker/src/DateRangePicker.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ function DateRangePicker<T extends DateValue>(props: SpectrumDateRangePickerProp
6262
autoFocus
6363
});
6464

65+
let {isFocused: isFocusedButton, focusProps: focusPropsButton} = useFocusRing({
66+
within: false,
67+
isTextInput: false,
68+
autoFocus
69+
});
70+
6571
let className = classNames(
6672
styles,
6773
'spectrum-InputGroup',
@@ -71,7 +77,7 @@ function DateRangePicker<T extends DateValue>(props: SpectrumDateRangePickerProp
7177
'is-disabled': isDisabled,
7278
'is-hovered': isHovered,
7379
'is-focused': isFocused,
74-
'focus-ring': isFocusVisible
80+
'focus-ring': isFocusVisible && !isFocusedButton
7581
}
7682
);
7783

@@ -150,7 +156,7 @@ function DateRangePicker<T extends DateValue>(props: SpectrumDateRangePickerProp
150156
onOpenChange={setOpen}
151157
shouldFlip={props.shouldFlip}>
152158
<FieldButton
153-
{...buttonProps}
159+
{...mergeProps(buttonProps, focusPropsButton)}
154160
UNSAFE_className={classNames(styles, 'spectrum-FieldButton')}
155161
isQuiet={isQuiet}
156162
validationState={state.validationState}

0 commit comments

Comments
 (0)