"use client"; import { motion } from "motion/react"; import { FAB_MENU_GAP, FAB_MENU_ITEM_H, H, Item, Kind, MEASURED, NAV_BAR_H, Palette, Radii, STATUS_BAR_H, baseRadii, cardFillOf, onToken, scaleR, sizeOf, variantShadow, variantStyle, SETTLE_MS, RAIL_W, RAIL_TOP, RAIL_ITEM_H, RAIL_GAP, } from "../lib/tokens"; import { CircularProgress, LinearProgress, LoadingIndicator } from "./Loading"; import { t, useLang } from "../lib/i18n"; import { useTheme } from "../lib/theme"; /** weight of a heading or label: heavier under the emphasized type setting */ const useWeight = () => { const emphasized = useTheme().emphasized; return (normal: number, strong: number) => (emphasized ? strong : normal); }; export function Icon({ name, size = 24, color, fill, weight, }: { name: string; size?: number; color?: string; fill?: boolean; weight?: number; }) { return ( {name} ); } const ellipsis = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", } as const; const NO_BOX: Kind[] = [ "circularProgress", "linearProgress", "loadingIndicator", "switch", "checkbox", "radio", "slider", "text", "divider", "splitButton", "fabMenu", "badge", ]; /** Padding follows M3: icon+label is tighter than label alone. */ export function ButtonContent({ item }: { item: Item }) { const w = useWeight(); const hasIcon = !!item.icon; const hasLabel = item.label.trim().length > 0; const padX = hasLabel ? (hasIcon ? 22 : 26) : 16; return ( {hasIcon && } {hasLabel && {item.label}} ); } function ExtendedFabContent({ item }: { item: Item }) { const w = useWeight(); const hasIcon = !!item.icon; const hasLabel = item.label.trim().length > 0; return ( {hasIcon && } {hasLabel && {item.label}} ); } function ChipContent({ item, p }: { item: Item; p: Palette }) { const on = !!item.checked; const lead = on ? "check" : item.icon; return ( {lead && } {item.label} ); } function SwitchContent({ item, p }: { item: Item; p: Palette }) { const hasLabel = item.label.trim().length > 0; return ( {hasLabel && {item.label}} ); } /** the M3 switch track and handle, 52 × 32 */ function SwitchControl({ on, noCheck, p }: { on: boolean; noCheck?: boolean; p: Palette }) { return ( {on && !noCheck && } ); } function CheckboxContent({ item, p }: { item: Item; p: Palette }) { const on = !!item.checked; const hasLabel = item.label.trim().length > 0; return ( {on && } {hasLabel && {item.label}} ); } function TextContent({ item, p }: { item: Item; p: Palette }) { const w = useWeight(); const fs = item.size ?? 28; return ( = 22 ? 600 : 500), letterSpacing: fs >= 28 ? -0.25 : 0, color: p.onSurface, whiteSpace: "nowrap", padding: "0 2px", }} > {item.label || " "} ); } /** A split button: the labeled action and, after a hairline gap, a menu trigger. * The two halves keep their own corners, so the box around them stays plain. */ function SplitButtonContent({ item, p }: { item: Item; p: Palette }) { const w = useWeight(); const st = variantStyle(item.variant, p); const outer = scaleR(28); const inner = scaleR(8); const hasLabel = item.label.trim().length > 0; const shadow = variantShadow(item.variant); return ( {item.icon && } {hasLabel && {item.label}} ); } function RadioContent({ item, p }: { item: Item; p: Palette }) { const on = !!item.checked; const hasLabel = item.label.trim().length > 0; return ( {on && } {hasLabel && {item.label}} ); } /** A badge: a 6dp dot when it has no text, a 16dp pill with the count otherwise. */ function BadgeContent({ item, p }: { item: Item; p: Palette }) { const text = item.label.trim(); if (!text) return ; return ( {text} ); } /** Content for kinds that size to their text; rendered again offscreen to measure. */ export function MeasuredContent({ item, p }: { item: Item; p: Palette }) { switch (item.kind) { case "button": return ; case "extendedFab": return ; case "chip": return ; case "switch": return ; case "checkbox": return ; case "text": return ; case "splitButton": return ; case "radio": return ; case "badge": return ; default: return null; } } function Body({ item, p }: { item: Item; p: Palette }) { const lang = useLang(); const w = useWeight(); const hasLabel = item.label.trim().length > 0; const hasSupporting = !!item.supporting?.trim(); if (MEASURED.includes(item.kind)) return ; switch (item.kind) { case "box": return item.checked ? (
) : null; case "iconButton": { const s = item.size ?? 48; return (
{item.icon && }
); } case "fab": { const s = item.size ?? 56; return (
{item.icon && }
); } case "topAppBar": return (
{item.icon && }
{item.label}
{item.icon2 && }
); case "searchBar": return (
{item.icon && }
{item.label}
{item.icon2 && }
); case "card": { const cw = item.size ?? 320; return (
{!item.noImage && (
{item.src ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( item.icon && )}
)} {hasLabel && (
{item.label}
)} {hasSupporting && (
{item.supporting}
)}
); } case "listItem": { const iconBg = item.iconFill === "none" ? null : (item.iconFill ?? "primaryContainer"); return (
{item.icon && (
)}
{hasLabel &&
{item.label}
} {hasSupporting && (
{item.supporting}
)}
{item.switch ? : item.icon2 && }
); } case "dialog": return (
{item.icon && (
)} {hasLabel && (
{item.label}
)} {hasSupporting && (
{item.supporting}
)}
{[t("cancel", lang), t("ok", lang)].map((t) => ( {t} ))}
); case "snackbar": return (
{item.label}
{hasSupporting && ( {item.supporting} )}
); case "textField": { const filled = item.variant === "filled"; return (
{item.icon && } {filled ? "" : ""}
{hasLabel && ( {item.label} )} {filled && ( )} {hasSupporting && ( {item.supporting} )}
); } case "slider": { const v = Math.min(100, Math.max(0, item.value ?? 40)) / 100; const w = item.size ?? 280; const handleX = 2 + (w - 4) * v; return (
); } case "image": if (item.src) { return ( // eslint-disable-next-line @next/next/no-img-element ); } return (
{item.icon && }
); case "divider": return (
); case "navRail": { const tabs = item.tabs ?? []; return (
{tabs.map((t, i) => { const on = i === Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1)); const withLabel = t.label.trim().length > 0; return (
{t.icon && }
{withLabel && ( {t.label} )}
); })}
); } case "bottomNav": { const tabs = item.tabs ?? []; return (
{tabs.map((t, i) => { const on = i === Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1)); const withLabel = t.label.trim().length > 0; return (
{t.icon && }
{withLabel && ( {t.label} )}
); })}
); } case "circularProgress": return ( ); case "linearProgress": return (
); case "fabMenu": { const tabs = item.tabs ?? []; const filled = item.variant === "filled"; const fabStyle = variantStyle(item.variant, p); const itemStyle = filled ? { background: p.primaryContainer, color: p.onPrimaryContainer } : { background: p.secondaryContainer, color: p.onSecondaryContainer }; return (
{tabs.map((tab, i) => ( {tab.icon && } {tab.label} ))} {item.icon && }
); } case "toolbar": { const tabs = item.tabs ?? []; const vibrant = item.variant === "filled"; return (
{tabs.map((tab, i) => ( {tab.icon && } ))}
); } case "tabs": { const tabs = item.tabs ?? []; return (
{tabs.map((tab, i) => { const on = i === Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1)); return (
{tab.label} {on && ( )}
); })}
); } case "loadingIndicator": { const s = item.size ?? 48; return ( ); } } return null; } function boxStyle(item: Item, p: Palette): React.CSSProperties { if (NO_BOX.includes(item.kind)) return { background: "transparent", border: "none" }; switch (item.kind) { case "box": { const t = item.fill ?? "surfaceContainerLow"; return { background: p[t], color: onToken(t, p), border: "none" }; } case "button": case "iconButton": case "fab": case "extendedFab": return variantStyle(item.variant, p); case "chip": if (item.checked) return { background: p.secondaryContainer, color: p.onSecondaryContainer, border: "none" }; return item.variant === "outlined" ? { background: "transparent", color: p.onSurfaceVariant, border: `1px solid ${p.outlineVariant}` } : { background: p.surfaceContainerLow, color: p.onSurfaceVariant, border: "none" }; case "card": return { background: p[cardFillOf(item)], border: item.variant === "outlined" ? `1px solid ${p.outlineVariant}` : "none" }; case "textField": return item.variant === "filled" ? { background: p.surfaceContainerHighest, border: "none", color: p.onSurface } : { background: p.surface, border: `1px solid ${p.outline}`, color: p.onSurface }; case "topAppBar": case "bottomNav": case "navRail": return { background: p.surfaceContainer, border: "none", color: p.onSurface }; case "toolbar": return item.variant === "filled" ? { background: p.primaryContainer, border: "none", color: p.onPrimaryContainer } : { background: p.surfaceContainer, border: "none", color: p.onSurfaceVariant }; case "tabs": return { background: p.surface, border: "none", color: p.onSurface }; case "searchBar": return { background: p.surfaceContainerHigh, border: "none", color: p.onSurface }; case "dialog": return { background: p.surfaceContainerHigh, border: "none", color: p.onSurface }; case "snackbar": return { background: p.inverseSurface, border: "none", color: p.inverseOnSurface }; case "image": return { background: p.surfaceContainerHighest, border: "none" }; case "listItem": { const t = item.fill ?? "surfaceContainerLow"; return { background: p[t], border: "none", color: onToken(t, p) }; } default: return { background: p.surfaceContainerHigh, border: "none", color: p.onSurface }; } } function shadowOf(item: Item): string { if (NO_BOX.includes(item.kind)) return "none"; switch (item.kind) { case "button": case "iconButton": case "extendedFab": return variantShadow(item.variant); case "fab": return "0 3px 8px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)"; case "card": return item.variant === "elevated" ? "0 1px 3px rgba(0,0,0,0.20), 0 2px 6px rgba(0,0,0,0.10)" : "none"; case "dialog": return "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.10)"; case "snackbar": return "0 3px 8px rgba(0,0,0,0.18)"; case "toolbar": return "0 2px 6px rgba(0,0,0,0.14), 0 1px 2px rgba(0,0,0,0.10)"; default: return "none"; } } export type { Radii }; /** Corner radii are driven continuously by the magnet; a stiff spring keeps them on the pointer. */ const RADIUS_TWEEN = { type: "spring" as const, stiffness: 900, damping: 48, mass: 0.4 }; export function M3Node({ item, palette, radii, widths, pressed, dragging, selected, interactive = true, onPointerDown, }: { item: Item; palette: Palette; radii?: Radii; widths: Record; pressed?: boolean; dragging?: boolean; selected?: boolean; interactive?: boolean; onPointerDown?: (e: React.PointerEvent) => void; }) { const r = radii ?? baseRadii(item); const size = sizeOf(item, widths); const measured = MEASURED.includes(item.kind) && !((item.kind === "switch" || item.kind === "button") && item.size); const clips = !NO_BOX.includes(item.kind) && item.kind !== "textField"; return ( ); } /** Plain (non-animated) rendering of a part; used where frames must be deterministic. */ export function M3Static({ item, palette, radii, style, }: { item: Item; palette: Palette; radii?: Radii; style?: React.CSSProperties; }) { const r = radii ?? baseRadii(item); const size = sizeOf(item, {}); const measured = MEASURED.includes(item.kind) && !((item.kind === "switch" || item.kind === "button") && item.size); const clips = !NO_BOX.includes(item.kind) && item.kind !== "textField"; return (
); }