Skip to content

Commit d49f6b2

Browse files
committed
rough support for virtual focus taggroup
1 parent d2b5e51 commit d49f6b2

File tree

10 files changed

+59
-27
lines changed

10 files changed

+59
-27
lines changed

packages/@react-aria/autocomplete/src/useAutocomplete.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,20 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
227227
case 'PageDown':
228228
case 'PageUp':
229229
case 'ArrowUp':
230-
case 'ArrowDown': {
230+
case 'ArrowDown':
231+
case 'ArrowLeft':
232+
case 'ArrowRight': {
231233
if ((e.key === 'Home' || e.key === 'End') && focusedNodeId == null && e.shiftKey) {
232234
return;
233235
}
234236

235237
// Prevent these keys from moving the text cursor in the input
236-
e.preventDefault();
238+
// TODO: special case ArrowLeft/Right so they still do move the text cursor
239+
// However, this should really depend on the primary wrapped component's layout orientation (aka maybe shouldn't happen if TagGroup?)
240+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
241+
e.preventDefault();
242+
}
243+
237244
// Move virtual focus into the wrapped collection
238245
let focusCollection = new CustomEvent(FOCUS_EVENT, {
239246
cancelable: true,

packages/@react-aria/gridlist/src/useGridList.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,12 @@ export interface AriaGridListOptions<T> extends Omit<AriaGridListProps<T>, 'chil
9696
* - 'override': links override all other interactions (link items are not selectable).
9797
* @default 'action'
9898
*/
99-
linkBehavior?: 'action' | 'selection' | 'override'
99+
linkBehavior?: 'action' | 'selection' | 'override',
100+
// TODO: double check if this should be in props or options, it was in options for menu
101+
/**
102+
* Whether the grid list items should use virtual focus instead of being focused directly.
103+
*/
104+
shouldUseVirtualFocus?: boolean
100105
}
101106

102107
export interface GridListAria {
@@ -121,7 +126,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
121126
linkBehavior = 'action',
122127
keyboardNavigationBehavior = 'arrow',
123128
escapeKeyBehavior = 'clearSelection',
124-
shouldSelectOnPressUp
129+
shouldSelectOnPressUp,
130+
shouldUseVirtualFocus
125131
} = props;
126132

127133
if (!props['aria-label'] && !props['aria-labelledby']) {
@@ -141,11 +147,12 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
141147
linkBehavior,
142148
disallowTypeAhead,
143149
autoFocus: props.autoFocus,
144-
escapeKeyBehavior
150+
escapeKeyBehavior,
151+
shouldUseVirtualFocus
145152
});
146153

147154
let id = useId(props.id);
148-
listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp});
155+
listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp, shouldUseVirtualFocus});
149156

150157
let descriptionProps = useHighlightSelectionDescription({
151158
selectionManager: state.selectionManager,

packages/@react-aria/gridlist/src/useGridListItem.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
6767

6868
// let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist');
6969
let {direction} = useLocale();
70-
let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!;
70+
let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp, shouldUseVirtualFocus} = listMap.get(state)!;
7171
let descriptionId = useSlotId();
7272

7373
// We need to track the key of the item at the time it was last focused so that we force
@@ -127,7 +127,8 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
127127
shouldSelectOnPressUp: props.shouldSelectOnPressUp || shouldSelectOnPressUp,
128128
onAction: onAction || node.props?.onAction ? chain(node.props?.onAction, onAction ? () => onAction(node.key) : undefined) : undefined,
129129
focus,
130-
linkBehavior
130+
linkBehavior,
131+
shouldUseVirtualFocus
131132
});
132133

133134
let onKeyDownCapture = (e: ReactKeyboardEvent) => {

packages/@react-aria/gridlist/src/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ interface ListMapShared {
1818
onAction?: (key: Key) => void,
1919
linkBehavior?: 'action' | 'selection' | 'override',
2020
keyboardNavigationBehavior: 'arrow' | 'tab',
21-
shouldSelectOnPressUp?: boolean
21+
shouldSelectOnPressUp?: boolean,
22+
shouldUseVirtualFocus?: boolean
2223
}
2324

2425
// Used to share:

packages/@react-aria/selection/src/useSelectableItem.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export interface SelectableItemStates {
7878
isSelected: boolean,
7979
/** Whether the item is currently focused. */
8080
isFocused: boolean,
81+
/** Whether the item is keyboard focused. */
82+
isFocusVisible: boolean,
8183
/**
8284
* Whether the item is non-interactive, i.e. both selection and actions are disabled and the item may
8385
* not be focused. Dependent on `disabledKeys` and `disabledBehavior`.

packages/@react-aria/tag/src/useTag.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import {filterDOMProps, mergeProps, useDescription, useId, useSyntheticLinkProps
1616
import {hookData} from './useTagGroup';
1717
// @ts-ignore
1818
import intlMessages from '../intl/*.json';
19+
import {isFocusVisible, useFocusable, useInteractionModality} from '@react-aria/interactions';
1920
import {KeyboardEvent} from 'react';
2021
import type {ListState} from '@react-stately/list';
2122
import {SelectableItemStates} from '@react-aria/selection';
22-
import {useFocusable, useInteractionModality} from '@react-aria/interactions';
2323
import {useGridListItem} from '@react-aria/gridlist';
2424
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2525

@@ -113,6 +113,7 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO
113113
'aria-label': props['aria-label']
114114
}),
115115
...stateWithoutDescription,
116+
isFocusVisible: isItemFocused && state.selectionManager.isFocused && isFocusVisible(),
116117
allowsRemoving: !!onRemove
117118
};
118119
}

packages/@react-aria/tag/src/useTagGroup.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,17 @@ export interface AriaTagGroupOptions<T> extends Omit<AriaTagGroupProps<T>, 'chil
5656
* An optional keyboard delegate to handle arrow key navigation,
5757
* to override the default.
5858
*/
59-
keyboardDelegate?: KeyboardDelegate
59+
keyboardDelegate?: KeyboardDelegate,
60+
// TODO: double check if this should be in props or options, it was in options for menu
61+
/**
62+
* Whether the tags should use virtual focus instead of being focused directly.
63+
*/
64+
shouldUseVirtualFocus?: boolean
6065
}
6166

6267
interface HookData {
63-
onRemove?: (keys: Set<Key>) => void
68+
onRemove?: (keys: Set<Key>) => void,
69+
shouldUseVirtualFocus?: boolean
6470
}
6571

6672
export const hookData: WeakMap<ListState<any>, HookData> = new WeakMap<ListState<any>, HookData>();
@@ -86,6 +92,7 @@ export function useTagGroup<T>(props: AriaTagGroupOptions<T>, state: ListState<T
8692
...props,
8793
labelElementType: 'span'
8894
});
95+
8996
let {gridProps} = useGridList({
9097
...props,
9198
...fieldProps,
@@ -110,7 +117,7 @@ export function useTagGroup<T>(props: AriaTagGroupOptions<T>, state: ListState<T
110117
prevCount.current = state.collection.size;
111118
}, [state.collection.size, isFocusWithin, ref]);
112119

113-
hookData.set(state, {onRemove: props.onRemove});
120+
hookData.set(state, {onRemove: props.onRemove, shouldUseVirtualFocus: props.shouldUseVirtualFocus});
114121

115122
return {
116123
gridProps: mergeProps(gridProps, domProps, {

packages/react-aria-components/src/TagGroup.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import {ButtonContext} from './Button';
1515
import {Collection, CollectionBuilder, createLeafComponent, ItemNode} from '@react-aria/collections';
1616
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, usePersistedKeys} from './Collection';
1717
import {ContextValue, DOMProps, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils';
18-
import {filterDOMProps, mergeProps, useObjectRef} from '@react-aria/utils';
18+
import {filterDOMProps, mergeProps, mergeRefs, useObjectRef} from '@react-aria/utils';
1919
import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents} from '@react-types/shared';
2020
import {LabelContext} from './Label';
2121
import {ListState, Node, UNSTABLE_useFilteredListState, useListState} from 'react-stately';
2222
import {ListStateContext} from './ListBox';
23-
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useRef} from 'react';
23+
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react';
2424
import {TextContext} from './Text';
2525
import {UNSTABLE_InternalAutocompleteContext} from './Autocomplete';
2626

@@ -75,10 +75,10 @@ interface TagGroupInnerProps {
7575
}
7676

7777
function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProps) {
78-
let {filter, collectionProps} = useContext(UNSTABLE_InternalAutocompleteContext) || {};
79-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
80-
let {shouldUseVirtualFocus, disallowTypeAhead, ...DOMCollectionProps} = collectionProps || {};
78+
let {filter, collectionProps, collectionRef} = useContext(UNSTABLE_InternalAutocompleteContext) || {};
8179
let tagListRef = useRef<HTMLDivElement>(null);
80+
// Memoed so that useAutocomplete callback ref is properly only called once on mount and not everytime a rerender happens
81+
tagListRef = useObjectRef(useMemo(() => mergeRefs(tagListRef, collectionRef !== undefined ? collectionRef as RefObject<HTMLDivElement> : null), [collectionRef, tagListRef]));
8282
let [labelRef, label] = useSlot(
8383
!props['aria-label'] && !props['aria-labelledby']
8484
);
@@ -101,7 +101,7 @@ function TagGroupInner({props, forwardedRef: ref, collection}: TagGroupInnerProp
101101
} = useTagGroup({
102102
...props,
103103
...domPropOverrides,
104-
...DOMCollectionProps,
104+
...collectionProps,
105105
label
106106
}, filteredState, tagListRef);
107107

@@ -211,7 +211,6 @@ export const Tag = /*#__PURE__*/ createLeafComponent(TagItemNode, (props: TagPro
211211
let ref = useObjectRef<HTMLDivElement>(forwardedRef);
212212
let {focusProps, isFocusVisible} = useFocusRing({within: false});
213213
let {rowProps, gridCellProps, removeButtonProps, ...states} = useTag({item}, state, ref);
214-
215214
let {hoverProps, isHovered} = useHover({
216215
isDisabled: !states.allowsSelection,
217216
onHoverStart: item.props.onHoverStart,
@@ -226,7 +225,8 @@ export const Tag = /*#__PURE__*/ createLeafComponent(TagItemNode, (props: TagPro
226225
defaultClassName: 'react-aria-Tag',
227226
values: {
228227
...states,
229-
isFocusVisible,
228+
// TODO: check if I can get rid of useFocusRing
229+
isFocusVisible: isFocusVisible || states.isFocusVisible,
230230
isHovered,
231231
selectionMode: state.selectionManager.selectionMode,
232232
selectionBehavior: state.selectionManager.selectionBehavior
@@ -251,7 +251,7 @@ export const Tag = /*#__PURE__*/ createLeafComponent(TagItemNode, (props: TagPro
251251
data-disabled={states.isDisabled || undefined}
252252
data-hovered={isHovered || undefined}
253253
data-focused={states.isFocused || undefined}
254-
data-focus-visible={isFocusVisible || undefined}
254+
data-focus-visible={isFocusVisible || states.isFocusVisible || undefined}
255255
data-pressed={states.isPressed || undefined}
256256
data-allows-removing={states.allowsRemoving || undefined}
257257
data-selection-mode={state.selectionManager.selectionMode === 'none' ? undefined : state.selectionManager.selectionMode}>

packages/react-aria-components/stories/Autocomplete.stories.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,14 +1059,14 @@ export const AutocompleteWithTagGroup = () => {
10591059
<Label style={{display: 'block'}}>Test</Label>
10601060
<Input />
10611061
</TextField>
1062-
<TagGroup>
1062+
<TagGroup onRemove={action('onRemove')}>
10631063
<Label>Categories</Label>
10641064
<TagList style={{display: 'flex', gap: 4}}>
1065-
<MyTag href="https://nytimes.com">News</MyTag>
1066-
<MyTag>Travel</MyTag>
1067-
<MyTag>Gaming</MyTag>
1065+
<MyTag href="https://nytimes.com" textValue="News">News<Button slot="remove">X</Button></MyTag>
1066+
<MyTag textValue="Travel">Travel<Button slot="remove">X</Button></MyTag>
1067+
<MyTag textValue="Gaming">Gaming<Button slot="remove">X</Button></MyTag>
10681068
<TooltipTrigger>
1069-
<MyTag>Shopping</MyTag>
1069+
<MyTag textValue="Shopping">Shopping<Button slot="remove">X</Button></MyTag>
10701070
<Tooltip
10711071
offset={5}
10721072
style={{

packages/react-aria-components/stories/styles.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,9 @@
398398
align-items: center
399399
}
400400
}
401+
402+
:global(.react-aria-Tag) {
403+
&[data-focus-visible] {
404+
box-shadow: inset 0 0 0 2px slateblue;
405+
}
406+
}

0 commit comments

Comments
 (0)