"use client"; import { ChevronDown } from "lucide-react"; import { motion } from "motion/react"; import { type ReactNode, useEffect, useState } from "react"; import { isLightHexColor, normalizeHexColor } from "@/lib/color"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface SettingsGroupProps { /** Card keeps the preference shell; quiet becomes a flat hairline group. */ variant?: "card" | "quiet"; title?: ReactNode; /** Header-right slot, e.g. a `SettingsGhostButton` plus a `SettingsSelectButton`. */ actions?: ReactNode; className?: string; children?: ReactNode; } /** * Card shell for a group of preference rows. Renders a header row (title * left, `actions` right) only when either is passed; `children` — typically * a stack of `SettingsRow`s — sit in a region whose `[&>*+*]` selector draws * a hairline divider between adjacent rows without each row needing to know * about its neighbors. */ export function SettingsGroup({ variant = "card", title, actions, className, children, }: SettingsGroupProps) { const quiet = variant === "quiet"; return (
{title || actions ? (
{title ?
{title}
: } {actions ?
{actions}
: null}
) : null}
{children}
); } export interface SettingsRowProps { label: ReactNode; /** Muted one-liner rendered under `label`. */ description?: ReactNode; className?: string; /** Trailing control slot, e.g. a `SettingsColorField` or a `Switch`. */ children?: ReactNode; } /** One preference line inside a `SettingsGroup` — label (+ optional description) on the left, a control slot on the right. */ export function SettingsRow({ label, description, className, children }: SettingsRowProps) { return (
{label}
{description ?
{description}
: null}
{children ?
{children}
: null}
); } export interface SettingsColorFieldProps { /** Current color as `#RRGGBB` (or `#RGB`); the pill background and text contrast follow it. */ value: string; onChange?: (hex: string) => void; "aria-label"?: string; className?: string; } /** * Hex-color pill — a decorative ring dot plus an editable mono hex code, the * pill's own background painted in `value` with a smooth color transition. * Text and ring contrast auto-flip (dark ink on light swatches, white ink on * dark ones) via `isLightColor`. Typing edits a local draft so partial input * doesn't get clobbered by the controlled `value`; Enter or blur commits — * a valid hex calls `onChange`, an invalid one snaps the draft back to the * last committed `value`. */ export function SettingsColorField({ value, onChange, "aria-label": ariaLabel, className, }: SettingsColorFieldProps) { const [draft, setDraft] = useState(value); useEffect(() => { setDraft(value); }, [value]); const light = isLightHexColor(value); const textColor = light ? "#101010" : "#FFFFFF"; const commit = () => { const normalized = normalizeHexColor(draft); if (normalized) { setDraft(normalized); if (normalized !== value.toUpperCase()) onChange?.(normalized); } else { setDraft(value); } }; return ( setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); commit(); } }} aria-label={ariaLabel} spellCheck={false} style={{ color: textColor }} className="w-full bg-transparent font-mono text-xs uppercase outline-none" /> ); } export interface SettingsTextFieldProps { value: string; onChange?: (next: string) => void; placeholder?: string; "aria-label"?: string; className?: string; } /** Right-aligned lightweight text input, e.g. for a custom UI-font stack. */ export function SettingsTextField({ value, onChange, placeholder, "aria-label": ariaLabel, className, }: SettingsTextFieldProps) { return ( onChange?.(event.target.value)} placeholder={placeholder} aria-label={ariaLabel} className={cn( "h-7 w-[176px] truncate rounded-lg border border-[var(--wb-control-hairline)] bg-[var(--wb-inset-faint)] px-2 text-sm text-muted-foreground outline-none focus:text-foreground", className, )} /> ); } export interface SettingsSelectButtonProps { /** 14px leading icon. */ icon?: ReactNode; onClick?: () => void; children?: ReactNode; className?: string; "aria-label"?: string; } /** * Preset-picker-styled trigger button — icon, label, trailing chevron. It * doesn't render a dropdown itself; wire `onClick` to whatever popover/select * the caller already has (see the preview for a stateless demo usage). */ export function SettingsSelectButton({ icon, onClick, children, className, "aria-label": ariaLabel, }: SettingsSelectButtonProps) { return ( ); } export interface SettingsGhostButtonProps { onClick?: () => void; children?: ReactNode; className?: string; } /** Borderless header action, e.g. "Import" or "Copy" beside a `SettingsGroup` title. */ export function SettingsGhostButton({ onClick, children, className }: SettingsGhostButtonProps) { return ( ); }