Found by reviewing the plugin rather than running it. Nothing was live — the table does not exist and the plugin is not installed — so all of these were waiting for the first person to use it. ── The second unnamed design could not be saved ── `uniqueIndex(userId, title)`, and every design starts life called "Untitled": `titleOf` returns it for a document with no title and the API defaults to it. So the first saved, the second collided, and the insert threw under a panel that could only say "Changes are not being saved." Renaming one design to match another did the same. Two designs may share a name, as files and notes and dashboards do. Nothing looks a design up by title, so uniqueness bought nothing. The index is now ordinary and on the columns the list query actually uses. The lesson from `dashboards.id` that the schema comment cites is that a key must be scoped to its owner. `userId` on the row does that; making the pair unique as well imported the collision it was citing, one account at a time. ── New did not start a new design ── It cleared `?doc=`, and no parameter already means "open what I had last", answered with `/documents/latest`. So New handed back the design on screen and pointed the next save at its id. There is a `new` sentinel now, in its own module because both panels read it and neither owns it. ── Two ways the load effect raced the debounced save ── Changing `?doc=` does not unmount the panel, so the unmount flush never ran for it: an edit made within the debounce and navigated away from was written after `latest.current` had been replaced, which is to say never. And the first save of a new design puts its id in the URL, which lands in the same effect — re-reading the row overwrote in-memory state with the copy the server had just been given, discarding whatever was drawn during the round trip. The effect now flushes what is pending before it reads, and does not re-read a document it already holds. The editor is keyed on a load generation rather than on `row?.id`, which could not express "blank again" and left the previous design on screen with `rowId` cleared underneath it. ── The typecheck ── 354 of the 355 errors were upstream's tree measured against this platform's settings, which it was never written for. `bunx tsgo` is how this repo is kept clean and it could not be clean while this plugin existed. `exclude` does not help: adapter.ts imports the tree back in. So vendor.sh marks each vendored file unchecked in the same deterministic pass that rewrites its imports. Types still resolve; only the errors inside those files are silenced, which is what the adapter already says the seam is for. Whole repo: 0. ── PROVENANCE.md understated what is done to the copy ── It said "the one edit vendor.sh makes" and described the import rewrite. It also runs apply-host.py, which places our own lib/host.ts and wires three export paths. Four files are not upstream and the document whose job is making the copy trustworthy did not say so. It lists all four now. Also: web/host.ts imported a path only correct at its destination, so the file could never typecheck where it is edited — apply-host.py rewrites that line when placing it, and re-marks the placed copy, which it overwrites after vendor.sh has marked it. PUT is no longer full-replace: omitting `title` renamed a design to "Untitled" and omitting `ui` nulled it, a trap set for the second caller. And a document is bounded at 4 MB, measured in bytes, where the title was capped at 200 and the document at nothing.
1253 lines
38 KiB
TypeScript
1253 lines
38 KiB
TypeScript
// @ts-nocheck — vendored, see PROVENANCE.md
|
||
"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>
|
||
);
|
||
}
|