452 lines
13 KiB
TypeScript
452 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Check, ChevronDown } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type SelectOption = {
|
|
value: string;
|
|
label: React.ReactNode;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
type SelectPosition = {
|
|
left: number;
|
|
maxHeight: number;
|
|
top: number;
|
|
width: number;
|
|
};
|
|
|
|
type SelectProps = {
|
|
"aria-describedby"?: string;
|
|
"aria-label"?: string;
|
|
"aria-labelledby"?: string;
|
|
className?: string;
|
|
defaultValue?: string;
|
|
disabled?: boolean;
|
|
id?: string;
|
|
label?: React.ReactNode;
|
|
name?: string;
|
|
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
|
|
onFocus?: React.FocusEventHandler<HTMLButtonElement>;
|
|
onValueChange?: (value: string) => void;
|
|
options: SelectOption[];
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
value?: string;
|
|
};
|
|
|
|
const SELECT_GAP = 6;
|
|
const SELECT_MAX_HEIGHT = 288;
|
|
const VIEWPORT_PADDING = 8;
|
|
|
|
function getEnabledValues(options: SelectOption[]): string[] {
|
|
return options.filter((option) => option.disabled !== true).map((option) => option.value);
|
|
}
|
|
|
|
function getInitialValue(options: SelectOption[], defaultValue?: string, placeholder?: string): string {
|
|
if (defaultValue !== undefined) {
|
|
return defaultValue;
|
|
}
|
|
|
|
if (placeholder !== undefined) {
|
|
return "";
|
|
}
|
|
|
|
return getEnabledValues(options)[0] ?? "";
|
|
}
|
|
|
|
function getAdjacentValue(options: SelectOption[], currentValue: string, direction: 1 | -1): string {
|
|
const enabledValues = getEnabledValues(options);
|
|
|
|
if (enabledValues.length === 0) {
|
|
return currentValue;
|
|
}
|
|
|
|
const currentIndex = enabledValues.indexOf(currentValue);
|
|
const nextIndex =
|
|
currentIndex === -1
|
|
? direction === 1
|
|
? 0
|
|
: enabledValues.length - 1
|
|
: (currentIndex + direction + enabledValues.length) % enabledValues.length;
|
|
|
|
return enabledValues[nextIndex] ?? currentValue;
|
|
}
|
|
|
|
function getBoundaryValue(options: SelectOption[], boundary: "first" | "last", fallbackValue: string): string {
|
|
const enabledValues = getEnabledValues(options);
|
|
|
|
if (enabledValues.length === 0) {
|
|
return fallbackValue;
|
|
}
|
|
|
|
if (boundary === "first") {
|
|
return enabledValues[0] ?? fallbackValue;
|
|
}
|
|
|
|
return enabledValues[enabledValues.length - 1] ?? fallbackValue;
|
|
}
|
|
|
|
function findOption(options: SelectOption[], value: string): SelectOption | undefined {
|
|
return options.find((option) => option.value === value);
|
|
}
|
|
|
|
export function Select({
|
|
"aria-describedby": ariaDescribedBy,
|
|
"aria-label": ariaLabel,
|
|
"aria-labelledby": ariaLabelledBy,
|
|
className,
|
|
defaultValue,
|
|
disabled = false,
|
|
id,
|
|
label,
|
|
name,
|
|
onBlur,
|
|
onFocus,
|
|
onValueChange,
|
|
options,
|
|
placeholder,
|
|
required,
|
|
value,
|
|
}: SelectProps): React.ReactElement {
|
|
const generatedId = React.useId().replace(/:/g, "");
|
|
const controlId = id ?? `teatea-select-${generatedId}`;
|
|
const labelId = label ? `${controlId}-label` : undefined;
|
|
const listboxId = `${controlId}-listbox`;
|
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
const listboxRef = React.useRef<HTMLDivElement>(null);
|
|
const [portalRoot, setPortalRoot] = React.useState<HTMLElement | null>(null);
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
const [position, setPosition] = React.useState<SelectPosition | null>(null);
|
|
const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
|
|
getInitialValue(options, defaultValue, placeholder),
|
|
);
|
|
const isControlled = value !== undefined;
|
|
const selectedValue = isControlled ? value : uncontrolledValue;
|
|
const selectedOption = findOption(options, selectedValue);
|
|
const [highlightedValue, setHighlightedValue] = React.useState(selectedValue);
|
|
const highlightedIndex = options.findIndex((option) => option.value === highlightedValue);
|
|
const activeDescendant =
|
|
isOpen && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined;
|
|
|
|
const updatePosition = React.useCallback(() => {
|
|
const trigger = triggerRef.current;
|
|
|
|
if (!trigger || typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const rect = trigger.getBoundingClientRect();
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
const availableBelow = viewportHeight - rect.bottom - SELECT_GAP - VIEWPORT_PADDING;
|
|
const availableAbove = rect.top - SELECT_GAP - VIEWPORT_PADDING;
|
|
const estimatedHeight = Math.min(SELECT_MAX_HEIGHT, Math.max(120, options.length * 40 + 12));
|
|
const shouldOpenBelow = availableBelow >= estimatedHeight || availableBelow >= availableAbove;
|
|
const availableHeight = shouldOpenBelow ? availableBelow : availableAbove;
|
|
const maxHeight = Math.max(96, Math.min(SELECT_MAX_HEIGHT, availableHeight));
|
|
const left = Math.min(
|
|
Math.max(VIEWPORT_PADDING, rect.left),
|
|
Math.max(VIEWPORT_PADDING, viewportWidth - rect.width - VIEWPORT_PADDING),
|
|
);
|
|
const top = shouldOpenBelow
|
|
? Math.min(rect.bottom + SELECT_GAP, viewportHeight - maxHeight - VIEWPORT_PADDING)
|
|
: Math.max(VIEWPORT_PADDING, rect.top - SELECT_GAP - maxHeight);
|
|
|
|
setPosition({
|
|
left,
|
|
maxHeight,
|
|
top,
|
|
width: rect.width,
|
|
});
|
|
}, [options.length]);
|
|
|
|
const openSelect = React.useCallback(() => {
|
|
if (disabled || options.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const nextHighlightedValue =
|
|
findOption(options, selectedValue)?.disabled === true
|
|
? getBoundaryValue(options, "first", selectedValue)
|
|
: selectedValue;
|
|
|
|
setHighlightedValue(nextHighlightedValue);
|
|
setIsOpen(true);
|
|
}, [disabled, options, selectedValue]);
|
|
|
|
const closeSelect = React.useCallback(() => {
|
|
setIsOpen(false);
|
|
}, []);
|
|
|
|
const commitValue = React.useCallback(
|
|
(nextValue: string) => {
|
|
const nextOption = findOption(options, nextValue);
|
|
|
|
if (!nextOption || nextOption.disabled === true) {
|
|
return;
|
|
}
|
|
|
|
if (!isControlled) {
|
|
setUncontrolledValue(nextValue);
|
|
}
|
|
|
|
if (nextValue !== selectedValue) {
|
|
onValueChange?.(nextValue);
|
|
}
|
|
|
|
setHighlightedValue(nextValue);
|
|
closeSelect();
|
|
triggerRef.current?.focus();
|
|
},
|
|
[closeSelect, isControlled, onValueChange, options, selectedValue],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
setPortalRoot(document.body);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (isControlled) {
|
|
return;
|
|
}
|
|
|
|
if (findOption(options, uncontrolledValue)) {
|
|
return;
|
|
}
|
|
|
|
setUncontrolledValue(getInitialValue(options, defaultValue, placeholder));
|
|
}, [defaultValue, isControlled, options, placeholder, uncontrolledValue]);
|
|
|
|
React.useEffect(() => {
|
|
const trigger = triggerRef.current;
|
|
const form = trigger?.form;
|
|
|
|
if (!form || isControlled) {
|
|
return;
|
|
}
|
|
|
|
function handleReset(): void {
|
|
setUncontrolledValue(getInitialValue(options, defaultValue, placeholder));
|
|
}
|
|
|
|
form.addEventListener("reset", handleReset);
|
|
|
|
return () => {
|
|
form.removeEventListener("reset", handleReset);
|
|
};
|
|
}, [defaultValue, isControlled, options, placeholder]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
|
|
updatePosition();
|
|
}, [isOpen, updatePosition]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
|
|
function handlePointerDown(event: MouseEvent | TouchEvent): void {
|
|
const target = event.target;
|
|
|
|
if (!(target instanceof Node)) {
|
|
return;
|
|
}
|
|
|
|
if (triggerRef.current?.contains(target) || listboxRef.current?.contains(target)) {
|
|
return;
|
|
}
|
|
|
|
closeSelect();
|
|
}
|
|
|
|
document.addEventListener("mousedown", handlePointerDown);
|
|
document.addEventListener("touchstart", handlePointerDown);
|
|
window.addEventListener("resize", updatePosition);
|
|
window.addEventListener("scroll", updatePosition, true);
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handlePointerDown);
|
|
document.removeEventListener("touchstart", handlePointerDown);
|
|
window.removeEventListener("resize", updatePosition);
|
|
window.removeEventListener("scroll", updatePosition, true);
|
|
};
|
|
}, [closeSelect, isOpen, updatePosition]);
|
|
|
|
function handleTriggerClick(): void {
|
|
if (isOpen) {
|
|
closeSelect();
|
|
return;
|
|
}
|
|
|
|
openSelect();
|
|
}
|
|
|
|
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>): void {
|
|
switch (event.key) {
|
|
case "ArrowDown": {
|
|
event.preventDefault();
|
|
if (!isOpen) {
|
|
openSelect();
|
|
return;
|
|
}
|
|
setHighlightedValue((currentValue) => getAdjacentValue(options, currentValue, 1));
|
|
return;
|
|
}
|
|
case "ArrowUp": {
|
|
event.preventDefault();
|
|
if (!isOpen) {
|
|
openSelect();
|
|
return;
|
|
}
|
|
setHighlightedValue((currentValue) => getAdjacentValue(options, currentValue, -1));
|
|
return;
|
|
}
|
|
case "Home": {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setHighlightedValue((currentValue) => getBoundaryValue(options, "first", currentValue));
|
|
return;
|
|
}
|
|
case "End": {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setHighlightedValue((currentValue) => getBoundaryValue(options, "last", currentValue));
|
|
return;
|
|
}
|
|
case "Enter":
|
|
case " ": {
|
|
event.preventDefault();
|
|
if (!isOpen) {
|
|
openSelect();
|
|
return;
|
|
}
|
|
commitValue(highlightedValue);
|
|
return;
|
|
}
|
|
case "Escape": {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
closeSelect();
|
|
return;
|
|
}
|
|
case "Tab": {
|
|
closeSelect();
|
|
return;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
const field = (
|
|
<div className="grid gap-2">
|
|
{label ? (
|
|
<span id={labelId} className="text-sm font-medium text-foreground">
|
|
{label}
|
|
</span>
|
|
) : null}
|
|
<button
|
|
ref={triggerRef}
|
|
aria-activedescendant={activeDescendant}
|
|
aria-controls={isOpen ? listboxId : undefined}
|
|
aria-describedby={ariaDescribedBy}
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
aria-label={labelId ? undefined : ariaLabel}
|
|
aria-labelledby={ariaLabelledBy ?? labelId}
|
|
aria-required={required}
|
|
className={cn("teatea-select-trigger", className)}
|
|
data-open={isOpen ? "true" : undefined}
|
|
disabled={disabled}
|
|
id={controlId}
|
|
onBlur={onBlur}
|
|
onClick={handleTriggerClick}
|
|
onFocus={onFocus}
|
|
onKeyDown={handleKeyDown}
|
|
role="combobox"
|
|
type="button"
|
|
>
|
|
<span className={cn("min-w-0 truncate", !selectedOption && "text-muted-foreground")}>
|
|
{selectedOption?.label ?? placeholder ?? ""}
|
|
</span>
|
|
<ChevronDown
|
|
aria-hidden="true"
|
|
className={cn("size-4 shrink-0 text-primary transition-transform", isOpen && "rotate-180")}
|
|
/>
|
|
</button>
|
|
{name ? <input name={name} type="hidden" value={selectedValue} /> : null}
|
|
</div>
|
|
);
|
|
|
|
const popup =
|
|
portalRoot && isOpen && position
|
|
? createPortal(
|
|
<div
|
|
ref={listboxRef}
|
|
aria-labelledby={ariaLabelledBy ?? labelId}
|
|
className="teatea-select-panel"
|
|
id={listboxId}
|
|
role="listbox"
|
|
style={{
|
|
left: position.left,
|
|
maxHeight: position.maxHeight,
|
|
top: position.top,
|
|
width: position.width,
|
|
}}
|
|
>
|
|
{options.map((option, index) => {
|
|
const isSelected = option.value === selectedValue;
|
|
const isHighlighted = option.value === highlightedValue;
|
|
|
|
return (
|
|
<button
|
|
aria-disabled={option.disabled}
|
|
aria-selected={isSelected}
|
|
className="teatea-select-option"
|
|
data-highlighted={isHighlighted ? "true" : undefined}
|
|
data-selected={isSelected ? "true" : undefined}
|
|
disabled={option.disabled}
|
|
id={`${listboxId}-option-${index}`}
|
|
key={option.value}
|
|
onClick={() => commitValue(option.value)}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onMouseEnter={() => {
|
|
if (option.disabled !== true) {
|
|
setHighlightedValue(option.value);
|
|
}
|
|
}}
|
|
role="option"
|
|
type="button"
|
|
>
|
|
<span className="min-w-0 truncate">{option.label}</span>
|
|
{isSelected ? <Check aria-hidden="true" className="size-4 shrink-0 text-primary" /> : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>,
|
|
portalRoot,
|
|
)
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
{field}
|
|
{popup}
|
|
</>
|
|
);
|
|
}
|