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

@@ -117,6 +117,47 @@ import { Table } from "@/components/ui/table";
Visible form controls and data tables should use the Kumo-backed adapters. Native hidden Visible form controls and data tables should use the Kumo-backed adapters. Native hidden
inputs are acceptable only inside adapters when needed to preserve FormData semantics. inputs are acceptable only inside adapters when needed to preserve FormData semantics.
### Select Adapter Positioning Contract
`components/ui/select.tsx` is intentionally project-owned instead of directly wrapping
`@cloudflare/kumo/components/select`. Kumo Select 2.6 can inherit Base UI
`alignItemWithTrigger=true`, which may render `data-side="none"` and overlap the trigger in
production.
The project Select adapter must:
- Preserve the local API: `options`, `value`, `defaultValue`, `name`, `placeholder`,
`disabled`, `required`, `aria-*`, and `onValueChange`.
- Render a hidden form value only inside the adapter when `name` is provided.
- Use portal/fixed positioning for the popup and keep a positive gap between trigger and
panel. Browser validation should assert `verticalOverlap === 0` on desktop and mobile.
- Keep keyboard behavior for ArrowUp/ArrowDown, Home/End, Enter/Space, Escape, and Tab.
```typescript
// Good: feature code stays on the project adapter.
<Select name="organizationId" options={organizationOptions} />
// Avoid: this can reintroduce production overlay regressions.
import { Select } from "@cloudflare/kumo/components/select";
```
### Dialog Adapter Centering Contract
`components/ui/dialog.tsx` may use Kumo Dialog primitives for accessibility, but the
project adapter owns panel geometry. Kumo Dialog includes fixed `left-1/2 top-1/2`
and translate classes, and those transforms can interact with project animations or
production CSS ordering.
The project Dialog adapter must:
- Center the panel without relying on translate transforms. Use `fixed inset-0 m-auto`
plus explicit width, max-height, and `h-fit`.
- Override Kumo minimum width so mobile dialogs stay inside `100vw`.
- Keep `transform: none` and `translate: 0 0` on `.teatea-dialog`; use
opacity/scale-only motion if animation is needed.
- Browser validation should assert the panel center is within 2px of the viewport center
on desktop and mobile.
### Use Proper Elements ### Use Proper Elements
```typescript ```typescript

View File

@@ -0,0 +1,7 @@
type AppTemplateProps = {
children: React.ReactNode;
};
export default function AppTemplate({ children }: AppTemplateProps): React.ReactElement {
return <div className="route-transition min-h-full">{children}</div>;
}

View File

@@ -101,6 +101,17 @@ a,
} }
} }
@keyframes route-transition {
from {
opacity: 0;
transform: translateY(0.375rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes loading-progress { @keyframes loading-progress {
0% { 0% {
transform: translateX(-100%); transform: translateX(-100%);
@@ -128,6 +139,10 @@ a,
animation: page-enter 180ms ease-out both; animation: page-enter 180ms ease-out both;
} }
.route-transition {
animation: route-transition 180ms ease-out both;
}
.table-row-enter { .table-row-enter {
animation: page-enter 160ms ease-out both; animation: page-enter 160ms ease-out both;
} }
@@ -141,9 +156,15 @@ a,
} }
.teatea-button { .teatea-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
min-height: 2.5rem; min-height: 2.5rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-weight: 600; font-weight: 600;
line-height: 1.25;
white-space: nowrap;
transition: transition:
background-color 140ms ease, background-color 140ms ease,
border-color 140ms ease, border-color 140ms ease,
@@ -152,8 +173,13 @@ a,
opacity 140ms ease; opacity 140ms ease;
} }
.teatea-button :where(svg) {
flex-shrink: 0;
}
.teatea-button:focus-visible, .teatea-button:focus-visible,
.teatea-control:focus-visible, .teatea-control:focus-visible,
.teatea-select-trigger:focus-visible,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]):focus-visible { :where([data-kumo-component="Select"][data-kumo-part="trigger"]):focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 24%, transparent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 24%, transparent);
@@ -198,6 +224,7 @@ a,
} }
.teatea-control, .teatea-control,
.teatea-select-trigger,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) { :where([data-kumo-component="Select"][data-kumo-part="trigger"]) {
min-height: 2.5rem; min-height: 2.5rem;
width: 100%; width: 100%;
@@ -207,6 +234,7 @@ a,
color: var(--foreground); color: var(--foreground);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
line-height: 1.35;
box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 7%, transparent); box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 7%, transparent);
transition: transition:
background-color 140ms ease, background-color 140ms ease,
@@ -218,7 +246,12 @@ a,
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
} }
.teatea-select-trigger,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) { :where([data-kumo-component="Select"][data-kumo-part="trigger"]) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
appearance: none; appearance: none;
outline: none; outline: none;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
@@ -226,27 +259,89 @@ a,
} }
.teatea-control:hover, .teatea-control:hover,
.teatea-select-trigger:hover,
:where([data-kumo-component="Select"][data-kumo-part="trigger"]):hover { :where([data-kumo-component="Select"][data-kumo-part="trigger"]):hover {
border-color: color-mix(in oklch, var(--primary) 45%, var(--input)); border-color: color-mix(in oklch, var(--primary) 45%, var(--input));
background: color-mix(in oklch, var(--secondary) 34%, white); background: color-mix(in oklch, var(--secondary) 34%, white);
} }
.teatea-select-trigger[data-open="true"] {
border-color: color-mix(in oklch, var(--primary) 58%, var(--input));
background: var(--card);
}
.teatea-control::placeholder { .teatea-control::placeholder {
color: var(--muted-foreground); color: var(--muted-foreground);
} }
.teatea-control:disabled, .teatea-control:disabled,
.teatea-button:disabled, .teatea-button:disabled,
.teatea-select-trigger:disabled,
:where([data-kumo-component="Select"][data-kumo-part="trigger"][data-disabled]) { :where([data-kumo-component="Select"][data-kumo-part="trigger"][data-disabled]) {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
.teatea-select-trigger :where(svg),
.teatea-select :where(svg), .teatea-select :where(svg),
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) :where(svg) { :where([data-kumo-component="Select"][data-kumo-part="trigger"]) :where(svg) {
color: var(--primary); color: var(--primary);
} }
.teatea-select-panel {
position: fixed;
z-index: 60;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
color: var(--foreground);
padding: 0.375rem;
box-shadow:
0 18px 44px color-mix(in oklch, var(--foreground) 14%, transparent),
0 0 0 1px color-mix(in oklch, var(--primary) 10%, transparent);
animation: page-enter 120ms ease-out both;
}
.teatea-select-option {
display: flex;
min-height: 2.25rem;
width: 100%;
cursor: pointer;
align-items: center;
justify-content: space-between;
gap: 0.625rem;
border: 0;
border-radius: calc(var(--radius-md) - 2px);
background: transparent;
color: var(--foreground);
padding: 0.5rem 0.625rem;
text-align: left;
font-size: 0.875rem;
outline: none;
}
.teatea-select-option[data-highlighted="true"],
.teatea-select-option:hover {
background: var(--secondary);
color: var(--secondary-foreground);
}
.teatea-select-option:focus-visible {
box-shadow: inset 0 0 0 2px color-mix(in oklch, var(--ring) 36%, transparent);
}
.teatea-select-option[data-selected="true"] {
color: var(--primary);
font-weight: 600;
}
.teatea-select-option:disabled {
cursor: not-allowed;
opacity: 0.5;
}
:where([role="listbox"]:has([data-kumo-component="Select"][data-kumo-part="option"])) { :where([role="listbox"]:has([data-kumo-component="Select"][data-kumo-part="option"])) {
z-index: 60; z-index: 60;
outline: none; outline: none;
@@ -332,6 +427,14 @@ a,
} }
.teatea-dialog { .teatea-dialog {
inset: 0 !important;
height: fit-content;
margin: auto !important;
max-width: calc(100vw - 1.5rem);
min-width: min(calc(100vw - 1.5rem), 48rem) !important;
transform: none !important;
translate: 0 0 !important;
width: min(calc(100vw - 1.5rem), 48rem) !important;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--card); background: var(--card);
@@ -348,8 +451,10 @@ a,
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.page-enter, .page-enter,
.route-transition,
.table-row-enter, .table-row-enter,
.dialog-panel-enter, .dialog-panel-enter,
.teatea-select-panel,
.loading-progress { .loading-progress {
animation: none; animation: none;
} }

View File

@@ -16,13 +16,34 @@ const variantMap: Record<LegacyBadgeVariant, KumoBadgeProps["variant"]> = {
danger: "error", danger: "error",
}; };
export type BadgeProps = Omit<KumoBadgeProps, "variant" | "children"> & { export type BadgeProps = Omit<KumoBadgeProps, "variant"> & {
variant?: LegacyBadgeVariant | KumoBadgeProps["variant"]; variant?: LegacyBadgeVariant | KumoBadgeProps["variant"];
children?: React.ReactNode;
}; };
export function Badge({ className, variant = "default", ...props }: BadgeProps): React.ReactElement { function isLegacyBadgeVariant(variant: BadgeProps["variant"]): variant is LegacyBadgeVariant {
const mappedVariant = variant in variantMap ? variantMap[variant as LegacyBadgeVariant] : (variant as KumoBadgeProps["variant"]); switch (variant) {
case "default":
return <KumoBadge className={cn("teatea-badge", className)} variant={mappedVariant} {...(props as KumoBadgeProps)} />; 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", icon: "base",
}; };
export type ButtonProps = Omit<KumoButtonProps, "variant" | "size" | "shape"> & { type BaseButtonProps = Omit<KumoButtonProps, "variant" | "size" | "shape"> & {
variant?: ButtonVariant; 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"> & { export type LinkButtonProps = Omit<KumoLinkButtonProps, "variant" | "size"> & {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: Exclude<ButtonSize, "icon">; size?: Exclude<ButtonSize, "icon">;
@@ -40,17 +50,34 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function
{ className, variant = "default", size = "default", ...props }, { className, variant = "default", size = "default", ...props },
ref, ref,
): React.ReactElement { ): React.ReactElement {
const kumoProps = { if (size === "icon") {
className: cn("teatea-button", `teatea-button-${variant}`, className), const iconAriaLabel = props["aria-label"];
ref,
shape: size === "icon" ? "square" : "base", if (!iconAriaLabel) {
size: sizeMap[size], throw new Error("Icon buttons require an aria-label.");
variant: variantMap[variant], }
...props,
} as KumoButtonProps & React.RefAttributes<HTMLButtonElement>; 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 ( 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 <KumoDialog
className={cn( 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", "data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
className, className,
)} )}

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import * as React from "react"; 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"; import { cn } from "@/lib/utils";
@@ -11,13 +12,25 @@ export type SelectOption = {
disabled?: boolean; disabled?: boolean;
}; };
type SelectPosition = {
left: number;
maxHeight: number;
top: number;
width: number;
};
type SelectProps = { type SelectProps = {
"aria-describedby"?: string;
"aria-label"?: string; "aria-label"?: string;
"aria-labelledby"?: string;
className?: string; className?: string;
defaultValue?: string; defaultValue?: string;
disabled?: boolean; disabled?: boolean;
id?: string;
label?: React.ReactNode; label?: React.ReactNode;
name?: string; name?: string;
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
onFocus?: React.FocusEventHandler<HTMLButtonElement>;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
options: SelectOption[]; options: SelectOption[];
placeholder?: string; placeholder?: string;
@@ -25,37 +38,414 @@ type SelectProps = {
value?: string; 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({ export function Select({
"aria-describedby": ariaDescribedBy,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
className, className,
defaultValue,
disabled = false,
id,
label,
name,
onBlur,
onFocus,
onValueChange, onValueChange,
options, options,
placeholder, placeholder,
required,
value, value,
defaultValue,
...props
}: SelectProps): React.ReactElement { }: 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 ( return (
<KumoSelect<string> <>
className={cn( {field}
"teatea-select", {popup}
"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>
); );
} }

View File

@@ -25,6 +25,26 @@ const modeCopy: Record<AuthMode, { title: string; badge: string; submit: string
setup: { title: "初始化机构", badge: "First run", submit: "完成初始化" }, setup: { title: "初始化机构", badge: "First run", submit: "完成初始化" },
}; };
async function readApiResult<T extends Record<string, unknown>>(response: Response): Promise<ApiResult<T>> {
const text = await response.text();
if (!text) {
return {
success: false,
reason: response.ok ? "服务器返回了空响应" : `请求失败:${response.status}`,
};
}
try {
return JSON.parse(text) as ApiResult<T>;
} catch {
return {
success: false,
reason: response.ok ? "服务器返回了无法解析的数据" : `请求失败:${response.status}`,
};
}
}
function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -48,11 +68,14 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
fetch("/api/auth/session"), fetch("/api/auth/session"),
fetch("/api/organizations"), fetch("/api/organizations"),
]); ]);
const bootstrap = (await bootstrapResponse.json()) as ApiResult<{ setupRequired: boolean }>; const bootstrap = await readApiResult<{ setupRequired: boolean }>(bootstrapResponse);
const session = (await sessionResponse.json()) as ApiResult<{ account: unknown | null }>; const session = await readApiResult<{ account: unknown | null }>(sessionResponse);
const organizationsResult = (await organizationsResponse.json()) as ApiResult<{ organizations: Organization[] }>; const organizationsResult = await readApiResult<{ organizations: Organization[] }>(organizationsResponse);
if (!isMounted || !bootstrap.success || !session.success) { if (!isMounted || !bootstrap.success || !session.success) {
if (isMounted) {
setMessage(!bootstrap.success ? bootstrap.reason : session.reason);
}
return; return;
} }
@@ -122,7 +145,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
invitationToken, invitationToken,
}), }),
}); });
const result = (await response.json()) as ApiResult<{ account: unknown; pendingApproval?: boolean }>; const result = await readApiResult<{ account: unknown; pendingApproval?: boolean }>(response);
setIsPending(false); setIsPending(false);
if (!result.success) { if (!result.success) {

View File

@@ -5,8 +5,13 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function SignOutButton(): React.ReactElement { type SignOutButtonProps = {
className?: string;
};
export function SignOutButton({ className }: SignOutButtonProps): React.ReactElement {
const router = useRouter(); const router = useRouter();
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
@@ -25,6 +30,7 @@ export function SignOutButton(): React.ReactElement {
onClick={handleSignOut} onClick={handleSignOut}
disabled={isPending} disabled={isPending}
aria-label="退出登录" aria-label="退出登录"
className={cn(className)}
> >
<LogOut className="size-4" aria-hidden="true" /> <LogOut className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">退</span> <span className="hidden sm:inline">退</span>

View File

@@ -192,6 +192,17 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
))} ))}
</div> </div>
</nav> </nav>
<div className="border-t p-3">
<div className="flex min-w-0 items-center gap-3 rounded-md bg-secondary/55 p-2">
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-tight">{account.name}</p>
<p className="truncate text-xs leading-tight text-muted-foreground">{account.email}</p>
</div>
</div>
<SignOutButton className="mt-2 w-full justify-start" />
</div>
</div> </div>
</aside> </aside>
@@ -205,12 +216,12 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
<p className="text-sm font-medium"></p> <p className="text-sm font-medium"></p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2 xl:hidden">
<div className="hidden items-center gap-2 sm:flex"> <div className="hidden min-w-0 items-center gap-2 sm:flex">
<div className="text-right"> <p className="min-w-0 max-w-[min(52vw,24rem)] truncate whitespace-nowrap text-right text-sm leading-tight">
<p className="text-sm font-medium">{account.name}</p> <span className="font-medium">{account.name}</span>
<p className="text-xs text-muted-foreground">{account.email}</p> <span className="ml-2 text-muted-foreground">{account.email}</span>
</div> </p>
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" /> <UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
</div> </div>
<SignOutButton /> <SignOutButton />