diff --git a/src/components/input/SelectNext.tsx b/src/components/input/SelectNext.tsx index 2cfb2127..4892930d 100644 --- a/src/components/input/SelectNext.tsx +++ b/src/components/input/SelectNext.tsx @@ -1,5 +1,5 @@ import classnames from 'classnames'; -import type { ComponentChildren, RefObject } from 'preact'; +import type { ComponentChildren, JSX, RefObject } from 'preact'; import { useCallback, useContext, @@ -15,6 +15,7 @@ import { useFocusAway } from '../../hooks/use-focus-away'; import { useKeyPress } from '../../hooks/use-key-press'; import { useSyncedRef } from '../../hooks/use-synced-ref'; import type { CompositeProps } from '../../types'; +import { ListenerCollection } from '../../util/listener-collection'; import { downcastRef } from '../../util/typing'; import { MenuCollapseIcon, MenuExpandIcon } from '../icons'; import { inputGroupStyles } from './InputGroup'; @@ -103,38 +104,87 @@ function SelectOption({ SelectOption.displayName = 'SelectNext.Option'; -function useShouldDropUp( +const LISTBOX_TOGGLE_GAP = '.25rem'; +const OPTION_SELECTOR = '[role="option"]:not([aria-disabled="true"])'; + +function useListboxPositioning( buttonRef: RefObject, listboxRef: RefObject, listboxOpen: boolean, -): boolean { - const [shouldListboxDropUp, setShouldListboxDropUp] = useState(false); + nativePopoverSupported: boolean, +) { + const [positioningStyles, setPositioningStyles] = useState< + JSX.HTMLAttributes['style'] + >({}); useLayoutEffect(() => { - // Reset shouldListboxDropUp so that it does not affect calculations next - // time listbox opens - if (!buttonRef.current || !listboxRef.current || !listboxOpen) { - setShouldListboxDropUp(false); - return; + const listboxEl = listboxRef.current; + const buttonEl = buttonRef.current; + + if (!buttonEl || !listboxEl || !listboxOpen) { + return () => {}; } + const listeners = new ListenerCollection(); const viewportHeight = window.innerHeight; - const { top: buttonDistanceToTop, bottom: buttonBottom } = - buttonRef.current.getBoundingClientRect(); + const { + top: buttonDistanceToTop, + bottom: buttonBottom, + left: buttonLeft, + height: buttonHeight, + width: buttonWidth, + } = buttonEl.getBoundingClientRect(); const buttonDistanceToBottom = viewportHeight - buttonBottom; - const { bottom: listboxBottom } = - listboxRef.current.getBoundingClientRect(); - const listboxDistanceToBottom = viewportHeight - listboxBottom; + const { height: listboxHeight } = listboxEl.getBoundingClientRect(); // The listbox should drop up only if there's not enough space below to - // fit it, and there's also more absolute space above than below - setShouldListboxDropUp( - listboxDistanceToBottom < 0 && - buttonDistanceToTop > buttonDistanceToBottom, - ); - }, [buttonRef, listboxRef, listboxOpen]); - - return shouldListboxDropUp; + // fit it, and also, there's more absolute space above than below + const shouldListboxDropUp = + buttonDistanceToBottom < listboxHeight && + buttonDistanceToTop > buttonDistanceToBottom; + + if (!nativePopoverSupported) { + setPositioningStyles( + shouldListboxDropUp + ? { bottom: '100%', marginBottom: LISTBOX_TOGGLE_GAP } + : { top: '100%', marginTop: LISTBOX_TOGGLE_GAP }, + ); + } else { + const toggleHandler = (e: Event) => { + // e.newState determines if the element that toggled is transitioning to + // open or closed state + if (!(e instanceof ToggleEvent) || !e.newState) { + return; + } + + // When native popover is supported, we disable the automatic focusing + // in useArrowKeyNavigation, so we need to manually focus first/active + // option after opening the dropdown + const el = + listboxEl.querySelector(`${OPTION_SELECTOR}[aria-selected="true"]`) ?? + listboxEl.querySelector(OPTION_SELECTOR); + (el as HTMLElement | null)?.focus(); + }; + listeners.add(listboxEl, 'toggle', toggleHandler); + + const { top: bodyTop } = document.body.getBoundingClientRect(); + const absBodyTop = Math.abs(bodyTop); + setPositioningStyles({ + top: shouldListboxDropUp + ? `calc(${absBodyTop + buttonDistanceToTop - listboxHeight}px - ${LISTBOX_TOGGLE_GAP})` + : `calc(${absBodyTop + buttonDistanceToTop + buttonHeight}px + ${LISTBOX_TOGGLE_GAP})`, + left: buttonLeft && `${buttonLeft}px`, + minWidth: buttonWidth && `${buttonWidth}px`, + }); + } + + return () => { + listeners.removeAll(); + setPositioningStyles({}); + }; + }, [buttonRef, listboxRef, listboxOpen, nativePopoverSupported]); + + return positioningStyles; } export type SelectProps = CompositeProps & { @@ -166,6 +216,9 @@ export type SelectProps = CompositeProps & { 'aria-label'?: string; 'aria-labelledby'?: string; + + /** Test seam */ + nativePopoverSupported?: boolean; }; function SelectMain({ @@ -182,18 +235,31 @@ function SelectMain({ right = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, + + /* istanbul ignore next - test seam */ + nativePopoverSupported = 'popover' in document.body, }: SelectProps) { - const [listboxOpen, setListboxOpen] = useState(false); - const closeListbox = useCallback(() => setListboxOpen(false), []); const wrapperRef = useRef(null); const listboxRef = useRef(null); + const [listboxOpen, setListboxOpen] = useState(false); + const toggleListbox = useCallback( + (open: boolean) => { + setListboxOpen(open); + if (nativePopoverSupported) { + listboxRef.current?.togglePopover(open); + } + }, + [nativePopoverSupported], + ); + const closeListbox = useCallback(() => toggleListbox(false), [toggleListbox]); const listboxId = useId(); const buttonRef = useSyncedRef(elementRef); const defaultButtonId = useId(); - const shouldListboxDropUp = useShouldDropUp( + const positioningStyles = useListboxPositioning( buttonRef, listboxRef, listboxOpen, + nativePopoverSupported, ); const selectValue = useCallback( @@ -213,9 +279,9 @@ function SelectMain({ useArrowKeyNavigation(listboxRef, { horizontal: false, loop: false, - autofocus: true, + autofocus: !nativePopoverSupported, containerVisible: listboxOpen, - selector: '[role="option"]:not([aria-disabled="true"])', + selector: OPTION_SELECTOR, }); useLayoutEffect(() => { @@ -257,11 +323,11 @@ function SelectMain({ aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} ref={downcastRef(buttonRef)} - onClick={() => setListboxOpen(prev => !prev)} + onClick={() => toggleListbox(!listboxOpen)} onKeyDown={e => { if (e.key === 'ArrowDown' && !listboxOpen) { e.preventDefault(); - setListboxOpen(true); + toggleListbox(true); } }} data-testid="select-toggle-button" @@ -273,21 +339,22 @@ function SelectMain({