@hypothesis/frontend-shared
Version:
Shared components, styles and utilities for Hypothesis projects
380 lines (375 loc) • 12.6 kB
JavaScript
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