@octopusdeploy/design-system-components
Version:
The design systems component library.
238 lines (237 loc) • 16.4 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 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",
},
}),
};