New source found from dndbeyond.com
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user