From 5c46c89637cda6bc293ae18f1187d5e045857504 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 20 Nov 2024 11:45:38 +0100 Subject: [PATCH] Create new Popover component --- src/components/feedback/Popover.tsx | 259 +++++++++++++++++++++++ src/components/input/Select.tsx | 211 ++---------------- src/components/input/test/Select-test.js | 9 +- 3 files changed, 279 insertions(+), 200 deletions(-) create mode 100644 src/components/feedback/Popover.tsx diff --git a/src/components/feedback/Popover.tsx b/src/components/feedback/Popover.tsx new file mode 100644 index 00000000..8af21de3 --- /dev/null +++ b/src/components/feedback/Popover.tsx @@ -0,0 +1,259 @@ +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 relative element and the popover */ +const POPOVER_RELATIVE_EL_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 + * relative 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 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 relative element or not + */ +function usePopoverPositioning( + relativeElementRef: RefObject, + popoverRef: RefObject, + popoverOpen: boolean, + asNativePopover: boolean, + alignToRight: boolean, +) { + const adjustPopoverPositioning = useCallback(() => { + const popoverEl = popoverRef.current; + const relativeEl = relativeElementRef.current; + + if (!relativeEl || !popoverEl || !popoverOpen) { + 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: relativeElDistanceToTop, + bottom: relativeElBottom, + left: relativeElLeft, + height: relativeElHeight, + width: relativeElWidth, + } = relativeEl.getBoundingClientRect(); + const relativeElDistanceToBottom = viewportHeight - relativeElBottom; + 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 = + relativeElDistanceToBottom < popoverHeight && + relativeElDistanceToTop > relativeElDistanceToBottom; + + if (!asNativePopover) { + // Set styles for non-popover mode + if (shouldBeAbove) { + return setPopoverCSSProps({ + bottom: '100%', + marginBottom: POPOVER_RELATIVE_EL_GAP, + }); + } + + return setPopoverCSSProps({ + top: '100%', + marginTop: POPOVER_RELATIVE_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 relative element to + // right side of viewport + // - right-aligned popovers: distance from right side of relative element + // to left side of viewport + const availableSpace = + (alignToRight + ? relativeElLeft + relativeElWidth + : bodyWidth - relativeElLeft) - POPOVER_VIEWPORT_HORIZONTAL_GAP; + + let left = relativeElLeft; + 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 > relativeElWidth) { + // If a right-aligned popover fits the available space, but it's bigger + // than the relative element, move it to the left so that it is aligned + // with the right side of the element + left -= popoverWidth - relativeElWidth; + } + + return setPopoverCSSProps({ + minWidth: `${relativeElWidth}px`, + top: shouldBeAbove + ? `calc(${absBodyTop + relativeElDistanceToTop - popoverHeight}px - ${POPOVER_RELATIVE_EL_GAP})` + : `calc(${absBodyTop + relativeElDistanceToTop + relativeElHeight}px + ${POPOVER_RELATIVE_EL_GAP})`, + left: `${Math.max(POPOVER_VIEWPORT_HORIZONTAL_GAP, left)}px`, + }); + }, [ + asNativePopover, + relativeElementRef, + popoverOpen, + popoverRef, + alignToRight, + ]); + + useLayoutEffect(() => { + // First of all, toggle popover if it's using the native API, otherwise its + // size is 0x0 and positioning calculations won't work. + if (asNativePopover) { + popoverRef.current?.togglePopover(popoverOpen); + } + + const cleanup = adjustPopoverPositioning(); + + if (!asNativePopover) { + return cleanup; + } + + // Readjust popover position when any element scrolls, just in case that + // affected the relative element position. + const listeners = new ListenerCollection(); + listeners.add(document.body, 'scroll', adjustPopoverPositioning, { + capture: true, + }); + + return () => { + cleanup(); + listeners.removeAll(); + }; + }, [adjustPopoverPositioning, asNativePopover, popoverOpen, popoverRef]); +} + +export type PopoverProps = { + children?: ComponentChildren; + classes?: string | string[]; + + /** Whether the popover is currently open or not. Defaults to false */ + open?: boolean; + /** The element relative to which the popover should be positioned */ + relativeTo: RefObject; + + elementRef?: RefObject; + + /** + * Determines to what side of the relative element should the popover be + * aligned + */ + alignSide?: 'right' | 'left'; + + /** + * Determines if focus should be restored when the popover is closed. + * Defaults to true. + */ + 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({ + relativeTo, + children, + open = false, + alignSide = 'left', + classes, + restoreFocusOnClose = true, + /* eslint-disable-next-line no-prototype-builtins */ + asNativePopover = HTMLElement.prototype.hasOwnProperty('popover'), +}: PopoverProps) { + const popoverRef = useRef(null); + const restoreFocusEl = useRef(null); + + usePopoverPositioning( + relativeTo, + 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..6df82f3b 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; @@ -501,30 +358,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 +384,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 +426,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 +450,17 @@ 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;