diff --git a/src/components/feedback/Popover.tsx b/src/components/feedback/Popover.tsx new file mode 100644 index 00000000..929888d4 --- /dev/null +++ b/src/components/feedback/Popover.tsx @@ -0,0 +1,262 @@ +import classnames from 'classnames'; +import type { ComponentChildren, RefObject } from 'preact'; +import { useCallback, useLayoutEffect, useRef } from 'preact/hooks'; + +import { ListenerCollection } from '../../util/listener-collection'; + +/** Small space to apply between the anchor element and the popover */ +const POPOVER_ANCHOR_EL_GAP = '.15rem'; + +/** + * Space in pixels to apply between the popover and the viewport sides to + * prevent it from growing to the very edges. + */ +export const POPOVER_VIEWPORT_HORIZONTAL_GAP = 8; + +type PopoverCSSProps = + | 'top' + | 'left' + | 'minWidth' + | 'marginBottom' + | 'bottom' + | 'marginTop'; + +/** + * Manages the popover position manually to make sure it renders "next" to the + * anchor element (above or below). This is mainly needed when using the + * popover API, as that makes it render in the top layer, making it impossible + * to position it relative to the anchor element via regular CSS. + * + * @param asNativePopover - Native popover API is used to toggle the popover + * @param alignToRight - Whether the popover should be aligned to the right side + * of the anchor element or not + */ +function usePopoverPositioning( + anchorElementRef: RefObject, + popoverRef: RefObject, + popoverOpen: boolean, + asNativePopover: boolean, + alignToRight: boolean, +) { + const adjustPopoverPositioning = useCallback(() => { + const popoverEl = popoverRef.current; + const anchorEl = anchorElementRef.current; + + if (!anchorEl || !popoverEl) { + return () => {}; + } + + /** + * Set the positioning styles synchronously (not via `style` prop and a + * piece of state), to make sure positioning happens before other side + * effects. + */ + const setPopoverCSSProps = ( + props: Partial>, + ) => { + Object.assign(popoverEl.style, props); + const keys = Object.keys(props) as PopoverCSSProps[]; + return () => keys.map(prop => (popoverEl.style[prop] = '')); + }; + + const viewportHeight = window.innerHeight; + const { + top: anchorElDistanceToTop, + bottom: anchorElBottom, + left: anchorElLeft, + height: anchorElHeight, + width: anchorElWidth, + } = anchorEl.getBoundingClientRect(); + const anchorElDistanceToBottom = viewportHeight - anchorElBottom; + const { height: popoverHeight, width: popoverWidth } = + popoverEl.getBoundingClientRect(); + + // The popover should render above only if there's not enough space below to + // fit it, and also, there's more absolute space above than below + const shouldBeAbove = + anchorElDistanceToBottom < popoverHeight && + anchorElDistanceToTop > anchorElDistanceToBottom; + + if (!asNativePopover) { + // Set styles for non-popover mode + if (shouldBeAbove) { + return setPopoverCSSProps({ + bottom: '100%', + marginBottom: POPOVER_ANCHOR_EL_GAP, + }); + } + + return setPopoverCSSProps({ + top: '100%', + marginTop: POPOVER_ANCHOR_EL_GAP, + }); + } + + const { top: bodyTop, width: bodyWidth } = + document.body.getBoundingClientRect(); + const absBodyTop = Math.abs(bodyTop); + + // The available space is: + // - left-aligned popovers: distance from left side of anchor element to + // right side of viewport + // - right-aligned popovers: distance from right side of anchor element to + // left side of viewport + const availableSpace = + (alignToRight ? anchorElLeft + anchorElWidth : bodyWidth - anchorElLeft) - + POPOVER_VIEWPORT_HORIZONTAL_GAP; + + let left = anchorElLeft; + if (popoverWidth > availableSpace) { + // If the popover is not going to fit the available space, let it "grow" + // in the opposite direction + left = alignToRight + ? POPOVER_VIEWPORT_HORIZONTAL_GAP + : left - (popoverWidth - availableSpace); + } else if (alignToRight && popoverWidth > anchorElWidth) { + // If a right-aligned popover fits the available space, but it's bigger + // than the anchor element, move it to the left so that it is aligned with + // the right side of the element + left -= popoverWidth - anchorElWidth; + } + + return setPopoverCSSProps({ + minWidth: `${anchorElWidth}px`, + top: shouldBeAbove + ? `calc(${absBodyTop + anchorElDistanceToTop - popoverHeight}px - ${POPOVER_ANCHOR_EL_GAP})` + : `calc(${absBodyTop + anchorElDistanceToTop + anchorElHeight}px + ${POPOVER_ANCHOR_EL_GAP})`, + left: `${Math.max(POPOVER_VIEWPORT_HORIZONTAL_GAP, left)}px`, + }); + }, [asNativePopover, anchorElementRef, popoverRef, alignToRight]); + + useLayoutEffect(() => { + if (!popoverOpen) { + return () => {}; + } + + // First of all, open popover if it's using the native API, otherwise its + // size is 0x0 and positioning calculations won't work. + const popover = popoverRef.current; + if (asNativePopover) { + popover?.togglePopover(true); + } + + const cleanup = adjustPopoverPositioning(); + + if (!asNativePopover) { + return cleanup; + } + + // Readjust popover position when any element scrolls, just in case that + // affected the anchor element position. + const listeners = new ListenerCollection(); + listeners.add(document.body, 'scroll', adjustPopoverPositioning, { + capture: true, + }); + + return () => { + if (asNativePopover) { + popover?.togglePopover(false); + } + cleanup(); + listeners.removeAll(); + }; + }, [adjustPopoverPositioning, asNativePopover, popoverOpen, popoverRef]); +} + +export type PopoverProps = { + children?: ComponentChildren; + classes?: string | string[]; + variant?: 'panel' | 'custom'; // TODO 'tooltip' + + /** Whether the popover is currently open or not. Defaults to false */ + open?: boolean; + /** The element relative to which the popover should be positioned */ + anchorElementRef: RefObject; + + /** + * Determines to what side of the anchor element should the popover be aligned + */ + alignSide?: 'right' | 'left'; + + /** + * Determines if focus should be restored when the popover is closed. + * Defaults to false. + */ + restoreFocusOnClose?: boolean; + + /** + * Used to determine if the popover API should be used. + * Defaults to true, as long as the browser supports it. + */ + asNativePopover?: boolean; +}; + +export default function Popover({ + anchorElementRef, + children, + open = false, + alignSide = 'left', + classes, + variant = 'panel', + restoreFocusOnClose = false, + /* eslint-disable-next-line no-prototype-builtins */ + asNativePopover = HTMLElement.prototype.hasOwnProperty('popover'), +}: PopoverProps) { + const popoverRef = useRef(null); + const restoreFocusEl = useRef(null); + + usePopoverPositioning( + anchorElementRef, + popoverRef, + open, + asNativePopover, + alignSide === 'right', + ); + + useLayoutEffect(() => { + restoreFocusEl.current = open + ? (document.activeElement as HTMLElement) + : null; + + return () => { + if (restoreFocusOnClose) { + restoreFocusEl.current?.focus(); + } + }; + }, [open, restoreFocusOnClose]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/input/Select.tsx b/src/components/input/Select.tsx index ec1eb174..7f74faff 100644 --- a/src/components/input/Select.tsx +++ b/src/components/input/Select.tsx @@ -1,10 +1,9 @@ import classnames from 'classnames'; -import type { ComponentChildren, JSX, RefObject, Ref } from 'preact'; +import type { ComponentChildren, JSX, Ref } from 'preact'; import { useCallback, useContext, useId, - useLayoutEffect, useMemo, useRef, useState, @@ -16,8 +15,8 @@ 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 Popover from '../feedback/Popover'; import { CheckboxCheckedFilledIcon, CheckIcon, @@ -257,148 +256,6 @@ function SelectOption({ SelectOption.displayName = 'Select.Option'; -/** Small space to apply between the toggle button and the popover */ -const POPOVER_TOGGLE_GAP = '.25rem'; - -/** - * Space in pixels to apply between the popover and the viewport sides to - * prevent it from growing to the very edges. - */ -export const POPOVER_VIEWPORT_HORIZONTAL_GAP = 8; - -type PopoverCSSProps = - | 'top' - | 'left' - | 'minWidth' - | 'marginBottom' - | 'bottom' - | 'marginTop'; - -/** - * Manages the popover 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. - * - * @param asNativePopover - Native popover API is used to toggle the popover - * @param alignToRight - Whether the popover should be aligned to the right side - * of the button or not - */ -function usePopoverPositioning( - buttonRef: RefObject, - popoverRef: RefObject, - popoverOpen: boolean, - asNativePopover: boolean, - alignToRight: boolean, -) { - const adjustPopoverPositioning = useCallback(() => { - const popoverEl = popoverRef.current; - const buttonEl = buttonRef.current; - - if (!buttonEl || !popoverEl || !popoverOpen) { - 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 setPopoverCSSProps = ( - props: Partial>, - ) => { - Object.assign(popoverEl.style, props); - const keys = Object.keys(props) as PopoverCSSProps[]; - return () => keys.map(prop => (popoverEl.style[prop] = '')); - }; - - const viewportHeight = window.innerHeight; - const { - top: buttonDistanceToTop, - bottom: buttonBottom, - left: buttonLeft, - height: buttonHeight, - width: buttonWidth, - } = buttonEl.getBoundingClientRect(); - const buttonDistanceToBottom = viewportHeight - buttonBottom; - const { height: popoverHeight, width: popoverWidth } = - popoverEl.getBoundingClientRect(); - - // The popover should drop up only if there's not enough space below to - // fit it, and also, there's more absolute space above than below - const shouldPopoverDropUp = - buttonDistanceToBottom < popoverHeight && - buttonDistanceToTop > buttonDistanceToBottom; - - if (!asNativePopover) { - // Set styles for non-popover mode - if (shouldPopoverDropUp) { - return setPopoverCSSProps({ - bottom: '100%', - marginBottom: POPOVER_TOGGLE_GAP, - }); - } - - return setPopoverCSSProps({ top: '100%', marginTop: POPOVER_TOGGLE_GAP }); - } - - const { top: bodyTop, width: bodyWidth } = - document.body.getBoundingClientRect(); - const absBodyTop = Math.abs(bodyTop); - - // The available space is: - // - left-aligned Selects: distance from left side of toggle button to right - // side of viewport - // - right-aligned Selects: distance from right side of toggle button to - // left side of viewport - const availableSpace = - (alignToRight ? buttonLeft + buttonWidth : bodyWidth - buttonLeft) - - POPOVER_VIEWPORT_HORIZONTAL_GAP; - - let left = buttonLeft; - if (popoverWidth > availableSpace) { - // If the popover is not going to fit the available space, let it "grow" - // in the opposite direction - left = alignToRight - ? POPOVER_VIEWPORT_HORIZONTAL_GAP - : left - (popoverWidth - availableSpace); - } else if (alignToRight && popoverWidth > buttonWidth) { - // If a right-aligned popover fits the available space, but it's bigger - // than the button, move it to the left so that it is aligned with the - // right side of the button - left -= popoverWidth - buttonWidth; - } - - return setPopoverCSSProps({ - minWidth: `${buttonWidth}px`, - top: shouldPopoverDropUp - ? `calc(${absBodyTop + buttonDistanceToTop - popoverHeight}px - ${POPOVER_TOGGLE_GAP})` - : `calc(${absBodyTop + buttonDistanceToTop + buttonHeight}px + ${POPOVER_TOGGLE_GAP})`, - left: `${Math.max(POPOVER_VIEWPORT_HORIZONTAL_GAP, left)}px`, - }); - }, [asNativePopover, buttonRef, popoverOpen, popoverRef, alignToRight]); - - useLayoutEffect(() => { - const cleanup = adjustPopoverPositioning(); - - if (!asNativePopover) { - return cleanup; - } - - // Readjust popover position when any element scrolls, just in case that - // affected the toggle button position. - const listeners = new ListenerCollection(); - listeners.add(document.body, 'scroll', adjustPopoverPositioning, { - capture: true, - }); - - return () => { - cleanup(); - listeners.removeAll(); - }; - }, [adjustPopoverPositioning, asNativePopover]); -} - type SingleValueProps = { value: T; onChange: (newValue: T) => void; @@ -424,7 +281,7 @@ type BaseSelectProps = CompositeProps & { /** Additional classes to pass to toggle button */ buttonClasses?: string | string[]; /** Additional classes to pass to listbox */ - listboxClasses?: string | string[]; + popoverClasses?: string | string[]; /** @deprecated Use `alignListbox="right"` instead */ right?: boolean; @@ -463,6 +320,9 @@ type BaseSelectProps = CompositeProps & { * overflow. */ listboxOverflow?: ListboxOverflow; + + /** @deprecated. Use popoverClasses instead */ + listboxClasses?: string | string[]; }; export type SelectProps = BaseSelectProps & SingleValueProps; @@ -489,6 +349,7 @@ function SelectMain({ buttonId, buttonClasses, listboxClasses, + popoverClasses = listboxClasses, containerClasses, onListboxScroll, right = false, @@ -501,30 +362,16 @@ function SelectMain({ listboxAsPopover = HTMLElement.prototype.hasOwnProperty('popover'), }: SelectMainProps) { const wrapperRef = useRef(null); - const popoverRef = useRef(null); + const listboxRef = useRef(null); const [listboxOpen, setListboxOpen] = useState(false); - const toggleListbox = useCallback( - (open: boolean) => { - setListboxOpen(open); - if (listboxAsPopover) { - popoverRef.current?.togglePopover(open); - } - }, - [listboxAsPopover], + const closeListbox = useCallback( + () => setListboxOpen(false), + [setListboxOpen], ); - const closeListbox = useCallback(() => toggleListbox(false), [toggleListbox]); const listboxId = useId(); const buttonRef = useSyncedRef(elementRef); const defaultButtonId = useId(); - usePopoverPositioning( - buttonRef, - popoverRef, - listboxOpen, - listboxAsPopover, - alignListbox === 'right', - ); - const selectValue = useCallback( (value: unknown, options: SelectValueOptions) => { onChange(value as any); @@ -541,7 +388,7 @@ function SelectMain({ useKeyPress(['Escape'], closeListbox); // Vertical arrow key for options in the listbox - useArrowKeyNavigation(popoverRef, { + useArrowKeyNavigation(listboxRef, { horizontal: false, loop: false, autofocus: true, @@ -583,11 +430,11 @@ function SelectMain({ aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} ref={downcastRef(buttonRef)} - onClick={() => toggleListbox(!listboxOpen)} + onClick={() => setListboxOpen(prev => !prev)} onKeyDown={e => { if (e.key === 'ArrowDown' && !listboxOpen) { e.preventDefault(); - toggleListbox(true); + setListboxOpen(true); } }} data-testid="select-toggle-button" @@ -607,37 +454,18 @@ function SelectMain({ listboxOverflow, }} > -
    ({ > {listboxOpen && children}
-
+ ); diff --git a/src/components/input/test/Select-test.js b/src/components/input/test/Select-test.js index 24d4e631..f2fe5655 100644 --- a/src/components/input/test/Select-test.js +++ b/src/components/input/test/Select-test.js @@ -1,11 +1,8 @@ import { checkAccessibility, waitFor } from '@hypothesis/frontend-testing'; import { mount } from 'enzyme'; -import { - POPOVER_VIEWPORT_HORIZONTAL_GAP, - MultiSelect, - Select, -} from '../Select'; +import { POPOVER_VIEWPORT_HORIZONTAL_GAP } from '../../feedback/Popover'; +import { MultiSelect, Select } from '../Select'; describe('Select', () => { let wrappers; @@ -96,7 +93,7 @@ describe('Select', () => { const toggleListbox = wrapper => getToggleButton(wrapper).simulate('click'); - const getPopover = wrapper => wrapper.find('[data-testid="select-popover"]'); + const getPopover = wrapper => wrapper.find('[data-testid="popover"]'); const isListboxClosed = wrapper => wrapper.find('[role="listbox"]').prop('data-listbox-open') === false; diff --git a/src/pattern-library/components/patterns/input/SelectPage.tsx b/src/pattern-library/components/patterns/input/SelectPage.tsx index a2391c08..821e964c 100644 --- a/src/pattern-library/components/patterns/input/SelectPage.tsx +++ b/src/pattern-library/components/patterns/input/SelectPage.tsx @@ -28,7 +28,7 @@ function SelectExample({ ...rest }: Pick< SelectProps, - 'buttonClasses' | 'containerClasses' | 'listboxClasses' + 'buttonClasses' | 'containerClasses' | 'popoverClasses' > & { textOnly?: boolean; items?: ItemType[]; @@ -414,10 +414,10 @@ export default function SelectPage() { withSource /> - + - Additional classes to pass to listbox. + Additional classes to pass to the popover. string | string[] @@ -426,9 +426,9 @@ export default function SelectPage() { undefined - +
- +