@octopusdeploy/design-system-components
Version:
The design systems component library.
252 lines (251 loc) • 17.9 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SelectBase = SelectBase;
exports.isMissingSelectedOption = isMissingSelectedOption;
exports.getSelectedOptionLabel = getSelectedOptionLabel;
const jsx_runtime_1 = require("react/jsx-runtime");
const css_1 = require("@emotion/css");
const design_system_icons_1 = require("@octopusdeploy/design-system-icons");
const design_system_tokens_1 = require("@octopusdeploy/design-system-tokens");
const lodash_debounce_1 = __importDefault(require("lodash.debounce"));
const react_1 = require("react");
const useObserveElementSize_1 = require("../../../hooks/useObserveElementSize");
const utils_1 = require("../../../utils");
const alphanumericSort_1 = require("../../../utils/alphanumericSort");
const Button_1 = require("../../Button/Button");
const useDropdownButton_1 = require("../../Dropdown/useDropdownButton");
const SearchInput_1 = require("../../Input/SearchInput");
const TagGroup_1 = require("../../Tag/TagGroup");
const FormDescription_1 = require("../Primitives/FormDescription");
const InputLabel_1 = __importDefault(require("../Primitives/InputLabel"));
const InputValidationMessage_1 = require("../Primitives/InputValidationMessage");
const Listbox_1 = require("../Primitives/Listbox");
const SharedFormStyling_styles_1 = require("../Primitives/SharedFormStyling.styles");
const remToPx_1 = require("../utils/remToPx");
const MultiSelectCheckbox_1 = require("./MultiSelectCheckbox");
const SelectDropdown_1 = require("./SelectDropdown");
const SelectOptionLayout_1 = require("./SelectOptionLayout");
function SelectBase({ items: itemsArrayOrAsyncList, sortItems = false, getOption, label, value, placeholder, description, validationMessage, disabled, hasRequiredMarker, hasOptionalMarker, hasDefaultMarker, popover, autoFocus, readOnly, onOptionsSelected, allowFilter = false, allowClear = false, isMultiSelectable, renderSelection, renderOption, clearSelection, }) {
const dropdownButtonState = (0, useDropdownButton_1.useDropdownButton)({ trapFocus: true, dropdownAriaRole: "listbox" });
const { isOpen, closeDropdown, buttonElement: selectElement, setButtonRef: setSelectRef, toggleDropdown, setDropdownRef, dropdownAttributes, buttonAriaAttributes } = dropdownButtonState;
const [suffixElement, setSuffixElement] = (0, react_1.useState)(null);
const selectContainerRef = (0, react_1.useRef)(null);
const [isSearchInputFocused, setIsSearchInputFocused] = (0, react_1.useState)(false);
const baseId = (0, react_1.useId)();
const labelHtmlId = `label-${baseId}`;
const valueHtmlId = `value-${baseId}`;
const selectHtmlId = `select-${baseId}`;
const multiSelectSummaryHtmlId = `multiselect-summary-${baseId}`;
const listboxHtmlId = `listbox-${baseId}`;
const listboxRef = (0, react_1.useRef)(null);
const getOptionHtmlId = (index) => `${dropdownAttributes.dropdownId}-option-${index}`;
const [activeDescendantId, setActiveDescendantId] = (0, react_1.useState)(undefined);
const [inputValue, setInputValue] = (0, react_1.useState)("");
const [filterText, setFilterText] = (0, react_1.useState)("");
const isAsync = !Array.isArray(itemsArrayOrAsyncList);
const isLoading = isAsync && itemsArrayOrAsyncList.isLoading;
const hasAsyncError = isAsync && itemsArrayOrAsyncList.error !== null;
const debouncedSetFilterText = (0, react_1.useMemo)(() => (0, lodash_debounce_1.default)((value) => {
setFilterText(value);
if (isAsync)
itemsArrayOrAsyncList.setFilterText(value);
}, 200), [isAsync, itemsArrayOrAsyncList]);
const currentItems = isAsync ? itemsArrayOrAsyncList.loadedItems : itemsArrayOrAsyncList;
const currentOptions = (0, react_1.useMemo)(() => currentItems.map(getOption), [currentItems, getOption]);
const optionsToShow = (0, react_1.useMemo)(() => {
// Any filtering and sorting for async lists is handled externally so is accounted for in currentOptions
if (isAsync) {
return currentOptions;
}
const filteredOptions = filterText ? currentOptions.filter((option) => option.label.toLowerCase().includes(filterText.trim().toLowerCase())) : currentOptions;
return sortItems ? (0, alphanumericSort_1.alphanumericSort)(filteredOptions, (option) => option.label.toLowerCase()) : filteredOptions;
}, [isAsync, filterText, currentOptions, sortItems]);
const valuesSet = (0, react_1.useMemo)(() => {
if (!value)
return new Set();
return new Set(Array.isArray(value) ? value : [value]);
}, [value]);
const selectedOptions = (0, react_1.useMemo)(() => {
if (isAsync) {
return Array.from(valuesSet).map((v) => {
const item = itemsArrayOrAsyncList.getItemById(v);
return item ? getOption(item) : { missing: true, value: v };
});
}
const currentOptionsMap = new Map(currentOptions.map((option) => [option.value, option]));
return Array.from(valuesSet).map((v) => currentOptionsMap.get(v) ?? { missing: true, value: v });
}, [currentOptions, getOption, isAsync, itemsArrayOrAsyncList, valuesSet]);
(0, useObserveElementSize_1.useObserveElementSize)(suffixElement, ({ width }) => {
selectContainerRef.current?.style.setProperty(suffixWidthCssVar, `${width}px`);
}, () => {
selectContainerRef.current?.style.removeProperty(suffixWidthCssVar);
});
const handleOptionActive = (index) => {
setActiveDescendantId(getOptionHtmlId(index));
};
const handleOptionSelected = (option) => {
onOptionsSelected([option]);
if (!isMultiSelectable) {
closeDropdown({ moveFocusToButton: true });
}
};
const handleSelectKeyDown = (event) => {
const { key } = event;
if (key === "ArrowDown" || key === "Enter" || key === " ") {
event.preventDefault();
toggleDropdown();
}
};
const handleLoadMore = isAsync && !itemsArrayOrAsyncList.isLoading ? itemsArrayOrAsyncList.loadMore : undefined;
const handleSearchKeyDown = (event) => {
const { key } = event;
if (searchInputKeysToPassToListbox.has(key)) {
listboxRef.current?.handleKeyDown(event);
}
};
const handleFilterTextChange = (value) => {
setInputValue(value);
debouncedSetFilterText(value);
};
if (!isOpen && (inputValue || filterText)) {
setInputValue("");
setFilterText("");
debouncedSetFilterText.cancel();
if (isAsync) {
itemsArrayOrAsyncList.setFilterText("");
}
}
const showClearAllButton = allowClear && (isMultiSelectable ? valuesSet.size > 1 : valuesSet.size > 0);
const hasSomeOptionsSelected = optionsToShow.some((option) => valuesSet.has(option.value));
const hasAllOptionsSelected = optionsToShow.every((option) => valuesSet.has(option.value));
const selectAll = () => onOptionsSelected(optionsToShow);
const deselectAll = () => onOptionsSelected(optionsToShow.filter((option) => valuesSet.has(option.value)));
const visuallyHiddenSummary = selectedOptions.length > 1 ? `${selectedOptions.length} options selected: ${selectedOptions.map(getSelectedOptionLabel).join(", ")}` : selectedOptions.length === 1 ? getSelectedOptionLabel(selectedOptions[0]) : undefined;
const canChange = !disabled && !readOnly;
return ((0, jsx_runtime_1.jsxs)("div", { className: layoutStyles, children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(InputLabel_1.default, { id: labelHtmlId, label: label, htmlFor: selectHtmlId, isDisabled: disabled, hasRequiredMarker: hasRequiredMarker, hasOptionalMarker: hasOptionalMarker, hasDefaultMarker: hasDefaultMarker, popover: popover }), description && (0, jsx_runtime_1.jsx)(FormDescription_1.FormDescription, { id: `${selectHtmlId}-description`, description: description, isDisabled: disabled })] }), (0, jsx_runtime_1.jsxs)("div", { ref: selectContainerRef, className: selectStyles.container, children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", id: selectHtmlId, ref: setSelectRef, role: "combobox", onClick: canChange ? toggleDropdown : undefined, onKeyDown: handleSelectKeyDown, "aria-activedescendant": isOpen ? activeDescendantId : undefined, "aria-readonly": readOnly, "aria-required": hasRequiredMarker, tabIndex: 0, autoFocus: autoFocus, className: (0, css_1.cx)(selectStyles.buttonReset, SharedFormStyling_styles_1.inputStyles, selectStyles.select, {
[SharedFormStyling_styles_1.inputErrorStyles]: !!validationMessage,
[SharedFormStyling_styles_1.inputDisabledStyles]: disabled,
[SharedFormStyling_styles_1.inputReadOnlyStyles]: readOnly,
[selectStyles.readOnly]: readOnly,
}), "aria-disabled": disabled, "aria-invalid": !!validationMessage, "aria-describedby": [description ? `${selectHtmlId}-description` : null, validationMessage ? `${selectHtmlId}-validation` : null].filter(Boolean).join(" ") || undefined, ...buttonAriaAttributes, children: [(0, jsx_runtime_1.jsx)("span", { className: selectStyles.visuallyHiddenSummary, children: visuallyHiddenSummary }), selectedOptions.length === 0 && (0, jsx_runtime_1.jsx)("span", { className: (0, css_1.cx)(selectStyles.value, selectStyles.placeholder), children: placeholder })] }), (0, jsx_runtime_1.jsx)("div", { id: valueHtmlId, className: selectStyles.valueContainer, children: selectedOptions.length > 0 && (0, jsx_runtime_1.jsx)("span", { className: (0, css_1.cx)(selectStyles.value, { [selectStyles.multiSelectValue]: isMultiSelectable }), children: renderSelection(selectedOptions) }) }), (0, jsx_runtime_1.jsxs)("div", { ref: setSuffixElement, className: selectStyles.suffix, children: [showClearAllButton && canChange ? ((0, jsx_runtime_1.jsx)("span", { className: (0, css_1.cx)(selectStyles.clearButton), children: (0, jsx_runtime_1.jsx)(Button_1.Button, { importance: "ghost", icon: (0, jsx_runtime_1.jsx)(design_system_icons_1.XmarkIcon, { size: 20 }), size: "small", onClick: clearSelection, accessibleName: "Clear selection" }) })) : undefined, (0, jsx_runtime_1.jsx)("span", { className: (0, css_1.cx)(selectStyles.chevronIcon, { [selectStyles.rotatedChevronIcon]: isOpen }), children: (0, jsx_runtime_1.jsx)(design_system_icons_1.ChevronDownIcon, { size: 16 }) })] }), isOpen && ((0, jsx_runtime_1.jsxs)(SelectDropdown_1.SelectDropdown, { ref: setDropdownRef, dropdownButtonState: dropdownButtonState, children: [(allowFilter || isMultiSelectable) && ((0, jsx_runtime_1.jsxs)("div", { className: selectStyles.dropdownHeader, children: [allowFilter && ((0, jsx_runtime_1.jsx)(SearchInput_1.SearchInput, { value: inputValue, onChange: handleFilterTextChange, fullWidth: true, autoFocus: true, accessibleName: `Filter ${label}`, onKeyDown: handleSearchKeyDown, onFocus: () => setIsSearchInputFocused(true), onBlur: () => setIsSearchInputFocused(false), "aria-activedescendant": activeDescendantId, "aria-controls": listboxHtmlId, "aria-autocomplete": "list" })), isMultiSelectable && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: selectStyles.selectDeselectAll, disabled: optionsToShow.length === 0, onClick: hasSomeOptionsSelected ? deselectAll : selectAll, tabIndex: isOpen ? 0 : -1, children: [(0, jsx_runtime_1.jsx)(MultiSelectCheckbox_1.MultiSelectCheckbox, { isDisabled: optionsToShow.length === 0, checked: hasSomeOptionsSelected, isIndeterminate: hasSomeOptionsSelected && !hasAllOptionsSelected }), hasSomeOptionsSelected ? "Deselect all" : "Select all"] }))] })), optionsToShow.length === 0 && !isLoading && !hasAsyncError ? ((0, jsx_runtime_1.jsx)("div", { className: selectStyles.noResults, children: "No results found." })) : ((0, jsx_runtime_1.jsx)(Listbox_1.Listbox, { id: listboxHtmlId, ref: listboxRef, options: optionsToShow, value: value, renderOption: renderOption, getOptionHeight: (option) => (option.description ? SelectOptionLayout_1.selectOptionWithDescriptionHeight : SelectOptionLayout_1.selectOptionHeight), onOptionSelected: handleOptionSelected, getOptionHtmlId: getOptionHtmlId, accessibleName: label, onOptionActive: handleOptionActive, onLoadMore: handleLoadMore, isMultiSelectable: isMultiSelectable, focusType: allowFilter ? (isSearchInputFocused ? "sub-focus" : "none") : "focus", scrollPaddingTop: allowFilter || isMultiSelectable ? (0, remToPx_1.remToPx)(design_system_tokens_1.space[16]) : undefined, scrollPaddingBottom: allowFilter || isMultiSelectable ? (0, remToPx_1.remToPx)(design_system_tokens_1.space[12]) : undefined, isLoading: isLoading, hasError: hasAsyncError }))] }))] }), isMultiSelectable && valuesSet.size > 1 && ((0, jsx_runtime_1.jsx)("div", { id: multiSelectSummaryHtmlId, children: (0, jsx_runtime_1.jsx)(TagGroup_1.TagGroup, { tags: selectedOptions.map((option) => ({
label: getSelectedOptionLabel(option),
prefix: isMissingSelectedOption(option) ? (0, jsx_runtime_1.jsx)(design_system_icons_1.ExclamationIcon, { size: 16 }) : undefined,
onDismiss: canChange ? () => handleOptionSelected(option) : undefined,
})) }) })), (0, jsx_runtime_1.jsx)(InputValidationMessage_1.InputValidationMessage, { message: validationMessage, displayState: "error", id: `${selectHtmlId}-validation` })] }));
}
function isMissingSelectedOption(option) {
return "missing" in option;
}
function getSelectedOptionLabel(option) {
return isMissingSelectedOption(option) ? "Missing" : option.label;
}
const searchInputKeysToPassToListbox = new Set(["ArrowUp", "ArrowDown", "Home", "End", "Enter"]);
const layoutStyles = (0, css_1.css)({
display: "grid",
position: "relative",
gap: design_system_tokens_1.space[8],
width: "100%",
maxWidth: "600px",
});
const suffixWidthCssVar = `--select-suffix-width`;
const selectStyles = {
container: (0, css_1.css)({
position: "relative",
minWidth: 0,
}),
buttonReset: (0, css_1.css)(utils_1.resetStyles.button),
select: (0, css_1.css)({
display: "flex",
alignItems: "center",
gap: design_system_tokens_1.space[6],
padding: `0 var(${suffixWidthCssVar}, ${design_system_tokens_1.space[8]}) 0 ${design_system_tokens_1.space[8]}`,
}),
valueContainer: (0, css_1.css)({
display: "flex",
alignItems: "center",
position: "absolute",
inset: 0,
padding: `0 var(${suffixWidthCssVar}, ${design_system_tokens_1.space[8]}) 0 ${design_system_tokens_1.space[8]}`,
pointerEvents: "none",
}),
value: (0, css_1.css)({
display: "inline-block",
font: design_system_tokens_1.text.body.regular.medium,
color: design_system_tokens_1.themeTokens.color.text.primary,
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
}),
multiSelectValue: (0, css_1.css)({
// The value is absolutely positioned over the select button with pointerEvents: none.
// Re-enable it for multiselects so that the dismiss button is clickable.
pointerEvents: "auto",
}),
placeholder: (0, css_1.css)({
color: design_system_tokens_1.themeTokens.color.text.tertiary,
}),
readOnly: (0, css_1.css)({
cursor: "unset",
}),
suffix: (0, css_1.css)({
display: "flex",
alignItems: "center",
position: "absolute",
padding: `0 ${design_system_tokens_1.space[8]}`,
right: 0,
top: 0,
bottom: 0,
pointerEvents: "none",
}),
clearButton: (0, css_1.css)({
pointerEvents: "auto",
}),
chevronIcon: (0, css_1.css)({
transition: "transform 150ms ease-in-out",
padding: design_system_tokens_1.space[2],
}),
rotatedChevronIcon: (0, css_1.css)({
transform: "rotate(-180deg)",
}),
dropdownHeader: (0, css_1.css)({
display: "flex",
flexDirection: "column",
backgroundColor: design_system_tokens_1.themeTokens.color.background.primary.default,
padding: design_system_tokens_1.space[12],
gap: design_system_tokens_1.space[12],
borderBottom: `${design_system_tokens_1.borderWidth[1]} solid ${design_system_tokens_1.themeTokens.color.border.primary}`,
}),
noResults: (0, css_1.css)({
padding: design_system_tokens_1.space[16],
color: design_system_tokens_1.themeTokens.color.text.secondary,
font: design_system_tokens_1.text.body.regular.medium,
margin: "auto",
}),
selectDeselectAll: (0, css_1.css)({
...utils_1.resetStyles.button,
display: "flex",
alignItems: "center",
gap: design_system_tokens_1.space[8],
boxSizing: "border-box",
padding: `${design_system_tokens_1.space[6]} ${design_system_tokens_1.space[6]}`,
borderRadius: design_system_tokens_1.borderRadius.small,
font: design_system_tokens_1.text.body.regular.medium,
color: design_system_tokens_1.themeTokens.color.text.primary,
"&:hover, &:focus": {
backgroundColor: design_system_tokens_1.themeTokens.color.menuList.background.hover,
},
"&:active": {
backgroundColor: design_system_tokens_1.themeTokens.color.menuList.background.active,
},
"&:disabled": {
color: design_system_tokens_1.themeTokens.color.text.tertiary,
pointerEvents: "none",
},
}),
visuallyHiddenSummary: (0, css_1.css)({
...utils_1.accessibilityStyles.hidden,
}),
};