Drawer
Free, copy-and-go Drawer components built on the SevenUI Drawer primitive.Read the primitive docs.
"use client";
import {
Copy,
Download,
FolderInput,
Link2,
type LucideIcon,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
type Action = {
label: string;
hint: string;
icon: LucideIcon;
};
const actions: Action[] = [
{ label: "Copy link", hint: "Anyone in Acme can view", icon: Link2 },
{ label: "Duplicate", hint: "Creates “Q3 roadmap (copy)”", icon: Copy },
{ label: "Move to folder", hint: "Currently in Planning", icon: FolderInput },
{ label: "Download as PDF", hint: "2.4 MB, 12 pages", icon: Download },
];
export default function Drawer01() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger
render={<Button variant="outline">Document actions</Button>}
/>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
<DrawerHeader className="pb-2">
<DrawerTitle>Q3 roadmap</DrawerTitle>
<DrawerDescription>Edited by Maya Chen 2 hours ago</DrawerDescription>
</DrawerHeader>
<ul className="flex flex-col p-2">
{actions.map((action) => (
<li key={action.label}>
<DrawerClose
render={
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
/>
}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-foreground">
<action.icon aria-hidden="true" className="size-4" />
</span>
<span className="flex min-w-0 flex-col">
<span className="font-medium">{action.label}</span>
<span className="truncate text-xs text-muted-foreground">
{action.hint}
</span>
</span>
</DrawerClose>
</li>
))}
</ul>
<Separator />
<div className="p-2 pb-4">
<DrawerClose
render={
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left font-medium text-destructive outline-none transition-colors hover:bg-destructive/10 focus-visible:ring-3 focus-visible:ring-destructive/30"
/>
}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-destructive/10">
<Trash2 aria-hidden="true" className="size-4" />
</span>
Move to trash
</DrawerClose>
</div>
</div>
</DrawerContent>
</Drawer>
);
}
npx shadcn@latest add @sevenui/component/drawer-01pnpm dlx shadcn@latest add @sevenui/component/drawer-01yarn dlx shadcn@latest add @sevenui/component/drawer-01bunx --bun shadcn@latest add @sevenui/component/drawer-01"use client";
import * as React from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
type Preference = {
id: string;
label: string;
description: string;
};
const groups: { title: string; items: Preference[] }[] = [
{
title: "Activity",
items: [
{
id: "mentions",
label: "Mentions",
description: "When someone @mentions you in a comment.",
},
{
id: "assignments",
label: "Assignments",
description: "When an issue is assigned to you.",
},
],
},
{
title: "Digest",
items: [
{
id: "weekly",
label: "Weekly summary",
description: "Every Monday at 9:00, in your time zone.",
},
{
id: "product",
label: "Product updates",
description: "New features and changes, about twice a month.",
},
],
},
];
export default function Drawer02() {
const [enabled, setEnabled] = React.useState<Record<string, boolean>>({
mentions: true,
assignments: true,
weekly: false,
product: false,
});
return (
<Drawer swipeDirection="right">
<DrawerTrigger
render={<Button variant="outline">Notification settings</Button>}
/>
<DrawerContent>
<DrawerHeader className="flex-row items-start justify-between gap-4 border-b pb-4">
<div className="flex flex-col gap-1">
<DrawerTitle>Notifications</DrawerTitle>
<DrawerDescription>
Choose what reaches your inbox.
</DrawerDescription>
</div>
<DrawerClose
render={
<Button variant="ghost" size="icon-sm" aria-label="Close" />
}
>
<X aria-hidden="true" />
</DrawerClose>
</DrawerHeader>
<div className="flex flex-1 flex-col gap-6 overflow-y-auto p-4">
{groups.map((group, index) => (
<section
key={group.title}
aria-labelledby={`drawer-02-${group.title}`}
className="flex flex-col gap-4"
>
{index > 0 && <Separator className="-mt-2" />}
<h3
id={`drawer-02-${group.title}`}
className="text-xs font-medium text-muted-foreground"
>
{group.title}
</h3>
{group.items.map((item) => (
<div key={item.id} className="flex items-start gap-3">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor={`drawer-02-${item.id}`}>{item.label}</Label>
<p
id={`drawer-02-${item.id}-hint`}
className="text-xs text-muted-foreground"
>
{item.description}
</p>
</div>
<Switch
id={`drawer-02-${item.id}`}
aria-describedby={`drawer-02-${item.id}-hint`}
checked={enabled[item.id]}
onCheckedChange={(checked) =>
setEnabled((prev) => ({ ...prev, [item.id]: checked }))
}
/>
</div>
))}
</section>
))}
</div>
<DrawerFooter className="border-t pt-4">
<DrawerClose render={<Button>Save preferences</Button>} />
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
npx shadcn@latest add @sevenui/component/drawer-02pnpm dlx shadcn@latest add @sevenui/component/drawer-02yarn dlx shadcn@latest add @sevenui/component/drawer-02bunx --bun shadcn@latest add @sevenui/component/drawer-02ProjectAtlas mobile app
ClosedLast closed by: —
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
const reasonLabels: Record<string, string> = {
"close-press": "Cancel button",
"escape-key": "Escape key",
"outside-press": "Click outside",
swipe: "Swipe gesture",
"focus-out": "Focus left",
"trigger-press": "Trigger",
"imperative-action": "Save button",
};
export default function Drawer03() {
const [open, setOpen] = React.useState(false);
const [pointerDismiss, setPointerDismiss] = React.useState(true);
const [name, setName] = React.useState("Atlas mobile app");
const [draft, setDraft] = React.useState(name);
const [lastReason, setLastReason] = React.useState<string | null>(null);
const openDrawer = () => {
setDraft(name);
setOpen(true);
};
const save = () => {
setName(draft.trim() || name);
setLastReason("imperative-action");
setOpen(false);
};
return (
<div className="flex w-full max-w-xs flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="text-xs text-muted-foreground">Project</span>
<span className="truncate font-medium">{name}</span>
</div>
<Badge variant={open ? "default" : "outline"} aria-live="polite">
{open ? "Open" : "Closed"}
</Badge>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="drawer-03-pointer">Close on outside click</Label>
<Switch
id="drawer-03-pointer"
checked={pointerDismiss}
onCheckedChange={setPointerDismiss}
/>
</div>
<p className="text-xs text-muted-foreground">
Last closed by:{" "}
<span className="font-medium text-foreground">
{lastReason ? (reasonLabels[lastReason] ?? lastReason) : "—"}
</span>
</p>
<Button variant="outline" onClick={openDrawer}>
Rename project
</Button>
<Drawer
open={open}
disablePointerDismissal={!pointerDismiss}
onOpenChange={(next, details) => {
if (!next) setLastReason(details.reason);
setOpen(next);
}}
>
<DrawerContent>
<form
className="mx-auto flex w-full max-w-sm flex-col"
onSubmit={(event) => {
event.preventDefault();
save();
}}
>
<DrawerHeader>
<DrawerTitle>Rename project</DrawerTitle>
<DrawerDescription>
The new name shows up in the sidebar and in shared links.
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-2 p-4">
<Label htmlFor="drawer-03-name">Project name</Label>
<Input
id="drawer-03-name"
value={draft}
onChange={(event) => setDraft(event.target.value)}
autoComplete="off"
/>
</div>
<DrawerFooter>
<Button type="submit" disabled={!draft.trim()}>
Save name
</Button>
<DrawerClose render={<Button variant="outline">Cancel</Button>} />
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-03pnpm dlx shadcn@latest add @sevenui/component/drawer-03yarn dlx shadcn@latest add @sevenui/component/drawer-03bunx --bun shadcn@latest add @sevenui/component/drawer-03"use client";
import * as React from "react";
import { Car, Check, MapPin, Navigation, Share2 } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const snaps = [
{ value: "180px", label: "Peek" },
{ value: "0.55", label: "Half" },
{ value: "1", label: "Full" },
];
// Snap points are numbers (fraction of the viewport) or CSS lengths.
const snapPoints: (number | string)[] = ["180px", 0.55, 1];
const toKey = (point: number | string | null) =>
point == null ? "" : String(point);
const fromKey = (key: string) =>
key.endsWith("px") ? key : Number.parseFloat(key);
const stops = [
{ time: "8:42", place: "Pickup · 214 Harbor Street", icon: Navigation },
{ time: "8:51", place: "Stop · Central Library", icon: MapPin },
{ time: "9:04", place: "Drop-off · Terminal 2, Gate B", icon: MapPin },
];
const fare = [
{ label: "Base fare", amount: "$14.20" },
{ label: "Airport fee", amount: "$4.50" },
{ label: "Service fee", amount: "$2.15" },
];
export default function Drawer04() {
const [snapPoint, setSnapPoint] = React.useState<number | string | null>(
snapPoints[0],
);
const [shared, setShared] = React.useState(false);
React.useEffect(() => {
if (!shared) return;
const timeout = window.setTimeout(() => setShared(false), 2000);
return () => window.clearTimeout(timeout);
}, [shared]);
async function shareStatus() {
try {
await navigator.clipboard?.writeText("https://ride.example.com/t/7KXR219");
} catch {
// Clipboard can be blocked in sandboxed previews; still confirm intent.
}
setShared(true);
}
return (
<Drawer
snapPoints={snapPoints}
snapPoint={snapPoint}
onSnapPointChange={setSnapPoint}
showSwipeHandle
onOpenChange={(open) => {
if (open) setSnapPoint(snapPoints[0]);
}}
>
<DrawerTrigger render={<Button variant="outline">Track ride</Button>} />
<DrawerContent>
<div className="mx-auto flex min-h-0 w-full max-w-sm flex-1 flex-col">
<DrawerHeader className="gap-3 pb-3 text-left">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>DK</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col text-left">
<DrawerTitle>Daniel arrives in 4 min</DrawerTitle>
<DrawerDescription className="flex items-center gap-1.5">
<Car aria-hidden="true" className="size-3.5" />
Grey Toyota Prius · 7KXR 219
</DrawerDescription>
</div>
</div>
<ToggleGroup
aria-label="Drawer height"
variant="outline"
size="sm"
spacing={0}
className="w-full *:flex-1"
value={[toKey(snapPoint)]}
onValueChange={(value) => {
if (value[0]) setSnapPoint(fromKey(value[0]));
}}
>
{snaps.map((snap) => (
<ToggleGroupItem key={snap.value} value={snap.value}>
{snap.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</DrawerHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 pt-1">
<section aria-labelledby="drawer-04-route">
<h3
id="drawer-04-route"
className="mb-2 text-xs font-medium text-muted-foreground"
>
Route
</h3>
<ol className="flex flex-col gap-3">
{stops.map((stop) => (
<li key={stop.place} className="flex items-center gap-3">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted">
<stop.icon aria-hidden="true" className="size-3.5" />
</span>
<span className="flex-1 text-sm">{stop.place}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{stop.time}
</span>
</li>
))}
</ol>
</section>
<Separator />
<section aria-labelledby="drawer-04-fare">
<h3
id="drawer-04-fare"
className="mb-2 text-xs font-medium text-muted-foreground"
>
Fare estimate
</h3>
<dl className="flex flex-col gap-1.5 text-sm">
{fare.map((row) => (
<div key={row.label} className="flex justify-between">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="tabular-nums">{row.amount}</dd>
</div>
))}
<div className="mt-1 flex justify-between border-t pt-2 font-medium">
<dt>Total</dt>
<dd className="tabular-nums">$20.85</dd>
</div>
</dl>
</section>
<Button
variant="outline"
className="mt-auto"
aria-live="polite"
onClick={shareStatus}
>
{shared ? (
<Check aria-hidden="true" data-icon="inline-start" />
) : (
<Share2 aria-hidden="true" data-icon="inline-start" />
)}
{shared ? "Trip link copied" : "Share trip status"}
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
);
}
npx shadcn@latest add @sevenui/component/drawer-04pnpm dlx shadcn@latest add @sevenui/component/drawer-04yarn dlx shadcn@latest add @sevenui/component/drawer-04bunx --bun shadcn@latest add @sevenui/component/drawer-04Select files. The action bar stays open while you keep working.
"use client";
import * as React from "react";
import { Download, FileText, FolderInput, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerTitle,
} from "@/components/ui/drawer";
const initialFiles = [
{ id: "brief", name: "Brand brief.pdf", meta: "1.8 MB · Sep 12" },
{ id: "invoice", name: "Invoice 0421.pdf", meta: "240 KB · Sep 10" },
{ id: "contract", name: "Vendor contract.docx", meta: "96 KB · Sep 4" },
{ id: "notes", name: "Kickoff notes.md", meta: "12 KB · Aug 29" },
];
export default function Drawer05() {
const [files, setFiles] = React.useState(initialFiles);
const [selected, setSelected] = React.useState<string[]>([]);
const [notice, setNotice] = React.useState<string | null>(null);
const count = selected.length;
const noun = (n: number) => `${n} ${n === 1 ? "file" : "files"}`;
const removeSelected = (message: string) => {
setFiles((prev) => prev.filter((file) => !selected.includes(file.id)));
setSelected([]);
setNotice(message);
};
const toggle = (id: string, checked: boolean) => {
setNotice(null);
setSelected((prev) =>
checked ? [...prev, id] : prev.filter((item) => item !== id),
);
};
return (
<div className="w-full max-w-sm">
{files.length === 0 && (
<div className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
No files left.{" "}
<button
type="button"
className="font-medium text-foreground underline underline-offset-4"
onClick={() => {
setFiles(initialFiles);
setNotice(null);
}}
>
Restore
</button>
</div>
)}
<ul
hidden={files.length === 0}
aria-label="Project files"
className="flex flex-col divide-y rounded-xl border bg-card"
>
{files.map((file) => {
const checked = selected.includes(file.id);
return (
<li key={file.id}>
<label
htmlFor={`drawer-05-${file.id}`}
className="flex cursor-pointer items-center gap-3 px-3 py-2.5 transition-colors hover:bg-muted/50 has-data-checked:bg-muted/50"
>
<Checkbox
id={`drawer-05-${file.id}`}
checked={checked}
onCheckedChange={(value) => toggle(file.id, value)}
/>
<FileText
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
{file.meta}
</span>
</span>
</label>
</li>
);
})}
</ul>
<p
aria-live="polite"
className="mt-3 text-center text-xs text-muted-foreground"
>
{notice ??
"Select files. The action bar stays open while you keep working."}
</p>
<Drawer
modal={false}
disablePointerDismissal
open={count > 0}
onOpenChange={(open) => {
if (!open) setSelected([]);
}}
>
<DrawerContent
initialFocus={false}
finalFocus={false}
className="mx-auto w-[calc(100%-1rem)] max-w-md"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 p-3">
<div className="flex min-w-0 flex-1 flex-col">
<DrawerTitle aria-live="polite" className="text-sm">
{count} {count === 1 ? "file" : "files"} selected
</DrawerTitle>
<DrawerDescription className="text-xs">
Press Escape to clear
</DrawerDescription>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
aria-label="Download"
onClick={() =>
setNotice(`Downloading ${noun(count)} as a zip archive.`)
}
>
<Download aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Move to Archive"
onClick={() =>
removeSelected(`Moved ${noun(count)} to Archive.`)
}
>
<FolderInput aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Delete"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => removeSelected(`Deleted ${noun(count)}.`)}
>
<Trash2 aria-hidden="true" />
</Button>
<Button
variant="outline"
size="icon"
aria-label="Clear selection"
onClick={() => setSelected([])}
>
<X aria-hidden="true" />
</Button>
</div>
</div>
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-05pnpm dlx shadcn@latest add @sevenui/component/drawer-05yarn dlx shadcn@latest add @sevenui/component/drawer-05bunx --bun shadcn@latest add @sevenui/component/drawer-05"use client";
import * as React from "react";
import { CircleCheck, Gift } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
type Status = "idle" | "loading" | "error" | "success";
const VALID_CODE = "WELCOME25";
export default function Drawer06() {
const [code, setCode] = React.useState("");
const [status, setStatus] = React.useState<Status>("idle");
const [error, setError] = React.useState("");
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const reset = () => {
if (timer.current) clearTimeout(timer.current);
setCode("");
setStatus("idle");
setError("");
};
const redeem = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const value = code.trim().toUpperCase();
if (!value) {
setStatus("error");
setError("Enter the code printed on your gift card.");
return;
}
setStatus("loading");
setError("");
timer.current = setTimeout(() => {
if (value === VALID_CODE) {
setStatus("success");
} else {
setStatus("error");
setError(
`“${value}” has expired or was already used. Check the code and try again.`,
);
}
}, 1200);
};
const loading = status === "loading";
return (
<Drawer
onOpenChangeComplete={(open) => {
if (!open) reset();
}}
>
<DrawerTrigger
render={
<Button variant="outline">
<Gift aria-hidden="true" />
Redeem gift card
</Button>
}
/>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
{status === "success" ? (
<>
<DrawerHeader className="items-center pt-8 text-center md:text-center">
<span className="mb-3 flex size-12 items-center justify-center rounded-full bg-success/10 text-success">
<CircleCheck aria-hidden="true" className="size-6" />
</span>
<DrawerTitle>$25.00 added to your balance</DrawerTitle>
<DrawerDescription>
Your new balance is $61.40. It applies to your next invoice
automatically.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter className="pt-6">
<DrawerClose render={<Button>Done</Button>} />
</DrawerFooter>
</>
) : (
<form noValidate onSubmit={redeem}>
<DrawerHeader>
<DrawerTitle>Redeem gift card</DrawerTitle>
<DrawerDescription>
Credit is added to your workspace balance right away.
</DrawerDescription>
</DrawerHeader>
<div className="p-4">
<Field invalid={status === "error"} disabled={loading}>
<FieldLabel htmlFor="drawer-06-code">Gift code</FieldLabel>
<Input
id="drawer-06-code"
value={code}
onChange={(event) => {
setCode(event.target.value);
if (status === "error") setStatus("idle");
}}
placeholder="e.g. WELCOME25"
autoComplete="off"
spellCheck={false}
className="font-mono uppercase placeholder:normal-case"
/>
{status === "error" ? (
<FieldError match>{error}</FieldError>
) : (
<FieldDescription>
Codes are 9 characters, letters and numbers.
</FieldDescription>
)}
</Field>
</div>
<DrawerFooter>
<Button type="submit" disabled={loading} aria-busy={loading}>
{loading && <Spinner aria-hidden="true" />}
{loading ? "Checking code…" : "Redeem"}
</Button>
<DrawerClose
render={
<Button variant="outline" disabled={loading}>
Cancel
</Button>
}
/>
</DrawerFooter>
</form>
)}
</div>
</DrawerContent>
</Drawer>
);
}
npx shadcn@latest add @sevenui/component/drawer-06pnpm dlx shadcn@latest add @sevenui/component/drawer-06yarn dlx shadcn@latest add @sevenui/component/drawer-06bunx --bun shadcn@latest add @sevenui/component/drawer-06"use client";
import * as React from "react";
import { CreditCard, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type Card = {
id: string;
brand: string;
last4: string;
expires: string;
};
const initialCards: Card[] = [
{ id: "visa", brand: "Visa", last4: "4242", expires: "08/28" },
{ id: "mastercard", brand: "Mastercard", last4: "5100", expires: "02/27" },
];
export default function Drawer07() {
const [cards, setCards] = React.useState(initialCards);
const [selected, setSelected] = React.useState("visa");
const [addOpen, setAddOpen] = React.useState(false);
const [number, setNumber] = React.useState("");
const [expiry, setExpiry] = React.useState("");
const digits = number.replace(/\D/g, "");
const canSave = digits.length >= 12 && /^\d{2}\/\d{2}$/.test(expiry);
const addCard = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSave) return;
const card: Card = {
id: `card-${Date.now()}`,
brand: digits.startsWith("4") ? "Visa" : "Card",
last4: digits.slice(-4),
expires: expiry,
};
setCards((prev) => [...prev, card]);
setSelected(card.id);
setNumber("");
setExpiry("");
setAddOpen(false);
};
const current = cards.find((card) => card.id === selected);
return (
<Drawer>
<DrawerTrigger
render={
<Button variant="outline">
<CreditCard aria-hidden="true" />
{current ? `${current.brand} ·· ${current.last4}` : "Payment"}
</Button>
}
/>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>Payment method</DrawerTitle>
<DrawerDescription>
Used for your Team plan, billed monthly.
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-2 p-4">
<RadioGroup
aria-label="Saved cards"
value={selected}
onValueChange={(value) => setSelected(String(value))}
className="gap-2"
>
{cards.map((card) => (
<Label
key={card.id}
htmlFor={`drawer-07-${card.id}`}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50 has-data-checked:border-primary/40 has-data-checked:bg-primary/5"
>
<CreditCard
aria-hidden="true"
className="size-4 text-muted-foreground"
/>
<span className="flex flex-1 flex-col gap-0.5">
<span className="font-medium">
{card.brand} ending in {card.last4}
</span>
<span className="text-xs font-normal text-muted-foreground">
Expires {card.expires}
</span>
</span>
<RadioGroupItem id={`drawer-07-${card.id}`} value={card.id} />
</Label>
))}
</RadioGroup>
<Drawer open={addOpen} onOpenChange={setAddOpen}>
<DrawerTrigger
render={
<Button variant="ghost" className="justify-start">
<Plus aria-hidden="true" />
Add a new card
</Button>
}
/>
<DrawerContent>
<form
onSubmit={addCard}
className="mx-auto flex w-full max-w-sm flex-col"
>
<DrawerHeader>
<DrawerTitle>Add card</DrawerTitle>
<DrawerDescription>
You can remove it anytime from billing settings.
</DrawerDescription>
</DrawerHeader>
<FieldGroup className="p-4">
<Field>
<FieldLabel htmlFor="drawer-07-number">
Card number
</FieldLabel>
<Input
id="drawer-07-number"
inputMode="numeric"
autoComplete="cc-number"
placeholder="4242 4242 4242 4242"
value={number}
onChange={(event) => setNumber(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="drawer-07-expiry">
Expiry (MM/YY)
</FieldLabel>
<Input
id="drawer-07-expiry"
autoComplete="cc-exp"
placeholder="04/29"
maxLength={5}
value={expiry}
onChange={(event) => setExpiry(event.target.value)}
/>
</Field>
</FieldGroup>
<DrawerFooter>
<Button type="submit" disabled={!canSave}>
Save card
</Button>
<DrawerClose
render={<Button variant="outline">Back</Button>}
/>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
</div>
<DrawerFooter>
<DrawerClose render={<Button>Use this card</Button>} />
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}
npx shadcn@latest add @sevenui/component/drawer-07pnpm dlx shadcn@latest add @sevenui/component/drawer-07yarn dlx shadcn@latest add @sevenui/component/drawer-07bunx --bun shadcn@latest add @sevenui/component/drawer-07Merino Crew Sweater
Oat · Relaxed fit
"use client";
import * as React from "react";
import { CheckIcon, MinusIcon, PlusIcon, RulerIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const sizes = [
{ value: "S", stock: 6 },
{ value: "M", stock: 2 },
{ value: "L", stock: 11 },
{ value: "XL", stock: 0 },
];
const unitPrice = 128;
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
export default function Drawer08() {
const [open, setOpen] = React.useState(false);
const [size, setSize] = React.useState<string | null>(null);
const [quantity, setQuantity] = React.useState(1);
const [added, setAdded] = React.useState<{
size: string;
quantity: number;
} | null>(null);
const selected = sizes.find((option) => option.value === size);
const maxQuantity = Math.min(selected?.stock ?? 1, 5);
function handleAdd() {
if (!size) return;
setAdded({ size, quantity });
setOpen(false);
}
return (
<div className="w-full max-w-xs overflow-hidden rounded-xl border bg-card text-card-foreground">
<img
src="/placeholder.svg"
alt="Merino crew sweater in oat, front view"
className="aspect-[4/3] w-full bg-muted object-cover"
/>
<div className="flex flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="font-medium">Merino Crew Sweater</h3>
<p className="text-sm text-muted-foreground">Oat · Relaxed fit</p>
</div>
<span className="font-medium tabular-nums">
{currency.format(unitPrice)}
</span>
</div>
{added ? (
<p
role="status"
className="flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm"
>
<CheckIcon aria-hidden="true" className="size-4 shrink-0" />
Added {added.quantity} × size {added.size} to your bag
</p>
) : null}
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
<DrawerTrigger
render={
<Button size="lg" className="w-full">
{added ? "Add another" : "Add to bag"}
</Button>
}
/>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>Select a size</DrawerTitle>
<DrawerDescription>
Runs true to size. Free returns within 30 days.
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-5 p-4">
<ToggleGroup
aria-label="Size"
variant="outline"
spacing={2}
className="grid w-full grid-cols-4"
value={size ? [size] : []}
onValueChange={(value) => {
const next = value[0] ?? null;
setSize(next);
setQuantity(1);
}}
>
{sizes.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
disabled={option.stock === 0}
aria-label={
option.stock === 0
? `${option.value}, sold out`
: option.value
}
className="h-11 w-full data-disabled:line-through"
>
{option.value}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex min-h-5 items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{selected
? selected.stock <= 3
? `Only ${selected.stock} left in ${selected.value}`
: `In stock, ships tomorrow`
: "XL is sold out"}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<RulerIcon aria-hidden="true" className="size-3.5" />
Model wears M
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span id="drawer-08-quantity" className="text-sm font-medium">
Quantity
</span>
<fieldset
aria-labelledby="drawer-08-quantity"
className="flex items-center gap-1 rounded-lg border p-0.5"
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Decrease quantity"
disabled={quantity <= 1}
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
>
<MinusIcon aria-hidden="true" />
</Button>
<output
aria-live="polite"
className="w-6 text-center text-sm font-medium tabular-nums"
>
{quantity}
</output>
<Button
variant="ghost"
size="icon-sm"
aria-label="Increase quantity"
disabled={!selected || quantity >= maxQuantity}
onClick={() =>
setQuantity((q) => Math.min(maxQuantity, q + 1))
}
>
<PlusIcon aria-hidden="true" />
</Button>
</fieldset>
</div>
</div>
<DrawerFooter>
<Button size="lg" disabled={!size} onClick={handleAdd}>
{size
? `Add to bag · ${currency.format(unitPrice * quantity)}`
: "Choose a size"}
</Button>
<DrawerClose
render={
<Button variant="ghost" size="lg">
Keep browsing
</Button>
}
/>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-08pnpm dlx shadcn@latest add @sevenui/component/drawer-08yarn dlx shadcn@latest add @sevenui/component/drawer-08bunx --bun shadcn@latest add @sevenui/component/drawer-08Q3 board memo.pdf
2.4 MB · Edited 2h ago
"use client";
import * as React from "react";
import {
CheckIcon,
CopyIcon,
FileTextIcon,
GlobeIcon,
LockIcon,
Share2Icon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
const shareUrl = "https://files.acme.co/s/q3-board-memo";
const people = [
{ name: "Priya Raman", email: "priya@acme.co", role: "Owner" },
{ name: "Daniel Osei", email: "daniel@acme.co", role: "Can edit" },
{ name: "Lena Fischer", email: "lena@acme.co", role: "Can view" },
];
const accessOptions = [
{
value: "restricted",
label: "Restricted",
description: "Only people listed below can open it.",
icon: LockIcon,
},
{
value: "link",
label: "Anyone at Acme with the link",
description: "Colleagues can view without asking.",
icon: GlobeIcon,
},
];
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("");
}
export default function Drawer09() {
const [access, setAccess] = React.useState("restricted");
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 2000);
return () => window.clearTimeout(timeout);
}, [copied]);
async function copyLink() {
try {
await navigator.clipboard?.writeText(shareUrl);
} catch {
// Clipboard can be blocked in sandboxed previews; still confirm intent.
}
setCopied(true);
}
return (
<div className="flex w-full max-w-sm items-center gap-3 rounded-xl border bg-card p-3 text-card-foreground">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<FileTextIcon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">Q3 board memo.pdf</p>
<p className="truncate text-xs text-muted-foreground">
2.4 MB · Edited 2h ago
</p>
</div>
<Drawer>
<DrawerTrigger
render={
<Button variant="outline" size="sm">
<Share2Icon aria-hidden="true" data-icon="inline-start" />
Share
</Button>
}
/>
<DrawerContent>
<div className="mx-auto flex min-h-0 w-full max-w-md flex-col">
<DrawerHeader>
<DrawerTitle>Share “Q3 board memo.pdf”</DrawerTitle>
<DrawerDescription>
Anyone you add gets an email with a link to the file.
</DrawerDescription>
</DrawerHeader>
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto p-4">
<InputGroup>
<InputGroupInput
readOnly
value={shareUrl}
aria-label="Share link"
onFocus={(event) => event.currentTarget.select()}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
aria-label={copied ? "Link copied" : "Copy link"}
onClick={copyLink}
>
{copied ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
{copied ? "Copied" : "Copy"}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<section
aria-labelledby="drawer-09-access"
className="grid gap-2"
>
<h3 id="drawer-09-access" className="text-sm font-medium">
General access
</h3>
<RadioGroup
aria-labelledby="drawer-09-access"
value={access}
onValueChange={(value) => setAccess(value as string)}
className="gap-2"
>
{accessOptions.map((option) => (
<label
key={option.value}
htmlFor={`drawer-09-${option.value}`}
className="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors has-data-checked:border-ring has-data-checked:bg-muted/50"
>
<option.icon
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
/>
<span className="grid flex-1 gap-0.5">
<span className="text-sm font-medium">
{option.label}
</span>
<span className="text-xs text-muted-foreground">
{option.description}
</span>
</span>
<RadioGroupItem
id={`drawer-09-${option.value}`}
value={option.value}
className="mt-0.5"
/>
</label>
))}
</RadioGroup>
</section>
<Separator />
<section
aria-labelledby="drawer-09-people"
className="grid gap-3"
>
<h3 id="drawer-09-people" className="text-sm font-medium">
People with access
</h3>
<ul className="grid gap-3">
{people.map((person) => (
<li key={person.email} className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{initials(person.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{person.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{person.email}
</p>
</div>
<Badge
variant={
person.role === "Owner" ? "secondary" : "outline"
}
>
{person.role}
</Badge>
</li>
))}
</ul>
</section>
</div>
<DrawerFooter>
<DrawerClose render={<Button size="lg">Done</Button>} />
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-09pnpm dlx shadcn@latest add @sevenui/component/drawer-09yarn dlx shadcn@latest add @sevenui/component/drawer-09bunx --bun shadcn@latest add @sevenui/component/drawer-09"use client";
import * as React from "react";
import {
PauseIcon,
PlayIcon,
Repeat2Icon,
ShuffleIcon,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Slider } from "@/components/ui/slider";
type Track = {
id: string;
title: string;
artist: string;
length: number;
};
const queue: Track[] = [
{ id: "t1", title: "Low Tide Radio", artist: "Marlow Bay", length: 214 },
{ id: "t2", title: "Paper Lanterns", artist: "Ines Okafor", length: 187 },
{ id: "t3", title: "Northbound", artist: "The Quiet Hours", length: 243 },
{ id: "t4", title: "Salt & Static", artist: "Marlow Bay", length: 198 },
];
// Fixed play order used while shuffle is on, so the example stays deterministic.
const shuffleOrder = [2, 0, 3, 1];
function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60);
const rest = Math.floor(seconds % 60);
return `${minutes}:${rest.toString().padStart(2, "0")}`;
}
export default function Drawer10() {
const [index, setIndex] = React.useState(0);
const [position, setPosition] = React.useState(48);
const [playing, setPlaying] = React.useState(false);
const [shuffle, setShuffle] = React.useState(false);
const [repeat, setRepeat] = React.useState(false);
const track = queue[index];
const playTrack = React.useCallback((next: number) => {
setIndex((next + queue.length) % queue.length);
setPosition(0);
}, []);
const nextIndex = shuffle
? shuffleOrder[(shuffleOrder.indexOf(index) + 1) % shuffleOrder.length]
: (index + 1) % queue.length;
// Remaining tracks in the order they will play.
const upNextOrder: number[] = [];
for (let cursor = nextIndex; upNextOrder.length < queue.length - 1; ) {
upNextOrder.push(cursor);
cursor = shuffle
? shuffleOrder[(shuffleOrder.indexOf(cursor) + 1) % shuffleOrder.length]
: (cursor + 1) % queue.length;
}
// Advance the playhead once per second while playing.
React.useEffect(() => {
if (!playing) return;
const interval = window.setInterval(() => {
setPosition((current) => current + 1);
}, 1000);
return () => window.clearInterval(interval);
}, [playing]);
// Move to the next track (or loop the current one) at the end.
React.useEffect(() => {
if (position < track.length) return;
if (repeat) setPosition(0);
else playTrack(nextIndex);
}, [position, track.length, repeat, nextIndex, playTrack]);
const playButton = (
<Button
size="icon"
aria-label={playing ? "Pause" : "Play"}
onClick={() => setPlaying((current) => !current)}
>
{playing ? <PauseIcon aria-hidden="true" /> : <PlayIcon aria-hidden="true" />}
</Button>
);
return (
<div className="w-full max-w-sm overflow-hidden rounded-xl border bg-card text-card-foreground">
<Drawer showSwipeHandle>
<div className="flex items-center gap-3 p-2 pr-3">
<DrawerTrigger
aria-label={`Open player: ${track.title} by ${track.artist}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg p-1 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50"
>
<img
src="/placeholder.svg"
alt=""
className="size-10 shrink-0 rounded-md bg-muted object-cover"
/>
<span className="grid min-w-0 flex-1">
<span className="truncate text-sm font-medium">{track.title}</span>
<span className="truncate text-xs text-muted-foreground">
{track.artist}
</span>
</span>
</DrawerTrigger>
{playButton}
</div>
<div aria-hidden="true" className="h-0.5 bg-muted">
<div
className="h-full bg-primary transition-[width] duration-1000 ease-linear"
style={{ width: `${(position / track.length) * 100}%` }}
/>
</div>
<DrawerContent>
<div className="mx-auto flex min-h-0 w-full max-w-sm flex-col gap-5 overflow-y-auto p-4 pt-2">
<img
src="/placeholder.svg"
alt={`Cover art for ${track.title}`}
className="mx-auto aspect-square w-full max-w-56 rounded-xl bg-muted object-cover"
/>
<DrawerHeader className="p-0 text-left md:text-left">
<DrawerTitle className="text-lg">{track.title}</DrawerTitle>
<DrawerDescription>
{track.artist} · Track {index + 1} of {queue.length}
</DrawerDescription>
</DrawerHeader>
<div className="grid gap-2">
<Slider
aria-label="Seek"
min={0}
max={track.length}
value={position}
onValueChange={(value) =>
setPosition(Array.isArray(value) ? value[0] : value)
}
/>
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{formatTime(position)}</span>
<span>-{formatTime(track.length - position)}</span>
</div>
</div>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
aria-label="Shuffle"
aria-pressed={shuffle}
className="aria-pressed:text-primary"
onClick={() => setShuffle((current) => !current)}
>
<ShuffleIcon aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon-lg"
aria-label="Previous track"
onClick={() =>
position > 3 ? setPosition(0) : playTrack(index - 1)
}
>
<SkipBackIcon aria-hidden="true" />
</Button>
<Button
size="icon-lg"
className="size-12 rounded-full"
aria-label={playing ? "Pause" : "Play"}
onClick={() => setPlaying((current) => !current)}
>
{playing ? (
<PauseIcon aria-hidden="true" />
) : (
<PlayIcon aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon-lg"
aria-label="Next track"
onClick={() => playTrack(nextIndex)}
>
<SkipForwardIcon aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Repeat track"
aria-pressed={repeat}
className="aria-pressed:text-primary"
onClick={() => setRepeat((current) => !current)}
>
<Repeat2Icon aria-hidden="true" />
</Button>
</div>
<section aria-labelledby="drawer-10-next" className="grid gap-1">
<h3
id="drawer-10-next"
className="text-xs font-medium text-muted-foreground"
>
Up next
</h3>
<ul className="-mx-2 flex flex-col">
{upNextOrder.map((itemIndex) => {
const item = queue[itemIndex];
return (
<li key={item.id}>
<button
type="button"
onClick={() => {
playTrack(itemIndex);
setPlaying(true);
}}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span className="grid min-w-0 flex-1">
<span className="truncate font-medium">
{item.title}
</span>
<span className="truncate text-xs text-muted-foreground">
{item.artist}
</span>
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{formatTime(item.length)}
</span>
</button>
</li>
);
})}
</ul>
</section>
</div>
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-10pnpm dlx shadcn@latest add @sevenui/component/drawer-10yarn dlx shadcn@latest add @sevenui/component/drawer-10bunx --bun shadcn@latest add @sevenui/component/drawer-10Delivered at 7:42 PM
Pho Saigon · 3 items · $38.60
"use client";
import * as React from "react";
import { BikeIcon, StarIcon } from "lucide-react";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const ratingLabels = ["Terrible", "Poor", "Okay", "Good", "Excellent"];
const praise = ["Friendly courier", "Arrived hot", "Careful packing", "Fast"];
const problems = ["Late", "Missing items", "Arrived cold", "Wrong address"];
const tips = [0, 2, 4, 6];
type Review = { rating: number; tip: number };
export default function Drawer11() {
const [open, setOpen] = React.useState(false);
const [rating, setRating] = React.useState(0);
const [tags, setTags] = React.useState<string[]>([]);
const [tip, setTip] = React.useState(4);
const [note, setNote] = React.useState("");
const [review, setReview] = React.useState<Review | null>(null);
const positive = rating >= 4;
const tagOptions = rating === 0 ? [] : positive ? praise : problems;
function submit() {
if (rating === 0) return;
setReview({ rating, tip });
setOpen(false);
}
return (
<div className="flex w-full max-w-xs flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground">
<div className="flex items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<BikeIcon aria-hidden="true" className="size-5" />
</span>
<div className="min-w-0">
<p className="font-medium">Delivered at 7:42 PM</p>
<p className="text-sm text-muted-foreground">
Pho Saigon · 3 items · $38.60
</p>
</div>
</div>
{review ? (
<p role="status" className="rounded-lg bg-muted px-3 py-2 text-sm">
Thanks for rating Luis {review.rating} of 5
{review.tip > 0 ? ` and tipping $${review.tip}` : ""}.
</p>
) : null}
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
{review ? null : (
<DrawerTrigger
render={<Button className="w-full">Rate delivery</Button>}
/>
)}
<DrawerContent>
<div className="mx-auto flex min-h-0 w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>How was your delivery?</DrawerTitle>
<DrawerDescription>
Luis brought your order from Pho Saigon.
</DrawerDescription>
</DrawerHeader>
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto p-4">
<div className="flex flex-col items-center gap-1.5">
<ToggleGroup
aria-label="Rating"
spacing={1}
value={rating ? [String(rating)] : []}
onValueChange={(value) => {
const next = Number(value[0] ?? 0);
if (next >= 4 !== rating >= 4) setTags([]);
setRating(next);
}}
>
{ratingLabels.map((label, index) => (
<ToggleGroupItem
key={label}
value={String(index + 1)}
aria-label={`${index + 1} of 5, ${label}`}
className="size-11 data-[state=on]:bg-transparent aria-pressed:bg-transparent"
>
<StarIcon
aria-hidden="true"
className={cn(
"size-7 transition-colors",
index < rating
? "fill-primary text-primary"
: "text-muted-foreground",
)}
/>
</ToggleGroupItem>
))}
</ToggleGroup>
<p
aria-live="polite"
className={cn(
"min-h-5 text-sm",
rating ? "font-medium" : "text-muted-foreground",
)}
>
{rating ? ratingLabels[rating - 1] : "Tap a star to rate"}
</p>
</div>
{tagOptions.length > 0 ? (
<div className="grid gap-2">
<span
id="drawer-11-tags"
className="text-sm font-medium"
>
{positive ? "What went well?" : "What went wrong?"}
</span>
<ToggleGroup
aria-labelledby="drawer-11-tags"
multiple
variant="outline"
size="sm"
spacing={2}
className="flex-wrap"
value={tags}
onValueChange={(value) => setTags(value as string[])}
>
{tagOptions.map((tag) => (
<ToggleGroupItem
key={tag}
value={tag}
className="rounded-full"
>
{tag}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
) : null}
<div className="grid gap-2">
<span id="drawer-11-tip" className="text-sm font-medium">
Add a tip for Luis
</span>
<ToggleGroup
aria-labelledby="drawer-11-tip"
variant="outline"
spacing={2}
className="grid w-full grid-cols-4"
value={[String(tip)]}
onValueChange={(value) => {
if (value[0] !== undefined) setTip(Number(value[0]));
}}
>
{tips.map((amount) => (
<ToggleGroupItem
key={amount}
value={String(amount)}
className="w-full tabular-nums"
>
{amount === 0 ? "None" : `$${amount}`}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="grid gap-2">
<Label htmlFor="drawer-11-note">Note for the restaurant</Label>
<Textarea
id="drawer-11-note"
placeholder="Optional. Only Pho Saigon sees this."
rows={2}
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</div>
</div>
<DrawerFooter className="pt-2">
<Button size="lg" disabled={rating === 0} onClick={submit}>
{rating === 0
? "Choose a rating"
: tip > 0
? `Submit and tip $${tip}`
: "Submit rating"}
</Button>
<DrawerClose
render={
<Button variant="ghost" size="lg">
Not now
</Button>
}
/>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-11pnpm dlx shadcn@latest add @sevenui/component/drawer-11yarn dlx shadcn@latest add @sevenui/component/drawer-11bunx --bun shadcn@latest add @sevenui/component/drawer-11We text a 6-digit code to confirm it’s you.
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
type Country = { code: string; name: string; dial: string };
const countries: Country[] = [
{ code: "AU", name: "Australia", dial: "+61" },
{ code: "BR", name: "Brazil", dial: "+55" },
{ code: "CA", name: "Canada", dial: "+1" },
{ code: "DE", name: "Germany", dial: "+49" },
{ code: "IN", name: "India", dial: "+91" },
{ code: "JP", name: "Japan", dial: "+81" },
{ code: "MX", name: "Mexico", dial: "+52" },
{ code: "NL", name: "Netherlands", dial: "+31" },
{ code: "NG", name: "Nigeria", dial: "+234" },
{ code: "ES", name: "Spain", dial: "+34" },
{ code: "TR", name: "Türkiye", dial: "+90" },
{ code: "GB", name: "United Kingdom", dial: "+44" },
{ code: "US", name: "United States", dial: "+1" },
];
const suggested = ["US", "GB", "DE"];
export default function Drawer12() {
const [country, setCountry] = React.useState(countries[12]);
const [phone, setPhone] = React.useState("");
const [query, setQuery] = React.useState("");
const search = query.trim().toLowerCase();
const matches = countries.filter(
(item) =>
item.name.toLowerCase().includes(search) ||
item.dial.includes(search) ||
item.code.toLowerCase() === search,
);
const showSuggested = search === "";
const renderOption = (item: Country) => {
const selected = item.code === country.code;
return (
<li key={item.code}>
<DrawerClose
aria-current={selected ? "true" : undefined}
onClick={() => setCountry(item)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted"
>
<span className="flex h-6 w-8 shrink-0 items-center justify-center rounded border bg-background text-[0.625rem] font-medium tracking-wide text-muted-foreground">
{item.code}
</span>
<span className="min-w-0 flex-1 truncate">{item.name}</span>
<span className="text-muted-foreground tabular-nums">
{item.dial}
</span>
<CheckIcon
aria-hidden="true"
className={
selected ? "size-4 shrink-0" : "size-4 shrink-0 opacity-0"
}
/>
</DrawerClose>
</li>
);
};
return (
<div className="grid w-full max-w-xs gap-2">
<Label htmlFor="drawer-12-phone">Mobile number</Label>
<Drawer
onOpenChangeComplete={(open) => {
if (!open) setQuery("");
}}
>
<InputGroup>
<InputGroupAddon>
<DrawerTrigger
render={
<Button
variant="ghost"
size="sm"
className="-ml-1.5 gap-1 px-1.5 tabular-nums"
aria-label={`Country code: ${country.name} ${country.dial}`}
/>
}
>
<span className="text-xs font-medium text-muted-foreground">
{country.code}
</span>
{country.dial}
<ChevronDownIcon aria-hidden="true" className="size-3.5" />
</DrawerTrigger>
</InputGroupAddon>
<InputGroupInput
id="drawer-12-phone"
type="tel"
inputMode="tel"
autoComplete="tel-national"
placeholder="(555) 014-2290"
value={phone}
onChange={(event) => setPhone(event.target.value)}
/>
</InputGroup>
<p className="text-xs text-muted-foreground">
We text a 6-digit code to confirm it’s you.
</p>
<DrawerContent className="data-[swipe-axis=y]:[--drawer-height:min(34rem,calc(100dvh-6rem))]">
<div className="mx-auto flex min-h-0 w-full max-w-sm flex-1 flex-col">
<DrawerHeader className="gap-3 pb-3">
<div className="grid gap-0.5">
<DrawerTitle>Country or region</DrawerTitle>
<DrawerDescription>
Choose where your phone number is registered.
</DrawerDescription>
</div>
<InputGroup>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search countries"
placeholder="Search by name or code"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</InputGroup>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
{matches.length === 0 ? (
<p
role="status"
className="px-6 py-10 text-center text-sm text-muted-foreground"
>
No country matches “{query.trim()}”. Try the country name in
English or its dialing code.
</p>
) : (
<>
{showSuggested ? (
<section aria-labelledby="drawer-12-suggested">
<h3
id="drawer-12-suggested"
className="px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground"
>
Suggested
</h3>
<ul>
{countries
.filter((item) => suggested.includes(item.code))
.map(renderOption)}
</ul>
</section>
) : null}
<section aria-labelledby="drawer-12-all">
<h3
id="drawer-12-all"
className="px-3 pt-3 pb-1 text-xs font-medium text-muted-foreground"
>
{showSuggested
? "All countries"
: `${matches.length} ${matches.length === 1 ? "result" : "results"}`}
</h3>
<ul>{matches.map(renderOption)}</ul>
</section>
</>
)}
</div>
</div>
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-12pnpm dlx shadcn@latest add @sevenui/component/drawer-12yarn dlx shadcn@latest add @sevenui/component/drawer-12bunx --bun shadcn@latest add @sevenui/component/drawer-12Osteria Lume
Fri, Sep 25 · Table 12
"use client";
import * as React from "react";
import { ReceiptTextIcon, SendIcon } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
const subtotal = 164.8;
const friends = [
{ id: "you", name: "You", initials: "ME" },
{ id: "amara", name: "Amara Nwosu", initials: "AN" },
{ id: "jonas", name: "Jonas Weber", initials: "JW" },
{ id: "lucia", name: "Lucía Romero", initials: "LR" },
{ id: "kenji", name: "Kenji Sato", initials: "KS" },
];
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
export default function Drawer13() {
const [open, setOpen] = React.useState(false);
const [included, setIncluded] = React.useState<string[]>(
friends.map((friend) => friend.id),
);
const [tipPercent, setTipPercent] = React.useState(18);
const [sent, setSent] = React.useState<{
count: number;
share: number;
} | null>(null);
const tip = subtotal * (tipPercent / 100);
const total = subtotal + tip;
const share = included.length > 0 ? total / included.length : 0;
const others = included.filter((id) => id !== "you").length;
function toggle(id: string, checked: boolean) {
setIncluded((current) =>
checked ? [...current, id] : current.filter((item) => item !== id),
);
}
return (
<div className="flex w-full max-w-xs flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<ReceiptTextIcon aria-hidden="true" className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="font-medium">Osteria Lume</p>
<p className="text-sm text-muted-foreground">Fri, Sep 25 · Table 12</p>
</div>
<span className="font-medium tabular-nums">
{money.format(subtotal)}
</span>
</div>
{sent !== null ? (
<p role="status" className="rounded-lg bg-muted px-3 py-2 text-sm">
Requested {money.format(sent.share)} each from {sent.count}{" "}
{sent.count === 1 ? "friend" : "friends"}. Payments land in your
balance.
</p>
) : null}
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
<DrawerTrigger
render={
<Button variant={sent === null ? "default" : "outline"}>
{sent === null ? "Split the bill" : "Edit split"}
</Button>
}
/>
<DrawerContent>
<div className="mx-auto flex min-h-0 w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>Split the bill</DrawerTitle>
<DrawerDescription>
Everyone pays the same share, tip included.
</DrawerDescription>
</DrawerHeader>
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto p-4">
<fieldset className="grid gap-1">
<legend className="mb-2 text-sm font-medium">Who’s in</legend>
{friends.map((friend) => {
const id = `drawer-13-${friend.id}`;
const checked = included.includes(friend.id);
return (
<label
key={friend.id}
htmlFor={id}
className="-mx-2 flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/50"
>
<Avatar size="sm">
<AvatarFallback className="text-[0.625rem]">
{friend.initials}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm">
{friend.name}
</span>
<span className="text-sm text-muted-foreground tabular-nums">
{checked ? money.format(share) : "—"}
</span>
<Checkbox
id={id}
checked={checked}
onCheckedChange={(value) => toggle(friend.id, value)}
/>
</label>
);
})}
</fieldset>
<Separator />
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<span id="drawer-13-tip" className="text-sm font-medium">
Tip
</span>
<span className="text-sm text-muted-foreground tabular-nums">
{tipPercent}% · {money.format(tip)}
</span>
</div>
<Slider
aria-labelledby="drawer-13-tip"
min={0}
max={30}
step={1}
value={tipPercent}
onValueChange={(value) =>
setTipPercent(Array.isArray(value) ? value[0] : value)
}
/>
</div>
<dl className="grid gap-1.5 rounded-lg bg-muted/50 p-3 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{money.format(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Tip</dt>
<dd className="tabular-nums">{money.format(tip)}</dd>
</div>
<div className="flex justify-between font-medium">
<dt>Total</dt>
<dd className="tabular-nums">{money.format(total)}</dd>
</div>
</dl>
</div>
<DrawerFooter>
<Button
size="lg"
disabled={others === 0}
onClick={() => {
setSent({ count: others, share });
setOpen(false);
}}
>
<SendIcon aria-hidden="true" data-icon="inline-start" />
{others === 0
? "Add at least one friend"
: `Request ${money.format(share)} from ${others}`}
</Button>
<DrawerClose
render={
<Button variant="ghost" size="lg">
Cancel
</Button>
}
/>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-13pnpm dlx shadcn@latest add @sevenui/component/drawer-13yarn dlx shadcn@latest add @sevenui/component/drawer-13bunx --bun shadcn@latest add @sevenui/component/drawer-13Deployments
tidewater/storefront
"use client";
import * as React from "react";
import {
CheckCircle2Icon,
CircleDashedIcon,
GitBranchIcon,
GitCommitHorizontalIcon,
Maximize2Icon,
RotateCwIcon,
XCircleIcon,
} from "lucide-react";
import { cn } from "cn";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
type Status = "ready" | "error" | "building";
type Deployment = {
id: string;
branch: string;
commit: string;
message: string;
author: string;
age: string;
status: Status;
failedStep?: number;
};
const steps = [
{ name: "Clone repository", duration: "1.8s" },
{ name: "Install dependencies", duration: "14.2s" },
{ name: "Type check", duration: "6.9s" },
{ name: "Build", duration: "31.4s" },
{ name: "Upload to edge", duration: "3.1s" },
];
const logs: Record<string, string[]> = {
"Clone repository": [
"Cloning github.com/tidewater/storefront (branch: main)",
"Cloned in 1.8s",
],
"Install dependencies": [
"Lockfile found, using pnpm 10.4.1",
"Packages: +1,284",
"Done in 14.2s",
],
"Type check": ["tsc --noEmit -p tsconfig.json", "No type errors found"],
Build: ["next build", "Compiled 214 routes in 31.4s"],
"Upload to edge": ["Uploading 1,902 files", "Deployment is live"],
};
const failedLogs = [
"tsc --noEmit -p tsconfig.json",
"src/cart/summary.tsx(42,17): error TS2322: Type 'string' is not assignable to type 'number'.",
"Found 1 error in src/cart/summary.tsx",
];
const initialDeployments: Deployment[] = [
{
id: "dpl_8kq2",
branch: "main",
commit: "a41c9e2",
message: "Show tax estimate in cart summary",
author: "Iris Novak",
age: "6m ago",
status: "error",
failedStep: 2,
},
{
id: "dpl_7hx0",
branch: "main",
commit: "f02b7d1",
message: "Lazy-load product reviews",
author: "Sam Ortiz",
age: "2h ago",
status: "ready",
},
{
id: "dpl_6mw4",
branch: "feat/gift-cards",
commit: "9d3e5a0",
message: "Add gift card balance lookup",
author: "Iris Novak",
age: "Yesterday",
status: "ready",
},
];
const statusMeta: Record<
Status,
{ label: string; icon: typeof CheckCircle2Icon; className: string }
> = {
ready: { label: "Ready", icon: CheckCircle2Icon, className: "text-success" },
error: { label: "Failed", icon: XCircleIcon, className: "text-destructive" },
building: {
label: "Building",
icon: CircleDashedIcon,
className: "text-muted-foreground",
},
};
const snapPoints = [0.5, 1];
export default function Drawer14() {
const [deployments, setDeployments] = React.useState(initialDeployments);
const [activeId, setActiveId] = React.useState<string | null>(null);
const [snapPoint, setSnapPoint] = React.useState<number | string | null>(0.5);
// Index of the step currently running during a redeploy, per deployment.
const [runningStep, setRunningStep] = React.useState<Record<string, number>>(
{},
);
const active = deployments.find((item) => item.id === activeId) ?? null;
const lastActive = React.useRef<Deployment | null>(null);
if (active) lastActive.current = active;
const shown = active ?? lastActive.current;
const buildingId = deployments.find((item) => item.status === "building")?.id;
const buildingStep = buildingId ? (runningStep[buildingId] ?? 0) : 0;
// Advance the simulated build one step at a time.
React.useEffect(() => {
if (!buildingId) return;
const interval = window.setInterval(() => {
setRunningStep((current) => ({
...current,
[buildingId]: (current[buildingId] ?? 0) + 1,
}));
}, 900);
return () => window.clearInterval(interval);
}, [buildingId]);
// Mark the deployment ready once every step has run.
React.useEffect(() => {
if (!buildingId || buildingStep < steps.length) return;
setDeployments((items) =>
items.map((item) =>
item.id === buildingId
? { ...item, status: "ready", age: "Just now" }
: item,
),
);
}, [buildingId, buildingStep]);
function redeploy(id: string) {
setRunningStep((current) => ({ ...current, [id]: 0 }));
setDeployments((items) =>
items.map((item) =>
item.id === id
? { ...item, status: "building", failedStep: undefined }
: item,
),
);
}
function stepState(deployment: Deployment, index: number) {
if (deployment.status === "building") {
const running = runningStep[deployment.id] ?? 0;
if (index < running) return "done";
if (index === running) return "running";
return "pending";
}
if (deployment.status === "error" && deployment.failedStep !== undefined) {
if (index < deployment.failedStep) return "done";
if (index === deployment.failedStep) return "failed";
return "skipped";
}
return "done";
}
const progressValue = shown
? shown.status === "building"
? Math.round(((runningStep[shown.id] ?? 0) / steps.length) * 100)
: 100
: 0;
return (
<div className="w-full max-w-md rounded-xl border bg-card text-card-foreground">
<div className="px-4 pt-4 pb-3">
<h3 className="font-medium">Deployments</h3>
<p className="text-sm text-muted-foreground">tidewater/storefront</p>
</div>
<Drawer
open={activeId !== null}
onOpenChange={(open) => {
if (!open) setActiveId(null);
}}
snapPoints={snapPoints}
snapPoint={snapPoint}
onSnapPointChange={setSnapPoint}
showSwipeHandle
>
<ul className="border-t">
{deployments.map((deployment) => {
const meta = statusMeta[deployment.status];
const Icon = deployment.status === "building" ? null : meta.icon;
return (
<li key={deployment.id} className="border-b last:border-b-0">
<DrawerTrigger
onClick={() => {
setSnapPoint(0.5);
setActiveId(deployment.id);
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors outline-none hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
>
<span className={cn("shrink-0", meta.className)}>
{Icon ? (
<Icon aria-hidden="true" className="size-4" />
) : (
<Spinner className="size-4" />
)}
<span className="sr-only">{meta.label}</span>
</span>
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate text-sm font-medium">
{deployment.message}
</span>
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<GitBranchIcon
aria-hidden="true"
className="size-3 shrink-0"
/>
<span className="truncate">{deployment.branch}</span>
<span aria-hidden="true">·</span>
<span className="shrink-0">{deployment.age}</span>
</span>
</span>
</DrawerTrigger>
</li>
);
})}
</ul>
<DrawerContent>
{shown ? (
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col">
<DrawerHeader className="gap-3 text-left md:gap-3">
<div className="flex items-start justify-between gap-3">
<div className="grid min-w-0 gap-1">
<DrawerTitle className="truncate">
{shown.message}
</DrawerTitle>
<DrawerDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="flex items-center gap-1">
<GitBranchIcon
aria-hidden="true"
className="size-3.5"
/>
{shown.branch}
</span>
<span className="flex items-center gap-1 font-mono">
<GitCommitHorizontalIcon
aria-hidden="true"
className="size-3.5"
/>
{shown.commit}
</span>
<span>by {shown.author}</span>
</DrawerDescription>
</div>
<Badge
variant={
shown.status === "error" ? "destructive" : "secondary"
}
className="shrink-0"
>
{statusMeta[shown.status].label}
</Badge>
</div>
<Progress value={progressValue} aria-label="Build progress" />
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={shown.status === "building"}
onClick={() => redeploy(shown.id)}
>
<RotateCwIcon aria-hidden="true" data-icon="inline-start" />
Redeploy
</Button>
{snapPoint !== 1 ? (
<Button
size="sm"
variant="outline"
onClick={() => setSnapPoint(1)}
>
<Maximize2Icon
aria-hidden="true"
data-icon="inline-start"
/>
View full logs
</Button>
) : null}
</div>
</DrawerHeader>
<ol
aria-label="Build steps"
className="mt-4 flex min-h-0 flex-1 flex-col overflow-y-auto border-t"
>
{steps.map((step, index) => {
const state = stepState(shown, index);
const lines =
state === "failed"
? failedLogs
: state === "done"
? logs[step.name]
: [];
return (
<li key={step.name} className="border-b last:border-b-0">
<div className="flex items-center gap-3 px-4 py-2.5 text-sm">
{state === "done" ? (
<CheckCircle2Icon
aria-hidden="true"
className="size-4 shrink-0 text-success"
/>
) : state === "failed" ? (
<XCircleIcon
aria-hidden="true"
className="size-4 shrink-0 text-destructive"
/>
) : state === "running" ? (
<Spinner className="size-4 shrink-0" />
) : (
<CircleDashedIcon
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
)}
<span
className={cn(
"flex-1",
(state === "pending" || state === "skipped") &&
"text-muted-foreground",
)}
>
{step.name}
<span className="sr-only">, {state}</span>
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{state === "done" || state === "failed"
? step.duration
: state === "skipped"
? "Skipped"
: ""}
</span>
</div>
{lines.length > 0 &&
(state === "failed" || snapPoint === 1) ? (
<pre className="mx-4 mb-3 overflow-x-auto rounded-lg bg-muted px-3 py-2 font-mono text-xs leading-relaxed">
{lines.map((line) => (
<code
key={line}
className={cn(
"block",
state === "failed" && line.includes("error")
? "text-destructive"
: "text-muted-foreground",
)}
>
{line}
</code>
))}
</pre>
) : null}
</li>
);
})}
</ol>
</div>
) : null}
</DrawerContent>
</Drawer>
</div>
);
}
npx shadcn@latest add @sevenui/component/drawer-14pnpm dlx shadcn@latest add @sevenui/component/drawer-14yarn dlx shadcn@latest add @sevenui/component/drawer-14bunx --bun shadcn@latest add @sevenui/component/drawer-14