UNPKG

@octopusdeploy/design-system-components

Version:
252 lines (251 loc) • 17.9 kB
"use strict"; 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, }), };