UNPKG

@octopusdeploy/design-system-components

Version:
238 lines (237 loc) • 16.4 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 react_1 = require("react"); const useObserveElementSize_1 = require("../../../hooks/useObserveElementSize"); const utils_1 = require("../../../utils"); const Button_1 = require("../../Button/Button"); const useDropdownButton_1 = require("../../Dropdown/useDropdownButton"); const Tooltip_1 = require("../../Tooltip"); const FormDescription_1 = require("../Primitives/FormDescription"); const InputLabel_1 = __importDefault(require("../Primitives/InputLabel")); const InputValidationMessage_1 = require("../Primitives/InputValidationMessage"); const SharedFormStyling_styles_1 = require("../Primitives/SharedFormStyling.styles"); const formFieldMaxWidth_1 = require("../formFieldMaxWidth"); const SelectDropdown_1 = require("./SelectDropdown"); const SelectOptionList_1 = require("./SelectOptionList"); function SelectBase({ items: itemsArrayOrAsyncList, sortItems = false, getOption, label, value, placeholder, description, validationMessage, disabled, required, hasRequiredMarker, hasOptionalMarker, hasDefaultMarker, popover, autoFocus, readOnly, onOptionsSelected, allowFilter = false, allowClear = false, actions, isMultiSelectable, renderSelection, renderSelectionSummary, renderOption, clearSelection, }) { const dropdownButtonState = (0, useDropdownButton_1.useDropdownButton)({ trapFocus: true, dropdownAriaRole: "listbox" }); const { isOpen, closeDropdown, buttonElement: selectElement, setButtonRef: setSelectRef, toggleDropdown, setDropdownRef, buttonAriaAttributes } = dropdownButtonState; const [suffixElement, setSuffixElement] = (0, react_1.useState)(null); const selectContainerRef = (0, react_1.useRef)(null); const baseId = (0, react_1.useId)(); const labelHtmlId = `label-${baseId}`; const valueHtmlId = `value-${baseId}`; const selectHtmlId = `select-${baseId}`; const multiSelectSummaryHtmlId = `multiselect-summary-${baseId}`; const [activeDescendantId, setActiveDescendantId] = (0, react_1.useState)(undefined); // `value` is fully controlled by the caller (like TextField's), so a change is detected by comparing // against what we saw last render rather than intercepting onOptionsSelected/clearSelection directly - // this works the same way for both Select (string) and MultiSelect (string[]) without SelectBase needing // to know how each computes its next value. Mirrors TextField's required validator: no error on mount, // only once the selection has actually changed. const [previousValue, setPreviousValue] = (0, react_1.useState)(value); const [localValidationErrorMessage, setLocalValidationErrorMessage] = (0, react_1.useState)(null); if (value !== previousValue) { setPreviousValue(value); const isEmpty = value === undefined || (Array.isArray(value) ? value.length === 0 : value === ""); setLocalValidationErrorMessage(required && isEmpty ? "This field is required." : null); } const isAsync = !Array.isArray(itemsArrayOrAsyncList); const refresh = isAsync ? itemsArrayOrAsyncList.refresh : undefined; const asyncError = isAsync ? itemsArrayOrAsyncList.error : null; // A validation error means the field's *value* is invalid, so it drives aria-invalid and the // error border. // A load/refresh failure is different: the value can be perfectly valid, the // data just didn't load. We still surface it as a message here (visible even while the // dropdown is closed, e.g. right after clicking the field's own refresh action) but it must not mark the field invalid. const asyncErrorMessage = asyncError && !isOpen ? "Failed to load results" : undefined; const combinedValidationMessage = localValidationErrorMessage ?? validationMessage; const displayedMessage = combinedValidationMessage ?? asyncErrorMessage; // Only spin/announce for refreshes the user actually triggered. `refresh` resolves when the // reload completes, so we can announce the outcome without tracking loading-state transitions. const [spinState, setSpinState] = (0, react_1.useState)("idle"); const [refreshStatusMessage, setRefreshStatusMessage] = (0, react_1.useState)(""); const handleRefresh = async () => { // Ensures the trigger stays focusable while refreshing otherwise we'd loose keyboard focus mid-action. if (!refresh || spinState === "spinning") return; setRefreshStatusMessage(`Refreshing ${label}`); setSpinState("spinning"); try { await refresh(); setRefreshStatusMessage(`${label} refreshed`); } catch { setRefreshStatusMessage(`Failed to refresh ${label}`); } finally { // Under prefers-reduced-motion the animation never runs, so no iteration event moves the state back to rest. setSpinState((prev) => (prev === "spinning" ? "stopping" : prev)); } }; const handleSpinIteration = () => { setSpinState((prev) => (prev === "stopping" ? "idle" : prev)); }; const currentItems = isAsync ? itemsArrayOrAsyncList.loadedItems : itemsArrayOrAsyncList; const currentOptions = (0, react_1.useMemo)(() => currentItems.map(getOption), [currentItems, getOption]); 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 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 showClearAllButton = allowClear && (isMultiSelectable ? valuesSet.size > 1 : valuesSet.size > 0); const hasActions = (actions !== undefined && actions.length > 0) || refresh !== undefined; 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]: !!combinedValidationMessage, [SharedFormStyling_styles_1.inputDisabledStyles]: disabled, [SharedFormStyling_styles_1.inputReadOnlyStyles]: readOnly, [selectStyles.readOnly]: readOnly, }), "aria-disabled": disabled, "aria-invalid": !!combinedValidationMessage, "aria-describedby": [description ? `${selectHtmlId}-description` : null, displayedMessage ? `${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: (0, css_1.cx)(selectStyles.suffix, { [selectStyles.suffixWithActions]: hasActions }), 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 }) }), hasActions && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { className: SharedFormStyling_styles_1.actionsDividerStyles }), (0, jsx_runtime_1.jsxs)("div", { className: selectStyles.actions, children: [refresh && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(Tooltip_1.Tooltip, { content: "Refresh", children: (0, jsx_runtime_1.jsx)(Button_1.Button, { size: "small", importance: "ghost", icon: (0, jsx_runtime_1.jsx)("span", { className: (0, css_1.cx)(selectStyles.refreshIcon, { [selectStyles.refreshIconSpinning]: spinState !== "idle" }), onAnimationIteration: handleSpinIteration, children: (0, jsx_runtime_1.jsx)(design_system_icons_1.ArrowRefreshIcon, { size: 20 }) }), onClick: () => void handleRefresh(), accessibleName: "Refresh", disabled: !canChange }) }), (0, jsx_runtime_1.jsx)("span", { role: "status", "aria-live": "polite", className: selectStyles.visuallyHidden, children: refreshStatusMessage })] })), actions] })] }))] }), isOpen && ((0, jsx_runtime_1.jsx)(SelectDropdown_1.SelectDropdown, { ref: setDropdownRef, dropdownButtonState: dropdownButtonState, children: (0, jsx_runtime_1.jsx)(SelectOptionList_1.SelectOptionList, { items: itemsArrayOrAsyncList, getOption: getOption, sortItems: sortItems, value: value, isMultiSelectable: isMultiSelectable, allowFilter: allowFilter, label: label, renderOption: renderOption, onOptionsSelected: onOptionsSelected, closeDropdown: closeDropdown, onActiveDescendantChange: setActiveDescendantId, onRefresh: refresh ? () => void handleRefresh() : undefined }) }))] }), isMultiSelectable && valuesSet.size > 1 && renderSelectionSummary && (0, jsx_runtime_1.jsx)("div", { id: multiSelectSummaryHtmlId, children: renderSelectionSummary(selectedOptions) }), (0, jsx_runtime_1.jsx)(InputValidationMessage_1.InputValidationMessage, { message: displayedMessage, displayState: "error", id: `${selectHtmlId}-validation` })] })); } function isMissingSelectedOption(option) { return "missing" in option; } function getSelectedOptionLabel(option) { return isMissingSelectedOption(option) ? "Missing" : option.label; } const spinKeyframes = (0, css_1.keyframes)({ from: { transform: "rotate(0deg)" }, to: { transform: "rotate(360deg)" }, }); const layoutStyles = (0, css_1.css)({ display: "grid", position: "relative", gap: design_system_tokens_1.space[8], width: "100%", maxWidth: formFieldMaxWidth_1.formFieldMaxWidth, }); 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, lineHeight: "normal", 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", color: design_system_tokens_1.themeTokens.color.text.secondary, padding: `0 ${design_system_tokens_1.space[8]}`, right: 0, top: 0, bottom: 0, pointerEvents: "none", }), suffixWithActions: (0, css_1.css)({ ...SharedFormStyling_styles_1.suffixSharedStyles, }), 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)", }), visuallyHiddenSummary: (0, css_1.css)({ ...utils_1.accessibilityStyles.hidden, }), visuallyHidden: (0, css_1.css)({ ...utils_1.accessibilityStyles.hidden, }), actions: (0, css_1.css)({ display: "flex", pointerEvents: "auto", }), // Fixed-size, centered box for the refresh icon so the layout never shifts. refreshIcon: (0, css_1.css)({ display: "flex", alignItems: "center", justifyContent: "center", width: "20px", height: "20px", }), refreshIconSpinning: (0, css_1.css)({ animation: `${spinKeyframes} 0.8s linear infinite`, "@media (prefers-reduced-motion: reduce)": { animation: "none", }, }), };