feat: Keyboard shortcut handler#9929
Conversation
# Conflicts: # packages/react-aria/src/selection/useSelectableCollection.ts
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
|
||
| const isSelected = selectedKey === key; | ||
|
|
||
| let onKeyDown = (event) => { |
There was a problem hiding this comment.
This seems just designed to skip arrow down and up, i think it'd be better to do this in the keyboard delegate or just allow it, i'm not sure why we wouldn't, there could be vertical steplists and it's a nice non-rtl dependent set of keys.
Just removing it for now as it'd make debugging difficult anyways
|
Build successful! 🎉 |
# Conflicts: # packages/react-aria-components/test/Calendar.test.js # packages/react-aria-components/test/RangeCalendar.test.tsx # packages/react-aria/src/actiongroup/useActionGroup.ts # packages/react-aria/src/calendar/useCalendarGrid.ts # packages/react-aria/src/color/useColorArea.ts # packages/react-aria/src/combobox/useComboBox.ts # packages/react-aria/src/datepicker/useDateField.ts # packages/react-aria/src/datepicker/useDateSegment.ts # packages/react-aria/src/interactions/useKeyboard.ts # packages/react-aria/src/menu/useMenuItem.ts # packages/react-aria/src/menu/useMenuTrigger.ts # packages/react-aria/src/menu/useSubmenuTrigger.ts # packages/react-aria/src/numberfield/useNumberField.ts # packages/react-aria/src/overlays/useOverlay.ts # packages/react-aria/src/radio/useRadioGroup.ts # packages/react-aria/src/searchfield/useSearchField.ts # packages/react-aria/src/select/useSelect.ts # packages/react-aria/src/selection/useSelectableCollection.ts # packages/react-aria/src/spinbutton/useSpinButton.ts # packages/react-aria/src/steplist/useStepListItem.ts # packages/react-aria/src/table/useTableColumnResize.ts # packages/react-aria/test/combobox/useComboBox.test.js
|
Build successful! 🎉 |
| 'ctrl', | ||
| 'control', | ||
| 'meta', | ||
| 'mod', // OS dependent - Cmd on Mac, Ctrl on Windows/Linux |
There was a problem hiding this comment.
different name? just CMD?
There was a problem hiding this comment.
Leaving alone for the moment, needs more opinions
| return; | ||
| } | ||
| shortcutHandler(e); | ||
| props.onKeyDown?.(e); |
There was a problem hiding this comment.
call first and allow users to cancel our behaviour?
There was a problem hiding this comment.
Can't do it based on preventDefault, we break that too much right now. We could address that, but it'd make the PR harder.
It's also a little odd to use preventDefault to handle this, preventDefault for the browser works at any and every level, the event's default takes place after the bubble phase is complete. If someone preventing default on our actions wanted to do it at some other level, they couldn't. It must be on the same element as the handler that defined the action.
If we created a special (preventDefaultSelection/preventDefaultNavigation), would we do it individually per handler like that? or just a preventAnyRACDefault?
who has the final say on continue propagation?
Maybe it'd be better to expose shortcuts as a prop on everything using useKeyboard. We could merge the shortcut objects. Then we could merge the results and go with the most limited returns.
We could allow an extra property on shortcuts that specifies how the merge should occur, so people could completely prevent us from running by using the merge strategy "replace" or something like that?
import {MergeStrategySymbol} from 'wherever';
<Button shortcuts={{
'Enter': (e) => {
console.log('just doing my own thing');
return {preventDefault: true, continuePropagation: false};
},
[MergeStrategySymbol]: 'replace'
}} onPress={() => console.log('pressed')} />Just click</Button>
// Pressing Enter just does the console log, none of our handlers run, so onPress console log never happens
import {MergeStrategySymbol} from 'wherever';
<Button shortcuts={{
'Enter': (e) => {
console.log('just doing my own thing');
return {preventDefault: true, continuePropagation: true};
},
[MergeStrategySymbol]: 'merge'
}} onPress={() => console.log('pressed')} />Just click</Button>
// Pressing Enter calls their handler first, then our onPress is called. Even though they set continuePropagation to true, our handler sets it false, which is more restrictive and we don't continue propagation
| let action = map.get(canonical); | ||
| let result = action?.(e); | ||
| if (typeof result === 'boolean') { | ||
| result = {shouldContinuePropagation: !result, shouldPreventDefault: result}; |
There was a problem hiding this comment.
allow return type of void to behave like return true?
There was a problem hiding this comment.
I don't love this, it's too easy to forget to return something and returning true does a fair amount already, it stops propagation and prevents default. I think it's better to make a choice. Makes it easier to read the handlers as well, you can't just fall through to void/true accidentally
I'll change it though and see if others agree, can always undo it
There was a problem hiding this comment.
returning true is what you want most of the time though right?
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
|
Build successful! 🎉 |
## API Changes
@react-aria/interactions/@react-aria/interactions:KeyboardProps KeyboardProps {
+ ignorePortalRef?: RefObject<Element | null> | null
isDisabled?: boolean
onKeyDown?: (KeyboardEvent) => void
onKeyUp?: (KeyboardEvent) => void
+ shortcuts?: KeyboardShortcutBindings
}@react-spectrum/calendar/@react-spectrum/calendar:Calendar Calendar <T extends DateValue> {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
aria-describedby?: string
aria-details?: string
aria-label?: string
aria-labelledby?: string
autoFocus?: boolean = false
bottom?: Responsive<DimensionValue>
createCalendar?: (CalendarIdentifier) => Calendar
defaultFocusedValue?: DateValue | null
defaultValue?: CalendarValueType<DateValue | null, CalendarSelectionMode>
end?: Responsive<DimensionValue>
errorMessage?: ReactNode
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
flex?: Responsive<string | number | boolean>
flexBasis?: Responsive<number | string>
flexGrow?: Responsive<number>
flexShrink?: Responsive<number>
focusedValue?: DateValue | null
gridArea?: Responsive<string>
gridColumn?: Responsive<string>
gridColumnEnd?: Responsive<string>
gridColumnStart?: Responsive<string>
gridRow?: Responsive<string>
gridRowEnd?: Responsive<string>
gridRowStart?: Responsive<string>
height?: Responsive<DimensionValue>
id?: string
isDateUnavailable?: (DateValue) => boolean
isDisabled?: boolean = false
isHidden?: Responsive<boolean>
isInvalid?: boolean
isReadOnly?: boolean = false
justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
left?: Responsive<DimensionValue>
margin?: Responsive<DimensionValue>
marginBottom?: Responsive<DimensionValue>
marginEnd?: Responsive<DimensionValue>
marginStart?: Responsive<DimensionValue>
marginTop?: Responsive<DimensionValue>
marginX?: Responsive<DimensionValue>
marginY?: Responsive<DimensionValue>
maxHeight?: Responsive<DimensionValue>
maxValue?: DateValue | null
maxWidth?: Responsive<DimensionValue>
minHeight?: Responsive<DimensionValue>
minValue?: DateValue | null
minWidth?: Responsive<DimensionValue>
onChange?: (CalendarValueType<MappedDateValue<DateValue>, CalendarSelectionMode>) => void
onFocusChange?: (CalendarDate) => void
order?: Responsive<number>
pageBehavior?: PageBehavior = visible
position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
right?: Responsive<DimensionValue>
selectionAlignment?: 'start' | 'center' | 'end' = 'center'
+ selectionMode?: CalendarSelectionMode = 'single'
start?: Responsive<DimensionValue>
top?: Responsive<DimensionValue>
value?: CalendarValueType<DateValue | null, CalendarSelectionMode>
visibleMonths?: number = 1
+ weeksInMonth?: number
width?: Responsive<DimensionValue>
zIndex?: Responsive<number>
}/@react-spectrum/calendar:RangeCalendar RangeCalendar <T extends DateValue> {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
allowsNonContiguousRanges?: boolean
aria-describedby?: string
aria-details?: string
aria-label?: string
aria-labelledby?: string
autoFocus?: boolean = false
bottom?: Responsive<DimensionValue>
commitBehavior?: 'clear' | 'reset' | 'select' = 'select'
createCalendar?: (CalendarIdentifier) => Calendar
defaultFocusedValue?: DateValue | null
defaultValue?: RangeValue<DateValue> | null
end?: Responsive<DimensionValue>
errorMessage?: ReactNode
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
flex?: Responsive<string | number | boolean>
flexBasis?: Responsive<number | string>
flexGrow?: Responsive<number>
flexShrink?: Responsive<number>
focusedValue?: DateValue | null
gridArea?: Responsive<string>
gridColumn?: Responsive<string>
gridColumnEnd?: Responsive<string>
gridColumnStart?: Responsive<string>
gridRow?: Responsive<string>
gridRowEnd?: Responsive<string>
gridRowStart?: Responsive<string>
height?: Responsive<DimensionValue>
id?: string
isDateUnavailable?: (DateValue, CalendarDate | null) => boolean
isDisabled?: boolean = false
isHidden?: Responsive<boolean>
isInvalid?: boolean
isReadOnly?: boolean = false
justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
left?: Responsive<DimensionValue>
margin?: Responsive<DimensionValue>
marginBottom?: Responsive<DimensionValue>
marginEnd?: Responsive<DimensionValue>
marginStart?: Responsive<DimensionValue>
marginTop?: Responsive<DimensionValue>
marginX?: Responsive<DimensionValue>
marginY?: Responsive<DimensionValue>
maxHeight?: Responsive<DimensionValue>
maxValue?: DateValue | null
maxWidth?: Responsive<DimensionValue>
minHeight?: Responsive<DimensionValue>
minValue?: DateValue | null
minWidth?: Responsive<DimensionValue>
onChange?: (RangeValue<MappedDateValue<DateValue>>) => void
onFocusChange?: (CalendarDate) => void
order?: Responsive<number>
pageBehavior?: PageBehavior = visible
position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
right?: Responsive<DimensionValue>
selectionAlignment?: 'start' | 'center' | 'end' = 'center'
start?: Responsive<DimensionValue>
top?: Responsive<DimensionValue>
value?: RangeValue<DateValue> | null
visibleMonths?: number = 1
+ weeksInMonth?: number
width?: Responsive<DimensionValue>
zIndex?: Responsive<number>
}/@react-spectrum/calendar:SpectrumCalendarProps SpectrumCalendarProps <T extends DateValue> {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
aria-describedby?: string
aria-details?: string
aria-label?: string
aria-labelledby?: string
autoFocus?: boolean = false
bottom?: Responsive<DimensionValue>
createCalendar?: (CalendarIdentifier) => Calendar
defaultFocusedValue?: DateValue | null
defaultValue?: CalendarValueType<DateValue | null, CalendarSelectionMode>
end?: Responsive<DimensionValue>
errorMessage?: ReactNode
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
flex?: Responsive<string | number | boolean>
flexBasis?: Responsive<number | string>
flexGrow?: Responsive<number>
flexShrink?: Responsive<number>
focusedValue?: DateValue | null
gridArea?: Responsive<string>
gridColumn?: Responsive<string>
gridColumnEnd?: Responsive<string>
gridColumnStart?: Responsive<string>
gridRow?: Responsive<string>
gridRowEnd?: Responsive<string>
gridRowStart?: Responsive<string>
height?: Responsive<DimensionValue>
id?: string
isDateUnavailable?: (DateValue) => boolean
isDisabled?: boolean = false
isHidden?: Responsive<boolean>
isInvalid?: boolean
isReadOnly?: boolean = false
justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
left?: Responsive<DimensionValue>
margin?: Responsive<DimensionValue>
marginBottom?: Responsive<DimensionValue>
marginEnd?: Responsive<DimensionValue>
marginStart?: Responsive<DimensionValue>
marginTop?: Responsive<DimensionValue>
marginX?: Responsive<DimensionValue>
marginY?: Responsive<DimensionValue>
maxHeight?: Responsive<DimensionValue>
maxValue?: DateValue | null
maxWidth?: Responsive<DimensionValue>
minHeight?: Responsive<DimensionValue>
minValue?: DateValue | null
minWidth?: Responsive<DimensionValue>
onChange?: (CalendarValueType<MappedDateValue<DateValue>, CalendarSelectionMode>) => void
onFocusChange?: (CalendarDate) => void
order?: Responsive<number>
pageBehavior?: PageBehavior = visible
position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
right?: Responsive<DimensionValue>
selectionAlignment?: 'start' | 'center' | 'end' = 'center'
+ selectionMode?: CalendarSelectionMode = 'single'
start?: Responsive<DimensionValue>
top?: Responsive<DimensionValue>
value?: CalendarValueType<DateValue | null, CalendarSelectionMode>
visibleMonths?: number = 1
+ weeksInMonth?: number
width?: Responsive<DimensionValue>
zIndex?: Responsive<number>
}/@react-spectrum/calendar:SpectrumRangeCalendarProps SpectrumRangeCalendarProps <T extends DateValue> {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
allowsNonContiguousRanges?: boolean
aria-describedby?: string
aria-details?: string
aria-label?: string
aria-labelledby?: string
autoFocus?: boolean = false
bottom?: Responsive<DimensionValue>
commitBehavior?: 'clear' | 'reset' | 'select' = 'select'
createCalendar?: (CalendarIdentifier) => Calendar
defaultFocusedValue?: DateValue | null
defaultValue?: RangeValue<DateValue> | null
end?: Responsive<DimensionValue>
errorMessage?: ReactNode
firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
flex?: Responsive<string | number | boolean>
flexBasis?: Responsive<number | string>
flexGrow?: Responsive<number>
flexShrink?: Responsive<number>
focusedValue?: DateValue | null
gridArea?: Responsive<string>
gridColumn?: Responsive<string>
gridColumnEnd?: Responsive<string>
gridColumnStart?: Responsive<string>
gridRow?: Responsive<string>
gridRowEnd?: Responsive<string>
gridRowStart?: Responsive<string>
height?: Responsive<DimensionValue>
id?: string
isDateUnavailable?: (DateValue, CalendarDate | null) => boolean
isDisabled?: boolean = false
isHidden?: Responsive<boolean>
isInvalid?: boolean
isReadOnly?: boolean = false
justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
left?: Responsive<DimensionValue>
margin?: Responsive<DimensionValue>
marginBottom?: Responsive<DimensionValue>
marginEnd?: Responsive<DimensionValue>
marginStart?: Responsive<DimensionValue>
marginTop?: Responsive<DimensionValue>
marginX?: Responsive<DimensionValue>
marginY?: Responsive<DimensionValue>
maxHeight?: Responsive<DimensionValue>
maxValue?: DateValue | null
maxWidth?: Responsive<DimensionValue>
minHeight?: Responsive<DimensionValue>
minValue?: DateValue | null
minWidth?: Responsive<DimensionValue>
onChange?: (RangeValue<MappedDateValue<DateValue>>) => void
onFocusChange?: (CalendarDate) => void
order?: Responsive<number>
pageBehavior?: PageBehavior = visible
position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
right?: Responsive<DimensionValue>
selectionAlignment?: 'start' | 'center' | 'end' = 'center'
start?: Responsive<DimensionValue>
top?: Responsive<DimensionValue>
value?: RangeValue<DateValue> | null
visibleMonths?: number = 1
+ weeksInMonth?: number
width?: Responsive<DimensionValue>
zIndex?: Responsive<number>
}@react-spectrum/s2/@react-spectrum/s2:DragAndDropOptions DragAndDropOptions <T = {}> {
acceptedDragTypes?: 'all' | Array<string | symbol> = 'all'
dropTargetDelegate?: DropTargetDelegate
getAllowedDropOperations?: () => Array<DropOperation>
getDropOperation?: (DropTarget, DragTypes, Array<DropOperation>) => DropOperation
getItems?: (Set<Key>, Array<T>) => Array<DragItem> = () => []
isDisabled?: boolean
onDragEnd?: (DraggableCollectionEndEvent) => void
onDragMove?: (DraggableCollectionMoveEvent) => void
onDragStart?: (DraggableCollectionStartEvent) => void
onDrop?: (DroppableCollectionDropEvent) => void
onDropActivate?: (DroppableCollectionActivateEvent) => void
onDropEnter?: (DroppableCollectionEnterEvent) => void
onDropExit?: (DroppableCollectionExitEvent) => void
onInsert?: (DroppableCollectionInsertDropEvent) => void
onItemDrop?: (DroppableCollectionOnItemDropEvent) => void
onMove?: (DroppableCollectionReorderEvent) => void
onReorder?: (DroppableCollectionReorderEvent) => void
onRootDrop?: (DroppableCollectionRootDropEvent) => void
renderDragPreview?: (Array<DragItem>) => JSX.Element | {
element: JSX.Element
x: number
y: number
}
+ renderDropIndicator?: (DropTarget) => JSX.Element
shouldAcceptItemDrop?: (ItemDropTarget, DragTypes) => boolean
} |
Agent Skills ChangesModified (12)
InstallReact Spectrum S2: React Aria: |
| let {keyboardProps} = useKeyboard({ | ||
| shortcuts: { | ||
| ArrowRight: e => { | ||
| if (!nodeContains(e.currentTarget, getEventTarget(e))) { |
There was a problem hiding this comment.
what do you think about making this an option (maybe the default?) for useKeyboard? Seems like we do this in pretty much every keyboard handler...
| state.setAnchorDate(null); | ||
| } | ||
| break; | ||
| return false; // TODO: is this really correct? or should it return true when we cancel and only propagate if there's nothing to do |
There was a problem hiding this comment.
Should we return true inside the if statement? Was there a test that broke?
| 'shift', | ||
| 'alt', | ||
| 'ctrl', | ||
| 'control', |
There was a problem hiding this comment.
are control and ctrl different? should we standardize on one? I believe the standard e.key is Control
| /** Keyboard shortcuts to handle. */ | ||
| shortcuts?: KeyboardShortcutBindings; | ||
| /** A ref to the element to ignore portal events. */ | ||
| ignorePortalRef?: RefObject<Element | null> | null; |
There was a problem hiding this comment.
Oh I see you do have kind of an option for this? But is a ref necessary? Why not use e.target and e.currentTarget?
| // to the newly opened submenu's first item. | ||
| shortcuts: { | ||
| ' ': e => { | ||
| if (e.repeat) { |
There was a problem hiding this comment.
Maybe skipping repeated keys should also be an option (the default)?
| let {keyboardProps} = useKeyboard({ | ||
| shortcuts: { | ||
| Escape: e => { | ||
| if (!isKeyboardDismissDisabled && !e.nativeEvent.isComposing) { |
There was a problem hiding this comment.
Ignoring composing events by default might also be a good default? We have been inconsistent in our handling of these things...
| isDisabled: isDisabled || isReadOnly, | ||
| shortcuts: { | ||
| Enter: () => { | ||
| if (isDisabled || isReadOnly) { |
There was a problem hiding this comment.
Is this necessary if isDisabled is passed into useKeyboard as an option? Will the handler get called in that case?
|
|
||
| const isSelected = selectedKey === key; | ||
|
|
||
| let onKeyDown = event => { |
Closes
Aiming to help us solve the issue of event leaks, this will allow us more fine grained control of what useKeyboard blocks/prevents default on in a more declarative way.
New behaviour is opt in through
shortcutsinuseKeyboard. This will preventDefault and stopPropagation if the associated function in the map returns true. Or it will let the event pass without altering it if false is returned. In addition, an object can be returned to alter that behaviour in whatever way necessary. Given that all key combinations are exclusive matches, this should make it more obvious what events are continuing or not.✅ Pull Request Checklist:
📝 Test Instructions:
🧢 Your Project: