@itwin/itwinui-react
Version:
A react component library for iTwinUI
493 lines (492 loc) • 14.2 kB
JavaScript
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,
);
});