Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add popover capabilities to SelectNext listbox #1540

Merged
merged 1 commit into from
May 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
acelaya marked this conversation as resolved.
Show resolved Hide resolved

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.
*/
acelaya marked this conversation as resolved.
Show resolved Hide resolved
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`,
});
acelaya marked this conversation as resolved.
Show resolved Hide resolved
}

// 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