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

@@ -16,13 +16,34 @@ const variantMap: Record<LegacyBadgeVariant, KumoBadgeProps["variant"]> = {
danger: "error",
};
export type BadgeProps = Omit<KumoBadgeProps, "variant" | "children"> & {
export type BadgeProps = Omit<KumoBadgeProps, "variant"> & {
variant?: LegacyBadgeVariant | KumoBadgeProps["variant"];
children?: React.ReactNode;
};
export function Badge({ className, variant = "default", ...props }: BadgeProps): React.ReactElement {
const mappedVariant = variant in variantMap ? variantMap[variant as LegacyBadgeVariant] : (variant as KumoBadgeProps["variant"]);
return <KumoBadge className={cn("teatea-badge", className)} variant={mappedVariant} {...(props as KumoBadgeProps)} />;
function isLegacyBadgeVariant(variant: BadgeProps["variant"]): variant is LegacyBadgeVariant {
switch (variant) {
case "default":
case "secondary":
case "outline":
case "success":
case "warning":
case "danger":
return true;
default:
return false;
}
}
function mapBadgeVariant(variant: BadgeProps["variant"]): KumoBadgeProps["variant"] {
if (isLegacyBadgeVariant(variant)) {
return variantMap[variant];
}
return variant;
}
export function Badge({ className, variant = "default", ...props }: BadgeProps): React.ReactElement {
const mappedVariant = mapBadgeVariant(variant);
return <KumoBadge className={cn("teatea-badge", className)} variant={mappedVariant} {...props} />;
}

View File

@@ -26,11 +26,21 @@ const sizeMap: Record<ButtonSize, KumoButtonProps["size"]> = {
icon: "base",
};
export type ButtonProps = Omit<KumoButtonProps, "variant" | "size" | "shape"> & {
type BaseButtonProps = Omit<KumoButtonProps, "variant" | "size" | "shape"> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
type TextButtonProps = BaseButtonProps & {
size?: Exclude<ButtonSize, "icon">;
};
type IconButtonProps = BaseButtonProps & {
"aria-label": string;
size: "icon";
};
export type ButtonProps = TextButtonProps | IconButtonProps;
export type LinkButtonProps = Omit<KumoLinkButtonProps, "variant" | "size"> & {
variant?: ButtonVariant;
size?: Exclude<ButtonSize, "icon">;
@@ -40,17 +50,34 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
{ className, variant = "default", size = "default", ...props },
ref,
): React.ReactElement {
const kumoProps = {
className: cn("teatea-button", `teatea-button-${variant}`, className),
ref,
shape: size === "icon" ? "square" : "base",
size: sizeMap[size],
variant: variantMap[variant],
...props,
} as KumoButtonProps & React.RefAttributes<HTMLButtonElement>;
if (size === "icon") {
const iconAriaLabel = props["aria-label"];
if (!iconAriaLabel) {
throw new Error("Icon buttons require an aria-label.");
}
return (
<KumoButton
{...props}
ref={ref}
aria-label={iconAriaLabel}
className={cn("teatea-button", `teatea-button-${variant}`, className)}
shape="square"
size={sizeMap[size]}
variant={variantMap[variant]}
/>
);
}
return (
<KumoButton {...kumoProps} />
<KumoButton
ref={ref}
className={cn("teatea-button", `teatea-button-${variant}`, className)}
size={sizeMap[size]}
variant={variantMap[variant]}
{...props}
/>
);
});

View File

@@ -40,7 +40,7 @@ export function Dialog({
>
<KumoDialog
className={cn(
"teatea-dialog dialog-panel-enter fixed left-1/2 top-1/2 z-50 flex max-h-[min(92svh,52rem)] w-[min(calc(100vw-1.5rem),48rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
"teatea-dialog fixed inset-0 z-50 m-auto flex h-fit max-h-[min(calc(100svh-1.5rem),52rem)] w-[min(calc(100vw-1.5rem),48rem)] min-w-0 flex-col overflow-hidden rounded-lg border border-kumo-line bg-kumo-base p-0 shadow-xl outline-none ring-1 ring-kumo-line",
"data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
className,
)}

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