Files

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>
);
};