diff --git a/.trellis/spec/frontend/components.md b/.trellis/spec/frontend/components.md index f36fe27..09f5629 100644 --- a/.trellis/spec/frontend/components.md +++ b/.trellis/spec/frontend/components.md @@ -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. + : null} + + ); + + const popup = + portalRoot && isOpen && position + ? createPortal( +
+ {options.map((option, index) => { + const isSelected = option.value === selectedValue; + const isHighlighted = option.value === highlightedValue; + + return ( + + ); + })} +
, + portalRoot, + ) + : null; + return ( - - 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) => ( - - {option.label} - - ))} - + <> + {field} + {popup} + ); } diff --git a/modules/auth/components/AuthPanel.tsx b/modules/auth/components/AuthPanel.tsx index df5c834..eb352c1 100644 --- a/modules/auth/components/AuthPanel.tsx +++ b/modules/auth/components/AuthPanel.tsx @@ -25,6 +25,26 @@ const modeCopy: Record>(response: Response): Promise> { + const text = await response.text(); + + if (!text) { + return { + success: false, + reason: response.ok ? "服务器返回了空响应" : `请求失败:${response.status}`, + }; + } + + try { + return JSON.parse(text) as ApiResult; + } 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) { diff --git a/modules/auth/components/SignOutButton.tsx b/modules/auth/components/SignOutButton.tsx index e0d5c26..2faf9fe 100644 --- a/modules/auth/components/SignOutButton.tsx +++ b/modules/auth/components/SignOutButton.tsx @@ -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)} >