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 10, 2024
1 parent f1f477b commit b25768d
Show file tree
Hide file tree
Showing 3 changed files with 307 additions and 42 deletions.
169 changes: 136 additions & 33 deletions src/components/input/SelectNext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
Expand All @@ -15,6 +16,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 +105,115 @@ function SelectOption<T>({

SelectOption.displayName = 'SelectNext.Option';

function useShouldDropUp(
/** Small space to apply between the toggle button and the listbox */
const LISTBOX_TOGGLE_GAP = '.25rem';

type ListboxCSSProps =
| 'top'
| 'left'
| 'minWidth'
| 'marginBottom'
| 'bottom'
| 'marginTop';

/**
* Manages the listbox position manually to make sure it renders "next" to the
* toggle button (below or over). This is mainly needed when the listbox is used
* as a popover, as that makes it render in the top layer, making it impossible
* to position it relative to the toggle button via regular CSS.
*/
function useListboxPositioning(
buttonRef: RefObject<HTMLElement | undefined>,
listboxRef: RefObject<HTMLElement | null>,
listboxOpen: boolean,
): boolean {
const [shouldListboxDropUp, setShouldListboxDropUp] = useState(false);
asPopover: boolean,
right: boolean,
) {
const adjustListboxPositioning = useCallback(() => {
const listboxEl = listboxRef.current;
const buttonEl = buttonRef.current;

useLayoutEffect(() => {
// Reset shouldListboxDropUp so that it does not affect calculations next
// time listbox opens
if (!buttonRef.current || !listboxRef.current || !listboxOpen) {
setShouldListboxDropUp(false);
return;
if (!buttonEl || !listboxEl || !listboxOpen) {
return () => {};
}

/**
* We need to set the positioning styles synchronously (not via `style`
* prop and a piece of state), to make sure positioning happens before
* `useArrowKeyNavigation` runs, focusing the first option in the listbox.
*/
const setListboxCSSProps = (
props: Partial<Record<ListboxCSSProps, string>>,
) => {
Object.assign(listboxEl.style, props);
const keys = Object.keys(props) as ListboxCSSProps[];
return () => keys.map(prop => (listboxEl.style[prop] = ''));
};

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, width: listboxWidth } =
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 (asPopover) {
const { top: bodyTop } = document.body.getBoundingClientRect();
const absBodyTop = Math.abs(bodyTop);

return setListboxCSSProps({
minWidth: `${buttonWidth}px`,
top: shouldListboxDropUp
? `calc(${absBodyTop + buttonDistanceToTop - listboxHeight}px - ${LISTBOX_TOGGLE_GAP})`
: `calc(${absBodyTop + buttonDistanceToTop + buttonHeight}px + ${LISTBOX_TOGGLE_GAP})`,
left:
right && listboxWidth > buttonWidth
? `${buttonLeft - (listboxWidth - buttonWidth)}px`
: `${buttonLeft}px`,
});
}

// Set styles for non-popover mode
if (shouldListboxDropUp) {
return setListboxCSSProps({
bottom: '100%',
marginBottom: LISTBOX_TOGGLE_GAP,
});
}

return setListboxCSSProps({ top: '100%', marginTop: LISTBOX_TOGGLE_GAP });
}, [asPopover, buttonRef, listboxOpen, listboxRef, right]);

useLayoutEffect(() => {
const cleanup = adjustListboxPositioning();

if (!asPopover) {
return cleanup;
}

// Readjust listbox position when any element scrolls, just in case that
// affected the toggle button position.
const listeners = new ListenerCollection();
listeners.add(document.body, 'scroll', adjustListboxPositioning, {
capture: true,
});

return () => {
cleanup();
listeners.removeAll();
};
}, [adjustListboxPositioning, asPopover]);
}

export type SelectProps<T> = CompositeProps & {
Expand Down Expand Up @@ -166,6 +245,12 @@ export type SelectProps<T> = CompositeProps & {

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

/**
* Used to determine if the listbox should use the popover API.
* Defaults to true, as long as the browser supports it.
*/
listboxAsPopover?: boolean;
};

function SelectMain<T>({
Expand All @@ -182,18 +267,36 @@ function SelectMain<T>({
right = false,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
/* eslint-disable-next-line no-prototype-builtins */
listboxAsPopover = HTMLElement.prototype.hasOwnProperty('popover'),
}: 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 (listboxAsPopover) {
listboxRef.current?.togglePopover(open);
}
},
[listboxAsPopover],
);
const closeListbox = useCallback(() => toggleListbox(false), [toggleListbox]);
const listboxId = useId();
const buttonRef = useSyncedRef(elementRef);
const defaultButtonId = useId();
const shouldListboxDropUp = useShouldDropUp(
const extraProps = useMemo(
() => (listboxAsPopover ? { popover: true } : {}),
[listboxAsPopover],
);

useListboxPositioning(
buttonRef,
listboxRef,
listboxOpen,
listboxAsPopover,
right,
);

const selectValue = useCallback(
Expand Down Expand Up @@ -257,11 +360,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,18 +376,17 @@ function SelectMain<T>({
</button>
<SelectContext.Provider value={{ selectValue, value }}>
<ul
{...extraProps}
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,

!listboxAsPopover && {
// 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,
'right-0': right,
'min-w-full': true,
},
listboxClasses,
)}
Expand All @@ -294,6 +396,7 @@ function SelectMain<T>({
aria-labelledby={buttonId ?? defaultButtonId}
aria-orientation="vertical"
data-testid="select-listbox"
data-listbox-open={listboxOpen}
>
{children}
</ul>
Expand Down
Loading

0 comments on commit b25768d

Please sign in to comment.