UNPKG

@octopusdeploy/design-system-components

Version:
159 lines (158 loc) • 10.5 kB
"use strict"; 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", }, }), };