From d78c20e90099226a237cfc0d23e98d2d182ca749 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Tue, 30 Apr 2024 10:13:21 +0200
Subject: [PATCH] Add popover capabilities to SelectNext listbox
---
src/components/input/SelectNext.tsx | 164 ++++++++++++++----
src/components/input/test/SelectNext-test.js | 98 ++++++++++-
.../patterns/prototype/SelectNextPage.tsx | 18 ++
3 files changed, 239 insertions(+), 41 deletions(-)
diff --git a/src/components/input/SelectNext.tsx b/src/components/input/SelectNext.tsx
index 2cfb2127d..45dbf009e 100644
--- a/src/components/input/SelectNext.tsx
+++ b/src/components/input/SelectNext.tsx
@@ -5,6 +5,7 @@ import {
useContext,
useId,
useLayoutEffect,
+ useMemo,
useRef,
useState,
} from 'preact/hooks';
@@ -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';
@@ -103,38 +105,108 @@ function SelectOption({
SelectOption.displayName = 'SelectNext.Option';
-function useShouldDropUp(
+const LISTBOX_TOGGLE_GAP = '.25rem';
+
+type ListboxCSSProps =
+ | 'top'
+ | 'left'
+ | 'minWidth'
+ | 'marginBottom'
+ | 'bottom'
+ | 'marginTop';
+
+function useListboxPositioning(
buttonRef: RefObject,
listboxRef: RefObject,
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 () => {};
}
+ /**
+ * Sets some CSS props in the listbox, and returns a cleanup function that
+ * resets them to their default values.
+ */
+ const setListboxCSSProps = (
+ props: Partial>,
+ ) => {
+ const entries = Object.entries(props) as Array<[ListboxCSSProps, string]>;
+ entries.forEach(([prop, value]) => (listboxEl.style[prop] = value));
+
+ return () => entries.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 = CompositeProps & {
@@ -166,6 +238,13 @@ export type SelectProps = CompositeProps & {
'aria-label'?: string;
'aria-labelledby'?: string;
+
+ /**
+ * Test seam.
+ * Used to determine if current browser supports native popovers.
+ * Defaults to `'popover' in document.body`
+ */
+ nativePopoverSupported?: boolean;
};
function SelectMain({
@@ -182,18 +261,37 @@ 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 extraProps = useMemo(
+ () => (nativePopoverSupported ? { popover: true } : {}),
+ [nativePopoverSupported],
+ );
+
+ useListboxPositioning(
buttonRef,
listboxRef,
listboxOpen,
+ nativePopoverSupported,
+ right,
);
const selectValue = useCallback(
@@ -257,11 +355,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,18 +371,17 @@ function SelectMain({
({
aria-labelledby={buttonId ?? defaultButtonId}
aria-orientation="vertical"
data-testid="select-listbox"
+ data-listbox-open={listboxOpen}
>
{children}
diff --git a/src/components/input/test/SelectNext-test.js b/src/components/input/test/SelectNext-test.js
index 61cdd8cde..7e0008181 100644
--- a/src/components/input/test/SelectNext-test.js
+++ b/src/components/input/test/SelectNext-test.js
@@ -1,4 +1,4 @@
-import { checkAccessibility } from '@hypothesis/frontend-testing';
+import { checkAccessibility, waitFor } from '@hypothesis/frontend-testing';
import { mount } from 'enzyme';
import SelectNext from '../SelectNext';
@@ -28,6 +28,8 @@ describe('SelectNext', () => {
container.style.paddingTop = `${paddingTop}px`;
document.body.append(container);
+ props.nativePopoverSupported = props.nativePopoverSupported ?? false;
+
const wrapper = mount(
{items.map(item => (
@@ -74,10 +76,18 @@ describe('SelectNext', () => {
const getListbox = wrapper => wrapper.find('[data-testid="select-listbox"]');
const isListboxClosed = wrapper =>
- getListbox(wrapper).prop('className').includes('hidden');
+ getListbox(wrapper).prop('data-listbox-open') === false;
+
+ const listboxDidDropUp = wrapper => {
+ const { top: listboxTop } = getListbox(wrapper)
+ .getDOMNode()
+ .getBoundingClientRect();
+ const { top: buttonTop } = getToggleButton(wrapper)
+ .getDOMNode()
+ .getBoundingClientRect();
- const listboxDidDropUp = wrapper =>
- getListbox(wrapper).prop('className').includes('bottom-full');
+ return listboxTop < buttonTop;
+ };
it('changes selected value when an option is clicked', () => {
const onChange = sinon.stub();
@@ -261,11 +271,32 @@ describe('SelectNext', () => {
});
[
- { containerPaddingTop: 0, shouldDropUp: false },
- { containerPaddingTop: 1000, shouldDropUp: true },
- ].forEach(({ containerPaddingTop, shouldDropUp }) => {
+ {
+ containerPaddingTop: 0,
+ shouldDropUp: false,
+ nativePopoverSupported: true,
+ },
+ {
+ containerPaddingTop: 0,
+ shouldDropUp: false,
+ nativePopoverSupported: false,
+ },
+ {
+ containerPaddingTop: 1000,
+ shouldDropUp: true,
+ nativePopoverSupported: true,
+ },
+ {
+ containerPaddingTop: 1000,
+ shouldDropUp: true,
+ nativePopoverSupported: false,
+ },
+ ].forEach(({ containerPaddingTop, shouldDropUp, nativePopoverSupported }) => {
it('makes listbox drop up or down based on available space below', () => {
- const wrapper = createComponent({}, { paddingTop: containerPaddingTop });
+ const wrapper = createComponent(
+ { nativePopoverSupported },
+ { paddingTop: containerPaddingTop },
+ );
toggleListbox(wrapper);
assert.equal(listboxDidDropUp(wrapper), shouldDropUp);
@@ -282,6 +313,57 @@ describe('SelectNext', () => {
});
});
+ context('when popover is supported', () => {
+ it('opens listbox via popover API', async () => {
+ const wrapper = createComponent({ nativePopoverSupported: true });
+ let resolve;
+ const promise = new Promise(res => (resolve = res));
+
+ getListbox(wrapper).getDOMNode().addEventListener('toggle', resolve);
+ toggleListbox(wrapper);
+
+ // This test will timeout if the toggle event is not dispatched
+ await promise;
+ });
+ });
+
+ context('when listbox is bigger than toggle button', () => {
+ [
+ {
+ nativePopoverSupported: true,
+ getListboxLeft: wrapper => {
+ const leftStyle = getListbox(wrapper).getDOMNode().style.left;
+ // Remove last 2 chars, which are the `px` unit indicator
+ return Number(leftStyle.slice(0, -2));
+ },
+ },
+ {
+ nativePopoverSupported: false,
+ getListboxLeft: wrapper =>
+ getListbox(wrapper).getDOMNode().getBoundingClientRect().left,
+ },
+ ].forEach(({ nativePopoverSupported, getListboxLeft }) => {
+ it('aligns listbox to the right if `right` prop is true', async () => {
+ const wrapper = createComponent({
+ nativePopoverSupported,
+ right: true,
+ buttonClasses: '!w-8', // Set a small width in the button
+ });
+ toggleListbox(wrapper);
+
+ // Wait for listbox to be open
+ await waitFor(() => !isListboxClosed(wrapper));
+
+ const { left: buttonLeft } = getToggleButton(wrapper)
+ .getDOMNode()
+ .getBoundingClientRect();
+ const listboxLeft = getListboxLeft(wrapper);
+
+ assert.isTrue(listboxLeft < buttonLeft);
+ });
+ });
+ });
+
it(
'should pass a11y checks',
checkAccessibility([
diff --git a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx
index 4ba0b0829..225cbd471 100644
--- a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx
+++ b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx
@@ -1,6 +1,7 @@
import classnames from 'classnames';
import { useCallback, useId, useMemo, useState } from 'preact/hooks';
+import { Link } from '../../../..';
import { ArrowLeftIcon, ArrowRightIcon } from '../../../../components/icons';
import type { SelectNextProps } from '../../../../components/input';
import { IconButton, InputGroup } from '../../../../components/input';
@@ -206,6 +207,23 @@ export default function SelectNextPage() {
SelectNext
toggles a listbox where Options
{"'"} UI can be customized and values can be objects.
+
+ In browsers that support it, the listbox uses the{' '}
+
+ popover
+ {' '}
+ attribute and gets toggled via{' '}
+
+ popover API
+
+ . Otherwise, it is rendered as an absolute-positioned element.
+