UNPKG

@hypothesis/frontend-shared

Version:

Shared components, styles and utilities for Hypothesis projects

380 lines (375 loc) 12.6 kB
var _jsxFileName = "/home/runner/work/frontend-shared/frontend-shared/src/components/input/Select.tsx"; import classnames from 'classnames'; import { useCallback, useContext, useId, useMemo, useRef, useState } from 'preact/hooks'; import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation'; import { useFocusAway } from '../../hooks/use-focus-away'; import { useSyncedRef } from '../../hooks/use-synced-ref'; import { downcastRef } from '../../util/typing'; import Popover from '../feedback/Popover'; import { CheckboxCheckedFilledIcon, CheckIcon, MenuCollapseIcon, MenuExpandIcon } from '../icons'; import Checkbox from './Checkbox'; import { inputGroupStyles } from './InputGroup'; import SelectContext from './SelectContext'; import { jsxDEV as _jsxDEV } from "preact/jsx-dev-runtime"; function optionChildren(children, status) { if (typeof children === 'function') { return children(status); } return children; } function SelectOption({ value, children, disabled = false, classes, elementRef, title }) { const checkboxRef = useRef(null); const checkboxContainerRef = useRef(null); const optionRef = useSyncedRef(elementRef); const eventTriggeredInCheckbox = e => e.target === checkboxRef.current || e.target === checkboxContainerRef.current; const selectContext = useContext(SelectContext); if (!selectContext) { throw new Error('Select.Option can only be used as Select or MultiSelect child'); } const { selectValue, value: currentValue, multiple } = selectContext; const selected = useMemo(() => { if (disabled) { return false; } if (!multiple) { return currentValue === value; } // In multi-select, the option should be marked as selected for values // which are explicitly part of the array, or for `undefined` values if the // array is empty return currentValue.includes(value) || currentValue.length === 0 && value === undefined; }, [currentValue, disabled, multiple, value]); const selectOneValue = useCallback(() => { const options = { closeListbox: true }; if (!multiple) { selectValue(value, options); } else { selectValue(value !== undefined ? [value] : [], options); } }, [multiple, selectValue, value]); const toggleValue = useCallback(() => { /* istanbul ignore next - This will never be invoked in single-select, but TS doesn't know it */ if (!multiple) { return; } const options = { // Close listbox only if selected value is a "clear" option. Clear options // are those with `undefined` value closeListbox: value === undefined }; // In multi-select, clear selection for `undefined` values if (value === undefined) { selectValue([], options); return; } // In multi-select, toggle clicked items const index = currentValue.indexOf(value); if (index === -1) { selectValue([...currentValue, value], options); } else { const copy = [...currentValue]; copy.splice(index, 1); selectValue(copy, options); } }, [currentValue, multiple, selectValue, value]); return _jsxDEV("li", { className: classnames('w-full ring-inset outline-none rounded-none select-none', 'px-1 mb-1 first:mt-1 whitespace-nowrap group', { 'text-grey-4': disabled, 'cursor-pointer': !disabled }, classes), onClick: e => { if (!disabled && // Do not invoke callback if clicked element is the checkbox or its // container, as it has its own event handler. !eventTriggeredInCheckbox(e)) { selectOneValue(); } }, onKeyDown: e => { if (disabled) { return; } if (['Enter', ' '].includes(e.key) && // Do not invoke callback if event triggered in the checkbox or its // container, as it has its own event handler. !eventTriggeredInCheckbox(e)) { e.preventDefault(); selectOneValue(); } else if (checkboxRef.current && e.key === 'ArrowRight') { e.preventDefault(); checkboxRef.current.focus(); } }, role: "option", "aria-disabled": disabled, "aria-selected": selected // Set tabIndex to 0 for selected option, so that useArrowKeyNavigation // initially focuses it , tabIndex: selected ? 0 : -1, ref: downcastRef(optionRef), title: title, children: _jsxDEV("div", { className: classnames('flex justify-between items-center', 'w-full rounded', { 'hover:bg-grey-1 group-focus-visible:ring': !disabled, 'bg-grey-1 hover:bg-grey-2': selected }), children: [_jsxDEV("div", { className: classnames('py-2 pl-3', { truncate: selectContext.listboxOverflow === 'truncate', 'whitespace-normal': selectContext.listboxOverflow === 'wrap' }), children: optionChildren(children, { selected, disabled }) }, void 0, false, { fileName: _jsxFileName, lineNumber: 203, columnNumber: 9 }, this), !multiple && _jsxDEV("div", { className: "px-3", children: _jsxDEV(CheckIcon, { className: classnames('text-grey-6 scale-125', { // Make the icon visible/invisible, instead of conditionally // rendering it, to ensure consistent spacing among selected and // non-selected options 'opacity-0': !selected }) }, void 0, false, { fileName: _jsxFileName, lineNumber: 213, columnNumber: 13 }, this) }, void 0, false, { fileName: _jsxFileName, lineNumber: 212, columnNumber: 11 }, this), multiple && _jsxDEV(Checkbox, { containerClasses: classnames( // Make the checkbox stretch, so that its actionable surface spans // to the very edges of the option containing it. 'self-stretch px-3', // The checkbox is sized based on the container's font size. Make // it a bit larger. 'text-lg', { 'text-grey-6': selected, 'text-grey-3 hover:text-grey-6': !selected }), checked: selected, checkedIcon: CheckboxCheckedFilledIcon, elementRef: checkboxRef, containerRef: checkboxContainerRef, onChange: toggleValue, onKeyDown: e => { if (e.key === 'ArrowLeft') { var _optionRef$current; e.preventDefault(); (_optionRef$current = optionRef.current) === null || _optionRef$current === void 0 || _optionRef$current.focus(); } } }, void 0, false, { fileName: _jsxFileName, lineNumber: 224, columnNumber: 11 }, this)] }, void 0, true, { fileName: _jsxFileName, lineNumber: 193, columnNumber: 7 }, this) }, void 0, false, { fileName: _jsxFileName, lineNumber: 146, columnNumber: 5 }, this); } SelectOption.displayName = 'Select.Option'; function SelectMain({ buttonContent, value, onChange, children, disabled, elementRef, buttonId, buttonClasses, popoverClasses, containerClasses, onPopoverScroll, alignListbox = 'left', multiple, listboxOverflow = 'truncate', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, listboxAsPopover }) { const wrapperRef = useRef(null); const listboxRef = useRef(null); const [listboxOpen, setListboxOpen] = useState(false); const closeListbox = useCallback(() => setListboxOpen(false), [setListboxOpen]); const listboxId = useId(); const buttonRef = useSyncedRef(elementRef); const defaultButtonId = useId(); const selectValue = useCallback((value, options) => { onChange(value); if (options.closeListbox) { closeListbox(); } }, [onChange, closeListbox]); // Close the listbox when focusing away useFocusAway(wrapperRef, closeListbox); // Vertical arrow key for options in the listbox useArrowKeyNavigation(listboxRef, { horizontal: false, loop: false, autofocus: true, containerVisible: listboxOpen, selector: '[role="option"]:not([aria-disabled="true"])' }); return _jsxDEV("div", { className: classnames('relative w-full border rounded', { 'border-grey-5': listboxOpen }, inputGroupStyles, containerClasses), ref: wrapperRef, children: [_jsxDEV("button", { id: buttonId !== null && buttonId !== void 0 ? buttonId : defaultButtonId, className: classnames('focus-visible-ring transition-colors whitespace-nowrap', 'w-full flex items-center justify-between gap-x-2', 'bg-grey-0 disabled:bg-grey-1 disabled:text-grey-6', // Buttons are center-aligned by default. Overwrite it. 'text-left', // Add inherited rounded corners so that the toggle is consistent with // the wrapper, which is the element rendering borders. // Using overflow-hidden in the parent is not an option here, because // that would hide the listbox 'rounded-[inherit]', buttonClasses), type: "button", role: "combobox", disabled: disabled, "aria-expanded": listboxOpen, "aria-haspopup": "listbox", "aria-controls": listboxId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ref: downcastRef(buttonRef), onClick: () => setListboxOpen(prev => !prev), onKeyDown: e => { if (e.key === 'ArrowDown' && !listboxOpen) { e.preventDefault(); setListboxOpen(true); } }, "data-testid": "select-toggle-button", children: [_jsxDEV("div", { className: "pl-2 py-2 truncate grow", children: buttonContent }, void 0, false, { fileName: _jsxFileName, lineNumber: 429, columnNumber: 9 }, this), _jsxDEV("div", { className: "pr-2 py-2 text-grey-6", children: listboxOpen ? _jsxDEV(MenuCollapseIcon, {}, void 0, false, { fileName: _jsxFileName, lineNumber: 431, columnNumber: 26 }, this) : _jsxDEV(MenuExpandIcon, {}, void 0, false, { fileName: _jsxFileName, lineNumber: 431, columnNumber: 49 }, this) }, void 0, false, { fileName: _jsxFileName, lineNumber: 430, columnNumber: 9 }, this)] }, void 0, true, { fileName: _jsxFileName, lineNumber: 396, columnNumber: 7 }, this), _jsxDEV(SelectContext.Provider, { value: { // Explicit type casting needed here value: value, selectValue, multiple, listboxOverflow }, children: _jsxDEV(Popover, { anchorElementRef: wrapperRef, open: listboxOpen, onClose: closeListbox, asNativePopover: listboxAsPopover, align: alignListbox, classes: popoverClasses, onScroll: onPopoverScroll, children: _jsxDEV("ul", { role: "listbox", id: listboxId, ref: listboxRef, "aria-multiselectable": multiple, "aria-labelledby": buttonId !== null && buttonId !== void 0 ? buttonId : defaultButtonId, "aria-orientation": "vertical", children: children }, void 0, false, { fileName: _jsxFileName, lineNumber: 453, columnNumber: 11 }, this) }, void 0, false, { fileName: _jsxFileName, lineNumber: 444, columnNumber: 9 }, this) }, void 0, false, { fileName: _jsxFileName, lineNumber: 435, columnNumber: 7 }, this)] }, void 0, true, { fileName: _jsxFileName, lineNumber: 387, columnNumber: 5 }, this); } export const Select = Object.assign( // eslint-disable-next-line prefer-arrow-callback function (props) { // Calling the function directly instead of returning a JSX element, to // avoid an unnecessary extra layer in the component tree // eslint-disable-next-line new-cap return SelectMain({ ...props, multiple: false }); }, { Option: SelectOption, displayName: 'Select' }); export const MultiSelect = Object.assign( // eslint-disable-next-line prefer-arrow-callback function (props) { // Calling the function directly instead of returning a JSX element, to // avoid an unnecessary extra layer in the component tree // eslint-disable-next-line new-cap return SelectMain({ ...props, multiple: true }); }, { Option: SelectOption, displayName: 'MultiSelect' }); //# sourceMappingURL=Select.js.map