upstream imports through a @/ alias, and this platform already binds @/* to src/apps/officer-web and @/components/* to its own shared components — so an unrewritten import resolves to the WRONG module rather than failing. the rewrite is mechanical and lives in scripts/vendor.sh so a re-vendor produces a readable diff. lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them meant editing vendored code, and Canvas.tsx references them throughout. a faithful copy carrying an unused module beats a doctored one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1252 lines
38 KiB
TypeScript
1252 lines
38 KiB
TypeScript
"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 (
|
||
<span
|
||
className="msr"
|
||
data-fill={fill ? "1" : "0"}
|
||
style={{
|
||
fontSize: size,
|
||
color,
|
||
fontVariationSettings: weight
|
||
? `"FILL" ${fill ? 1 : 0}, "wght" ${weight}, "GRAD" 0, "opsz" 24`
|
||
: undefined,
|
||
}}
|
||
>
|
||
{name}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: item.size ? "100%" : undefined,
|
||
boxSizing: "border-box",
|
||
gap: hasIcon && hasLabel ? 8 : 0,
|
||
paddingLeft: padX,
|
||
paddingRight: padX,
|
||
height: H,
|
||
fontSize: 16,
|
||
fontWeight: w(500, 700),
|
||
letterSpacing: 0.1,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{hasIcon && <Icon name={item.icon!} size={24} fill={item.variant === "filled"} />}
|
||
{hasLabel && <span>{item.label}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ExtendedFabContent({ item }: { item: Item }) {
|
||
const w = useWeight();
|
||
const hasIcon = !!item.icon;
|
||
const hasLabel = item.label.trim().length > 0;
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: hasIcon && hasLabel ? 12 : 0,
|
||
padding: "0 20px",
|
||
height: 56,
|
||
fontSize: 14,
|
||
fontWeight: w(500, 700),
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{hasIcon && <Icon name={item.icon!} size={24} />}
|
||
{hasLabel && <span>{item.label}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ChipContent({ item, p }: { item: Item; p: Palette }) {
|
||
const on = !!item.checked;
|
||
const lead = on ? "check" : item.icon;
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
paddingLeft: lead ? 8 : 16,
|
||
paddingRight: 16,
|
||
height: 32,
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
whiteSpace: "nowrap",
|
||
color: on ? p.onSecondaryContainer : undefined,
|
||
}}
|
||
>
|
||
{lead && <Icon name={lead} size={18} />}
|
||
<span>{item.label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function SwitchContent({ item, p }: { item: Item; p: Palette }) {
|
||
const hasLabel = item.label.trim().length > 0;
|
||
return (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 14, height: H - 8, whiteSpace: "nowrap", width: item.size ? "100%" : undefined, justifyContent: item.size ? "space-between" : undefined }}>
|
||
{hasLabel && <span style={{ fontSize: 16, color: p.onSurface, overflow: "hidden", textOverflow: "ellipsis" }}>{item.label}</span>}
|
||
<SwitchControl on={!!item.checked} noCheck={!!item.noCheck} p={p} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** the M3 switch track and handle, 52 × 32 */
|
||
function SwitchControl({ on, noCheck, p }: { on: boolean; noCheck?: boolean; p: Palette }) {
|
||
return (
|
||
<span
|
||
style={{
|
||
position: "relative",
|
||
width: 52,
|
||
height: 32,
|
||
borderRadius: 16,
|
||
background: on ? p.primary : p.surfaceContainerHighest,
|
||
border: on ? "2px solid transparent" : `2px solid ${p.outline}`,
|
||
boxSizing: "border-box",
|
||
flex: "0 0 auto",
|
||
transition: "background 160ms",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
top: "50%",
|
||
left: on ? 22 : 4,
|
||
width: on ? 24 : 16,
|
||
height: on ? 24 : 16,
|
||
marginTop: on ? -12 : -8,
|
||
borderRadius: 12,
|
||
background: on ? p.onPrimary : p.outline,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
color: p.onPrimaryContainer,
|
||
transition: "left 160ms, width 160ms, height 160ms",
|
||
}}
|
||
>
|
||
{on && !noCheck && <Icon name="check" size={16} weight={600} />}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function CheckboxContent({ item, p }: { item: Item; p: Palette }) {
|
||
const on = !!item.checked;
|
||
const hasLabel = item.label.trim().length > 0;
|
||
return (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, height: 40, whiteSpace: "nowrap" }}>
|
||
<span style={{ width: 40, height: 40, display: "grid", placeItems: "center", flex: "0 0 auto" }}>
|
||
<span
|
||
style={{
|
||
width: 18,
|
||
height: 18,
|
||
borderRadius: 3,
|
||
boxSizing: "border-box",
|
||
border: on ? "none" : `2px solid ${p.onSurfaceVariant}`,
|
||
background: on ? p.primary : "transparent",
|
||
color: p.onPrimary,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
}}
|
||
>
|
||
{on && <Icon name="check" size={16} weight={700} />}
|
||
</span>
|
||
</span>
|
||
{hasLabel && <span style={{ fontSize: 16, color: p.onSurface, paddingRight: 8 }}>{item.label}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function TextContent({ item, p }: { item: Item; p: Palette }) {
|
||
const w = useWeight();
|
||
const fs = item.size ?? 28;
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
fontSize: fs,
|
||
lineHeight: 1.3,
|
||
fontWeight: item.bold ? w(700, 800) : w(400, fs >= 22 ? 600 : 500),
|
||
letterSpacing: fs >= 28 ? -0.25 : 0,
|
||
color: p.onSurface,
|
||
whiteSpace: "nowrap",
|
||
padding: "0 2px",
|
||
}}
|
||
>
|
||
{item.label || " "}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 2, height: H }}>
|
||
<span
|
||
style={{
|
||
...st,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
height: H,
|
||
padding: hasLabel ? "0 20px 0 22px" : "0 16px",
|
||
borderTopLeftRadius: outer,
|
||
borderBottomLeftRadius: outer,
|
||
borderTopRightRadius: inner,
|
||
borderBottomRightRadius: inner,
|
||
fontSize: 16,
|
||
fontWeight: w(500, 700),
|
||
whiteSpace: "nowrap",
|
||
boxSizing: "border-box",
|
||
boxShadow: shadow,
|
||
}}
|
||
>
|
||
{item.icon && <Icon name={item.icon} size={22} fill={item.variant === "filled"} />}
|
||
{hasLabel && <span>{item.label}</span>}
|
||
</span>
|
||
<span
|
||
style={{
|
||
...st,
|
||
display: "inline-grid",
|
||
placeItems: "center",
|
||
width: 52,
|
||
height: H,
|
||
borderTopLeftRadius: inner,
|
||
borderBottomLeftRadius: inner,
|
||
borderTopRightRadius: outer,
|
||
borderBottomRightRadius: outer,
|
||
boxSizing: "border-box",
|
||
boxShadow: shadow,
|
||
}}
|
||
>
|
||
<Icon name="keyboard_arrow_down" size={24} />
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function RadioContent({ item, p }: { item: Item; p: Palette }) {
|
||
const on = !!item.checked;
|
||
const hasLabel = item.label.trim().length > 0;
|
||
return (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, height: 40, whiteSpace: "nowrap" }}>
|
||
<span style={{ width: 40, height: 40, display: "grid", placeItems: "center", flex: "0 0 auto" }}>
|
||
<span
|
||
style={{
|
||
width: 20,
|
||
height: 20,
|
||
borderRadius: 10,
|
||
boxSizing: "border-box",
|
||
border: `2px solid ${on ? p.primary : p.onSurfaceVariant}`,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
}}
|
||
>
|
||
{on && <span style={{ width: 10, height: 10, borderRadius: 5, background: p.primary }} />}
|
||
</span>
|
||
</span>
|
||
{hasLabel && <span style={{ fontSize: 16, color: p.onSurface, paddingRight: 8 }}>{item.label}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 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 <span style={{ display: "inline-block", width: 6, height: 6, borderRadius: 3, background: p.error }} />;
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
minWidth: 16,
|
||
height: 16,
|
||
padding: "0 4px",
|
||
borderRadius: 8,
|
||
boxSizing: "border-box",
|
||
background: p.error,
|
||
color: p.onError,
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
lineHeight: 1,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{text}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 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 <ButtonContent item={item} />;
|
||
case "extendedFab":
|
||
return <ExtendedFabContent item={item} />;
|
||
case "chip":
|
||
return <ChipContent item={item} p={p} />;
|
||
case "switch":
|
||
return <SwitchContent item={item} p={p} />;
|
||
case "checkbox":
|
||
return <CheckboxContent item={item} p={p} />;
|
||
case "text":
|
||
return <TextContent item={item} p={p} />;
|
||
case "splitButton":
|
||
return <SplitButtonContent item={item} p={p} />;
|
||
case "radio":
|
||
return <RadioContent item={item} p={p} />;
|
||
case "badge":
|
||
return <BadgeContent item={item} p={p} />;
|
||
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 <MeasuredContent item={item} p={p} />;
|
||
|
||
switch (item.kind) {
|
||
case "box":
|
||
return item.checked ? (
|
||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 16 }}>
|
||
<div
|
||
style={{
|
||
width: 32,
|
||
height: 4,
|
||
borderRadius: 2,
|
||
background: onToken(item.fill ?? "surfaceContainerLow", p),
|
||
opacity: 0.4,
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null;
|
||
|
||
case "iconButton": {
|
||
const s = item.size ?? 48;
|
||
return (
|
||
<div style={{ display: "grid", placeItems: "center", height: "100%" }}>
|
||
{item.icon && <Icon name={item.icon} size={Math.round(s / 2)} fill={item.variant === "filled"} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "fab": {
|
||
const s = item.size ?? 56;
|
||
return (
|
||
<div style={{ display: "grid", placeItems: "center", height: "100%" }}>
|
||
{item.icon && <Icon name={item.icon} size={Math.round(s * 0.42)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "topAppBar":
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
/* the inset the bar has, if any (see sizeOf) */
|
||
padding: `${sizeOf(item, {}).h - 64}px 4px 0`,
|
||
height: "100%",
|
||
boxSizing: "border-box",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
<div style={{ width: 48, height: 48, display: "grid", placeItems: "center", flex: "0 0 auto" }}>
|
||
{item.icon && <Icon name={item.icon} size={24} color={p.onSurface} />}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0, fontSize: 22, fontWeight: w(400, 600), color: p.onSurface, ...ellipsis }}>
|
||
{item.label}
|
||
</div>
|
||
<div style={{ width: 48, height: 48, display: "grid", placeItems: "center", flex: "0 0 auto" }}>
|
||
{item.icon2 && <Icon name={item.icon2} size={24} color={p.onSurfaceVariant} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
case "searchBar":
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "0 16px", height: "100%" }}>
|
||
{item.icon && <Icon name={item.icon} size={24} color={p.onSurface} />}
|
||
<div style={{ flex: 1, minWidth: 0, fontSize: 16, color: p.onSurfaceVariant, ...ellipsis }}>
|
||
{item.label}
|
||
</div>
|
||
{item.icon2 && <Icon name={item.icon2} size={24} color={p.onSurfaceVariant} />}
|
||
</div>
|
||
);
|
||
|
||
case "card": {
|
||
const cw = item.size ?? 320;
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: 12,
|
||
height: "100%",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
{!item.noImage && (
|
||
<div
|
||
style={{
|
||
height: Math.round(cw * 0.28),
|
||
borderRadius: scaleR(14),
|
||
background: p.primaryContainer,
|
||
color: p.onPrimaryContainer,
|
||
display: "grid",
|
||
placeItems: "center",
|
||
flex: "0 0 auto",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{item.src ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={item.src} alt="" draggable={false} style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
|
||
) : (
|
||
item.icon && <Icon name={item.icon} size={34} />
|
||
)}
|
||
</div>
|
||
)}
|
||
{hasLabel && (
|
||
<div style={{ fontSize: 16, fontWeight: w(600, 700), color: item.fill ? onToken(item.fill, p) : p.onSurface, ...ellipsis }}>{item.label}</div>
|
||
)}
|
||
{hasSupporting && (
|
||
<div style={{ fontSize: 13, lineHeight: 1.5, color: item.fill ? onToken(item.fill, p) : p.onSurfaceVariant, opacity: item.fill ? 0.8 : 1, overflow: "hidden" }}>
|
||
{item.supporting}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "listItem": {
|
||
const iconBg = item.iconFill === "none" ? null : (item.iconFill ?? "primaryContainer");
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 16, padding: "0 16px", height: "100%" }}>
|
||
{item.icon && (
|
||
<div
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
background: iconBg ? p[iconBg] : "transparent",
|
||
color: iconBg ? onToken(iconBg, p) : onToken(item.fill ?? "surfaceContainerLow", p),
|
||
display: "grid",
|
||
placeItems: "center",
|
||
flex: "0 0 auto",
|
||
}}
|
||
>
|
||
<Icon name={item.icon} size={22} />
|
||
</div>
|
||
)}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{hasLabel && <div style={{ fontSize: 16, color: p.onSurface, ...ellipsis }}>{item.label}</div>}
|
||
{hasSupporting && (
|
||
<div style={{ fontSize: 13, color: p.onSurfaceVariant, ...ellipsis }}>{item.supporting}</div>
|
||
)}
|
||
</div>
|
||
{item.switch ? <SwitchControl on={!!item.checked} noCheck={!!item.noCheck} p={p} /> : item.icon2 && <Icon name={item.icon2} size={22} color={p.onSurfaceVariant} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "dialog":
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: 24,
|
||
height: "100%",
|
||
boxSizing: "border-box",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
{item.icon && (
|
||
<div style={{ textAlign: "center", color: p.primary }}>
|
||
<Icon name={item.icon} size={24} />
|
||
</div>
|
||
)}
|
||
{hasLabel && (
|
||
<div
|
||
style={{
|
||
fontSize: 24,
|
||
fontWeight: w(400, 600),
|
||
color: p.onSurface,
|
||
textAlign: item.icon ? "center" : "left",
|
||
...ellipsis,
|
||
}}
|
||
>
|
||
{item.label}
|
||
</div>
|
||
)}
|
||
{hasSupporting && (
|
||
<div style={{ fontSize: 14, lineHeight: 1.5, color: p.onSurfaceVariant, flex: 1, overflow: "hidden" }}>
|
||
{item.supporting}
|
||
</div>
|
||
)}
|
||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||
{[t("cancel", lang), t("ok", lang)].map((t) => (
|
||
<span
|
||
key={t}
|
||
style={{
|
||
padding: "0 12px",
|
||
height: 40,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
color: p.primary,
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{t}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
case "snackbar":
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 8px 0 16px", height: "100%" }}>
|
||
<div style={{ flex: 1, minWidth: 0, fontSize: 14, color: p.inverseOnSurface, ...ellipsis }}>
|
||
{item.label}
|
||
</div>
|
||
{hasSupporting && (
|
||
<span
|
||
style={{
|
||
padding: "0 12px",
|
||
height: 36,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
color: p.inversePrimary,
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{item.supporting}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
case "textField": {
|
||
const filled = item.variant === "filled";
|
||
return (
|
||
<div style={{ position: "relative", height: "100%" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "0 16px", height: "100%" }}>
|
||
{item.icon && <Icon name={item.icon} size={24} color={p.onSurfaceVariant} />}
|
||
<span style={{ flex: 1, minWidth: 0, fontSize: 16, color: p.onSurfaceVariant, ...ellipsis }}>
|
||
{filled ? "" : ""}
|
||
</span>
|
||
</div>
|
||
{hasLabel && (
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
left: item.icon ? 52 : 16,
|
||
top: filled ? 8 : -8,
|
||
fontSize: 12,
|
||
lineHeight: "16px",
|
||
color: p.primary,
|
||
background: filled ? "transparent" : p.surface,
|
||
padding: filled ? 0 : "0 4px",
|
||
marginLeft: filled ? 0 : -4,
|
||
}}
|
||
>
|
||
{item.label}
|
||
</span>
|
||
)}
|
||
{filled && (
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
height: 2,
|
||
background: p.primary,
|
||
}}
|
||
/>
|
||
)}
|
||
{hasSupporting && (
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
left: 16,
|
||
top: "100%",
|
||
marginTop: 4,
|
||
fontSize: 12,
|
||
color: p.onSurfaceVariant,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{item.supporting}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div style={{ position: "relative", height: "100%" }}>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
width: Math.max(0, handleX - 8),
|
||
top: 14,
|
||
height: 16,
|
||
borderRadius: "8px 2px 2px 8px",
|
||
background: p.primary,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: handleX + 8,
|
||
right: 0,
|
||
top: 14,
|
||
height: 16,
|
||
borderRadius: "2px 8px 8px 2px",
|
||
background: p.secondaryContainer,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
right: 6,
|
||
top: 20,
|
||
width: 4,
|
||
height: 4,
|
||
borderRadius: 2,
|
||
background: p.onSecondaryContainer,
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
left: handleX - 2,
|
||
top: 0,
|
||
width: 4,
|
||
height: 44,
|
||
borderRadius: 2,
|
||
background: p.primary,
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "image":
|
||
if (item.src) {
|
||
return (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={item.src}
|
||
alt=""
|
||
draggable={false}
|
||
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<div style={{ display: "grid", placeItems: "center", height: "100%", color: p.outline }}>
|
||
{item.icon && <Icon name={item.icon} size={Math.min(48, Math.round((item.size ?? 200) * 0.3))} />}
|
||
</div>
|
||
);
|
||
|
||
case "divider":
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", height: "100%" }}>
|
||
<div style={{ width: "100%", height: 1, background: p.outlineVariant }} />
|
||
</div>
|
||
);
|
||
|
||
case "navRail": {
|
||
const tabs = item.tabs ?? [];
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: RAIL_GAP,
|
||
height: "100%",
|
||
padding: `${RAIL_TOP}px 0`,
|
||
boxSizing: "border-box",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{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 (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
width: RAIL_W - 12,
|
||
height: RAIL_ITEM_H,
|
||
flex: "0 0 auto",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 56,
|
||
height: 32,
|
||
borderRadius: scaleR(16),
|
||
display: "grid",
|
||
placeItems: "center",
|
||
background: on ? p.secondaryContainer : "transparent",
|
||
color: on ? p.onSecondaryContainer : p.onSurfaceVariant,
|
||
transition: "background 160ms, color 160ms",
|
||
}}
|
||
>
|
||
{t.icon && <Icon name={t.icon} size={22} fill={on} />}
|
||
</div>
|
||
{withLabel && (
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: on ? w(600, 700) : w(400, 500),
|
||
color: on ? p.onSurface : p.onSurfaceVariant,
|
||
maxWidth: "100%",
|
||
...ellipsis,
|
||
}}
|
||
>
|
||
{t.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "bottomNav": {
|
||
const tabs = item.tabs ?? [];
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-around",
|
||
height: "100%",
|
||
padding: `0 4px ${NAV_BAR_H}px`,
|
||
boxSizing: "border-box",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{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 (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
flex: 1,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 56,
|
||
height: 32,
|
||
borderRadius: scaleR(16),
|
||
display: "grid",
|
||
placeItems: "center",
|
||
background: on ? p.secondaryContainer : "transparent",
|
||
color: on ? p.onSecondaryContainer : p.onSurfaceVariant,
|
||
transition: "background 160ms, color 160ms",
|
||
}}
|
||
>
|
||
{t.icon && <Icon name={t.icon} size={22} fill={on} />}
|
||
</div>
|
||
{withLabel && (
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: on ? w(600, 700) : w(400, 500),
|
||
color: on ? p.onSurface : p.onSurfaceVariant,
|
||
maxWidth: "100%",
|
||
...ellipsis,
|
||
}}
|
||
>
|
||
{t.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "circularProgress":
|
||
return (
|
||
<CircularProgress
|
||
size={item.size ?? 48}
|
||
color={p.primary}
|
||
trackColor={p.secondaryContainer}
|
||
wavy={item.wavy}
|
||
value={item.value === undefined ? undefined : item.value / 100}
|
||
/>
|
||
);
|
||
|
||
case "linearProgress":
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", height: "100%" }}>
|
||
<LinearProgress
|
||
width={item.size ?? 320}
|
||
color={p.primary}
|
||
trackColor={p.secondaryContainer}
|
||
wavy={item.wavy}
|
||
value={item.value === undefined ? undefined : item.value / 100}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
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 (
|
||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: FAB_MENU_GAP, height: "100%" }}>
|
||
{tabs.map((tab, i) => (
|
||
<span
|
||
key={i}
|
||
style={{
|
||
...itemStyle,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
height: FAB_MENU_ITEM_H,
|
||
padding: "0 24px 0 20px",
|
||
borderRadius: scaleR(28),
|
||
fontSize: 16,
|
||
fontWeight: w(500, 700),
|
||
whiteSpace: "nowrap",
|
||
maxWidth: "100%",
|
||
boxSizing: "border-box",
|
||
boxShadow: "0 1px 3px rgba(0,0,0,0.16)",
|
||
}}
|
||
>
|
||
{tab.icon && <Icon name={tab.icon} size={22} />}
|
||
<span style={ellipsis}>{tab.label}</span>
|
||
</span>
|
||
))}
|
||
<span
|
||
style={{
|
||
...fabStyle,
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: scaleR(16),
|
||
display: "grid",
|
||
placeItems: "center",
|
||
boxShadow: "0 3px 8px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)",
|
||
flex: "0 0 auto",
|
||
}}
|
||
>
|
||
{item.icon && <Icon name={item.icon} size={24} />}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "toolbar": {
|
||
const tabs = item.tabs ?? [];
|
||
const vibrant = item.variant === "filled";
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "0 8px", height: "100%", boxSizing: "border-box" }}>
|
||
{tabs.map((tab, i) => (
|
||
<span
|
||
key={i}
|
||
style={{
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: scaleR(24),
|
||
display: "grid",
|
||
placeItems: "center",
|
||
color: vibrant ? p.onPrimaryContainer : p.onSurfaceVariant,
|
||
flex: "0 0 auto",
|
||
}}
|
||
>
|
||
{tab.icon && <Icon name={tab.icon} size={24} />}
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "tabs": {
|
||
const tabs = item.tabs ?? [];
|
||
return (
|
||
<div style={{ display: "flex", alignItems: "stretch", height: "100%", position: "relative" }}>
|
||
{tabs.map((tab, i) => {
|
||
const on = i === Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1));
|
||
return (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "flex-end",
|
||
position: "relative",
|
||
padding: "0 8px",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 14,
|
||
fontWeight: w(500, 700),
|
||
color: on ? p.primary : p.onSurfaceVariant,
|
||
padding: "0 4px 14px",
|
||
maxWidth: "100%",
|
||
...ellipsis,
|
||
}}
|
||
>
|
||
{tab.label}
|
||
</span>
|
||
{on && (
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
left: "50%",
|
||
bottom: 0,
|
||
transform: "translateX(-50%)",
|
||
width: `calc(100% - 24px)`,
|
||
height: 3,
|
||
borderTopLeftRadius: 3,
|
||
borderTopRightRadius: 3,
|
||
background: p.primary,
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
<span style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, background: p.outlineVariant }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case "loadingIndicator": {
|
||
const s = item.size ?? 48;
|
||
return (
|
||
<LoadingIndicator
|
||
size={s}
|
||
color={item.contained ? p.onPrimaryContainer : p.primary}
|
||
contained={item.contained}
|
||
containerColor={p.primaryContainer}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
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<string, number>;
|
||
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 (
|
||
<motion.div
|
||
data-node={item.id}
|
||
data-kind={item.kind}
|
||
onPointerDown={onPointerDown}
|
||
initial={false}
|
||
animate={{
|
||
borderTopLeftRadius: r.tl,
|
||
borderBottomLeftRadius: r.bl,
|
||
borderTopRightRadius: r.tr,
|
||
borderBottomRightRadius: r.br,
|
||
scale: pressed ? 0.97 : 1,
|
||
}}
|
||
transition={{
|
||
borderTopLeftRadius: RADIUS_TWEEN,
|
||
borderBottomLeftRadius: RADIUS_TWEEN,
|
||
borderTopRightRadius: RADIUS_TWEEN,
|
||
borderBottomRightRadius: RADIUS_TWEEN,
|
||
scale: { type: "spring", stiffness: 700, damping: 30, mass: 0.5 },
|
||
}}
|
||
style={{
|
||
...boxStyle(item, palette),
|
||
width: measured ? undefined : size.w,
|
||
height: size.h,
|
||
display: measured ? "inline-flex" : "block",
|
||
alignItems: "center",
|
||
overflow: clips ? "hidden" : "visible",
|
||
cursor: !interactive ? "default" : dragging ? "grabbing" : "grab",
|
||
userSelect: "none",
|
||
touchAction: "none",
|
||
boxSizing: "border-box",
|
||
boxShadow: shadowOf(item),
|
||
outline: selected ? `2px solid ${palette.primary}` : "2px solid transparent",
|
||
outlineOffset: 3,
|
||
/* a part that changes width with its screen eases the way the screen does */
|
||
transition: measured ? "outline-color 120ms" : `outline-color 120ms, width ${SETTLE_MS}ms cubic-bezier(0.2, 0, 0, 1)`,
|
||
flex: "0 0 auto",
|
||
}}
|
||
>
|
||
<Body item={item} p={palette} />
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/** 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 (
|
||
<div
|
||
style={{
|
||
...boxStyle(item, palette),
|
||
width: measured ? undefined : size.w,
|
||
height: size.h,
|
||
display: measured ? "inline-flex" : "block",
|
||
alignItems: "center",
|
||
overflow: clips ? "hidden" : "visible",
|
||
boxSizing: "border-box",
|
||
boxShadow: shadowOf(item),
|
||
borderTopLeftRadius: r.tl,
|
||
borderTopRightRadius: r.tr,
|
||
borderBottomLeftRadius: r.bl,
|
||
borderBottomRightRadius: r.br,
|
||
flex: "0 0 auto",
|
||
...style,
|
||
}}
|
||
>
|
||
<Body item={item} p={palette} />
|
||
</div>
|
||
);
|
||
}
|