Viewport Bookmarks

Saved-view list for the canvas — pinned spatial locations with optional active state.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/viewport-bookmarks.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

4 stories available:

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, } from "react"; import { cn } from "../../lib/utils"; /** * One saved viewport. * * @public */ export type ViewportBookmark = { /** Optional accent color for the row glyph. */ color?: string; /** Optional secondary line (zoom level, last-visited, owner). */ detail?: ReactNode; /** Stable identifier — used as the React key. */ id: string; /** Display name for the bookmark. */ label: ReactNode; }; /** * Localizable strings. * * @public */ export type ViewportBookmarksLabels = { /** Empty-state copy. Defaults to `"No saved views"`. */ empty?: string; /** Aria-label override. Defaults to `"Viewport bookmarks"`. */ region?: string; }; const DEFAULT_LABELS = { empty: "No saved views", region: "Viewport bookmarks", } as const satisfies Required<ViewportBookmarksLabels>; /** * Props for {@link ViewportBookmarks}. * * @public */ export type ViewportBookmarksProps = { /** Optional active bookmark id — renders the row in the selected state. */ activeId?: string; /** Bookmark entries in render order. */ bookmarks: ViewportBookmark[]; /** Localizable strings. */ labels?: ViewportBookmarksLabels; /** Click handler — receives the activated bookmark id. */ onSelect?: (id: string) => void; /** Optional title rendered above the rows. Defaults to `"Saved views"`. */ title?: ReactNode; } & ComponentPropsWithoutRef<"section">; const Row = (props: { active: boolean; bookmark: ViewportBookmark; onSelect?: (id: string) => void; }): React.ReactElement => { const { active, bookmark, onSelect } = props; const handleSelectBookmark = (): void => { onSelect?.(bookmark.id); }; const rowClass = "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors"; const activeClass = active ? "bg-muted/60 text-foreground" : "text-muted-foreground hover:bg-muted/30 hover:text-foreground"; if (onSelect) { return ( <button aria-pressed={active} className={cn( rowClass, activeClass, "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", )} data-viewport-bookmark={bookmark.id} data-viewport-bookmark-active={active} onClick={handleSelectBookmark} type="button" > <RowBody bookmark={bookmark} /> </button> ); } return ( <span className={cn(rowClass, activeClass)} data-viewport-bookmark={bookmark.id} data-viewport-bookmark-active={active} > <RowBody bookmark={bookmark} /> </span> ); }; const RowBody = (props: { bookmark: ViewportBookmark }): React.ReactElement => { const { bookmark } = props; return ( <> <span aria-hidden="true" className="size-1.5 rounded-full" style={{ backgroundColor: bookmark.color ?? "oklch(var(--foreground))", }} /> <span className="flex flex-1 flex-col text-left"> <span className="truncate font-medium">{bookmark.label}</span> {bookmark.detail ? ( <span className="truncate text-[10px] text-muted-foreground" data-viewport-bookmark-detail > {bookmark.detail} </span> ) : null} </span> </> ); }; /** * Saved-view list for the canvas — the spatial parallel of a tab * bar's pinned tabs. Each bookmark stores a viewport target the host * resolves to a pan / zoom transition. Pure presentation; the host * owns the bookmark store and the camera animation. * * @example * ```tsx * <ViewportBookmarks * activeId={active} * bookmarks={[ * { id: "home", label: "Home base", color: "#5b8def" }, * { id: "incidents", label: "Incidents", detail: "5 open", color: "#ef4444" }, * ]} * onSelect={jumpTo} * /> * ``` * * @public */ export const ViewportBookmarks = forwardRef< HTMLElement, ViewportBookmarksProps >((props, ref) => { const { activeId, bookmarks, className, labels, onSelect, title = "Saved views", ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; return ( <section aria-label={resolvedLabels.region} className={cn( "flex w-full flex-col gap-1 rounded-lg border border-border bg-background p-2 text-foreground", className, )} data-viewport-bookmarks ref={ref} {...rest} > <header className="px-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> {title} </header> {bookmarks.length === 0 ? ( <p className="px-2 py-3 text-center text-[11px] text-muted-foreground" data-viewport-bookmarks-state="empty" > {resolvedLabels.empty} </p> ) : ( <ul className="space-y-0.5"> {bookmarks.map((bookmark) => ( <li key={bookmark.id}> <Row active={activeId === bookmark.id} bookmark={bookmark} onSelect={onSelect} /> </li> ))} </ul> )} </section> ); }); ViewportBookmarks.displayName = "ViewportBookmarks";

Dependencies

  • @vllnt/ui@^0.2.1