UNPKG

@octopusdeploy/design-system-components

Version:
223 lines (222 loc) • 11.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Listbox = void 0; 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_virtual_1 = require("@tanstack/react-virtual"); const react_1 = require("react"); const useObserveElementSize_1 = require("../../../hooks/useObserveElementSize"); const utils_1 = require("../../../utils"); const Button_1 = require("../../Button"); const CircularProgress_1 = require("../../Progress/CircularProgress"); const remToPx_1 = require("../utils/remToPx"); function ListboxInner({ id, value, options, accessibleName, renderOption, getOptionHeight, onOptionSelected, getOptionHtmlId, onOptionActive, onLoadMore, onRetry, isLoading, hasError, isMultiSelectable, scrollPaddingTop, scrollPaddingBottom, focusType, }, ref) { const [footerElement, setFooterElement] = (0, react_1.useState)(null); const [footerHeight, setFooterHeight] = (0, react_1.useState)(0); const selectedValues = (0, react_1.useMemo)(() => { return new Set(Array.isArray(value) ? value : [value]); }, [value]); const interactionMode = (0, react_1.useRef)(undefined); const containerRef = (0, react_1.useRef)(null); const [activeIndex, setActiveIndex] = (0, react_1.useState)(Math.max(options.findIndex((option) => selectedValues.has(option.value)), 0)); const rowVirtualizer = (0, react_virtual_1.useVirtualizer)({ count: options.length, getScrollElement: () => containerRef.current, getItemKey: (index) => options[index].value, estimateSize: (index) => getOptionHeight(options[index]), gap: (0, remToPx_1.remToPx)(design_system_tokens_1.space[4]), paddingStart: scrollPaddingTop ?? (0, remToPx_1.remToPx)(design_system_tokens_1.space[6]), paddingEnd: scrollPaddingBottom ?? (0, remToPx_1.remToPx)(design_system_tokens_1.space[6]), overscan: 5, }); const handleActiveIndexChange = (0, react_1.useCallback)((newActiveIndex) => { setActiveIndex(newActiveIndex); const getOption = () => { const optionHtmlId = getOptionHtmlId(newActiveIndex); return document.getElementById(optionHtmlId); }; if (interactionMode.current !== "mouse") { const option = getOption(); if (option) { option.scrollIntoView({ block: "nearest" }); } else { rowVirtualizer.scrollToIndex(newActiveIndex); } } }, [getOptionHtmlId, rowVirtualizer]); if (options.length > 0 && activeIndex >= options.length) { handleActiveIndexChange(0); } (0, react_1.useEffect)(() => { onOptionActive?.(activeIndex); }, [activeIndex, onOptionActive]); (0, react_1.useEffect)(() => { rowVirtualizer.scrollToIndex(activeIndex); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleKeyDown = (0, react_1.useCallback)((event) => { interactionMode.current = "keyboard"; const { key } = event; switch (key) { case "ArrowUp": event.preventDefault(); if (activeIndex > 0) { handleActiveIndexChange(activeIndex - 1); } else { rowVirtualizer.scrollToOffset(0); } break; case "ArrowDown": event.preventDefault(); if (activeIndex < options.length - 1) { handleActiveIndexChange(activeIndex + 1); } else { rowVirtualizer.scrollToOffset(rowVirtualizer.getTotalSize()); } break; case "Home": case "End": event.preventDefault(); handleActiveIndexChange(key === "Home" ? 0 : options.length - 1); break; case "Enter": case " ": event.preventDefault(); if (activeIndex >= 0 && activeIndex < options.length) { onOptionSelected(options[activeIndex]); } break; } }, [activeIndex, handleActiveIndexChange, onOptionSelected, options, rowVirtualizer]); (0, react_1.useImperativeHandle)(ref, () => ({ handleKeyDown, }), [handleKeyDown]); const onActiveOptionRef = (0, react_1.useCallback)((element) => { if (focusType === "focus") { element?.focus({ preventScroll: true }); } }, [focusType]); // Use a ref to remove loadMore as a dependency of the effect. We only want it to run when isLoading changes, so that loadMore can trigger once per load. const onLoadMoreRef = (0, react_1.useRef)(onLoadMore); onLoadMoreRef.current = onLoadMore; (0, react_1.useEffect)(() => { const container = containerRef.current; if (isLoading || !onLoadMoreRef.current || !container || hasError) return; // "Near" is defined here as having less than the client height of scrollable area remaining to scroll const isNearEndOfList = () => { const { scrollTop, scrollHeight, clientHeight } = container; const remainingScroll = scrollHeight - scrollTop - clientHeight; return remainingScroll <= clientHeight; }; if (isNearEndOfList()) { onLoadMoreRef?.current(); return; } const handleScroll = () => { if (isNearEndOfList()) { onLoadMoreRef?.current?.(); container.removeEventListener("scroll", handleScroll); } }; container.addEventListener("scroll", handleScroll, { passive: true }); return () => { container.removeEventListener("scroll", handleScroll); }; }, [hasError, isLoading]); (0, useObserveElementSize_1.useObserveElementSize)(footerElement, ({ height }) => { setFooterHeight(height); }, () => { setFooterHeight(0); }); const showFooter = isLoading || hasError; const scrollHeight = showFooter ? rowVirtualizer.getTotalSize() + footerHeight : rowVirtualizer.getTotalSize(); const scrollHeightStyles = { height: `${scrollHeight}px` }; return ((0, jsx_runtime_1.jsx)("div", { ref: containerRef, className: listboxContainerStyles, onKeyDown: handleKeyDown, children: (0, jsx_runtime_1.jsxs)("ul", { id: id, className: listboxStyles, style: scrollHeightStyles, role: "listbox", "aria-label": accessibleName, "aria-multiselectable": isMultiSelectable ? true : undefined, children: [rowVirtualizer.getVirtualItems().map(({ key, index, size, start }) => { const virtualRowSizeStartStyles = { height: `${size}px`, transform: `translateY(${start}px)`, }; const option = options[index]; const selected = selectedValues.has(option.value); return ((0, jsx_runtime_1.jsx)("li", { ref: activeIndex === index ? onActiveOptionRef : undefined, id: getOptionHtmlId(index), role: "option", tabIndex: focusType === "focus" && activeIndex === index ? 0 : -1, "aria-selected": selected, className: (0, css_1.cx)(virtualRowStyles, optionStyles, { [activeOptionStyles]: focusType === "sub-focus" && activeIndex === index, }), style: virtualRowSizeStartStyles, onClick: () => onOptionSelected(option), onMouseDown: (event) => { if (focusType !== "focus") { event.preventDefault(); } }, onMouseMove: () => { if (interactionMode.current !== "mouse") { interactionMode.current = "mouse"; handleActiveIndexChange(index); } }, onMouseEnter: () => { if (interactionMode.current === "mouse") { handleActiveIndexChange(index); } }, children: renderOption(option, selected) }, key)); }), showFooter && ((0, jsx_runtime_1.jsxs)("li", { ref: setFooterElement, className: footerStyles, children: [isLoading && (0, jsx_runtime_1.jsx)(CircularProgress_1.CircularProgress, { size: "small" }), hasError && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: errorStyles, children: [(0, jsx_runtime_1.jsx)(design_system_icons_1.ExclamationTriangleIcon, { size: 20, color: design_system_tokens_1.themeTokens.color.icon.warning }), " Failed to load results"] }), onRetry && (0, jsx_runtime_1.jsx)(Button_1.Button, { label: "Retry", importance: "quiet", onClick: onRetry })] }))] }, "footer"))] }) })); } // forwardRef doesn't like function components with generics, so this assertion is required // revisit this in React 19 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions exports.Listbox = (0, react_1.forwardRef)(ListboxInner); const listboxContainerStyles = (0, css_1.css)({ margin: 0, overflowY: "auto", }); const listboxStyles = (0, css_1.css)({ ...utils_1.resetStyles.list, display: "flex", flexDirection: "column", position: "relative", }); const virtualRowStyles = (0, css_1.css)({ position: "absolute", top: 0, left: design_system_tokens_1.space[6], right: design_system_tokens_1.space[6], }); const optionStyles = (0, css_1.css)({ listStyleType: "none", flexShrink: 0, cursor: "pointer", borderRadius: design_system_tokens_1.borderRadius.small, scrollMargin: `${design_system_tokens_1.space[8]} 0`, ":focus-visible": { outline: "none", }, "&:hover, &:focus": { backgroundColor: design_system_tokens_1.themeTokens.color.menuList.background.hover, }, "&:active": { backgroundColor: design_system_tokens_1.themeTokens.color.menuList.background.active, }, }); const activeOptionStyles = (0, css_1.css)({ backgroundColor: design_system_tokens_1.themeTokens.color.menuList.background.hover, }); const footerStyles = (0, css_1.css)({ display: "flex", flexDirection: "column", gap: design_system_tokens_1.space[4], alignItems: "center", justifyContent: "center", position: "absolute", width: "100%", boxSizing: "border-box", bottom: design_system_tokens_1.space[8], // should be same as list bottom padding in virtualizer paddingTop: design_system_tokens_1.space[8], // should be same as gap in virtualizer }); const errorStyles = (0, css_1.css)({ display: "flex", color: design_system_tokens_1.themeTokens.color.text.secondary, font: design_system_tokens_1.text.body.regular.medium, gap: design_system_tokens_1.space[6], alignItems: "center", });