fix: stabilize kumo ui components
This commit is contained in:
@@ -117,6 +117,47 @@ import { Table } from "@/components/ui/table";
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
```typescript
|
||||
|
||||
7
app/(app)/app/template.tsx
Normal file
7
app/(app)/app/template.tsx
Normal 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>;
|
||||
}
|
||||
105
app/globals.css
105
app/globals.css
@@ -101,6 +101,17 @@ a,
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes route-transition {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.375rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-progress {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
@@ -128,6 +139,10 @@ a,
|
||||
animation: page-enter 180ms ease-out both;
|
||||
}
|
||||
|
||||
.route-transition {
|
||||
animation: route-transition 180ms ease-out both;
|
||||
}
|
||||
|
||||
.table-row-enter {
|
||||
animation: page-enter 160ms ease-out both;
|
||||
}
|
||||
@@ -141,9 +156,15 @@ a,
|
||||
}
|
||||
|
||||
.teatea-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
min-height: 2.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
@@ -152,8 +173,13 @@ a,
|
||||
opacity 140ms ease;
|
||||
}
|
||||
|
||||
.teatea-button :where(svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.teatea-button:focus-visible,
|
||||
.teatea-control:focus-visible,
|
||||
.teatea-select-trigger:focus-visible,
|
||||
:where([data-kumo-component="Select"][data-kumo-part="trigger"]):focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 24%, transparent);
|
||||
@@ -198,6 +224,7 @@ a,
|
||||
}
|
||||
|
||||
.teatea-control,
|
||||
.teatea-select-trigger,
|
||||
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) {
|
||||
min-height: 2.5rem;
|
||||
width: 100%;
|
||||
@@ -207,6 +234,7 @@ a,
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 7%, transparent);
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
@@ -218,7 +246,12 @@ a,
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.teatea-select-trigger,
|
||||
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -226,27 +259,89 @@ a,
|
||||
}
|
||||
|
||||
.teatea-control:hover,
|
||||
.teatea-select-trigger:hover,
|
||||
:where([data-kumo-component="Select"][data-kumo-part="trigger"]):hover {
|
||||
border-color: color-mix(in oklch, var(--primary) 45%, var(--input));
|
||||
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 {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.teatea-control:disabled,
|
||||
.teatea-button:disabled,
|
||||
.teatea-select-trigger:disabled,
|
||||
:where([data-kumo-component="Select"][data-kumo-part="trigger"][data-disabled]) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.teatea-select-trigger :where(svg),
|
||||
.teatea-select :where(svg),
|
||||
:where([data-kumo-component="Select"][data-kumo-part="trigger"]) :where(svg) {
|
||||
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"])) {
|
||||
z-index: 60;
|
||||
outline: none;
|
||||
@@ -332,6 +427,14 @@ a,
|
||||
}
|
||||
|
||||
.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-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
@@ -348,8 +451,10 @@ a,
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter,
|
||||
.route-transition,
|
||||
.table-row-enter,
|
||||
.dialog-panel-enter,
|
||||
.teatea-select-panel,
|
||||
.loading-progress {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,26 @@ const modeCopy: Record<AuthMode, { title: string; badge: string; submit: string
|
||||
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 {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -48,11 +68,14 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
||||
fetch("/api/auth/session"),
|
||||
fetch("/api/organizations"),
|
||||
]);
|
||||
const bootstrap = (await bootstrapResponse.json()) as ApiResult<{ setupRequired: boolean }>;
|
||||
const session = (await sessionResponse.json()) as ApiResult<{ account: unknown | null }>;
|
||||
const organizationsResult = (await organizationsResponse.json()) as ApiResult<{ organizations: Organization[] }>;
|
||||
const bootstrap = await readApiResult<{ setupRequired: boolean }>(bootstrapResponse);
|
||||
const session = await readApiResult<{ account: unknown | null }>(sessionResponse);
|
||||
const organizationsResult = await readApiResult<{ organizations: Organization[] }>(organizationsResponse);
|
||||
|
||||
if (!isMounted || !bootstrap.success || !session.success) {
|
||||
if (isMounted) {
|
||||
setMessage(!bootstrap.success ? bootstrap.reason : session.reason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,7 +145,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
||||
invitationToken,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ account: unknown; pendingApproval?: boolean }>;
|
||||
const result = await readApiResult<{ account: unknown; pendingApproval?: boolean }>(response);
|
||||
setIsPending(false);
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -5,8 +5,13 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
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 [isPending, setIsPending] = useState(false);
|
||||
|
||||
@@ -25,6 +30,7 @@ export function SignOutButton(): React.ReactElement {
|
||||
onClick={handleSignOut}
|
||||
disabled={isPending}
|
||||
aria-label="退出登录"
|
||||
className={cn(className)}
|
||||
>
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">退出</span>
|
||||
|
||||
@@ -192,6 +192,17 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
@@ -205,12 +216,12 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
|
||||
<p className="text-sm font-medium">工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{account.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{account.email}</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 xl:hidden">
|
||||
<div className="hidden min-w-0 items-center gap-2 sm:flex">
|
||||
<p className="min-w-0 max-w-[min(52vw,24rem)] truncate whitespace-nowrap text-right text-sm leading-tight">
|
||||
<span className="font-medium">{account.name}</span>
|
||||
<span className="ml-2 text-muted-foreground">{account.email}</span>
|
||||
</p>
|
||||
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
|
||||
</div>
|
||||
<SignOutButton />
|
||||
|
||||
Reference in New Issue
Block a user