import clsx from "clsx"; import groupBy from "lodash/groupBy"; import { ChangeEvent, CSSProperties, FC, SelectHTMLAttributes, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { FeatureChoiceOption, HtmlSelectOption, HtmlSelectOptionGroup, } from "@dndbeyond/character-rules-engine"; import Check from "@dndbeyond/fontawesome-cache/svgs/regular/check.svg"; import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg"; import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible"; import styles from "./styles.module.css"; export interface SelectOption { value: string | number; label: string; groupLabel?: string; index: number; } export interface SelectProps extends Omit< SelectHTMLAttributes, "value" | "onChange" | "name" | "size" > { options: (FeatureChoiceOption | HtmlSelectOption | HtmlSelectOptionGroup)[]; value: string | number | null; onChange?: (value: string | number) => void; onChangeConfirm?: ( newValue: string, oldValue: string, accept: () => void, reject: () => void ) => void; placeholder?: string; searchThreshold?: number | null; searchPlaceholder?: string; isHighlighted?: boolean; name: string; optionsWidth?: "medium" | "large" | "x-large"; hidePlaceholderOption?: boolean; size?: "small" | "medium"; resetAfterChoice?: boolean; } export const Select: FC = ({ className, placeholder = "-- Choose an Option --", name, onChange, onChangeConfirm, options, value, disabled, searchThreshold = 10, searchPlaceholder = "Search...", isHighlighted, optionsWidth = "medium", hidePlaceholderOption, size = "medium", resetAfterChoice, ...props }) => { const buttonRef = useRef(null!); const searchRef = useRef(null!); const optionsRef = useRef(null!); const optionsListRef = useRef(null!); const isSearchActive = useRef(false); const { ref: selectRef, isVisible, setIsVisible } = useIsVisible(false); const [internalValue, setInternalValue] = useState(value || ""); const [query, setQuery] = useState(""); const [focused, setFocused] = useState(0); const [buttonPosition, setButtonPosition] = useState(null); const getOptions = () => { let counter = 1; const optionArr: SelectOption[] = []; options.forEach((option: FeatureChoiceOption | HtmlSelectOptionGroup) => { if ("optGroupLabel" in option) { option.options.forEach((opt: HtmlSelectOption) => { optionArr.push({ label: opt.label || "", value: opt.value, groupLabel: option.optGroupLabel, index: counter, }); counter++; }); } else { optionArr.push({ label: option.label || "", value: option.value, index: counter, }); counter++; } }); return optionArr; }; const [results, setResults] = useState(getOptions()); const currentSelection = results.find( (option) => String(option.value) === String(internalValue) ); const scrollIntoView = useCallback(() => { if (!optionsRef.current) return; const optionList = optionsRef.current.querySelector( `.${styles.optionList}` ); if (!optionList) return; let targetElement: Element | null = null; // Look for the focused option first (including placeholder at index 0) if (focused >= 0) { targetElement = optionList.querySelector(`[data-index="${focused}"]`); } // If no focused option, look for the currently selected option else { targetElement = optionList.querySelector(`[aria-selected="true"]`); } // Scroll the target element into view within the dropdown container if (targetElement) { targetElement.scrollIntoView({ behavior: "auto", block: "nearest", inline: "nearest", }); } }, [focused]); const buildOptionsHtml = (options: SelectOption[]) => [ ...options.map((option) => ( )), ]; const getOptionsList = () => { // Group items by groupLabel const grouped = groupBy(results, "groupLabel"); // Turn grouped object into an array with groups and values const entries = Object.entries(grouped); // Map over grouped items to render return entries.map(([groupName, options]) => { // If there is a valid groupName if (groupName !== "undefined") { return (
{buildOptionsHtml(options)}
); } // If the groupName is "undefined" return buildOptionsHtml(options); }); }; const getOptionsPosition = (): CSSProperties | undefined => { const maxHeight = 300; // Maximum height of the options dropdown // If the button exists, set styles based on its position if (buttonPosition && optionsListRef.current) { // This logic checks to see if the options should be above or below the button const buttonBottom = buttonPosition.y + buttonPosition.height; // Get search height to account for that in the height calculation const searchHeight = searchRef.current?.scrollHeight || 0; // Get the height of the options list const optionsListHeight = optionsListRef.current.scrollHeight; // Calculate the height of the options list (capped at maxHeight) const optionsHeight = optionsListHeight + searchHeight > maxHeight ? maxHeight - searchHeight : optionsListHeight + searchHeight; // Determine if there is enoughy room below the button to show the entire options list const isEnoughRoomBelow = buttonBottom + optionsHeight < window.innerHeight; const selectStyle = selectRef.current?.style; selectStyle?.setProperty("--options-width", `${buttonPosition.width}px`); selectStyle?.setProperty( "--options-top", isEnoughRoomBelow ? `${buttonBottom}px` : `${buttonPosition.y - optionsHeight + 8}px` ); selectStyle?.setProperty("--options-height", `${optionsHeight}px`); } return undefined; }; const handleToggleVisibility = () => { const isHidden = isVisible; setIsVisible(!isHidden); if (!isHidden) { // Get the position of the button when it is clicked if (buttonRef.current) setButtonPosition(buttonRef.current.getBoundingClientRect()); // Reset state and scroll to selected option setFocused(0); setQuery(""); setResults(getOptions()); searchRef.current?.focus(); // Use setTimeout to ensure the DOM is updated before scrolling setTimeout(() => { scrollIntoView(); }, 0); } }; const handleChange = (e: ChangeEvent) => { if (onChangeConfirm) { // If a confirm modal is needed, use that handler onChangeConfirm( e.target.value, String(internalValue), () => { if (onChange) { onChange(e.target.value); } setInternalValue(e.target.value); }, () => {} ); } // If no confirm modal is needed, use the normal handler else { if (onChange) onChange?.(e.target.value); setInternalValue(e.target.value); } // Reset state handleReset(); }; const handleClickOption = (value: string | number) => { if (value === currentSelection?.value) { handleReset(); } }; const handleReset = () => { if (resetAfterChoice) setInternalValue(""); setIsVisible(false); setQuery(""); setResults(getOptions()); }; const handleSearch = (e) => { // Reset results to default if (!e.target.value) { setResults(getOptions()); } else { const filteredOptions = getOptions() .filter((opt) => opt.label.toLowerCase().includes(e.target.value.toLowerCase()) ) .map((opt, index) => ({ ...opt, index, })); setResults(filteredOptions); } setQuery(e.target.value); }; const handleKeypress = (e) => { // Get the currently focused element in the DOM const activeElement = document.activeElement as HTMLElement; switch (e.key) { case "ArrowDown": e.preventDefault(); if (isVisible) { // Go to the next option if (focused < results.length) { setFocused(focused + 1); } // If there are no more options, go back to the top (placeholder at index 0) else { setFocused(0); } } // If the button is focused and the dropdown is closed, open it else if (activeElement === buttonRef.current) { setIsVisible(true); setFocused(currentSelection?.index || 0); } scrollIntoView(); break; case "ArrowUp": e.preventDefault(); if (isVisible) { // Go to the previous option if (focused > 0) { setFocused(focused - 1); } // If there are no previous options, go back to the bottom else { setFocused(results.length); } } // If the button is focused and the dropdown is closed, open it else if (activeElement === buttonRef.current) { setIsVisible(true); setFocused(currentSelection?.index || results.length); } scrollIntoView(); break; case "Enter": e.preventDefault(); // If the dropdown is open, select the focused option if (isVisible) { if (focused === 0) { // Handle the placeholder option (empty value) handleChange({ target: { value: "" }, } as ChangeEvent); buttonRef.current.focus(); } else { const focusedOption = results.find((opt) => opt.index === focused); if (focusedOption) { handleChange({ target: { value: focusedOption.value.toString() }, } as ChangeEvent); buttonRef.current.focus(); } } } // If the button is focused and the dropdown is closed, open it else if (activeElement === buttonRef.current) { handleToggleVisibility(); } scrollIntoView(); break; case "Escape": handleReset(); break; case "Tab": if (isVisible) { // If the search is focused when tab is pressed go to item if (document.activeElement === searchRef.current) { e.preventDefault(); setFocused(currentSelection?.index || 0); scrollIntoView(); searchRef.current.blur(); } else { setFocused(0); searchRef.current?.focus(); handleToggleVisibility(); } } break; default: break; } }; useEffect(() => { const selectElement = selectRef.current; if (!selectElement) return; selectElement.addEventListener("keydown", handleKeypress); return () => { selectElement.removeEventListener("keydown", handleKeypress); }; }); useEffect( () => { // keep the search active is searchThreshold is set and results are more than the threshold - this prevents the search from going away if the search results are less than the threshold after a query is entered isSearchActive.current = !!searchThreshold && searchThreshold >= 0 && results.length > searchThreshold; }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ); useEffect(() => { // If the select is visible, prevent the page from scrolling. This prevents us from having to // recalculate the position of the option group on scroll. document.body.style.overflowY = isVisible ? "hidden" : "unset"; getOptionsPosition(); // Scroll to the selected item when the dropdown becomes visible if (isVisible) { setTimeout(() => { scrollIntoView(); }, 0); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible, scrollIntoView]); const optionsKey = useMemo( () => options.map((o) => ("value" in o ? o.value : o.optGroupLabel)).join(","), // eslint-disable-next-line react-hooks/exhaustive-deps [options] ); useEffect(() => { if (!isSearchActive.current) { setResults(getOptions()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [optionsKey]); useEffect(() => { if (currentSelection) { setFocused(currentSelection.index); } }, [currentSelection]); useEffect(() => { setInternalValue(value || ""); }, [value]); return (
{ e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }} {...props} >
{isSearchActive.current && ( )}
{!!searchThreshold && query.length > 0 && results.length < 1 && ( )} {!query && !hidePlaceholderOption && ( )} {getOptionsList()}
); };