Skip to content

Commit 4314a1e

Browse files
feat: Automatically detect if wrapped Autocomplete collection supports virtual focus (#8862)
* exporting contexts for quarry * refactor autocomplete hook so it detects if it is attached to a filterable collection and if said collection supports virtual focus * initial attempt to get rid of Dialog from S2 Popover * fix tests * add inner div with overflow so that popover arrow shows up * fix items overflowing when partially scrolled into view see https://github.com/adobe/react-spectrum/pull/7672/files * update Popover so it accepts more values in styles instead * fix lint * export colorSchemeContext * clean up extra comment * fix chromatic * review comments * fix calc * substitute custom event in favor of collection tabindex check collection should only have a tab index if it isnt using virtual focus * update width calculations turns out the border of the popover shouldnt be included in the total width calculation, it is considered outside of the popover so no need to adjust. The border will be removed in favor of a box shadow to conform with update designs later * forgot to remove commented out code * move popover styling to user provided inner div as per discussion with team, if someone wants to modify their popover internals, they are expected to add a inner wrapping div themselves and turn off padding like custom dialog. * forgot to bring back styles --------- Co-authored-by: Robert Snow <[email protected]>
1 parent aab295f commit 4314a1e

File tree

22 files changed

+317
-277
lines changed

22 files changed

+317
-277
lines changed

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, RefObject, ValueBase} from '@react-types/shared';
1414
import {AriaTextFieldProps} from '@react-aria/textfield';
1515
import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete';
16-
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useLabels, useObjectRef, useSlotId} from '@react-aria/utils';
16+
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils';
1717
import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus';
1818
import {getInteractionModality} from '@react-aria/interactions';
1919
// @ts-ignore
2020
import intlMessages from '../intl/*.json';
21-
import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react';
21+
import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react';
2222
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2323

2424
export interface CollectionOptions extends DOMProps, AriaLabelingProps {
@@ -88,7 +88,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
8888
disableVirtualFocus = false
8989
} = props;
9090

91-
let collectionId = useSlotId();
91+
let collectionId = useId();
9292
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
9393
let delayNextActiveDescendant = useRef(false);
9494
let queuedActiveDescendant = useRef<string | null>(null);
@@ -97,7 +97,11 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
9797
// For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually
9898
// moving focus back to the subtriggers
9999
let isMobileScreenReader = getInteractionModality() === 'virtual' && (isIOS() || isAndroid());
100-
let shouldUseVirtualFocus = !isMobileScreenReader && !disableVirtualFocus;
100+
let [shouldUseVirtualFocus, setShouldUseVirtualFocus] = useState(!isMobileScreenReader && !disableVirtualFocus);
101+
// Tracks if a collection has been connected to the autocomplete. If false, we don't want to add various attributes to the autocomplete input
102+
// since it isn't attached to a filterable collection (e.g. Tabs)
103+
let [hasCollection, setHasCollection] = useState(false);
104+
101105
useEffect(() => {
102106
return () => clearTimeout(timeout.current);
103107
}, []);
@@ -145,8 +149,16 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
145149
lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant);
146150
lastCollectionNode.current = collectionNode;
147151
collectionNode.addEventListener('focusin', updateActiveDescendant);
152+
// If useSelectableCollection isn't passed shouldUseVirtualFocus even when useAutocomplete provides it
153+
// that means the collection doesn't support it (e.g. Table). If that is the case, we need to disable it here regardless
154+
// of what the user's provided so that the input doesn't recieve the onKeyDown and autocomplete props.
155+
if (collectionNode.getAttribute('tabindex') != null) {
156+
setShouldUseVirtualFocus(false);
157+
}
158+
setHasCollection(true);
148159
} else {
149160
lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant);
161+
setHasCollection(false);
150162
}
151163
}, [updateActiveDescendant]);
152164

@@ -393,7 +405,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
393405
onFocus
394406
};
395407

396-
if (collectionId) {
408+
if (hasCollection) {
397409
inputProps = {
398410
...inputProps,
399411
...(shouldUseVirtualFocus && virtualFocusProps),
@@ -413,7 +425,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
413425
inputProps,
414426
collectionProps: mergeProps(collectionProps, {
415427
shouldUseVirtualFocus,
416-
disallowTypeAhead: true
428+
disallowTypeAhead: shouldUseVirtualFocus
417429
}),
418430
collectionRef: mergedCollectionRef,
419431
filter: filter != null ? filterFn : undefined

packages/@react-spectrum/s2/src/ComboBox.tsx

Lines changed: 47 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import intlMessages from '../intl/*.json';
5959
import {mergeRefs, useResizeObserver, useSlotId} from '@react-aria/utils';
6060
import {Node} from 'react-stately';
6161
import {Placement} from 'react-aria';
62-
import {PopoverBase} from './Popover';
62+
import {Popover} from './Popover';
6363
import {pressScale} from './pressScale';
6464
import {ProgressCircle} from './ProgressCircle';
6565
import {TextFieldRef} from '@react-types/textfield';
@@ -478,7 +478,7 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps<any
478478
}
479479

480480
let triggerRef = useRef<HTMLDivElement>(null);
481-
// Make menu width match input + button
481+
// Make menu width match input + button
482482
let [triggerWidth, setTriggerWidth] = useState<string | null>(null);
483483
let onResize = useCallback(() => {
484484
if (triggerRef.current) {
@@ -643,57 +643,62 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps<any
643643
description={descriptionMessage}>
644644
{errorMessage}
645645
</HelpText>
646-
<PopoverBase
646+
<Popover
647647
hideArrow
648648
triggerRef={triggerRef}
649649
offset={menuOffset}
650650
placement={`${direction} ${align}` as Placement}
651651
shouldFlip={shouldFlip}
652652
UNSAFE_style={{
653-
width: menuWidth ? `${menuWidth}px` : undefined,
654-
// manually subtract border as we can't set Popover to border-box, it causes the contents to spill out
655-
'--trigger-width': `calc(${triggerWidth} - 2px)`
653+
'--trigger-width': (menuWidth ? menuWidth + 'px' : triggerWidth)
656654
} as CSSProperties}
655+
padding="none"
657656
styles={style({
658657
minWidth: '--trigger-width',
659658
width: '--trigger-width'
660659
})}>
661-
<Provider
662-
values={[
663-
[HeaderContext, {styles: listboxHeader({size})}],
664-
[HeadingContext, {
665-
// @ts-ignore
666-
role: 'presentation',
667-
styles: sectionHeading
668-
}],
669-
[TextContext, {
670-
slots: {
671-
'description': {styles: description({size})}
672-
}
673-
}]
674-
]}>
675-
<Virtualizer
676-
layout={ListLayout}
677-
layoutOptions={{
678-
estimatedRowHeight: 32,
679-
padding: 8,
680-
estimatedHeadingHeight: 50,
681-
loaderHeight: LOADER_ROW_HEIGHTS[size][scale]
682-
}}>
683-
<ListBox
684-
dependencies={props.dependencies}
685-
renderEmptyState={() => (
686-
<span className={emptyStateText({size})}>
687-
{loadingState === 'loading' ? stringFormatter.format('table.loading') : stringFormatter.format('combobox.noResults')}
688-
</span>
689-
)}
690-
items={items}
691-
className={listbox({size})}>
692-
{renderer}
693-
</ListBox>
694-
</Virtualizer>
695-
</Provider>
696-
</PopoverBase>
660+
<div
661+
className={style({
662+
display: 'flex',
663+
size: 'full'
664+
})}>
665+
<Provider
666+
values={[
667+
[HeaderContext, {styles: listboxHeader({size})}],
668+
[HeadingContext, {
669+
// @ts-ignore
670+
role: 'presentation',
671+
styles: sectionHeading
672+
}],
673+
[TextContext, {
674+
slots: {
675+
'description': {styles: description({size})}
676+
}
677+
}]
678+
]}>
679+
<Virtualizer
680+
layout={ListLayout}
681+
layoutOptions={{
682+
estimatedRowHeight: 32,
683+
padding: 8,
684+
estimatedHeadingHeight: 50,
685+
loaderHeight: LOADER_ROW_HEIGHTS[size][scale]
686+
}}>
687+
<ListBox
688+
dependencies={props.dependencies}
689+
renderEmptyState={() => (
690+
<span className={emptyStateText({size})}>
691+
{loadingState === 'loading' ? stringFormatter.format('table.loading') : stringFormatter.format('combobox.noResults')}
692+
</span>
693+
)}
694+
items={items}
695+
className={listbox({size})}>
696+
{renderer}
697+
</ListBox>
698+
</Virtualizer>
699+
</Provider>
700+
</div>
701+
</Popover>
697702
</InternalComboboxContext.Provider>
698703
</>
699704
);

packages/@react-spectrum/s2/src/ContextualHelp.tsx

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import InfoIcon from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg';
1111
// @ts-ignore
1212
import intlMessages from '../intl/*.json';
1313
import {mergeStyles} from '../style/runtime';
14-
import {PopoverBase, PopoverDialogProps} from './Popover';
14+
import {Popover, PopoverDialogProps} from './Popover';
1515
import {space, style} from '../style' with {type: 'macro'};
1616
import {StyleProps} from './style-utils' with { type: 'macro' };
1717
import {useLocalizedStringFormatter} from '@react-aria/i18n';
@@ -39,11 +39,12 @@ export interface ContextualHelpProps extends
3939
size?: 'XS' | 'S'
4040
}
4141

42-
const popover = style({
43-
fontFamily: 'sans',
42+
const wrappingDiv = style({
4443
minWidth: 268,
4544
width: 268,
46-
padding: 24
45+
padding: 24,
46+
boxSizing: 'border-box',
47+
height: 'full'
4748
});
4849

4950
export const ContextualHelpContext = createContext<ContextValue<Partial<ContextualHelpProps>, FocusableRefValue<HTMLButtonElement>>>(null);
@@ -96,39 +97,42 @@ export const ContextualHelp = forwardRef(function ContextualHelp(props: Contextu
9697
isQuiet>
9798
{variant === 'info' ? <InfoIcon /> : <HelpIcon />}
9899
</ActionButton>
99-
<PopoverBase
100+
<Popover
101+
padding="none"
100102
placement={placement}
101103
shouldFlip={shouldFlip}
102104
// not working => containerPadding={containerPadding}
103105
offset={offset}
104106
crossOffset={crossOffset}
105-
hideArrow
106-
styles={popover}>
107-
<RACDialog className={mergeStyles(dialogInner, style({borderRadius: 'none', margin: 'calc(self(paddingTop) * -1)', padding: 24}))}>
108-
<Provider
109-
values={[
110-
[TextContext, {
111-
slots: {
112-
[DEFAULT_SLOT]: {}
113-
}
114-
}],
115-
[HeadingContext, {styles: style({
116-
font: 'heading-xs',
117-
margin: 0,
118-
marginBottom: space(8) // This only makes it 10px on mobile and should be 12px
119-
})}],
120-
[ContentContext, {styles: style({
121-
font: 'body-sm'
122-
})}],
123-
[FooterContext, {styles: style({
124-
font: 'body-sm',
125-
marginTop: 16
126-
})}]
127-
]}>
128-
{children}
129-
</Provider>
130-
</RACDialog>
131-
</PopoverBase>
107+
hideArrow>
108+
<div
109+
className={wrappingDiv}>
110+
<RACDialog className={mergeStyles(dialogInner, style({borderRadius: 'none', margin: 'calc(self(paddingTop) * -1)', padding: 24}))}>
111+
<Provider
112+
values={[
113+
[TextContext, {
114+
slots: {
115+
[DEFAULT_SLOT]: {}
116+
}
117+
}],
118+
[HeadingContext, {styles: style({
119+
font: 'heading-xs',
120+
margin: 0,
121+
marginBottom: space(8) // This only makes it 10px on mobile and should be 12px
122+
})}],
123+
[ContentContext, {styles: style({
124+
font: 'body-sm'
125+
})}],
126+
[FooterContext, {styles: style({
127+
font: 'body-sm',
128+
marginTop: 16
129+
})}]
130+
]}>
131+
{children}
132+
</Provider>
133+
</RACDialog>
134+
</div>
135+
</Popover>
132136
</DialogTrigger>
133137
);
134138
});

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

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {FieldGroup, FieldLabel, HelpText} from './Field';
3333
import {forwardRefType, GlobalDOMAttributes, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared';
3434
// @ts-ignore
3535
import intlMessages from '../intl/*.json';
36-
import {PopoverBase} from './Popover';
36+
import {Popover} from './Popover';
3737
import {pressScale} from './pressScale';
3838
import {useLocalizedStringFormatter} from '@react-aria/i18n';
3939
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -237,25 +237,30 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function
237237

238238
export function CalendarPopover(props: PropsWithChildren): ReactElement {
239239
return (
240-
<PopoverBase
240+
<Popover
241241
hideArrow
242-
styles={style({
243-
paddingX: 16,
244-
paddingY: 32,
245-
overflow: 'auto',
246-
display: 'flex',
247-
flexDirection: 'column',
248-
gap: 16
249-
})}>
250-
<Dialog>
251-
<Provider
252-
values={[
253-
[OverlayTriggerStateContext, null]
254-
]}>
255-
{props.children}
256-
</Provider>
257-
</Dialog>
258-
</PopoverBase>
242+
padding="none">
243+
<div
244+
className={style({
245+
paddingX: 16,
246+
paddingY: 32,
247+
overflow: 'auto',
248+
display: 'flex',
249+
flexDirection: 'column',
250+
gap: 16,
251+
boxSizing: 'border-box',
252+
size: 'full'
253+
})}>
254+
<Dialog>
255+
<Provider
256+
values={[
257+
[OverlayTriggerStateContext, null]
258+
]}>
259+
{props.children}
260+
</Provider>
261+
</Dialog>
262+
</div>
263+
</Popover>
259264
);
260265
}
261266

packages/@react-spectrum/s2/src/Menu.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {forwardRefType} from './types';
4141
import {HeaderContext, HeadingContext, KeyboardContext, Text, TextContext} from './Content';
4242
import {IconContext} from './Icon'; // chevron right removed??
4343
import {ImageContext} from './Image';
44-
import {InPopoverContext, PopoverBase, PopoverContext} from './Popover';
44+
import {InPopoverContext, Popover, PopoverContext} from './Popover';
4545
import LinkOutIcon from '../ui-icons/LinkOut';
4646
import {mergeStyles} from '../style/runtime';
4747
import {Placement, useLocale} from 'react-aria';
@@ -320,6 +320,11 @@ let InternalMenuContext = createContext<{size: 'S' | 'M' | 'L' | 'XL', isSubmenu
320320

321321
let InternalMenuTriggerContext = createContext<Omit<MenuTriggerProps, 'children'> | null>(null);
322322

323+
let wrappingDiv = style({
324+
display: 'flex',
325+
size: 'full'
326+
});
327+
323328
/**
324329
* Menus display a list of actions or options that a user can choose.
325330
*/
@@ -366,14 +371,16 @@ export const Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(function Menu<T
366371

367372
if (isPopover) {
368373
return (
369-
<PopoverBase
374+
<Popover
370375
ref={ref}
371-
hideArrow
372-
UNSAFE_style={UNSAFE_style}
373-
UNSAFE_className={UNSAFE_className}
374-
styles={styles}>
375-
{content}
376-
</PopoverBase>
376+
padding="none"
377+
hideArrow>
378+
<div
379+
style={UNSAFE_style}
380+
className={(UNSAFE_className || '') + wrappingDiv}>
381+
{content}
382+
</div>
383+
</Popover>
377384
);
378385
}
379386

0 commit comments

Comments
 (0)