analytica-frontend-lib
Version:
Repositório público dos componentes utilizados nas plataformas da Analytica Ensino
484 lines (482 loc) • 17.3 kB
JavaScript
import {
Input_default
} from "./chunk-EXVVHZOO.mjs";
import {
Button_default
} from "./chunk-LAYB7IKW.mjs";
import {
Text_default
} from "./chunk-IMCIR6TJ.mjs";
import {
cn
} from "./chunk-53ICLDGS.mjs";
// src/components/SearchSelect/SearchSelect.tsx
import {
useEffect,
useRef,
useState,
useCallback,
forwardRef,
useId,
useMemo
} from "react";
import { createPortal } from "react-dom";
import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
import { CheckIcon } from "@phosphor-icons/react/dist/csr/Check";
import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/csr/MagnifyingGlass";
import { SpinnerGapIcon } from "@phosphor-icons/react/dist/csr/SpinnerGap";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
var SIZE_CLASSES = {
small: "text-sm h-8",
medium: "text-md h-9",
large: "text-lg h-10"
};
var PADDING_CLASSES = {
small: "px-3 py-1",
medium: "px-3 py-2",
large: "px-4 py-3"
};
var VARIANT_CLASSES = {
outlined: "border-2 rounded-lg focus:border-primary-950",
underlined: "border-b-2 focus:border-primary-950",
rounded: "border-2 rounded-full focus:border-primary-950"
};
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
var SearchSelect = forwardRef(
({
value,
onValueChange,
options,
placeholder = "Selecione...",
searchPlaceholder = "Buscar...",
label,
helperText,
errorMessage,
disabled = false,
loading = false,
size = "small",
variant = "outlined",
className,
emptyText = "Nenhum resultado encontrado",
loadingText = "Carregando...",
onSearch,
searchDebounce = 300,
pagination,
onLoadMore,
loadingMore = false,
id,
filterLocally = true
}, ref) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const triggerRef = useRef(null);
const contentRef = useRef(null);
const searchInputRef = useRef(null);
const listRef = useRef(null);
const isFetchingMoreRef = useRef(false);
const generatedId = useId();
const selectId = id ?? `search-select-${generatedId}`;
const listboxId = `${selectId}-listbox`;
const helperId = `${selectId}-helper`;
const errorId = `${selectId}-error`;
const debouncedSearch = useDebounce(searchQuery, searchDebounce);
useEffect(() => {
if (onSearch && debouncedSearch !== void 0) {
onSearch(debouncedSearch);
}
}, [debouncedSearch, onSearch]);
const filteredOptions = useMemo(() => {
if (!filterLocally || !searchQuery) {
return options;
}
const query = searchQuery.toLowerCase();
return options.filter((opt) => opt.label.toLowerCase().includes(query));
}, [options, searchQuery, filterLocally]);
useEffect(() => {
if (highlightedIndex >= filteredOptions.length) {
setHighlightedIndex(Math.max(filteredOptions.length - 1, -1));
}
}, [filteredOptions.length, highlightedIndex]);
const selectedLabel = useMemo(() => {
const selected = options.find((opt) => opt.value === value);
return selected?.label;
}, [options, value]);
const [triggerRect, setTriggerRect] = useState(null);
const updateTriggerRect = useCallback(() => {
if (triggerRef.current) {
setTriggerRect(triggerRef.current.getBoundingClientRect());
}
}, []);
const handleToggle = useCallback(() => {
if (disabled || loading) return;
const newOpen = !open;
if (newOpen) {
updateTriggerRect();
setSearchQuery("");
setHighlightedIndex(-1);
}
setOpen(newOpen);
}, [disabled, loading, open, updateTriggerRect]);
const handleSelect = useCallback(
(optionValue) => {
onValueChange?.(optionValue);
setOpen(false);
setSearchQuery("");
requestAnimationFrame(() => {
triggerRef.current?.focus();
});
},
[onValueChange]
);
useEffect(() => {
if (open && searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
}
}, [open]);
useEffect(() => {
if (!open) return;
const handleClickOutside = (event) => {
const target = event.target;
const isInsideTrigger = triggerRef.current?.contains(target);
const isInsideContent = contentRef.current?.contains(target);
if (!isInsideTrigger && !isInsideContent) {
setOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [open]);
useEffect(() => {
if (!open) return;
const handleUpdate = () => updateTriggerRect();
window.addEventListener("scroll", handleUpdate, true);
window.addEventListener("resize", handleUpdate);
return () => {
window.removeEventListener("scroll", handleUpdate, true);
window.removeEventListener("resize", handleUpdate);
};
}, [open, updateTriggerRect]);
const findNextEnabledIndex = useCallback(
(currentIndex, direction) => {
const len = filteredOptions.length;
if (len === 0) return -1;
let startIndex;
if (currentIndex < 0) {
startIndex = direction === "next" ? -1 : len;
} else {
startIndex = currentIndex;
}
for (let i = 0; i < len; i++) {
if (direction === "next") {
startIndex = (startIndex + 1) % len;
} else {
startIndex = (startIndex - 1 + len) % len;
}
if (!filteredOptions[startIndex].disabled) {
return startIndex;
}
}
return -1;
},
[filteredOptions]
);
const handleKeyDown = useCallback(
(e) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((prev) => findNextEnabledIndex(prev, "next"));
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((prev) => findNextEnabledIndex(prev, "prev"));
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length && !filteredOptions[highlightedIndex].disabled) {
handleSelect(filteredOptions[highlightedIndex].value);
}
break;
case "Escape":
e.preventDefault();
setOpen(false);
setSearchQuery("");
triggerRef.current?.focus();
break;
}
},
[filteredOptions, highlightedIndex, handleSelect, findNextEnabledIndex]
);
useEffect(() => {
if (!loadingMore) {
isFetchingMoreRef.current = false;
}
}, [loadingMore]);
const handleScroll = useCallback(() => {
if (!listRef.current || !pagination?.hasNext || loadingMore || isFetchingMoreRef.current || !onLoadMore)
return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
const scrollThreshold = 50;
if (scrollHeight - scrollTop - clientHeight < scrollThreshold) {
isFetchingMoreRef.current = true;
const result = onLoadMore();
if (result && typeof result.then === "function") {
result.finally(() => {
isFetchingMoreRef.current = false;
});
}
}
}, [pagination?.hasNext, loadingMore, onLoadMore]);
useEffect(() => {
if (highlightedIndex >= 0 && listRef.current) {
const items = listRef.current.querySelectorAll("[data-option]");
const item = items[highlightedIndex];
if (item) {
item.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex]);
const setRefs = useCallback(
(element) => {
triggerRef.current = element;
if (typeof ref === "function") {
ref(element);
} else if (ref) {
ref.current = element;
}
},
[ref]
);
const sizeClasses = SIZE_CLASSES[size];
const paddingClasses = PADDING_CLASSES[size];
const variantClasses = VARIANT_CLASSES[variant];
const invalid = !!errorMessage;
const getDropdownStyles = useCallback(() => {
if (!triggerRect) return {};
const gap = 4;
const dropdownMaxHeight = 300;
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - triggerRect.bottom - gap;
const spaceAbove = triggerRect.top - gap;
const openAbove = spaceBelow < dropdownMaxHeight && spaceAbove > spaceBelow;
const styles = {
position: "fixed",
left: triggerRect.left,
width: triggerRect.width,
zIndex: 9999,
maxHeight: openAbove ? Math.min(spaceAbove, dropdownMaxHeight) : Math.min(spaceBelow, dropdownMaxHeight)
};
if (openAbove) {
styles.bottom = viewportHeight - triggerRect.top + gap;
} else {
styles.top = triggerRect.bottom + gap;
}
return styles;
}, [triggerRect]);
const renderOptionsContent = () => {
if (loading) {
return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-2 p-4 text-text-500", children: [
/* @__PURE__ */ jsx(SpinnerGapIcon, { size: 18, className: "animate-spin" }),
/* @__PURE__ */ jsx(Text_default, { size: "sm", className: "text-text-500", children: loadingText })
] });
}
if (filteredOptions.length === 0) {
return /* @__PURE__ */ jsx(Text_default, { size: "sm", className: "p-4 text-center text-text-500", children: emptyText });
}
return /* @__PURE__ */ jsxs(Fragment, { children: [
filteredOptions.map((option, index) => {
const isSelected = option.value === value;
const isHighlighted = index === highlightedIndex;
const optionId = `${listboxId}-option-${index}`;
return /* @__PURE__ */ jsxs(
"div",
{
id: optionId,
"data-option": true,
role: "option",
"aria-selected": isSelected,
"aria-disabled": option.disabled,
tabIndex: -1,
onClick: () => {
if (!option.disabled) {
handleSelect(option.value);
}
},
onKeyDown: (e) => {
if (option.disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect(option.value);
}
},
className: cn(
"relative flex items-center gap-2 px-3 py-2.5 cursor-pointer transition-colors text-sm",
isSelected && "bg-primary-50",
isHighlighted && !isSelected && "bg-background-50",
option.disabled && "opacity-50 cursor-not-allowed pointer-events-none",
!option.disabled && !isSelected && !isHighlighted && "hover:bg-background-50"
),
children: [
/* @__PURE__ */ jsx(Text_default, { size: "sm", className: "flex-1 text-text-700", children: option.label }),
isSelected && /* @__PURE__ */ jsx(
CheckIcon,
{
size: 16,
className: "text-primary-700",
weight: "bold"
}
)
]
},
option.value
);
}),
loadingMore && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-2 p-3 text-text-500 border-t border-border-100", children: [
/* @__PURE__ */ jsx(SpinnerGapIcon, { size: 16, className: "animate-spin" }),
/* @__PURE__ */ jsx(Text_default, { size: "xs", className: "text-text-500", children: "Carregando mais..." })
] }),
pagination && !loadingMore && pagination.total > 0 && !(filterLocally && searchQuery) && /* @__PURE__ */ jsxs(
Text_default,
{
size: "xs",
className: "px-3 py-2 text-text-400 border-t border-border-100 text-center",
children: [
options.length,
" de ",
pagination.total,
" itens"
]
}
)
] });
};
const dropdownContent = open && triggerRect && /* @__PURE__ */ jsxs(
"div",
{
ref: contentRef,
"data-search-select-id": selectId,
style: getDropdownStyles(),
className: "bg-secondary rounded-md border border-border-100 shadow-lg overflow-hidden flex flex-col",
children: [
/* @__PURE__ */ jsx("div", { className: "p-2 border-b border-border-100", children: /* @__PURE__ */ jsx(
Input_default,
{
ref: searchInputRef,
type: "text",
role: "combobox",
"aria-autocomplete": "list",
"aria-controls": listboxId,
"aria-activedescendant": highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : void 0,
value: searchQuery,
onChange: (e) => setSearchQuery(e.target.value),
onKeyDown: handleKeyDown,
placeholder: searchPlaceholder,
size: "small",
variant: "outlined",
iconRight: /* @__PURE__ */ jsx(MagnifyingGlassIcon, { size: 16 }),
containerClassName: "w-full"
}
) }),
/* @__PURE__ */ jsx(
"div",
{
ref: listRef,
id: listboxId,
role: "listbox",
"aria-label": label || "Options",
onScroll: handleScroll,
className: "flex-1 overflow-y-auto",
children: renderOptionsContent()
}
)
]
}
);
const getDescribedById = () => {
if (errorMessage) return errorId;
if (helperText) return helperId;
return void 0;
};
const describedById = getDescribedById();
return /* @__PURE__ */ jsxs("div", { className: cn("w-full", className), children: [
label && /* @__PURE__ */ jsx(
"label",
{
htmlFor: selectId,
className: cn(
"block font-bold text-text-900 mb-1.5",
size === "small" && "text-sm",
size === "medium" && "text-md",
size === "large" && "text-lg"
),
children: label
}
),
/* @__PURE__ */ jsx(
Button_default,
{
ref: setRefs,
id: selectId,
variant: "raw",
onClick: handleToggle,
disabled: disabled || loading,
"aria-expanded": open,
"aria-haspopup": "listbox",
"aria-controls": open ? listboxId : void 0,
"aria-invalid": !!errorMessage,
"aria-describedby": describedById,
className: cn(
"flex w-full items-center justify-between border-border-300 bg-background",
sizeClasses,
paddingClasses,
variantClasses,
invalid && "border-indicator-error",
disabled || loading ? "cursor-not-allowed opacity-50 text-text-400" : "cursor-pointer hover:bg-background-50 text-text-700"
),
iconRight: /* @__PURE__ */ jsx(
CaretDownIcon,
{
className: cn(
"h-4 w-4 opacity-50 transition-transform shrink-0",
open && "rotate-180"
)
}
),
children: /* @__PURE__ */ jsx("div", { className: cn("truncate", !selectedLabel && "text-text-500"), children: loading ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
/* @__PURE__ */ jsx(SpinnerGapIcon, { size: 14, className: "animate-spin" }),
/* @__PURE__ */ jsx(Text_default, { size: "sm", className: "text-text-500", children: loadingText })
] }) : selectedLabel || placeholder })
}
),
(helperText || errorMessage) && /* @__PURE__ */ jsxs("div", { className: "mt-1.5", children: [
helperText && !errorMessage && /* @__PURE__ */ jsx(Text_default, { id: helperId, size: "sm", className: "text-text-500", children: helperText }),
errorMessage && /* @__PURE__ */ jsx(Text_default, { id: errorId, size: "sm", className: "text-indicator-error", children: errorMessage })
] }),
typeof document !== "undefined" && createPortal(dropdownContent, document.body)
] });
}
);
SearchSelect.displayName = "SearchSelect";
var SearchSelect_default = SearchSelect;
export {
SearchSelect,
SearchSelect_default
};
//# sourceMappingURL=chunk-HZDLDWBN.mjs.map