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. +