Record panel
One record of a list opened beside it in a sheet — title, previous and next with the arrow keys or J and K, a link to its full page, actions and a pinned footer.
No live demo yet.
pnpm exec shadcn add @intelligo/record-panelInside an app made by intelligo create, or any shadcn project that names the registry.
used by these pages
Source
What shadcn add writes to components/ui/record-panel.tsx. It is yours from then on.
"use client";
import * as React from "react";
import { ChevronDownIcon, ChevronUpIcon, ExpandIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
export interface RecordPanelLabels {
previous: string;
next: string;
fullPage: string;
}
export const recordPanelLabels: RecordPanelLabels = {
previous: "Previous record",
next: "Next record",
fullPage: "Open full page",
};
type LinkComponent = React.ComponentType<{
href: string;
children?: React.ReactNode;
className?: string;
}>;
function DefaultLink({
href,
children,
className,
}: React.ComponentProps<LinkComponent>) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
export interface RecordPanelProps {
open: boolean;
onClose: () => void;
/** The record's name, read out and shown at the top of the panel. */
title: React.ReactNode;
/** A line under the title: its kind, its state, where it came from. */
description?: React.ReactNode;
/** Steps to the neighbouring record of the list; omit at either end. */
onPrevious?: () => void;
onNext?: () => void;
/** The same record on its own page. */
fullPageHref?: string;
LinkComponent?: LinkComponent;
/** Actions for the record, at the top right beside the full-page link. */
actions?: React.ReactNode;
/** Pinned under the body: the record's primary decisions. */
footer?: React.ReactNode;
labels?: Partial<RecordPanelLabels>;
className?: string;
children: React.ReactNode;
}
/**
* One record of a list, opened beside it: the list stays in place, ↑ and ↓
* (or J and K) step through its rows, and the record can open on its own
* page. The caller owns which record is open — usually a URL parameter — so
* the panel survives a refresh and can be linked to.
*/
function RecordPanel({
open,
onClose,
title,
description,
onPrevious,
onNext,
fullPageHref,
LinkComponent,
actions,
footer,
labels,
className,
children,
}: RecordPanelProps) {
const l = { ...recordPanelLabels, ...labels };
const Link = LinkComponent ?? DefaultLink;
React.useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
if (target?.closest("input,textarea,select,[contenteditable=true]"))
return;
if ((event.key === "ArrowUp" || event.key === "k") && onPrevious) {
event.preventDefault();
onPrevious();
} else if ((event.key === "ArrowDown" || event.key === "j") && onNext) {
event.preventDefault();
onNext();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onPrevious, onNext]);
return (
<Sheet
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<SheetContent
side="right"
data-slot="record-panel"
className={cn(
"gap-0 p-0 data-[side=right]:w-full data-[side=right]:sm:max-w-xl data-[side=right]:xl:max-w-3xl",
className
)}
>
<div className="flex h-12 shrink-0 items-center gap-1 border-b px-3 pr-12">
<Button
variant="ghost"
size="icon-sm"
aria-label={l.previous}
disabled={!onPrevious}
onClick={onPrevious}
>
<ChevronUpIcon aria-hidden />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={l.next}
disabled={!onNext}
onClick={onNext}
>
<ChevronDownIcon aria-hidden />
</Button>
<div className="ml-auto flex items-center gap-2">
{actions}
{fullPageHref && (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link href={fullPageHref} />}
>
<ExpandIcon data-icon="inline-start" aria-hidden />
{l.fullPage}
</Button>
)}
</div>
</div>
<div className="flex flex-col gap-1 px-5 pt-4 pb-3">
<SheetTitle className="text-base font-semibold">{title}</SheetTitle>
{description ? (
<SheetDescription>{description}</SheetDescription>
) : (
<SheetDescription className="sr-only">{title}</SheetDescription>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
{children}
</div>
{footer && (
<div className="flex shrink-0 items-center justify-end gap-2 border-t px-5 py-3">
{footer}
</div>
)}
</SheetContent>
</Sheet>
);
}
export { RecordPanel };Ready to try it? Three commands and the app runs against a stub model.