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 7, 2024
1 parent 41f27ed commit c77948c
Showing 1 changed file with 87 additions and 32 deletions.
119 changes: 87 additions & 32 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 @@ -47,6 +48,8 @@ function optionChildren(
return children;
}

const nativePopoverSupported = 'popover' in document.body;

function SelectOption<T>({
value,
children,
Expand Down Expand Up @@ -103,38 +106,83 @@ function SelectOption<T>({

SelectOption.displayName = 'SelectNext.Option';

function useShouldDropUp(
function useListboxPositioning(
buttonRef: RefObject<HTMLElement | undefined>,
listboxRef: RefObject<HTMLElement | null>,
listboxOpen: boolean,
): boolean {
const [shouldListboxDropUp, setShouldListboxDropUp] = useState(false);
) {
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,
);
// 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: '.25em' }
: { top: '100%', marginTop: '.25em' },
);
} else {
const { top: bodyTop } = document.body.getBoundingClientRect();
const absBodyTop = Math.abs(bodyTop);
const toggleHandler = (e: Event) => {
if (!(e instanceof ToggleEvent) || !e.newState) {
return;
}

// Focus first/active option after opening dropdown
const el =
listboxEl.querySelector(
'[role="option"][aria-selected="true"]:not([aria-disabled="true"])',
) ??
listboxEl.querySelector(
'[role="option"]:not([aria-disabled="true"])',
);
(el as HTMLElement | null)?.focus();
};
listeners.add(listboxEl, 'toggle', toggleHandler);

setPositioningStyles({
top: shouldListboxDropUp
? `${absBodyTop + buttonDistanceToTop - listboxHeight}px`
: `${absBodyTop + buttonDistanceToTop + buttonHeight}px`,
left: buttonLeft && `${buttonLeft}px`,
minWidth: buttonWidth && `${buttonWidth}px`,
});
}

return () => {
listeners.removeAll();
setPositioningStyles({});
};
}, [buttonRef, listboxRef, listboxOpen]);

return shouldListboxDropUp;
return positioningStyles;
}

export type SelectProps<T> = CompositeProps & {
Expand Down Expand Up @@ -183,14 +231,20 @@ function SelectMain<T>({
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: 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);
}
}, []);
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,
Expand All @@ -213,7 +267,7 @@ function SelectMain<T>({
useArrowKeyNavigation(listboxRef, {
horizontal: false,
loop: false,
autofocus: true,
autofocus: !nativePopoverSupported,
containerVisible: listboxOpen,
selector: '[role="option"]:not([aria-disabled="true"])',
});
Expand Down Expand Up @@ -257,11 +311,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 +327,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 c77948c

Please sign in to comment.