feat: shared tile grid for plugin pages and many-tile dashboard cards (SDK 1.0.26)

This commit is contained in:
LukeGus committed 2026-11-04 15:29:46 -06:00
1 parent 1c4fa52de3
commit 9613b787a7
22 files changed
+1997 -7

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* The look every tile shares with the dashboard: a small uppercase title row
* over a body that scrolls when the tile has a fixed height. The grid draws
* the lines between tiles. `bare` drops the title row.
*/
export function TileFrame({
title,
icon,
actions,
bare = false,
onTitleClick,
className,
bodyClassName,
children,
}: {
title?: ReactNode;
icon?: ReactNode;
actions?: ReactNode;
bare?: boolean;
onTitleClick?: () => void;
className?: string;
bodyClassName?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-col overflow-hidden",
className,
)}
>
{!bare && (title || icon || actions) && (
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
{icon && (
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
{icon}
</span>
)}
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
className="min-w-0 truncate text-left text-[10px] font-semibold uppercase tracking-widest text-muted-foreground hover:text-foreground"
>
{title}
</button>
) : (
<span className="min-w-0 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{title}
</span>
)}
{actions && (
<span className="ml-auto flex shrink-0 items-center gap-1">
{actions}
</span>
)}
</div>
)}
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-auto thin-scrollbar",
bodyClassName,
)}
>
{children}
</div>
</div>
);
}
+827
View File
@@ -0,0 +1,827 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { GripVertical, Settings2, X } from "lucide-react";
import { remScale } from "@/lib/rem";
import { cn } from "@/lib/utils";
import {
MAX_TILE_HEIGHT,
MIN_TILE_COLUMNS,
MIN_TILE_HEIGHT,
TILE_COLUMNS,
clampTileColumns,
settleTiles,
tileBottom,
tileBoxes,
tilesFromBoxes,
type TileBox,
type TileSlot,
} from "./layout";
import { SeamlessTileContext } from "./context";
import { gridLines } from "./layout";
type Edge = "left" | "right" | "bottom" | "corner";
interface Proposal {
x?: number;
y?: number;
w?: number;
h?: number | null;
}
interface Interaction {
id: string;
kind: "move" | "resize";
base: TileSlot[];
proposal: Proposal;
frame: { left: number; top: number; width: number; height?: number };
}
const DRAG_THRESHOLD = 4;
const SNAP = 8;
const SCROLL_EDGE = 48;
const styles = `
.tg-grid { position: relative; }
.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; }
.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
.tg-tile[data-active] { z-index: 40; }
.tg-tile[data-active="move"] { opacity: 0.85; cursor: grabbing; }
.tg-grid[data-edit-mode="true"] .tg-tile { user-select: none; }
.tg-grid[data-edit-mode="true"] .tg-tile:not([data-active]) { cursor: grab; }
[data-tg-drop-preview] {
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; box-sizing: border-box;
background: color-mix(in srgb, var(--accent-brand, #60a5fa) 12%, transparent);
border: 2px dashed color-mix(in srgb, var(--accent-brand, #60a5fa) 70%, transparent);
}
.tg-handle { position: absolute; z-index: 30; display: flex; touch-action: none; align-items: center; justify-content: center; }
.tg-handle > span { background: var(--border); }
.tg-handle:hover > span { background: var(--accent-brand, #60a5fa); }
.tg-edge-left, .tg-edge-right { top: 2rem; bottom: 1rem; width: 0.75rem; cursor: ew-resize; }
.tg-edge-left { left: 0; }
.tg-edge-right { right: 0; }
.tg-edge-bottom { bottom: 0; left: 1rem; right: 1rem; height: 0.75rem; cursor: ns-resize; }
.tg-edge-corner { bottom: 0; right: 0; width: 1rem; height: 1rem; cursor: nwse-resize; }
.tg-edge-corner::after { content: ""; width: 0.5rem; height: 0.5rem; border-right: 2px solid var(--border); border-bottom: 2px solid var(--border); }
.tg-handle:focus-visible { outline: 2px solid var(--accent-brand, #60a5fa); }
.tg-card[data-fixed-height="true"] { height: 100%; }
.tg-card[data-fixed-height="true"] > .tg-content { overflow: hidden; }
.tg-card[data-fixed-height="true"] > .tg-content > * { height: 100%; }
`;
const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value));
function propose(base: TileSlot[], id: string, proposal: Proposal) {
return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot));
}
function scrollParent(element: HTMLElement | null) {
for (let node = element?.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return null;
}
const NO_DRAG =
"button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle";
export interface TileGridProps {
slots: TileSlot[];
editMode: boolean;
renderTile: (id: string) => ReactNode;
/** Name of a tile, for the edit controls. */
tileLabel: (id: string) => string;
onChange: (slots: TileSlot[]) => void;
/** Shows a settings button on each tile while editing. */
onSettings?: (id: string) => void;
/** Space between tiles in px. 0 draws tiles edge to edge with shared borders, like the dashboard. */
gap?: number;
/** Below this width tiles stack in one column and editing is off. */
stackBelow?: number;
minColumns?: number;
minHeight?: number;
/** Shown while editing an empty grid. */
empty?: ReactNode;
className?: string;
}
/**
* A 12 column grid of tiles the user can drag and resize. Tiles keep their
* column and settle upward, so a layout never has holes it did not ask for.
*/
export function TileGrid({
slots,
editMode,
renderTile,
tileLabel,
onChange,
onSettings,
gap = 0,
stackBelow = 640,
minColumns = MIN_TILE_COLUMNS,
minHeight = MIN_TILE_HEIGHT,
empty,
className,
}: TileGridProps) {
const { t } = useTranslation();
const root = useRef<HTMLDivElement>(null);
const gridEl = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
// Whether the grid reaches the bottom of the view it scrolls in. Then its
// last row meets the bottom of the screen and needs no line under it.
const [overflows, setOverflows] = useState(false);
const overflowsRef = useRef(overflows);
overflowsRef.current = overflows;
const [heights, setHeights] = useState<Record<string, number>>({});
const [active, setActive] = useState<Interaction | null>(null);
const cancel = useRef<(() => void) | null>(null);
const heightsRef = useRef(heights);
heightsRef.current = heights;
const scale = remScale();
const seamless = gap <= 0;
// Edge to edge tiles draw no lines of their own. The grid works out every
// line between them and draws each once, so a line is never doubled at any
// display scaling and a taller neighbour still gets its edge.
const inset = seamless ? 0 : gap * scale;
const settleGap = seamless ? 0 : gap;
const pitch = (width + inset) / TILE_COLUMNS;
const stacked = width > 0 && width < stackBelow;
const editing = editMode && !stacked;
const settled = useMemo(
() => settleTiles(tileBoxes(slots, heights), settleGap),
[slots, heights, settleGap],
);
const preview = useMemo(
() =>
active
? settleTiles(
tileBoxes(
propose(active.base, active.id, active.proposal),
heights,
),
settleGap,
)
: settled,
[active, settled, heights, settleGap],
);
const boxById = useMemo(
() => new Map(preview.map((box) => [box.id, box])),
[preview],
);
// Same elements across pointer moves, so tiles only re-render on new data.
const content = useMemo(
() =>
new Map<string, ReactNode>(
slots.map((slot) => [slot.id, renderTile(slot.id)]),
),
[slots, renderTile],
);
useLayoutEffect(() => {
const element = root.current;
if (!element) return;
const update = () =>
setWidth(element.getBoundingClientRect().width || element.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
const element = root.current;
const scroller = seamless ? scrollParent(element) : null;
if (!element || !scroller) {
setOverflows(false);
return;
}
// Where the grid's last row ends inside the scrolled content, without the
// kept bottom line's px, so adding or dropping that line never flips it.
const update = () => {
const end =
element.getBoundingClientRect().bottom -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
(overflowsRef.current ? 0 : 1);
setOverflows(end >= scroller.clientHeight - 0.5);
};
update();
const observer = new ResizeObserver(update);
observer.observe(scroller);
observer.observe(element);
return () => observer.disconnect();
}, [seamless, settled, width]);
// An outside change (undo, reset, host switch) ends any drag in progress.
useEffect(() => () => cancel.current?.(), [slots, editing]);
const reportHeight = useCallback(
(id: string, height: number) =>
setHeights((previous) =>
Math.abs((previous[id] ?? -1) - height) < 0.5
? previous
: { ...previous, [id]: height },
),
[],
);
const commit = (base: TileSlot[], id: string | null, proposal: Proposal) => {
const next = id ? propose(base, id, proposal) : base;
const boxes = settleTiles(tileBoxes(next, heightsRef.current), settleGap);
onChange(tilesFromBoxes(next, boxes));
};
const settledBase = (): TileSlot[] =>
slots.map((slot) => ({
...slot,
y: settled.find((box) => box.id === slot.id)?.y ?? slot.y,
}));
const begin = (
event: ReactPointerEvent<HTMLElement>,
id: string,
edge?: Edge,
) => {
if (event.button !== 0 || !editing || cancel.current) return;
const element = gridEl.current;
const box = settled.find((b) => b.id === id);
const slot = slots.find((s) => s.id === id);
if (!element || !box || !slot) return;
if (!edge) {
const target = event.target as HTMLElement;
if (target.closest(NO_DRAG) && !target.closest("[data-tg-grip]")) return;
const native = event.nativeEvent;
const scrolls =
target.scrollHeight > target.clientHeight ||
target.scrollWidth > target.clientWidth;
if (
scrolls &&
(native.offsetX > target.clientWidth ||
native.offsetY > target.clientHeight)
)
return;
} else {
event.preventDefault();
event.stopPropagation();
}
const kind = edge ? "resize" : "move";
const base = settledBase();
const origin = element.getBoundingClientRect();
const startLeft = box.x * pitch;
const startTop = box.y * scale;
const startWidth = box.w * pitch - inset;
const startHeight = box.h * scale;
const fixed = slot.h != null;
const startX = event.clientX;
const startY = event.clientY;
const grabX = startX - origin.left - startLeft;
const grabY = startY - origin.top - startTop;
const scroller = scrollParent(element);
const others = settled.filter((b) => b.id !== id);
let pointer = { x: startX, y: startY };
let started = kind === "resize";
let current: Interaction | null = null;
let timer: ReturnType<typeof setInterval> | undefined;
const compute = (): Interaction => {
const rect = element.getBoundingClientRect();
if (kind === "move") {
const left = pointer.x - rect.left - grabX;
const top = pointer.y - rect.top - grabY;
return {
id,
kind,
base,
proposal: {
x: clamp(Math.round(left / pitch), 0, TILE_COLUMNS - box.w),
y: Math.max(0, top / scale),
},
frame: {
left,
top,
width: startWidth,
height: fixed ? startHeight : undefined,
},
};
}
const dx = pointer.x - startX;
const dy = pointer.y - rect.top - (startY - origin.top);
const proposal: Proposal = {};
const frame: Interaction["frame"] = {
left: startLeft,
top: startTop,
width: startWidth,
height: fixed ? startHeight : undefined,
};
const minWidth = minColumns * pitch - inset;
if (edge === "right" || edge === "corner") {
const raw = clamp(
startWidth + dx,
minWidth,
(TILE_COLUMNS - box.x) * pitch - inset,
);
proposal.w = Math.min(
clampTileColumns((raw + inset) / pitch, minColumns),
TILE_COLUMNS - box.x,
);
frame.width = raw;
}
if (edge === "left") {
const right = box.x + box.w;
const raw = clamp(startWidth - dx, minWidth, right * pitch - inset);
proposal.w = Math.min(
clampTileColumns((raw + inset) / pitch, minColumns),
right,
);
proposal.x = right - proposal.w;
frame.width = raw;
frame.left = startLeft + startWidth - raw;
}
if (edge === "bottom" || edge === "corner") {
let height =
clamp(startHeight + dy, minHeight * scale, MAX_TILE_HEIGHT * scale) /
scale;
// Line up with the bottom of a nearby tile.
for (const other of others) {
const bottom = other.y + other.h - box.y;
if (bottom >= minHeight && Math.abs(bottom - height) <= SNAP) {
height = bottom;
break;
}
}
proposal.h = Math.round(height);
frame.height = proposal.h * scale;
}
return { id, kind, base, proposal, frame };
};
const update = () => {
current = compute();
setActive(current);
};
const autoScroll = () => {
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const step =
pointer.y < rect.top + SCROLL_EDGE
? -12
: pointer.y > rect.bottom - SCROLL_EDGE
? 12
: 0;
if (!step) return;
const before = scroller.scrollTop;
scroller.scrollTop += step;
if (scroller.scrollTop !== before) update();
};
const move = (e: PointerEvent) => {
if (e.pointerId !== event.pointerId) return;
pointer = { x: e.clientX, y: e.clientY };
if (
!started &&
Math.hypot(pointer.x - startX, pointer.y - startY) < DRAG_THRESHOLD
)
return;
if (!started) {
started = true;
window.getSelection?.()?.removeAllRanges();
if (scroller) timer = setInterval(autoScroll, 16);
}
update();
};
const finish = (save: boolean) => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancelled);
window.removeEventListener("keydown", key);
window.removeEventListener("blur", blur);
clearInterval(timer);
cancel.current = null;
setActive(null);
if (save && started && current) commit(base, id, current.proposal);
};
const up = (e: PointerEvent) => {
if (e.pointerId === event.pointerId) finish(true);
};
const cancelled = (e: PointerEvent) => {
if (e.pointerId === event.pointerId) finish(false);
};
const key = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
finish(false);
};
const blur = () => finish(false);
cancel.current = () => finish(false);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cancelled);
window.addEventListener("keydown", key);
window.addEventListener("blur", blur);
if (started) update();
};
const moveByKey = (event: ReactKeyboardEvent, id: string) => {
const box = settled.find((b) => b.id === id);
if (!box || !event.key.startsWith("Arrow")) return;
const near = settled.filter(
(b) => b.id !== id && b.x < box.x + box.w && box.x < b.x + b.w,
);
let proposal: Proposal | null = null;
if (event.key === "ArrowLeft" && box.x > 0) proposal = { x: box.x - 1 };
if (event.key === "ArrowRight" && box.x + box.w < TILE_COLUMNS)
proposal = { x: box.x + 1 };
if (event.key === "ArrowUp") {
const above = near
.filter((b) => b.y < box.y)
.sort((a, b) => b.y - a.y)[0];
if (above) proposal = { y: Math.max(0, above.y - 1) };
}
if (event.key === "ArrowDown") {
const below = near
.filter((b) => b.y > box.y)
.sort((a, b) => a.y - b.y)[0];
if (below) proposal = { y: below.y + 1 };
}
event.preventDefault();
if (proposal) commit(settledBase(), id, proposal);
};
const resizeByKey = (
event: ReactKeyboardEvent,
id: string,
axis: "width" | "height",
) => {
const box = settled.find((b) => b.id === id);
if (!box) return;
const keys =
axis === "width" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
if (![...keys, "Home", "End"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const sign = event.key === keys[1] ? 1 : -1;
if (axis === "width") {
const max = TILE_COLUMNS - box.x;
const w =
event.key === "Home"
? minColumns
: event.key === "End"
? max
: clamp(box.w + sign, minColumns, max);
if (w !== box.w) commit(settledBase(), id, { w });
} else {
const current = Math.round(box.h);
const h =
event.key === "Home"
? minHeight
: event.key === "End"
? MAX_TILE_HEIGHT
: clamp(
current + sign * (event.shiftKey ? 50 : 10),
minHeight,
MAX_TILE_HEIGHT,
);
if (h !== current) commit(settledBase(), id, { h });
}
};
const remove = (id: string) =>
commit(
settledBase().filter((slot) => slot.id !== id),
null,
{},
);
if (stacked) {
return (
<SeamlessTileContext.Provider value={seamless}>
<div
ref={root}
className={cn("flex flex-col", !seamless && "gap-3", className)}
>
{[...settled]
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((box) => (
<div
key={box.id}
className={cn(
"w-full min-w-0",
seamless && "border-b border-border",
)}
>
{content.get(box.id)}
</div>
))}
</div>
</SeamlessTileContext.Provider>
);
}
const activeBox = active ? boxById.get(active.id) : undefined;
// Edge to edge tiles sit on whole px so the lines between them are crisp.
const snap = (value: number) => (seamless ? Math.round(value) : value);
const leftOf = (box: TileBox) => snap(box.x * pitch);
const widthOf = (box: TileBox) =>
seamless
? snap((box.x + box.w) * pitch) - snap(box.x * pitch)
: box.w * pitch - inset;
const bottom = tileBottom(preview, settleGap) * scale;
// A kept bottom line sits one px under the last row.
const height = Math.max(
0,
seamless ? snap(bottom) + (overflows ? 0 : 1) : bottom - inset,
);
const lines = seamless
? gridLines(
preview.map((box) => ({
l: leftOf(box),
t: snap(box.y * scale),
r: leftOf(box) + widthOf(box),
b: snap((box.y + box.h) * scale),
})),
snap(width),
overflows,
)
: [];
return (
<SeamlessTileContext.Provider value={seamless}>
<div ref={root} className={className}>
<div
ref={gridEl}
className="tg-grid"
data-edit-mode={editing}
data-animate={editing}
style={{ height }}
>
<style>{styles}</style>
{lines.map((line) => (
<div
key={`${line.x},${line.y},${line.w},${line.h}`}
data-tg-line
aria-hidden="true"
// A real border, so it is as thin as every other line at any
// display scaling; a 1 px fill rounds up to two screen px.
className={cn(
"pointer-events-none absolute z-[25] border-border",
line.w === 1 ? "border-l" : "border-t",
)}
style={{
left: line.x,
top: line.y,
width: line.w === 1 ? 0 : line.w,
height: line.h === 1 ? 0 : line.h,
}}
/>
))}
{activeBox && (
<div
data-tg-drop-preview={activeBox.id}
aria-hidden="true"
style={{
transform: `translate(${leftOf(activeBox)}px, ${snap(activeBox.y * scale)}px)`,
width: widthOf(activeBox),
height: activeBox.h * scale,
}}
/>
)}
{slots.map((slot) => {
const box = boxById.get(slot.id)!;
const frame = active?.id === slot.id ? active.frame : null;
const tileHeight = frame
? frame.height
: seamless
? snap((box.y + box.h) * scale) - snap(box.y * scale)
: slot.h != null
? slot.h * scale
: undefined;
return (
<Tile
key={slot.id}
id={slot.id}
box={box}
label={tileLabel(slot.id)}
editMode={editing}
active={active?.id === slot.id ? active.kind : undefined}
left={frame ? frame.left : leftOf(box)}
top={frame ? frame.top : snap(box.y * scale)}
width={frame ? frame.width : widthOf(box)}
height={tileHeight}
measure={slot.h == null}
scale={scale}
seamless={seamless}
minColumns={minColumns}
minHeight={minHeight}
onHeight={reportHeight}
onPointerDown={(event, edge) => begin(event, slot.id, edge)}
onMoveKey={(event) => moveByKey(event, slot.id)}
onResizeKey={(event, axis) => resizeByKey(event, slot.id, axis)}
onSettings={onSettings ? () => onSettings(slot.id) : undefined}
onRemove={() => remove(slot.id)}
>
{content.get(slot.id)}
</Tile>
);
})}
</div>
{editing && slots.length === 0 && (
<div className="flex items-center justify-center border border-dashed border-border py-10 text-xs text-muted-foreground">
{empty ?? t("tileGrid.empty")}
</div>
)}
</div>
</SeamlessTileContext.Provider>
);
}
function Tile({
id,
box,
label,
editMode,
active,
left,
top,
width,
height,
measure,
scale,
seamless,
minColumns,
minHeight,
onHeight,
onPointerDown,
onMoveKey,
onResizeKey,
onSettings,
onRemove,
children,
}: {
id: string;
box: TileBox;
label: string;
editMode: boolean;
active?: Interaction["kind"];
left: number;
top: number;
width: number;
height?: number;
/** The tile has no height of its own and reports its content's. */
measure: boolean;
scale: number;
seamless: boolean;
minColumns: number;
minHeight: number;
onHeight: (id: string, height: number) => void;
onPointerDown: (event: ReactPointerEvent<HTMLElement>, edge?: Edge) => void;
onMoveKey: (event: ReactKeyboardEvent) => void;
onResizeKey: (event: ReactKeyboardEvent, axis: "width" | "height") => void;
onSettings?: () => void;
onRemove: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
const card = useRef<HTMLDivElement>(null);
const body = useRef<HTMLDivElement>(null);
const fixed = !measure;
// Tiles with no height of their own report their content's so the grid can
// place them. Edge to edge, that is rounded up to a whole px.
useLayoutEffect(() => {
const element = seamless ? body.current : card.current;
if (!element || fixed) return;
const report = () => {
const own = element.getBoundingClientRect().height;
onHeight(id, (seamless ? Math.ceil(own) : own) / scale);
};
report();
const observer = new ResizeObserver(report);
observer.observe(element);
return () => observer.disconnect();
}, [id, fixed, scale, seamless, onHeight]);
const button =
"flex size-6 items-center justify-center border border-border bg-card text-muted-foreground hover:text-foreground";
return (
<div
data-tg-tile={id}
data-active={active}
className="tg-tile"
style={{
transform: `translate(${left}px, ${top}px)`,
width,
height,
}}
onPointerDown={editMode ? (event) => onPointerDown(event) : undefined}
>
<div
ref={card}
className={cn(
"tg-card relative flex min-h-0 min-w-0 flex-col",
seamless && "min-h-full",
)}
data-tg-card={id}
data-fixed-height={fixed}
>
<div
ref={body}
className={cn(
"tg-content min-h-0",
seamless && measure ? "flex-none" : "flex-1",
)}
>
{children}
</div>
{editMode && (
<>
<div className="pointer-events-none absolute inset-0 z-10 border-2 border-dashed border-accent-brand/30" />
<div className="absolute top-1 right-1 z-20 flex items-center gap-1">
<button
type="button"
className={cn(button, "cursor-grab active:cursor-grabbing")}
title={t("tileGrid.move")}
aria-label={t("tileGrid.moveTile", { tile: label })}
onKeyDown={onMoveKey}
data-tg-grip
>
<GripVertical className="size-3" />
</button>
{onSettings && (
<button
type="button"
className={button}
title={t("tileGrid.settings", { tile: label })}
aria-label={t("tileGrid.settings", { tile: label })}
onClick={onSettings}
>
<Settings2 className="size-3" />
</button>
)}
<button
type="button"
onClick={onRemove}
className={cn(
button,
"hover:border-destructive/40 hover:text-destructive",
)}
title={t("tileGrid.remove", { tile: label })}
aria-label={t("tileGrid.remove", { tile: label })}
>
<X className="size-3" />
</button>
</div>
<div
className="tg-handle tg-edge-left"
aria-hidden="true"
onPointerDown={(event) => onPointerDown(event, "left")}
/>
{(["width", "height"] as const).map((axis) => (
<div
key={axis}
role="separator"
tabIndex={0}
aria-orientation={axis === "width" ? "vertical" : "horizontal"}
aria-label={`${label}: ${t(axis === "width" ? "tileGrid.width" : "tileGrid.height")}`}
aria-valuemin={axis === "width" ? minColumns : minHeight}
aria-valuemax={
axis === "width" ? TILE_COLUMNS : MAX_TILE_HEIGHT
}
aria-valuenow={axis === "width" ? box.w : Math.round(box.h)}
title={t(
axis === "width" ? "tileGrid.width" : "tileGrid.height",
)}
className={`tg-handle ${axis === "width" ? "tg-edge-right" : "tg-edge-bottom"}`}
onPointerDown={(event) =>
onPointerDown(event, axis === "width" ? "right" : "bottom")
}
onKeyDown={(event) => onResizeKey(event, axis)}
>
<span
className={
axis === "width"
? "h-12 w-0.5 rounded-full"
: "h-0.5 w-12 rounded-full"
}
/>
</div>
))}
<div
className="tg-handle tg-edge-corner"
aria-hidden="true"
onPointerDown={(event) => onPointerDown(event, "corner")}
/>
</>
)}
</div>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
ChevronRight,
Plus,
Search,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type TileKind =
"card" | "signal" | "action" | "session" | "resource" | "chart";
export interface TilePickerEntry {
/** Tile id the grid gets when this entry is added. */
id: string;
title: string;
description?: string;
/** Group heading, usually the plugin name. */
group: string;
kind: TileKind;
icon?: ReactNode;
/** Already on the grid; shown checked and not added twice. */
placed?: boolean;
}
const KINDS: TileKind[] = [
"card",
"chart",
"signal",
"action",
"resource",
"session",
];
const KIND_LABELS: Record<TileKind | "all", string> = {
all: "tileGrid.kind.all",
card: "tileGrid.kind.card",
chart: "tileGrid.kind.chart",
signal: "tileGrid.kind.signal",
action: "tileGrid.kind.action",
resource: "tileGrid.kind.resource",
session: "tileGrid.kind.session",
};
/**
* Everything that can go on a grid, in a narrow column beside it while
* editing. Search, filter by kind and fold groups, so long lists from many
* plugins stay usable.
*/
export function TilePicker({
entries,
onAdd,
onClose,
className,
}: {
entries: TilePickerEntry[];
onAdd: (entry: TilePickerEntry) => void;
onClose?: () => void;
className?: string;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [kind, setKind] = useState<TileKind | null>(null);
const [folded, setFolded] = useState<Set<string>>(() => new Set());
const kinds = useMemo(
() => KINDS.filter((k) => entries.some((entry) => entry.kind === k)),
[entries],
);
const groups = useMemo(() => {
const needle = query.trim().toLowerCase();
const shown = entries.filter(
(entry) =>
(!kind || entry.kind === kind) &&
(!needle ||
entry.title.toLowerCase().includes(needle) ||
entry.group.toLowerCase().includes(needle) ||
entry.description?.toLowerCase().includes(needle)),
);
const map = new Map<string, TilePickerEntry[]>();
for (const entry of shown) {
const list = map.get(entry.group) ?? [];
list.push(entry);
map.set(entry.group, list);
}
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [entries, query, kind]);
const searching = query.trim().length > 0;
const toggle = (group: string) =>
setFolded((current) => {
const next = new Set(current);
if (next.has(group)) next.delete(group);
else next.add(group);
return next;
});
return (
<div
className={cn(
"flex h-full w-72 shrink-0 flex-col border-l border-border bg-surface/40",
className,
)}
>
<div className="flex h-8 shrink-0 items-center gap-2 border-b border-border px-3">
<Plus className="size-3 text-accent-brand" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("tileGrid.addTiles")}
</span>
{onClose && (
<button
type="button"
onClick={onClose}
className="ml-auto text-muted-foreground hover:text-foreground"
title={t("tileGrid.closePicker")}
aria-label={t("tileGrid.closePicker")}
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 border-b border-border p-2">
<label className="flex h-7 items-center gap-1.5 border border-border bg-background px-2">
<Search className="size-3 shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("tileGrid.search")}
aria-label={t("tileGrid.search")}
className="min-w-0 flex-1 bg-transparent text-xs outline-none"
/>
</label>
{kinds.length > 1 && (
<div className="flex flex-wrap gap-1">
{[null, ...kinds].map((k) => (
<button
key={k ?? "all"}
type="button"
onClick={() => setKind(k)}
className={cn(
"h-5 border px-1.5 text-[10px] transition-colors",
kind === k
? "border-accent-brand text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(KIND_LABELS[k ?? "all"])}
</button>
))}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{groups.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">
{t("tileGrid.noMatches")}
</p>
)}
{groups.map(([group, list]) => {
const open = searching || !folded.has(group);
return (
<div key={group} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => toggle(group)}
aria-expanded={open}
className="flex h-7 w-full items-center gap-1.5 px-2 text-left hover:bg-hover"
>
{open ? (
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<ChevronRight className="size-3 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group}
</span>
<span className="text-[10px] tabular-nums text-muted-foreground/60">
{list.length}
</span>
</button>
{open && (
<ul className="pb-1">
{list.map((entry) => (
<li key={entry.id}>
<button
type="button"
disabled={entry.placed}
onClick={() => onAdd(entry)}
title={entry.description}
className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-hover disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent"
>
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-3">
{entry.icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{entry.title}</span>
{entry.description && (
<span className="block truncate text-[10px] text-muted-foreground">
{entry.description}
</span>
)}
</span>
{entry.placed ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<Plus className="size-3 shrink-0 text-muted-foreground" />
)}
</button>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { createContext, useContext } from "react";
/** True inside an edge to edge TileGrid, where the grid draws the lines. */
export const SeamlessTileContext = createContext(false);
export function useInSeamlessTile(): boolean {
return useContext(SeamlessTileContext);
}
+4
View File
@@ -0,0 +1,4 @@
export { TileGrid, type TileGridProps } from "./TileGrid";
export { TileFrame } from "./TileFrame";
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
export * from "./layout";
+234
View File
@@ -0,0 +1,234 @@
/**
* A tile on a 12 column grid. x and w are columns, y is unscaled px from the
* top (the grid settles it), h is a fixed height in unscaled px or null for
* the tile's natural height.
*/
export interface TileSlot {
id: string;
x: number;
y: number;
w: number;
h?: number | null;
}
/** A tile with a resolved height, as the grid draws it. */
export interface TileBox {
id: string;
x: number;
y: number;
w: number;
h: number;
}
export const TILE_COLUMNS = 12;
export const MIN_TILE_COLUMNS = 2;
export const MIN_TILE_HEIGHT = 48;
export const MAX_TILE_HEIGHT = 10000;
/** y given to a new tile so it settles under everything else. */
export const BOTTOM = 1_000_000;
export const clampTileColumns = (w: number, min = MIN_TILE_COLUMNS) =>
Math.max(min, Math.min(TILE_COLUMNS, Math.round(w) || min));
const overlapsX = (a: TileBox, b: TileBox) =>
a.x < b.x + b.w && b.x < a.x + a.w;
/**
* Settles tiles top to bottom in (y, x) order. Each tile sits right under the
* lowest tile placed before it in the columns it covers. Tiles never move
* sideways and never jump above a tile that came before them.
*/
export function settleTiles(boxes: TileBox[], gap = 0): TileBox[] {
const order = [...boxes].sort(
(a, b) => a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : 1),
);
const placed: TileBox[] = [];
for (const box of order) {
const y = placed.reduce(
(top, p) => (overlapsX(p, box) ? Math.max(top, p.y + p.h + gap) : top),
0,
);
placed.push({ ...box, y });
}
return placed;
}
export function tileBoxes(
slots: TileSlot[],
heights: Record<string, number>,
fallback = 160,
): TileBox[] {
return slots.map((slot) => ({
id: slot.id,
x: slot.x,
y: slot.y,
w: slot.w,
h: slot.h ?? heights[slot.id] ?? fallback,
}));
}
export function tileBottom(boxes: TileBox[], gap = 0) {
return boxes.reduce((max, box) => Math.max(max, box.y + box.h + gap), 0);
}
/** Drops broken entries and duplicates and keeps every tile inside the grid. */
export function sanitizeTiles(
value: unknown,
min = MIN_TILE_COLUMNS,
): TileSlot[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: TileSlot[] = [];
for (const raw of value) {
if (!raw || typeof raw !== "object") continue;
const slot = raw as Record<string, unknown>;
const id = typeof slot.id === "string" ? slot.id : "";
if (!id || id.length > 300 || seen.has(id)) continue;
seen.add(id);
const w = clampTileColumns(Number(slot.w), min);
const x = Math.max(
0,
Math.min(TILE_COLUMNS - w, Math.round(Number(slot.x)) || 0),
);
const y = Math.max(0, Number(slot.y) || 0);
const h =
slot.h == null || !Number.isFinite(Number(slot.h))
? null
: Math.max(
MIN_TILE_HEIGHT,
Math.min(MAX_TILE_HEIGHT, Math.round(Number(slot.h))),
);
out.push({ id, x, y, w, h });
}
return out.slice(0, 200);
}
/** Adds a tile at the bottom, at the left. */
export function appendTile(
slots: TileSlot[],
tile: { id: string; w?: number; h?: number | null },
): TileSlot[] {
if (slots.some((slot) => slot.id === tile.id)) return slots;
return [
...slots,
{
id: tile.id,
x: 0,
y: BOTTOM,
w: clampTileColumns(tile.w ?? 4),
h: tile.h ?? null,
},
];
}
export function removeTile(slots: TileSlot[], id: string): TileSlot[] {
return slots.filter((slot) => slot.id !== id);
}
/** Writes settled positions back, in reading order. */
export function tilesFromBoxes(
slots: TileSlot[],
boxes: TileBox[],
): TileSlot[] {
const byId = new Map(boxes.map((box) => [box.id, box]));
return slots
.map((slot) => {
const box = byId.get(slot.id);
return box ? { ...slot, x: box.x, y: Math.round(box.y), w: box.w } : slot;
})
.sort((a, b) => a.y - b.y || a.x - b.x);
}
/**
* Lays tiles out left to right in rows, for layouts that only know an order
* and a width (old dashboards, generated defaults).
*/
export function flowTiles(
tiles: { id: string; w: number; h?: number | null }[],
rowHeight = 160,
): TileSlot[] {
const out: TileSlot[] = [];
let x = 0;
let row = 0;
for (const tile of tiles) {
const w = clampTileColumns(tile.w);
if (x + w > TILE_COLUMNS) {
x = 0;
row++;
}
out.push({ id: tile.id, x, y: row * rowHeight, w, h: tile.h ?? null });
x += w;
}
return out;
}
/** A tile's box in whole screen px. */
export interface PixelRect {
l: number;
t: number;
r: number;
b: number;
}
/** A 1 px line the grid draws, as a box. */
export interface GridLine {
x: number;
y: number;
w: number;
h: number;
}
function mergeSpans(spans: [number, number][]): [number, number][] {
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
const out: [number, number][] = [];
for (const [start, end] of sorted) {
const last = out[out.length - 1];
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
else out.push([start, end]);
}
return out;
}
/**
* The lines between edge to edge tiles, each drawn once. A line sits on the
* first px column or row past a tile, so it covers the edge of whatever is
* next to it. The outer left, top and right edges are left out. With
* `openBottom` (the grid runs to the bottom of the screen) so is the line
* along the very bottom; a column that ends higher keeps its bottom line.
* Lines reach one px further so corners join.
*/
export function gridLines(
rects: PixelRect[],
width: number,
openBottom = false,
): GridLine[] {
const lowest = Math.max(0, ...rects.map((rect) => rect.b));
const end = openBottom ? lowest : Infinity;
const vertical = new Map<number, [number, number][]>();
const horizontal = new Map<number, [number, number][]>();
const add = (
map: Map<number, [number, number][]>,
at: number,
span: [number, number],
) => {
if (span[1] <= span[0]) return;
map.set(at, [...(map.get(at) ?? []), span]);
};
for (const rect of rects) {
if (rect.r <= rect.l || rect.b <= rect.t) continue;
const down: [number, number] = [rect.t, Math.min(end, rect.b + 1)];
if (rect.l > 0) add(vertical, rect.l, down);
if (rect.r < width) add(vertical, rect.r, down);
const across: [number, number] = [rect.l, Math.min(width, rect.r + 1)];
if (rect.t > 0) add(horizontal, rect.t, across);
if (rect.b < end) add(horizontal, rect.b, across);
}
const lines: GridLine[] = [];
for (const [x, spans] of vertical)
for (const [start, end] of mergeSpans(spans))
lines.push({ x, y: start, w: 1, h: end - start });
for (const [y, spans] of horizontal)
for (const [start, end] of mergeSpans(spans))
lines.push({ x: start, y, w: end - start, h: 1 });
return lines;
}