UNPKG

@itwin/itwinui-react

Version:

A react component library for iTwinUI

493 lines (492 loc) 14.2 kB
import * as React from 'react'; import cx from 'classnames'; import { MenuItem } from '../Menu/MenuItem.js'; import { SvgCaretDownSmall, useId, AutoclearingHiddenLiveRegion, Box, Portal, useMergedRefs, SvgCheckmark, useLatestRef, InputWithIcon, mergeEventHandlers, isReact17or18, } from '../../utils/index.js'; import { SelectTag } from './SelectTag.js'; import { SelectTagContainer } from './SelectTagContainer.js'; import { Icon } from '../Icon/Icon.js'; import { usePopover } from '../Popover/Popover.js'; import { List } from '../List/List.js'; import { Composite, CompositeItem } from '@floating-ui/react'; export const Select = React.forwardRef((props, forwardedRef) => { let { native, ...rest } = props; let Component = native ? NativeSelect : CustomSelect; return React.createElement(Component, { ...rest, ref: forwardedRef, }); }); if ('development' === process.env.NODE_ENV) Select.displayName = 'Select'; let NativeSelect = React.forwardRef((props, forwardedRef) => { let { triggerProps, options, disabled, placeholder, defaultValue: defaultValueProp = void 0 !== placeholder ? '' : void 0, value: valueProp, onChange: onChangeProp, size, status, styleType, required, ...rest } = props; return React.createElement( InputWithIcon, { ...rest, ref: forwardedRef, }, React.createElement( SelectButton, { as: 'select', size: size, status: status, styleType: styleType, disabled: disabled, defaultValue: void 0 === valueProp ? defaultValueProp : void 0, value: null === valueProp ? '' : valueProp, required: required, ...triggerProps, onKeyDown: mergeEventHandlers(triggerProps?.onKeyDown, (event) => { if ('Enter' === event.key) event.currentTarget.showPicker?.(); }), onChange: mergeEventHandlers(triggerProps?.onChange, (event) => { onChangeProp?.(event.currentTarget.value, event); }), }, 'borderless' !== styleType && void 0 !== placeholder ? React.createElement( 'option', { value: '', disabled: true, }, placeholder, ) : null, options.map((option) => React.createElement( 'option', { key: option.value, ...option, }, option.label, ), ), ), React.createElement(SelectEndIcon, { disabled: disabled, }), ); }); let CustomSelect = React.forwardRef((props, forwardedRef) => { let uid = useId(); let { options, value: valueProp, onChange: onChangeProp, placeholder, disabled = false, size, itemRenderer, selectedItemRenderer, menuClassName, menuStyle, multiple = false, triggerProps, status, popoverProps: { portal = true, ...popoverProps } = {}, styleType, ...rest } = props; let [isOpen, setIsOpen] = React.useState(false); let [liveRegionSelection, setLiveRegionSelection] = React.useState(''); let [uncontrolledValue, setUncontrolledValue] = React.useState(); let value = void 0 !== valueProp ? valueProp : uncontrolledValue; let onChangeRef = useLatestRef(onChangeProp); let selectRef = React.useRef(null); let show = React.useCallback(() => { if (disabled) return; setIsOpen(true); popoverProps?.onVisibleChange?.(true); }, [disabled, popoverProps]); let hide = React.useCallback(() => { setIsOpen(false); selectRef.current?.focus({ preventScroll: true, }); popoverProps?.onVisibleChange?.(false); }, [popoverProps]); let handleOptionSelection = React.useCallback( (option, { isSelected = false } = {}) => { if (isSingleOnChange(onChangeRef.current, multiple)) { setUncontrolledValue(option.value); onChangeRef.current?.(option.value); hide(); } else { setUncontrolledValue((prev) => isSelected ? prev?.filter((i) => option.value !== i) : [...(prev ?? []), option.value], ); onChangeRef.current?.(option.value, isSelected ? 'removed' : 'added'); } if (isMultipleEnabled(value, multiple)) { let prevSelectedValue = value || []; let newSelectedValue = isSelected ? prevSelectedValue.filter((i) => option.value !== i) : [...prevSelectedValue, option.value]; setLiveRegionSelection( options .filter((i) => newSelectedValue.includes(i.value)) .map((item) => item.label) .filter(Boolean) .join(', '), ); } }, [hide, multiple, onChangeRef, options, value], ); let menuItems = React.useMemo( () => options.map((option, index) => { let isSelected = isMultipleEnabled(value, multiple) ? value?.includes(option.value) ?? false : value === option.value; let menuItem = itemRenderer ? itemRenderer(option, { close: () => setIsOpen(false), isSelected, }) : React.createElement(MenuItem, null, option.label); let { label, icon, startIcon: startIconProp, value: _, ...restOption } = option; let startIcon = startIconProp ?? icon; return React.cloneElement(menuItem, { key: `${label}-${index}`, isSelected, startIcon: startIcon, endIcon: isSelected ? React.createElement(SvgCheckmark, { 'aria-hidden': true, }) : null, onClick: () => { if (option.disabled) return; handleOptionSelection(option, { isSelected, }); }, ref: (el) => { if (isSelected && !multiple) el?.scrollIntoView({ block: 'nearest', }); }, role: 'option', ...restOption, ...menuItem.props, }); }), [handleOptionSelection, itemRenderer, multiple, options, value], ); let selectedItems = React.useMemo(() => { if (null == value) return; return isMultipleEnabled(value, multiple) ? options.filter((option) => value.some((val) => val === option.value)) : options.find((option) => option.value === value); }, [multiple, options, value]); let defaultFocusedIndex = React.useMemo(() => { let index = 0; if (Array.isArray(value) && value.length > 0) index = options.findIndex((option) => option.value === value[0]); else if (value) index = options.findIndex((option) => option.value === value); return index >= 0 ? index : 0; }, [options, value]); let tagRenderer = React.useCallback( (option) => React.createElement(SelectTag, { key: option.label, label: option.label, onRemove: disabled ? void 0 : () => { handleOptionSelection(option, { isSelected: true, }); selectRef.current?.focus(); }, }), [disabled, handleOptionSelection], ); let popover = usePopover({ visible: isOpen, matchWidth: true, closeOnOutsideClick: true, middleware: { size: { maxHeight: 'var(--iui-menu-max-height)', }, }, ...popoverProps, onVisibleChange: (open) => (open ? show() : hide()), }); return React.createElement( React.Fragment, null, React.createElement( InputWithIcon, { ...rest, ref: useMergedRefs(popover.refs.setPositionReference, forwardedRef), }, React.createElement( SelectButton, { ...popover.getReferenceProps(), tabIndex: 0, role: 'combobox', size: size, status: status, 'aria-disabled': disabled ? 'true' : void 0, 'data-iui-disabled': disabled ? 'true' : void 0, 'aria-autocomplete': 'none', 'aria-expanded': isOpen, 'aria-haspopup': 'listbox', 'aria-controls': `${uid}-menu`, styleType: styleType, ...triggerProps, ref: useMergedRefs( selectRef, triggerProps?.ref, popover.refs.setReference, ), className: cx( { 'iui-placeholder': (!selectedItems || 0 === selectedItems.length) && !!placeholder, }, triggerProps?.className, ), 'data-iui-multi': multiple, }, (!selectedItems || 0 === selectedItems.length) && React.createElement( Box, { as: 'span', className: 'iui-content', }, placeholder, ), isMultipleEnabled(selectedItems, multiple) ? React.createElement(AutoclearingHiddenLiveRegion, { text: liveRegionSelection, }) : React.createElement(SingleSelectButton, { selectedItem: selectedItems, selectedItemRenderer: selectedItemRenderer, }), ), React.createElement(SelectEndIcon, { disabled: disabled, isOpen: isOpen, }), isMultipleEnabled(selectedItems, multiple) ? React.createElement(MultipleSelectButton, { selectedItems: selectedItems, selectedItemsRenderer: selectedItemRenderer, tagRenderer: tagRenderer, size: 'small' === size ? 'small' : void 0, }) : null, ), popover.open && React.createElement( Portal, { portal: portal, }, React.createElement( SelectListbox, { defaultFocusedIndex: defaultFocusedIndex, className: menuClassName, id: `${uid}-menu`, key: `${uid}-menu`, ...popover.getFloatingProps({ style: menuStyle, onKeyDown: ({ key }) => { if ('Tab' === key) hide(); }, }), ref: popover.refs.setFloating, }, menuItems, ), ), ); }); let isMultipleEnabled = (variable, multiple) => multiple; let isSingleOnChange = (onChange, multiple) => !multiple; let SelectButton = React.forwardRef((props, forwardedRef) => { let { size, status, styleType = 'default', ...rest } = props; return React.createElement(Box, { 'data-iui-size': size, 'data-iui-status': status, 'data-iui-variant': 'default' !== styleType ? styleType : void 0, ...rest, ref: forwardedRef, className: cx('iui-select-button', 'iui-field', props.className), }); }); let SelectEndIcon = React.forwardRef((props, forwardedRef) => { let { disabled, isOpen, ...rest } = props; return React.createElement( Icon, { 'aria-hidden': true, ...rest, ref: forwardedRef, className: cx( 'iui-end-icon', { 'iui-disabled': disabled, 'iui-open': isOpen, }, props.className, ), }, React.createElement(SvgCaretDownSmall, null), ); }); let SingleSelectButton = ({ selectedItem, selectedItemRenderer }) => { let startIcon = selectedItem?.startIcon ?? selectedItem?.icon; return React.createElement( React.Fragment, null, selectedItem && selectedItemRenderer && selectedItemRenderer(selectedItem), selectedItem && !selectedItemRenderer && React.createElement( React.Fragment, null, startIcon && React.createElement( Box, { as: 'span', className: 'iui-icon', 'aria-hidden': true, }, startIcon, ), React.createElement( Box, { as: 'span', className: 'iui-content', }, selectedItem.label, ), ), ); }; let MultipleSelectButton = ({ selectedItems, selectedItemsRenderer, tagRenderer, size, }) => { let selectedItemsElements = React.useMemo(() => { if (!selectedItems) return []; return selectedItems.map((item) => tagRenderer(item)); }, [selectedItems, tagRenderer]); return React.createElement( React.Fragment, null, selectedItems && React.createElement( Box, { as: 'span', className: 'iui-content', }, selectedItemsRenderer ? selectedItemsRenderer(selectedItems) : React.createElement(SelectTagContainer, { tags: selectedItemsElements, 'data-iui-size': size, }), ), ); }; let SelectListbox = React.forwardRef((props, forwardedRef) => { let { defaultFocusedIndex = 0, autoFocus = true, children: childrenProp, className, ...rest } = props; let [focusedIndex, setFocusedIndex] = React.useState(defaultFocusedIndex); let autoFocusRef = React.useCallback((element) => { queueMicrotask(() => { let firstFocusable = element?.querySelector('[tabindex="0"]'); firstFocusable?.focus(); }); }, []); let children = React.useMemo( () => React.Children.map(childrenProp, (child, index) => { if (React.isValidElement(child)) { let ref = isReact17or18 ? child.ref : child.props.ref; return React.createElement(CompositeItem, { key: index, ref: ref, render: child, }); } return child; }), [childrenProp], ); return React.createElement( Composite, { render: React.createElement(List, { as: 'div', className: cx('iui-menu', className), }), orientation: 'vertical', role: 'listbox', activeIndex: focusedIndex, onNavigate: setFocusedIndex, ref: useMergedRefs(forwardedRef, autoFocus ? autoFocusRef : void 0), ...rest, }, children, ); });