Skip to content

Commit

Permalink
Add popover capabilities to SelectNext listbox
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed May 8, 2024
1 parent 41f27ed commit 8714ed3
Showing 1 changed file with 102 additions and 35 deletions.
137 changes: 102 additions & 35 deletions src/components/input/SelectNext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classnames from 'classnames';
import type { ComponentChildren, RefObject } from 'preact';
import type { ComponentChildren, JSX, RefObject } from 'preact';
import {
useCallback,
useContext,
Expand All @@ -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';
Expand Down Expand Up @@ -103,38 +104,87 @@ function SelectOption<T>({

SelectOption.displayName = 'SelectNext.Option';

function useShouldDropUp(
const LISTBOX_TOGGLE_GAP = '.25rem';
const OPTION_SELECTOR = '[role="option"]:not([aria-disabled="true"])';

function useListboxPositioning(
buttonRef: RefObject<HTMLElement | undefined>,
listboxRef: RefObject<HTMLElement | null>,
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<T> = CompositeProps & {
Expand Down Expand Up @@ -166,6 +216,9 @@ export type SelectProps<T> = CompositeProps & {

'aria-label'?: string;
'aria-labelledby'?: string;

/** Test seam */
nativePopoverSupported?: boolean;
};

function SelectMain<T>({
Expand All @@ -182,18 +235,31 @@ function SelectMain<T>({
right = false,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,

/* istanbul ignore next - test seam */
nativePopoverSupported = 'popover' in document.body,
}: SelectProps<T>) {
const [listboxOpen, setListboxOpen] = useState(false);
const closeListbox = useCallback(() => setListboxOpen(false), []);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const listboxRef = useRef<HTMLUListElement | null>(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(
Expand All @@ -213,9 +279,9 @@ function SelectMain<T>({
useArrowKeyNavigation(listboxRef, {
horizontal: false,
loop: false,
autofocus: true,
autofocus: !nativePopoverSupported,
containerVisible: listboxOpen,
selector: '[role="option"]:not([aria-disabled="true"])',
selector: OPTION_SELECTOR,
});

useLayoutEffect(() => {
Expand Down Expand Up @@ -257,11 +323,11 @@ function SelectMain<T>({
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"
Expand All @@ -273,21 +339,22 @@ function SelectMain<T>({
</button>
<SelectContext.Provider value={{ selectValue, value }}>
<ul
// @ts-expect-error Popover attribute not yet part of type
popover
className={classnames(
'absolute z-5 min-w-full max-h-80 overflow-y-auto',
'absolute z-5 max-h-80 overflow-y-auto',
'rounded border bg-white shadow hover:shadow-md focus-within:shadow-md',
{
'top-full mt-1': !shouldListboxDropUp,
'bottom-full mb-1': shouldListboxDropUp,
'right-0': right,

{ 'right-0': right },
!nativePopoverSupported && {
// Hiding instead of unmounting to
// * Ensure screen readers detect button as a listbox handler
// * Listbox size can be computed to correctly drop up or down
hidden: !listboxOpen,
'min-w-full': true,
},
listboxClasses,
)}
style={positioningStyles}
role="listbox"
ref={listboxRef}
id={listboxId}
Expand Down

0 comments on commit 8714ed3

Please sign in to comment.