feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0

This commit is contained in:
LukeGus committed 2026-11-04 18:57:18 -06:00
1 parent 1867be3f58
commit 19bdd589f3
41 files changed
+1497 -207

No files matched your search

@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { SeamlessTileContext } from "./context";
import { BottomEdge, EdgeOwned } from "./edge";
/**
* A column of cards that can sit at any depth, like a settings page built
* from several panels. Cards drop their box and the outermost ones draw a
* line under themselves. Use TileStack when the sections are direct children.
*/
export function TileColumn({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<SeamlessTileContext.Provider value={true}>
<BottomEdge
className={className}
innerClassName="flex min-w-0 flex-col [&_[data-tile-card]]:border-x-0 [&_[data-tile-card]]:border-t-0 [&_[data-tile-card]]:border-b [&_[data-tile-card]]:border-border [&_[data-tile-card]_[data-tile-card]]:border-b-0"
>
<EdgeOwned>{children}</EdgeOwned>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
/**
* The bar over a tile grid while it is being edited: a hint or extra controls
* on the left, then Reset, Add tiles (while the picker is closed) and Done.
*/
export function TileEditBar({
children,
onReset,
onAdd,
onDone,
className,
}: {
children?: ReactNode;
onReset?: () => void;
/** Leave out while the tile picker is open. */
onAdd?: () => void;
onDone: () => void;
className?: string;
}) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 text-xs",
className,
)}
>
{children && (
<div className="flex min-w-0 flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
{children}
</div>
)}
<div className="ml-auto flex items-center gap-1">
{onReset && (
<Button size="xs" variant="ghost" className="gap-1" onClick={onReset}>
<RotateCcw className="size-3" />
{t("tileGrid.reset")}
</Button>
)}
{onAdd && (
<Button size="xs" variant="ghost" onClick={onAdd}>
{t("tileGrid.addTiles")}
</Button>
)}
<Button size="xs" onClick={onDone}>
{t("tileGrid.done")}
</Button>
</div>
</div>
);
}
+1 -8
View File
@@ -28,6 +28,7 @@ import {
} from "./layout";
import { SeamlessTileContext } from "./context";
import { gridLines } from "./layout";
import { scrollParent } from "./edge";
type Edge = "left" | "right" | "bottom" | "corner";
interface Proposal {
@@ -83,14 +84,6 @@ 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";
+37
View File
@@ -0,0 +1,37 @@
import { Children, isValidElement, type ReactNode } from "react";
import { SeamlessTileContext } from "./context";
import { BottomEdge, EdgeOwned } from "./edge";
/**
* Sections stacked edge to edge with a line under each, the column form of
* the tile look. Cards inside drop their own border. The last line goes away
* when the stack reaches the bottom of the screen.
*/
export function TileStack({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<SeamlessTileContext.Provider value={true}>
<BottomEdge className={className} innerClassName="flex min-w-0 flex-col">
<EdgeOwned>
{Children.toArray(children).map((section, index) => (
<div
key={keyOf(section, index)}
className="min-w-0 shrink-0 border-b border-border"
>
{section}
</div>
))}
</EdgeOwned>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
function keyOf(node: ReactNode, index: number): string | number {
return isValidElement(node) && node.key !== null ? node.key : index;
}
+53
View File
@@ -0,0 +1,53 @@
import { Children, isValidElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gridSizeStyle, type GridSize } from "@/components/panel-layout";
import { SeamlessTileContext } from "./context";
import { BottomEdge } from "./edge";
/**
* A fixed grid of tiles edge to edge with one line between neighbours, for
* stat strips and card lists. `size` is tiles per row ("auto" fits as many as
* `minWidth` allows). Use TileGrid when people should move and resize tiles.
*/
export function TileWall({
size = "auto",
minWidth = 280,
gridClassName,
className,
children,
}: {
size?: GridSize;
minWidth?: number;
/** Column classes (like "grid-cols-1 md:grid-cols-3") in place of size. */
gridClassName?: string;
className?: string;
children: ReactNode;
}) {
const tiles = Children.toArray(children);
return (
<SeamlessTileContext.Provider value={true}>
{/* The grid sits a pixel wider so the right edge line is cut off. */}
<BottomEdge className={className}>
<div
className={cn("-mr-px grid", gridClassName)}
style={
gridClassName ? undefined : gridSizeStyle(size, minWidth, "0px")
}
>
{tiles.map((tile, index) => (
<div
key={keyOf(tile, index)}
className="flex min-w-0 flex-col border-b border-r border-border *:flex-1"
>
{tile}
</div>
))}
</div>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
function keyOf(node: ReactNode, index: number): string | number {
return isValidElement(node) && node.key !== null ? node.key : index;
}
+86
View File
@@ -0,0 +1,86 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export 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;
}
/** True inside a TileStack or TileColumn section, which draws its own line. */
const EdgeOwnedContext = createContext(false);
export function EdgeOwned({ children }: { children: ReactNode }) {
return (
<EdgeOwnedContext.Provider value={true}>
{children}
</EdgeOwnedContext.Provider>
);
}
/**
* Whether the element's end reaches the bottom of the visible scroll area,
* where a closing line would sit on the screen edge and only add noise.
*/
function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
const [reaches, setReaches] = useState(false);
const reachesRef = useRef(reaches);
reachesRef.current = reaches;
useLayoutEffect(() => {
const element = ref.current;
const scroller = scrollParent(element);
if (!element || !scroller) return;
// Measured without the line itself, so showing it never flips the answer.
const update = () => {
const end =
element.getBoundingClientRect().bottom -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
(reachesRef.current ? 0 : 1);
setReaches(end >= scroller.clientHeight - 0.5);
};
update();
const observer = new ResizeObserver(update);
observer.observe(scroller);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return reaches;
}
/**
* Wraps tiles whose last row draws a line under itself. The line stays when
* there is room below and is clipped when the tiles reach the bottom of the
* screen, the same rule the tile grid uses. Inside a stack section the
* section owns the line, so it is always clipped.
*/
export function BottomEdge({
className,
innerClassName,
children,
}: {
className?: string;
innerClassName?: string;
children: ReactNode;
}) {
const owned = useContext(EdgeOwnedContext);
const ref = useRef<HTMLDivElement>(null);
const reaches = useReachesBottom(ref);
return (
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}>
{children}
</div>
</div>
);
}
+5
View File
@@ -1,4 +1,9 @@
export { TileGrid, type TileGridProps } from "./TileGrid";
export { TileFrame } from "./TileFrame";
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
export { TileWall } from "./TileWall";
export { TileStack } from "./TileStack";
export { TileColumn } from "./TileColumn";
export { TileEditBar } from "./TileEditBar";
export { SeamlessTileContext, useInSeamlessTile } from "./context";
export * from "./layout";