@octopusdeploy/design-system-components
Version:
The design systems component library.
223 lines (222 loc) • 11.2 kB
JavaScript
;
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, 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"] }), (0, jsx_runtime_1.jsx)(Button_1.Button, { label: "Retry", importance: "quiet", onClick: () => onLoadMoreRef.current?.() })] }))] }, "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",
});