546 lines
16 KiB
TypeScript
546 lines
16 KiB
TypeScript
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<HTMLDivElement>,
|
|
"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<SelectProps> = ({
|
|
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<HTMLButtonElement>(null!);
|
|
const searchRef = useRef<HTMLInputElement>(null!);
|
|
const optionsRef = useRef<HTMLDivElement>(null!);
|
|
const optionsListRef = useRef<HTMLDivElement>(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<DOMRect | null>(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) => (
|
|
<label
|
|
className={clsx([
|
|
styles.option,
|
|
focused === option.index && styles.focused,
|
|
])}
|
|
aria-selected={
|
|
(value === null && query === "" && option.index === 0) ||
|
|
String(internalValue) === String(option.value)
|
|
}
|
|
key={option.value}
|
|
data-index={option.index}
|
|
onMouseEnter={() => setFocused(option.index)}
|
|
>
|
|
<Check className={styles.check} aria-label={option.label} />
|
|
<input
|
|
type="radio"
|
|
name={name}
|
|
value={option.value}
|
|
onClick={() => handleClickOption(option.value)}
|
|
onChange={handleChange}
|
|
checked={value === option.value}
|
|
/>
|
|
{option.label}
|
|
</label>
|
|
)),
|
|
];
|
|
|
|
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 (
|
|
<div className={styles.optionGroup} key={groupName}>
|
|
<label className={styles.groupLabel}>{groupName}</label>
|
|
{buildOptionsHtml(options)}
|
|
</div>
|
|
);
|
|
}
|
|
// 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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>);
|
|
buttonRef.current.focus();
|
|
} else {
|
|
const focusedOption = results.find((opt) => opt.index === focused);
|
|
if (focusedOption) {
|
|
handleChange({
|
|
target: { value: focusedOption.value.toString() },
|
|
} as ChangeEvent<HTMLInputElement>);
|
|
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 (
|
|
<div
|
|
ref={selectRef}
|
|
className={clsx([
|
|
styles.select,
|
|
isHighlighted && styles.highlighted,
|
|
className,
|
|
])}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.nativeEvent.stopImmediatePropagation();
|
|
}}
|
|
{...props}
|
|
>
|
|
<button
|
|
className={clsx([styles.button, styles[size]])}
|
|
id={`${name}Button`}
|
|
onClick={(e) => handleToggleVisibility()}
|
|
role="combobox"
|
|
aria-haspopup="listbox"
|
|
aria-controls={`${name}Dropdown`}
|
|
aria-labelledby={`${name}Label`}
|
|
aria-expanded={isVisible}
|
|
tabIndex={0}
|
|
disabled={disabled || false}
|
|
ref={buttonRef}
|
|
>
|
|
<span className={styles.buttonText}>
|
|
{currentSelection ? currentSelection?.label : placeholder}
|
|
</span>
|
|
<ChevronDown />
|
|
</button>
|
|
<div
|
|
ref={optionsRef}
|
|
className={clsx([styles.options, styles[optionsWidth]])}
|
|
role="group"
|
|
id={`${name}Dropdown`}
|
|
>
|
|
{isSearchActive.current && (
|
|
<input
|
|
className={styles.search}
|
|
type="search"
|
|
value={query}
|
|
onChange={handleSearch}
|
|
placeholder={searchPlaceholder}
|
|
ref={searchRef}
|
|
/>
|
|
)}
|
|
<div ref={optionsListRef} className={styles.optionList} role="menu">
|
|
{!!searchThreshold && query.length > 0 && results.length < 1 && (
|
|
<label className={styles.notFound}>
|
|
No results found for <strong>{query}</strong>
|
|
</label>
|
|
)}
|
|
{!query && !hidePlaceholderOption && (
|
|
<label
|
|
className={clsx([styles.option, focused === 0 && styles.focused])}
|
|
aria-selected={!internalValue && query === ""}
|
|
data-index={0}
|
|
onMouseEnter={() => setFocused(0)}
|
|
>
|
|
<Check className={styles.check} />
|
|
<input
|
|
type="radio"
|
|
name={name}
|
|
value={""}
|
|
onClick={() => handleClickOption("")}
|
|
onChange={handleChange}
|
|
checked={value === ""}
|
|
/>
|
|
{placeholder}
|
|
</label>
|
|
)}
|
|
{getOptionsList()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|