diff --git a/src/components/input/SelectNext.tsx b/src/components/input/SelectNext.tsx index 2cfb2127d..021940dfc 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,115 @@ function SelectOption({ 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, 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 () => {}; } + /** + * 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>, + ) => { + 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 = CompositeProps & { @@ -166,6 +245,12 @@ export type SelectProps = 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({ @@ -182,18 +267,36 @@ function SelectMain({ right = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, + /* eslint-disable-next-line no-prototype-builtins */ + listboxAsPopover = HTMLElement.prototype.hasOwnProperty('popover'), }: 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 (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( @@ -257,11 +360,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 +376,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..d3cd841f9 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'; @@ -21,13 +21,26 @@ describe('SelectNext', () => { * Whether to renders SelectNext.Option children with callback notation. * Used primarily to test and cover both branches. * Defaults to true. + * @param {boolean} [options.defaultListboxAsPopover] - + * Whether we should let the `listboxAsPopover` prop use default value + * if not explicitly provided, or we should initialize it instead. */ const createComponent = (props = {}, options = {}) => { - const { paddingTop = 0, optionsChildrenAsCallback = true } = options; + const { + paddingTop = 0, + optionsChildrenAsCallback = true, + defaultListboxAsPopover = false, + } = options; const container = document.createElement('div'); container.style.paddingTop = `${paddingTop}px`; document.body.append(container); + // Explicitly disable listboxAsPopover, unless requested differently or a + // value has been provided + if (!defaultListboxAsPopover && !('listboxAsPopover' in props)) { + props.listboxAsPopover = false; + } + const wrapper = mount( {items.map(item => ( @@ -74,10 +87,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 => - getListbox(wrapper).prop('className').includes('bottom-full'); + const listboxDidDropUp = wrapper => { + const { top: listboxTop } = getListbox(wrapper) + .getDOMNode() + .getBoundingClientRect(); + const { top: buttonTop } = getToggleButton(wrapper) + .getDOMNode() + .getBoundingClientRect(); + + return listboxTop < buttonTop; + }; it('changes selected value when an option is clicked', () => { const onChange = sinon.stub(); @@ -261,11 +282,32 @@ describe('SelectNext', () => { }); [ - { containerPaddingTop: 0, shouldDropUp: false }, - { containerPaddingTop: 1000, shouldDropUp: true }, - ].forEach(({ containerPaddingTop, shouldDropUp }) => { + { + containerPaddingTop: 0, + shouldDropUp: false, + listboxAsPopover: true, + }, + { + containerPaddingTop: 0, + shouldDropUp: false, + listboxAsPopover: false, + }, + { + containerPaddingTop: 1000, + shouldDropUp: true, + listboxAsPopover: true, + }, + { + containerPaddingTop: 1000, + shouldDropUp: true, + listboxAsPopover: false, + }, + ].forEach(({ containerPaddingTop, shouldDropUp, listboxAsPopover }) => { it('makes listbox drop up or down based on available space below', () => { - const wrapper = createComponent({}, { paddingTop: containerPaddingTop }); + const wrapper = createComponent( + { listboxAsPopover }, + { paddingTop: containerPaddingTop }, + ); toggleListbox(wrapper); assert.equal(listboxDidDropUp(wrapper), shouldDropUp); @@ -282,6 +324,74 @@ describe('SelectNext', () => { }); }); + context('when popover is supported', () => { + it('opens listbox via popover API', async () => { + const wrapper = createComponent({ listboxAsPopover: 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', () => { + [ + // Inferring listboxAsPopover based on browser support + { + listboxAsPopover: 'default', + getListboxLeft: wrapper => { + const leftStyle = getListbox(wrapper).getDOMNode().style.left; + // Remove `px` unit indicator + return Number(leftStyle.replace('px', '')); + }, + }, + // Explicitly enabling listboxAsPopover + { + listboxAsPopover: true, + getListboxLeft: wrapper => { + const leftStyle = getListbox(wrapper).getDOMNode().style.left; + // Remove `px` unit indicator + return Number(leftStyle.replace('px', '')); + }, + }, + // Explicitly disabling listboxAsPopover + { + listboxAsPopover: false, + getListboxLeft: wrapper => + getListbox(wrapper).getDOMNode().getBoundingClientRect().left, + }, + ].forEach(({ listboxAsPopover, getListboxLeft }) => { + it('aligns listbox to the right if `right` prop is true', async () => { + const wrapper = createComponent( + { + listboxAsPopover: + typeof listboxAsPopover === 'boolean' + ? listboxAsPopover + : undefined, + right: true, + buttonClasses: '!w-8', // Set a small width in the button + }, + { defaultListboxAsPopover: listboxAsPopover === 'default' }, + ); + 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..ebd4bd089 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'; @@ -35,6 +36,7 @@ function SelectExample({ | 'listboxClasses' | 'disabled' | 'right' + | 'listboxAsPopover' > & { textOnly?: boolean; items?: ItemType[]; @@ -206,6 +208,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. +

@@ -469,6 +488,39 @@ export default function SelectNextPage() { + + + + Determines if the listbox should be rendered using the{' '} + + popover API + + . It{"'"}s mainly used as a test seam, but can help explicitly + disabling this behavior if needed. + + + boolean + + + true if the browser supports [popover] + . Otherwise it is false + + + +
+

+ When not using the popover API, the listbox will + be constrained by its container dimensions. +

+
+ +
+
+
+