fix: stabilize kumo ui components
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Select as KumoSelect } from "@cloudflare/kumo/components/select";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -11,13 +12,25 @@ export type SelectOption = {
|
||||
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;
|
||||
@@ -25,37 +38,414 @@ type SelectProps = {
|
||||
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,
|
||||
defaultValue,
|
||||
...props
|
||||
}: 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 (
|
||||
<KumoSelect<string>
|
||||
className={cn(
|
||||
"teatea-select",
|
||||
"min-h-10 w-full rounded-md border border-input bg-card px-3 py-2 text-left text-sm font-medium text-foreground shadow-sm",
|
||||
"transition-colors hover:border-primary/45 hover:bg-secondary/35",
|
||||
"focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"[&_[data-placeholder]]:text-muted-foreground [&_svg]:text-primary",
|
||||
className,
|
||||
)}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={(nextValue) => onValueChange?.(String(nextValue))}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<KumoSelect.Option key={option.value} disabled={option.disabled} value={option.value}>
|
||||
{option.label}
|
||||
</KumoSelect.Option>
|
||||
))}
|
||||
</KumoSelect>
|
||||
<>
|
||||
{field}
|
||||
{popup}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user