Files
app/frontend/src/components/Dashboard/ui/material/Surfaces.tsx
T
stroblme 579eb8e61a Size the rail to what it carries
Stretched end to end, a panel with two dashboards showed a pill nine
tenths empty — which was tolerable while the rail was chrome outside the
canvas and obvious once it moved onto the panel itself. It now hugs its
entries and sits centred in the column it reserves; that column is the
same width either way, so no arrangement moves.

A rail longer than the panel is tall still scrolls, and still draws no
bar: a wall panel is swiped, and there is no room for one.
2026-08-24 11:20:32 +02:00

189 lines
6.1 KiB
TypeScript

/**
* Material 3: the surfaces.
*
* Tonal rather than outlined — a widget is told from the ground by being a
* step further toward the primary, which is what lets a palette recolour the
* whole look. Nothing here holds state: the behaviour is in `ui/core`.
*/
import { Link } from "@tanstack/react-router"
import { Lock } from "lucide-react"
import { motion } from "motion/react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { ICONS } from "../../icons"
import type {
BackdropProps,
FrameProps,
NoticeProps,
RailProps,
} from "../core/contract"
import { TESTID } from "../core/contract"
import { LOOK } from "../core/motion"
/** Material's ground is the surface colour itself; only an image is drawn. */
export function Backdrop({ image }: BackdropProps) {
if (!image) return null
return (
<div
aria-hidden
data-testid="canvas-ground"
className="pointer-events-none absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${JSON.stringify(image)})` }}
/>
)
}
function Issue({ issue }: { issue: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className="block size-2 shrink-0 rounded-full bg-destructive"
aria-label={issue}
data-testid={TESTID.issue}
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">{issue}</TooltipContent>
</Tooltip>
)
}
export function Frame({
title,
issue,
grip,
scroll,
selected,
onClick,
children,
}: FrameProps) {
return (
// A card is not a control: the click only picks it in edit mode, and every
// interactive element inside keeps its own role and keyboard handling.
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
<div
data-testid={TESTID.frame}
data-selected={selected ? "" : undefined}
className="dui-frame m3-frame"
onClick={onClick}
>
{title ? (
<div
className={cn(
"dui-frame-head m3-frame-title",
grip &&
`${TESTID.grip} -m-1 cursor-grab p-1 active:cursor-grabbing`,
)}
>
<span className="dui-frame-title">{title}</span>
{issue ? <Issue issue={issue} /> : null}
</div>
) : null}
<div className={cn("dui-frame-body", scroll && "dui-frame-scroll")}>
{children}
</div>
{/* No header to sit in, so the fault takes the corner instead — a widget
drawn without its title still says when it is mis-wired. */}
{!title && issue ? (
<div className="dui-frame-corner">
<Issue issue={issue} />
</div>
) : null}
{/* And the editor still needs something to drag it by. */}
{!title && grip ? (
<span
aria-hidden
className={cn(
TESTID.grip,
"absolute left-1/2 top-1 z-[2] h-1 w-8 -translate-x-1/2 cursor-grab rounded-full bg-current opacity-20 active:cursor-grabbing",
)}
/>
) : null}
</div>
)
}
/** Two letters off the title, so a rail of four reads as four different things. */
function initials(label: string): string {
const words = label.split(/[\s_-]+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}
export function Rail({ entries }: RailProps) {
return (
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className={cn(
"m3-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
// As tall as what it carries, centred in the column it reserves: a
// rail of two stretched to the height of the panel is mostly empty
// pill. More dashboards than the panel is tall still scroll, but no
// bar is ever drawn — a wall panel is swiped, and there is no room.
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{entries.map((entry) => {
const Glyph = ICONS[entry.icon ?? ""]
return (
<Tooltip key={entry.name}>
<TooltipTrigger asChild>
<Link
{...entry.link}
aria-label={entry.label}
aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`}
className={cn(
"m3-pressable relative flex size-10 shrink-0 items-center justify-center rounded-full text-xs font-medium",
entry.active
? "text-card-foreground"
: "text-muted-foreground",
)}
>
<span className="m3-state" aria-hidden />
{entry.active ? (
<motion.span
aria-hidden
layoutId="m3-rail-active"
transition={LOOK.material.spring}
className="absolute inset-0 rounded-full"
style={{ background: "var(--m3-sc)" }}
/>
) : null}
<span className="relative z-[1]">
{Glyph ? <Glyph className="size-5" /> : initials(entry.label)}
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{entry.label}</TooltipContent>
</Tooltip>
)
})}
</nav>
)
}
export function Notice({ children }: NoticeProps) {
return (
<div
// Announced rather than merely drawn: `locked` may be driven by a flow,
// so the state can change under someone already looking at the page.
aria-live="polite"
data-testid={TESTID.locked}
className="m3-notice pointer-events-none absolute bottom-0 right-0 flex items-center gap-1.5 px-3 py-1.5 text-sm"
>
<Lock className="size-4" aria-hidden />
{children}
</div>
)
}