fix: stabilize kumo ui components

This commit is contained in:
2026-07-02 18:00:58 -07:00
parent c181e6fbdd
commit 381233c675
10 changed files with 685 additions and 54 deletions

View File

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