@octopusdeploy/design-system-components
Version:
The design systems component library.
159 lines (158 loc) • 10.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SelectOptionList = SelectOptionList;
const jsx_runtime_1 = require("react/jsx-runtime");
const css_1 = require("@emotion/css");
const design_system_tokens_1 = require("@octopusdeploy/design-system-tokens");
const lodash_debounce_1 = __importDefault(require("lodash.debounce"));
const react_1 = require("react");
const utils_1 = require("../../../utils");
const alphanumericSort_1 = require("../../../utils/alphanumericSort");
const SearchInput_1 = require("../../Input/SearchInput");
const Listbox_1 = require("../Primitives/Listbox");
const remToPx_1 = require("../utils/remToPx");
const MultiSelectCheckbox_1 = require("./MultiSelectCheckbox");
const SelectOptionLayout_1 = require("./SelectOptionLayout");
const searchInputKeysToPassToListbox = new Set(["ArrowUp", "ArrowDown", "Home", "End", "Enter"]);
/**
* The dropdown body of a `Select` / `MultiSelect`: an optional filter input, an optional
* select/deselect-all control, and the scrollable listbox of options.
*
* This owns its own filter state and listbox wiring, so it can be rendered standalone
* (e.g. inside a popover) as well as inside `SelectBase`. It does not render the trigger
* field, label, or positioning container.
*/
function SelectOptionList({ items: itemsArrayOrAsyncList, getOption, sortItems = false, value, isMultiSelectable, allowFilter, label, renderOption, onOptionsSelected, closeDropdown, onActiveDescendantChange, onRefresh, }) {
const baseId = (0, react_1.useId)();
const listboxHtmlId = `listbox-${baseId}`;
const getOptionHtmlId = (index) => `${baseId}-option-${index}`;
const listboxRef = (0, react_1.useRef)(null);
const [isSearchInputFocused, setIsSearchInputFocused] = (0, react_1.useState)(false);
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;
// Keep the async list's filter setter in a ref so the debounced fn (and the unmount cleanup)
// stay referentially stable even when `items` is a fresh array/object on each render. Without
// this, an unstable `items` would recreate the debounce every render and the cleanup below
// would fire mid-session, resetting the user's search.
const setAsyncFilterText = isAsync ? itemsArrayOrAsyncList.setFilterText : undefined;
const setAsyncFilterTextRef = (0, react_1.useRef)(setAsyncFilterText);
setAsyncFilterTextRef.current = setAsyncFilterText;
const debouncedSetFilterText = (0, react_1.useMemo)(() => (0, lodash_debounce_1.default)((value) => {
setFilterText(value);
setAsyncFilterTextRef.current?.(value);
}, 200), []);
// The list unmounts when the dropdown closes
(0, react_1.useEffect)(() => () => {
debouncedSetFilterText.cancel();
setAsyncFilterTextRef.current?.("");
}, [debouncedSetFilterText]);
(0, react_1.useEffect)(() => {
onActiveDescendantChange?.(activeDescendantId);
}, [activeDescendantId, onActiveDescendantChange]);
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) => (0, utils_1.matchesFilterText)(option.label, filterText)) : 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 handleOptionActive = (index) => {
setActiveDescendantId(getOptionHtmlId(index));
};
const handleOptionSelected = (option) => {
onOptionsSelected([option]);
if (!isMultiSelectable) {
closeDropdown({ moveFocusToButton: true });
}
};
const handleLoadMore = isAsync && !itemsArrayOrAsyncList.isLoading ? itemsArrayOrAsyncList.loadMore : undefined;
const handleRetry = isAsync ? getRetryHandler(itemsArrayOrAsyncList, onRefresh) : undefined;
const handleSearchKeyDown = (event) => {
const { key } = event;
if (searchInputKeysToPassToListbox.has(key)) {
listboxRef.current?.handleKeyDown(event);
}
};
const handleFilterTextChange = (value) => {
setInputValue(value);
debouncedSetFilterText(value);
};
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)));
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(allowFilter || isMultiSelectable) && ((0, jsx_runtime_1.jsxs)("div", { className: selectOptionListStyles.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: selectOptionListStyles.selectDeselectAll, disabled: optionsToShow.length === 0, onClick: hasSomeOptionsSelected ? deselectAll : selectAll, 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: selectOptionListStyles.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, onRetry: handleRetry, 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 }))] }));
}
// Retry after a failed load re-requests the failed page for paged lists, or reloads the whole
// list when only refresh is available. Paged lists retry through the isLoading-gated load-more
// handler so a click that lands mid-load can't double-request a page. Refresh-backed lists
// prefer the parent-supplied onRefresh (SelectBase's announced refresh lifecycle); the raw
// fallback exists for standalone usages (e.g. DropdownList) and swallows the rejection because
// the outcome is already surfaced through the list's isLoading/error state.
//
// This relies on loadMore and refresh being mutually exclusive (see AsyncList)
function getRetryHandler(list, onRefresh) {
if (list.loadMore) {
return list.isLoading ? undefined : list.loadMore;
}
if (onRefresh) {
return onRefresh;
}
const { refresh } = list;
if (!refresh) {
return undefined;
}
return () => void Promise.resolve(refresh()).catch(utils_1.noOp);
}
const selectOptionListStyles = {
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",
},
}),
};