Oct6
Onboarding call with Brightline
Tuesday, October 6 · 10:30 AM
45 min · Video call
Free, copy-and-go Popover components built on the SevenUI Popover primitive.Read the primitive docs.
"use client";
import { ArrowUpRightIcon, CircleHelpIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
export default function Popover01() {
return (
<div className="flex w-full max-w-xs items-center justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3 text-card-foreground">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">Billable hours</span>
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label="How billable hours are counted"
className="text-muted-foreground"
/>
}
>
<CircleHelpIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverContent side="top" className="w-64">
<PopoverHeader>
<PopoverTitle>How billable hours are counted</PopoverTitle>
<PopoverDescription>
Time entries tagged to a client project, rounded up to the
nearest 15 minutes. Internal meetings and time off are
excluded.
</PopoverDescription>
</PopoverHeader>
<a
href="#billable-hours"
className="inline-flex w-fit items-center gap-1 rounded-sm text-sm font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
>
Read the timesheet guide
<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
</a>
</PopoverContent>
</Popover>
</div>
<span className="text-2xl font-semibold tabular-nums">128.5 h</span>
</div>
<span className="text-xs text-muted-foreground">September</span>
</div>
);
}
npx shadcn@latest add @sevenui/component/popover-01pnpm dlx shadcn@latest add @sevenui/component/popover-01yarn dlx shadcn@latest add @sevenui/component/popover-01bunx --bun shadcn@latest add @sevenui/component/popover-01"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Side = "top" | "right" | "bottom" | "left";
type Align = "start" | "center" | "end";
const sides: Side[] = ["top", "right", "bottom", "left"];
const aligns: Align[] = ["start", "center", "end"];
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
export default function Popover02() {
const [side, setSide] = React.useState<Side>("bottom");
const [align, setAlign] = React.useState<Align>("center");
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span id="popover-02-side" className="text-sm font-medium">
Side
</span>
<ToggleGroup
aria-labelledby="popover-02-side"
variant="outline"
size="sm"
spacing={0}
value={[side]}
onValueChange={(next) => {
if (next.length > 0) setSide(next[0] as Side);
}}
className="w-full"
>
{sides.map((item) => (
<ToggleGroupItem
key={item}
value={item}
className="flex-1 aria-pressed:bg-accent aria-pressed:text-accent-foreground"
>
{capitalize(item)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="flex flex-col gap-1.5">
<span id="popover-02-align" className="text-sm font-medium">
Align
</span>
<ToggleGroup
aria-labelledby="popover-02-align"
variant="outline"
size="sm"
spacing={0}
value={[align]}
onValueChange={(next) => {
if (next.length > 0) setAlign(next[0] as Align);
}}
className="w-full"
>
{aligns.map((item) => (
<ToggleGroupItem
key={item}
value={item}
className="flex-1 aria-pressed:bg-accent aria-pressed:text-accent-foreground"
>
{capitalize(item)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="flex min-h-40 items-center justify-center rounded-lg border border-dashed border-border bg-muted/40">
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
Open schedule note
</PopoverTrigger>
<PopoverContent side={side} align={align} sideOffset={8} className="w-56">
<PopoverHeader>
<PopoverTitle>Maintenance window</PopoverTitle>
<PopoverDescription>
Deploys pause Sunday 02:00 to 04:00 UTC.
</PopoverDescription>
</PopoverHeader>
<p className="font-mono text-xs text-muted-foreground">
side="{side}" align="{align}"
</p>
</PopoverContent>
</Popover>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/popover-02pnpm dlx shadcn@latest add @sevenui/component/popover-02yarn dlx shadcn@latest add @sevenui/component/popover-02bunx --bun shadcn@latest add @sevenui/component/popover-02"use client";
import * as React from "react";
import { cn } from "cn";
import { ChevronRightIcon, PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const folders = ["Documents", "Research"];
const siblingNames = ["q3-board-update.pdf", "hiring-plan-2027.xlsx"];
function validate(name: string) {
const trimmed = name.trim();
if (trimmed.length === 0) return "Enter a file name.";
if (/[\\/:*?"<>|]/.test(trimmed)) {
return 'Remove these characters: \\ / : * ? " < > |';
}
if (siblingNames.includes(trimmed)) {
return "A file with this name already exists in this folder.";
}
return null;
}
export default function Popover03() {
const [open, setOpen] = React.useState(false);
const [fileName, setFileName] = React.useState("pricing-research.pdf");
const [draft, setDraft] = React.useState(fileName);
const [error, setError] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
// Start every edit from the saved name with no stale error.
setDraft(fileName);
setError(null);
}
setOpen(nextOpen);
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextError = validate(draft);
setError(nextError);
if (nextError) {
inputRef.current?.focus();
return;
}
setFileName(draft.trim());
setOpen(false);
}
return (
<nav aria-label="Breadcrumb" className="w-full max-w-md">
<ol className="flex min-w-0 items-center gap-1.5 text-sm text-muted-foreground">
{folders.map((folder, index) => (
<li
key={folder}
className={cn(
"flex shrink-0 items-center gap-1.5",
// Collapse all but the parent folder below sm.
index < folders.length - 1 && "hidden sm:flex",
)}
>
<a
href={`#${folder.toLowerCase()}`}
className="rounded-sm outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
>
{folder}
</a>
<ChevronRightIcon className="size-3.5" aria-hidden="true" />
</li>
))}
<li className="flex min-w-0 items-center" aria-current="page">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
aria-label={`Rename ${fileName}`}
className="min-w-0 shrink font-medium text-foreground"
/>
}
>
<span className="truncate">{fileName}</span>
<PencilIcon aria-hidden="true" className="text-muted-foreground" />
</PopoverTrigger>
<PopoverContent align="start" initialFocus={inputRef} className="w-72 max-w-[calc(100vw-2rem)]">
<form className="flex flex-col gap-3" onSubmit={handleSubmit} noValidate>
<PopoverHeader>
<PopoverTitle>Rename file</PopoverTitle>
</PopoverHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="popover-03-name">File name</Label>
<Input
ref={inputRef}
id="popover-03-name"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "popover-03-error" : undefined}
autoComplete="off"
spellCheck={false}
/>
{error ? (
<p id="popover-03-error" role="alert" className="text-xs text-destructive">
{error}
</p>
) : null}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" size="sm">
Rename
</Button>
</div>
</form>
</PopoverContent>
</Popover>
</li>
</ol>
</nav>
);
}
npx shadcn@latest add @sevenui/component/popover-03pnpm dlx shadcn@latest add @sevenui/component/popover-03yarn dlx shadcn@latest add @sevenui/component/popover-03bunx --bun shadcn@latest add @sevenui/component/popover-03"use client";
import * as React from "react";
import { CircleAlertIcon, CircleCheckIcon, UserPlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
type Status = "idle" | "loading" | "error" | "success";
const allowedDomain = "northwind.io";
export default function Popover04() {
const [status, setStatus] = React.useState<Status>("idle");
const [email, setEmail] = React.useState("");
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
function handleOpenChange(open: boolean) {
if (!open) {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setStatus("idle");
setEmail("");
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("loading");
// Simulate a request: only addresses on the workspace domain are accepted.
timeoutRef.current = setTimeout(() => {
const accepted = email.trim().toLowerCase().endsWith(`@${allowedDomain}`);
setStatus(accepted ? "success" : "error");
}, 1200);
}
const loading = status === "loading";
return (
<Popover onOpenChange={handleOpenChange}>
<PopoverTrigger render={<Button variant="outline" />}>
<UserPlusIcon aria-hidden="true" />
Invite teammate
</PopoverTrigger>
<PopoverContent className="w-80 max-w-[calc(100vw-2rem)] gap-3 p-4">
{status === "success" ? (
<div className="flex flex-col items-center gap-2 py-2 text-center">
<CircleCheckIcon className="size-8 text-success" aria-hidden="true" />
<PopoverTitle>Invite sent</PopoverTitle>
<PopoverDescription>
{email.trim()} will get an email with a link to join Northwind.
It expires in 7 days.
</PopoverDescription>
<Button
variant="outline"
size="sm"
className="mt-1"
onClick={() => {
setEmail("");
setStatus("idle");
}}
>
Invite someone else
</Button>
</div>
) : (
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
<PopoverHeader>
<PopoverTitle>Invite to Northwind</PopoverTitle>
<PopoverDescription>
Teammates join as members and can be promoted later.
</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-1.5">
<Label htmlFor="popover-04-email">Work email</Label>
<Input
id="popover-04-email"
type="email"
required
placeholder={`name@${allowedDomain}`}
value={email}
disabled={loading}
onChange={(event) => {
setEmail(event.target.value);
if (status === "error") setStatus("idle");
}}
aria-invalid={status === "error" ? true : undefined}
aria-describedby={status === "error" ? "popover-04-error" : undefined}
/>
</div>
{status === "error" ? (
<p
id="popover-04-error"
role="alert"
className="flex items-start gap-1.5 rounded-md bg-destructive/10 px-2.5 py-2 text-xs text-destructive"
>
<CircleAlertIcon className="mt-px size-3.5 shrink-0" aria-hidden="true" />
Only @{allowedDomain} addresses can join this workspace. Ask an
admin to allow guest invites.
</p>
) : null}
<Button type="submit" disabled={loading || email.trim() === ""}>
{loading ? (
<>
<Spinner />
Sending invite
</>
) : (
"Send invite"
)}
</Button>
</form>
)}
</PopoverContent>
</Popover>
);
}
npx shadcn@latest add @sevenui/component/popover-04pnpm dlx shadcn@latest add @sevenui/component/popover-04yarn dlx shadcn@latest add @sevenui/component/popover-04bunx --bun shadcn@latest add @sevenui/component/popover-04"use client";
import {
ChevronsUpDownIcon,
CloudIcon,
FolderKanbanIcon,
InboxIcon,
UsersIcon,
} from "lucide-react";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const quota = 100;
const usage = [
{ label: "Design files", size: 38.2, color: "bg-chart-1" },
{ label: "Screen recordings", size: 17.9, color: "bg-chart-2" },
{ label: "Exports", size: 6.4, color: "bg-chart-3" },
];
const navigation = [
{ label: "Inbox", icon: InboxIcon },
{ label: "Projects", icon: FolderKanbanIcon },
{ label: "Members", icon: UsersIcon },
];
const used = usage.reduce((sum, item) => sum + item.size, 0);
const percent = Math.round((used / quota) * 100);
function formatSize(value: number) {
return `${value.toFixed(1)} GB`;
}
export default function Popover05() {
return (
<nav
aria-label="Workspace"
className="flex w-full max-w-60 flex-col gap-6 rounded-lg border border-border bg-sidebar p-2 text-sidebar-foreground"
>
<ul className="flex flex-col gap-0.5">
{navigation.map((item) => (
<li key={item.label}>
<a
href={`#${item.label.toLowerCase()}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
>
<item.icon
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
{item.label}
</a>
</li>
))}
</ul>
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
className="h-auto w-full flex-col items-stretch gap-2 px-2 py-2 font-normal hover:bg-sidebar-accent"
/>
}
>
<span className="flex items-center gap-2 text-sm">
<CloudIcon
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
<span className="flex-1 text-left">Storage</span>
<span className="text-xs text-muted-foreground tabular-nums">
{percent}%
</span>
<ChevronsUpDownIcon
className="size-3.5 text-muted-foreground"
aria-hidden="true"
/>
</span>
<span
className="block h-1.5 w-full overflow-hidden rounded-full bg-muted"
aria-hidden="true"
>
<span
className="block h-full rounded-full bg-primary"
style={{ width: `${percent}%` }}
/>
</span>
</PopoverTrigger>
<PopoverContent
side="right"
align="end"
sideOffset={8}
className="w-72 max-w-[calc(100vw-2rem)] gap-4 p-4"
>
<PopoverHeader>
<PopoverTitle>
{formatSize(used)} of {quota} GB used
</PopoverTitle>
<PopoverDescription>
Shared by 12 members across the Lumen Studio workspace.
</PopoverDescription>
</PopoverHeader>
<div
role="img"
aria-label={`${formatSize(used)} of ${quota} GB used`}
className="flex h-2 w-full gap-0.5 overflow-hidden rounded-full bg-muted"
>
{usage.map((item) => (
<span
key={item.label}
className={cn("h-full", item.color)}
style={{ width: `${(item.size / quota) * 100}%` }}
/>
))}
</div>
<ul className="flex flex-col gap-2">
{usage.map((item) => (
<li key={item.label} className="flex items-center gap-2 text-sm">
<span
className={cn("size-2 shrink-0 rounded-full", item.color)}
aria-hidden="true"
/>
<span className="flex-1">{item.label}</span>
<span className="text-muted-foreground tabular-nums">
{formatSize(item.size)}
</span>
</li>
))}
<li className="flex items-center gap-2 text-sm">
<span
className="size-2 shrink-0 rounded-full bg-muted ring-1 ring-border"
aria-hidden="true"
/>
<span className="flex-1 text-muted-foreground">Available</span>
<span className="text-muted-foreground tabular-nums">
{formatSize(quota - used)}
</span>
</li>
</ul>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1">
Review large files
</Button>
<Button size="sm" className="flex-1">
Upgrade to 1 TB
</Button>
</div>
</PopoverContent>
</Popover>
</nav>
);
}
npx shadcn@latest add @sevenui/component/popover-05pnpm dlx shadcn@latest add @sevenui/component/popover-05yarn dlx shadcn@latest add @sevenui/component/popover-05bunx --bun shadcn@latest add @sevenui/component/popover-05"use client";
import * as React from "react";
import { ListFilterIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
type Filters = { statuses: string[]; minScore: number };
const statuses = [
{ value: "open", label: "Open", count: 24 },
{ value: "in-review", label: "In review", count: 9 },
{ value: "blocked", label: "Blocked", count: 3 },
{ value: "done", label: "Done", count: 58 },
];
const defaults: Filters = { statuses: ["open", "in-review"], minScore: 0 };
function countActive(filters: Filters) {
return filters.statuses.length + (filters.minScore > 0 ? 1 : 0);
}
export default function Popover06() {
const [open, setOpen] = React.useState(false);
const [applied, setApplied] = React.useState<Filters>(defaults);
const [draft, setDraft] = React.useState<Filters>(defaults);
const activeCount = countActive(applied);
function toggleStatus(value: string, checked: boolean) {
setDraft((current) => ({
...current,
statuses: checked
? [...current.statuses, value]
: current.statuses.filter((item) => item !== value),
}));
}
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
// Unapplied edits are discarded when the popover closes.
if (nextOpen) setDraft(applied);
setOpen(nextOpen);
}}
>
<PopoverTrigger
render={<Button variant="outline" aria-label={`Filters, ${activeCount} active`} />}
>
<ListFilterIcon aria-hidden="true" />
Filters
{activeCount > 0 ? (
<Badge variant="secondary" className="h-4.5 min-w-4.5 px-1 tabular-nums">
{activeCount}
</Badge>
) : null}
</PopoverTrigger>
<PopoverContent align="start" className="w-72 gap-0 p-0">
<PopoverHeader className="px-4 pt-4 pb-3">
<PopoverTitle>Filter issues</PopoverTitle>
</PopoverHeader>
<fieldset className="flex flex-col gap-2.5 px-4 pb-4">
<legend className="mb-2.5 text-xs font-medium text-muted-foreground">
Status
</legend>
{statuses.map((status) => {
const id = `popover-06-${status.value}`;
return (
<div key={status.value} className="flex items-center gap-2.5">
<Checkbox
id={id}
checked={draft.statuses.includes(status.value)}
onCheckedChange={(checked) => toggleStatus(status.value, checked)}
/>
<Label htmlFor={id} className="flex-1 font-normal">
{status.label}
</Label>
<span className="text-xs text-muted-foreground tabular-nums">
{status.count}
</span>
</div>
);
})}
</fieldset>
<Separator />
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<span id="popover-06-score" className="text-xs font-medium text-muted-foreground">
Minimum priority score
</span>
<span className="text-sm font-medium tabular-nums">{draft.minScore}</span>
</div>
<Slider
aria-labelledby="popover-06-score"
min={0}
max={100}
step={5}
value={[draft.minScore]}
onValueChange={(value) =>
setDraft((current) => ({
...current,
minScore: Array.isArray(value) ? value[0] : value,
}))
}
/>
</div>
<Separator />
<div className="flex items-center justify-between gap-2 p-3">
<Button
variant="ghost"
size="sm"
onClick={() => setDraft({ statuses: [], minScore: 0 })}
>
Clear all
</Button>
<Button
size="sm"
onClick={() => {
setApplied(draft);
setOpen(false);
}}
>
Show results
</Button>
</div>
</PopoverContent>
</Popover>
);
}
npx shadcn@latest add @sevenui/component/popover-06pnpm dlx shadcn@latest add @sevenui/component/popover-06yarn dlx shadcn@latest add @sevenui/component/popover-06bunx --bun shadcn@latest add @sevenui/component/popover-06"use client";
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
Clock3Icon,
LanguagesIcon,
Rows3Icon,
Settings2Icon,
} from "lucide-react";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type SettingKey = "language" | "timezone" | "density";
const settings: {
key: SettingKey;
label: string;
icon: typeof LanguagesIcon;
options: { value: string; label: string; hint?: string }[];
}[] = [
{
key: "language",
label: "Language",
icon: LanguagesIcon,
options: [
{ value: "en-US", label: "English (US)" },
{ value: "en-GB", label: "English (UK)" },
{ value: "de-DE", label: "Deutsch" },
{ value: "pt-PT", label: "Português" },
],
},
{
key: "timezone",
label: "Time zone",
icon: Clock3Icon,
options: [
{ value: "Europe/Lisbon", label: "Lisbon", hint: "UTC+1" },
{ value: "Europe/Berlin", label: "Berlin", hint: "UTC+2" },
{ value: "America/New_York", label: "New York", hint: "UTC−4" },
{ value: "Asia/Tokyo", label: "Tokyo", hint: "UTC+9" },
],
},
{
key: "density",
label: "Density",
icon: Rows3Icon,
options: [
{ value: "compact", label: "Compact", hint: "More rows on screen" },
{ value: "comfortable", label: "Comfortable", hint: "Default spacing" },
{ value: "spacious", label: "Spacious", hint: "Larger touch targets" },
],
},
];
const viewMotion =
"animate-in fade-in-0 duration-200 ease-out motion-reduce:animate-none";
export default function Popover07() {
const [view, setView] = React.useState<SettingKey | null>(null);
const [direction, setDirection] = React.useState<"forward" | "back">("forward");
const [values, setValues] = React.useState<Record<SettingKey, string>>({
language: "en-US",
timezone: "Europe/Lisbon",
density: "comfortable",
});
const panelRef = React.useRef<HTMLDivElement>(null);
const returnFocusTo = React.useRef<SettingKey | null>(null);
React.useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
// Moving between views unmounts the focused control, so place focus on
// the checked radio (forward) or the row that opened the subview (back).
const target = view
? panel.querySelector<HTMLElement>('[role="radio"][aria-checked="true"]')
: panel.querySelector<HTMLElement>(
`[data-setting="${returnFocusTo.current}"]`,
);
if (!view) {
target?.focus();
return;
}
// Wait a frame so the radio group has registered its items; focusing
// earlier leaves arrow-key navigation starting from the wrong radio.
const frame = requestAnimationFrame(() => target?.focus());
return () => cancelAnimationFrame(frame);
}, [view]);
const active = settings.find((setting) => setting.key === view);
function labelFor(key: SettingKey) {
const setting = settings.find((item) => item.key === key);
return setting?.options.find((option) => option.value === values[key])?.label;
}
function goTo(next: SettingKey | null) {
if (next) returnFocusTo.current = next;
setDirection(next ? "forward" : "back");
setView(next);
}
return (
<Popover
onOpenChange={(open) => {
// Always reopen on the overview.
if (!open) {
returnFocusTo.current = null;
setView(null);
}
}}
>
<PopoverTrigger render={<Button variant="outline" />}>
<Settings2Icon aria-hidden="true" />
Display preferences
</PopoverTrigger>
<PopoverContent
ref={panelRef}
className="w-72 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0"
>
{active ? (
<div
key={active.key}
className={cn(viewMotion, "slide-in-from-right-4 flex flex-col")}
>
<div className="flex items-center gap-1 border-b border-border p-1.5">
<Button
variant="ghost"
size="icon-sm"
aria-label="Back to display preferences"
onClick={() => goTo(null)}
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
<PopoverTitle id="popover-07-subtitle">{active.label}</PopoverTitle>
</div>
<RadioGroup
aria-labelledby="popover-07-subtitle"
value={values[active.key]}
onValueChange={(value) =>
setValues((current) => ({
...current,
[active.key]: value as string,
}))
}
className="gap-0 p-1.5"
>
{active.options.map((option) => (
<Label
key={option.value}
className="cursor-pointer justify-between rounded-md px-2.5 py-2 font-normal hover:bg-muted"
>
<span className="flex items-center gap-2.5">
<RadioGroupItem value={option.value} />
{option.label}
</span>
{option.hint ? (
<span className="text-xs text-muted-foreground">{option.hint}</span>
) : null}
</Label>
))}
</RadioGroup>
</div>
) : (
<div
key="overview"
className={cn(
viewMotion,
direction === "back" && "slide-in-from-left-4",
"flex flex-col",
)}
>
<div className="border-b border-border px-4 py-3">
<PopoverTitle>Display preferences</PopoverTitle>
</div>
<ul className="flex flex-col p-1.5">
{settings.map((setting) => (
<li key={setting.key}>
<button
type="button"
data-setting={setting.key}
onClick={() => goTo(setting.key)}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<setting.icon
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
<span className="flex-1">{setting.label}</span>
<span className="max-w-28 truncate text-muted-foreground">
{labelFor(setting.key)}
</span>
<ChevronRightIcon
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
</button>
</li>
))}
</ul>
</div>
)}
</PopoverContent>
</Popover>
);
}
npx shadcn@latest add @sevenui/component/popover-07pnpm dlx shadcn@latest add @sevenui/component/popover-07yarn dlx shadcn@latest add @sevenui/component/popover-07bunx --bun shadcn@latest add @sevenui/component/popover-07Q3 board deck.pdf
4.2 MB · Edited 2 hours ago
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, FileTextIcon, Link2Icon } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
NativeSelect,
NativeSelectOption,
} from "@/components/ui/native-select";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const shareUrl = "https://files.northwind.app/s/q3-board-deck-7fk2";
const people = [
{ name: "Priya Raman", initials: "PR", role: "Owner" },
{ name: "Daniel Okafor", initials: "DO", role: "Can edit" },
{ name: "Hannah Lindqvist", initials: "HL", role: "Can view" },
];
export default function Popover08() {
const [access, setAccess] = React.useState("team");
const [copied, setCopied] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (timeout.current) clearTimeout(timeout.current);
};
}, []);
function copyLink() {
navigator.clipboard?.writeText(shareUrl).catch(() => {});
setCopied(true);
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex w-full max-w-md items-center gap-3 rounded-lg border bg-card p-3 text-card-foreground">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileTextIcon className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">Q3 board deck.pdf</p>
<p className="truncate text-muted-foreground text-xs">
4.2 MB · Edited 2 hours ago
</p>
</div>
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<Link2Icon aria-hidden="true" />
Share
</Button>
}
/>
<PopoverContent align="end" className="w-80 gap-4 p-4">
<PopoverHeader>
<PopoverTitle>Share “Q3 board deck”</PopoverTitle>
<PopoverDescription>
{access === "restricted"
? "Only the people listed below can open this file."
: access === "team"
? "Anyone at Northwind with the link can view."
: "Anyone on the internet with the link can view."}
</PopoverDescription>
</PopoverHeader>
<div className="grid gap-1.5">
<label
htmlFor="popover-08-access"
className="font-medium text-muted-foreground text-xs"
>
General access
</label>
<NativeSelect
id="popover-08-access"
size="sm"
className="w-full"
value={access}
onChange={(event) => setAccess(event.target.value)}
>
<NativeSelectOption value="restricted">
Restricted
</NativeSelectOption>
<NativeSelectOption value="team">
Northwind workspace
</NativeSelectOption>
<NativeSelectOption value="public">
Anyone with the link
</NativeSelectOption>
</NativeSelect>
</div>
<ul className="grid gap-2.5" aria-label="People with access">
{people.map((person) => (
<li key={person.name} className="flex items-center gap-2.5">
<Avatar size="sm">
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm">
{person.name}
</span>
<span className="text-muted-foreground text-xs">
{person.role}
</span>
</li>
))}
</ul>
<div className="flex gap-2">
<Input
readOnly
value={shareUrl}
aria-label="Share link"
className="h-8 min-w-0 flex-1 font-mono text-xs"
onFocus={(event) => event.currentTarget.select()}
/>
<Button
size="sm"
className="h-8"
onClick={copyLink}
disabled={access === "restricted"}
>
{copied ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
<span className="sr-only" aria-live="polite">
{copied ? "Link copied to clipboard" : ""}
</span>
</PopoverContent>
</Popover>
</div>
);
}
npx shadcn@latest add @sevenui/component/popover-08pnpm dlx shadcn@latest add @sevenui/component/popover-08yarn dlx shadcn@latest add @sevenui/component/popover-08bunx --bun shadcn@latest add @sevenui/component/popover-08"use client";
import * as React from "react";
import { cn } from "cn";
import {
AtSignIcon,
BellIcon,
CheckCheckIcon,
GitPullRequestIcon,
ReceiptIcon,
UserPlusIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
type Notification = {
id: string;
icon: typeof BellIcon;
actor: string;
action: string;
target: string;
time: string;
mention: boolean;
unread: boolean;
};
const initialNotifications: Notification[] = [
{
id: "n1",
icon: AtSignIcon,
actor: "Lena Fischer",
action: "mentioned you in",
target: "Pricing page copy review",
time: "4 min ago",
mention: true,
unread: true,
},
{
id: "n2",
icon: GitPullRequestIcon,
actor: "Marcus Hale",
action: "requested your review on",
target: "PR 482: Retry failed webhooks",
time: "38 min ago",
mention: true,
unread: true,
},
{
id: "n3",
icon: UserPlusIcon,
actor: "Aiko Tanaka",
action: "joined",
target: "Design Systems",
time: "2 h ago",
mention: false,
unread: true,
},
{
id: "n4",
icon: ReceiptIcon,
actor: "Billing",
action: "sent the September invoice for",
target: "$1,240.00",
time: "Yesterday",
mention: false,
unread: false,
},
];
export default function Popover09() {
const [items, setItems] = React.useState(initialNotifications);
const [filter, setFilter] = React.useState("all");
const unreadCount = items.filter((item) => item.unread).length;
const visible =
filter === "mentions" ? items.filter((item) => item.mention) : items;
function markRead(id: string) {
setItems((current) =>
current.map((item) =>
item.id === id ? { ...item, unread: false } : item,
),
);
}
function markAllRead() {
setItems((current) => current.map((item) => ({ ...item, unread: false })));
}
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
size="icon"
className="relative"
aria-label={
unreadCount > 0
? `Notifications, ${unreadCount} unread`
: "Notifications"
}
>
<BellIcon aria-hidden="true" />
{unreadCount > 0 ? (
<span className="absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground tabular-nums ring-2 ring-background">
{unreadCount}
</span>
) : null}
</Button>
}
/>
<PopoverContent
align="end"
className="w-[min(22rem,calc(100vw-2rem))] gap-0 p-0"
>
<div className="flex items-center justify-between gap-2 px-4 pt-3 pb-2">
<PopoverTitle>Notifications</PopoverTitle>
<Button
variant="ghost"
size="xs"
onClick={markAllRead}
disabled={unreadCount === 0}
>
<CheckCheckIcon aria-hidden="true" />
Mark all as read
</Button>
</div>
<Tabs value={filter} onValueChange={(value) => setFilter(value)}>
<TabsList className="mx-4 mb-2 w-auto">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="mentions">Mentions</TabsTrigger>
</TabsList>
{["all", "mentions"].map((tab) => (
<TabsContent key={tab} value={tab}>
<ul className="max-h-80 overflow-y-auto border-t">
{visible.map((item) => (
<li key={item.id} className="border-b last:border-b-0">
<button
type="button"
onClick={() => markRead(item.id)}
className={cn(
"flex w-full items-start gap-3 px-4 py-3 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset",
item.unread && "bg-muted/30",
)}
>
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<item.icon className="size-3.5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm leading-snug">
<span className="font-medium">{item.actor}</span>{" "}
<span className="text-muted-foreground">
{item.action}
</span>{" "}
<span className="font-medium">{item.target}</span>
</span>
<span className="mt-1 block text-muted-foreground text-xs">
{item.time}
</span>
</span>
{item.unread ? (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unread</span>
</span>
) : null}
</button>
</li>
))}
</ul>
</TabsContent>
))}
</Tabs>
<div className="border-t p-1.5">
<Button variant="ghost" size="sm" className="w-full">
Notification settings
</Button>
</div>
</PopoverContent>
</Popover>
);
}
npx shadcn@latest add @sevenui/component/popover-09pnpm dlx shadcn@latest add @sevenui/component/popover-09yarn dlx shadcn@latest add @sevenui/component/popover-09bunx --bun shadcn@latest add @sevenui/component/popover-094 of 10 seats used
Sofia Martins
sofia@lumen.studio
Kwame Asante
kwame@lumen.studio
Elena Petrova
elena@lumen.studio
Noah Brennan
noah@freelance.dev
"use client";
import * as React from "react";
import { ChevronDownIcon, UserMinusIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
type Role = "admin" | "editor" | "viewer";
type Member = {
id: string;
name: string;
email: string;
initials: string;
image?: string;
role: Role | "owner";
};
const roles: { value: Role; label: string; description: string }[] = [
{
value: "admin",
label: "Admin",
description: "Manage billing, members, and every project.",
},
{
value: "editor",
label: "Editor",
description: "Create and edit projects, but not settings.",
},
{
value: "viewer",
label: "Viewer",
description: "Read and comment. Free on every plan.",
},
];
const roleLabel: Record<Member["role"], string> = {
owner: "Owner",
admin: "Admin",
editor: "Editor",
viewer: "Viewer",
};
const initialMembers: Member[] = [
{
id: "m1",
name: "Sofia Martins",
email: "sofia@lumen.studio",
initials: "SM",
image: "/placeholder.svg",
role: "owner",
},
{
id: "m2",
name: "Kwame Asante",
email: "kwame@lumen.studio",
initials: "KA",
role: "admin",
},
{
id: "m3",
name: "Elena Petrova",
email: "elena@lumen.studio",
initials: "EP",
image: "/placeholder.svg",
role: "editor",
},
{
id: "m4",
name: "Noah Brennan",
email: "noah@freelance.dev",
initials: "NB",
role: "viewer",
},
];
function RolePopover({
member,
onRoleChange,
onRemove,
}: {
member: Member;
onRoleChange: (role: Role) => void;
onRemove: () => void;
}) {
const [open, setOpen] = React.useState(false);
const firstName = member.name.split(" ")[0];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
aria-label={`Change role for ${member.name}, currently ${roleLabel[member.role]}`}
>
{roleLabel[member.role]}
<ChevronDownIcon aria-hidden="true" />
</Button>
}
/>
<PopoverContent align="end" className="w-72 gap-3 p-3">
<PopoverHeader>
<PopoverTitle>Role for {firstName}</PopoverTitle>
<PopoverDescription>Changes apply immediately.</PopoverDescription>
</PopoverHeader>
<RadioGroup
aria-label={`Role for ${member.name}`}
value={member.role}
onValueChange={(value) => {
onRoleChange(value as Role);
setOpen(false);
}}
className="gap-0"
>
{roles.map((role) => (
// biome-ignore lint/a11y/noLabelWithoutControl: the radio renders inside the label
<label
key={role.value}
className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 transition-colors hover:bg-muted has-data-checked:bg-muted"
>
<RadioGroupItem value={role.value} className="mt-0.5" />
<span className="grid gap-0.5">
<span className="font-medium text-sm">{role.label}</span>
<span className="text-muted-foreground text-xs">
{role.description}
</span>
</span>
</label>
))}
</RadioGroup>
<Separator />
<Button
variant="destructive"
size="sm"
className="justify-start"
onClick={() => {
setOpen(false);
onRemove();
}}
>
<UserMinusIcon aria-hidden="true" />
Remove from workspace
</Button>
</PopoverContent>
</Popover>
);
}
export default function Popover10() {
const [members, setMembers] = React.useState(initialMembers);
const [announcement, setAnnouncement] = React.useState("");
function changeRole(id: string, role: Role) {
const member = members.find((item) => item.id === id);
setMembers((current) =>
current.map((item) => (item.id === id ? { ...item, role } : item)),
);
if (member) {
setAnnouncement(`${member.name} is now ${roleLabel[role]}.`);
}
}
function removeMember(id: string) {
const member = members.find((item) => item.id === id);
setMembers((current) => current.filter((item) => item.id !== id));
if (member) setAnnouncement(`${member.name} was removed.`);
}
return (
<section
aria-labelledby="popover-10-heading"
className="w-full max-w-lg rounded-lg border bg-card text-card-foreground"
>
<header className="flex items-center justify-between gap-3 border-b px-4 py-3">
<div>
<h3 id="popover-10-heading" className="font-medium text-sm">
Members
</h3>
<p className="text-muted-foreground text-xs">
{members.length} of 10 seats used
</p>
</div>
<Button size="sm" variant="outline">
Invite
</Button>
</header>
<ul className="divide-y">
{members.map((member) => (
<li key={member.id} className="flex items-center gap-3 px-4 py-2.5">
<Avatar>
{member.image ? (
<AvatarImage src={member.image} alt="" />
) : null}
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{member.name}</p>
<p className="truncate text-muted-foreground text-xs">
{member.email}
</p>
</div>
{member.role === "owner" ? (
<span className="px-2.5 text-muted-foreground text-sm">
Owner
</span>
) : (
<RolePopover
member={member}
onRoleChange={(role) => changeRole(member.id, role)}
onRemove={() => removeMember(member.id)}
/>
)}
</li>
))}
</ul>
<p className="sr-only" aria-live="polite">
{announcement}
</p>
</section>
);
}
npx shadcn@latest add @sevenui/component/popover-10pnpm dlx shadcn@latest add @sevenui/component/popover-10yarn dlx shadcn@latest add @sevenui/component/popover-10bunx --bun shadcn@latest add @sevenui/component/popover-10Tuesday, October 6 · 10:30 AM
45 min · Video call
"use client";
import * as React from "react";
import { CalendarClockIcon, GlobeIcon, VideoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const slots = ["09:00", "10:30", "13:00", "14:30", "16:00", "17:30"];
// Slots that are already booked, keyed by "month-day" (months are 0-based).
const booked: Record<string, string[]> = {
"9-6": ["09:00", "13:00"],
"9-8": ["10:30", "14:30", "16:00"],
"9-13": ["09:00"],
"10-3": ["13:00", "14:30"],
};
const dateFormat = new Intl.DateTimeFormat("en-US", {
weekday: "long",
month: "long",
day: "numeric",
});
function formatSlot(slot: string) {
const [hours, minutes] = slot.split(":").map(Number);
const suffix = hours >= 12 ? "PM" : "AM";
const hour = hours % 12 === 0 ? 12 : hours % 12;
return `${hour}:${minutes.toString().padStart(2, "0")} ${suffix}`;
}
export default function Popover11() {
const [open, setOpen] = React.useState(false);
const [meeting, setMeeting] = React.useState({
date: new Date(2026, 9, 6),
slot: "10:30",
});
const [date, setDate] = React.useState<Date | undefined>(meeting.date);
const [slot, setSlot] = React.useState<string | undefined>(meeting.slot);
const taken = date
? (booked[`${date.getMonth()}-${date.getDate()}`] ?? [])
: [];
const unchanged =
date?.getTime() === meeting.date.getTime() && slot === meeting.slot;
function handleOpenChange(next: boolean) {
if (next) {
setDate(meeting.date);
setSlot(meeting.slot);
}
setOpen(next);
}
function confirm() {
if (!date || !slot) return;
setMeeting({ date, slot });
setOpen(false);
}
return (
<article className="w-full max-w-sm rounded-lg border bg-card p-4 text-card-foreground">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 flex-col items-center justify-center rounded-md bg-muted leading-none">
<span className="text-[10px] text-muted-foreground uppercase">
{meeting.date.toLocaleDateString("en-US", { month: "short" })}
</span>
<span className="font-semibold text-base tabular-nums">
{meeting.date.getDate()}
</span>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-sm">
Onboarding call with Brightline
</h3>
<p className="text-muted-foreground text-sm">
{dateFormat.format(meeting.date)} ·{" "}
<span className="whitespace-nowrap">
{formatSlot(meeting.slot)}
</span>
</p>
<p className="mt-1 flex items-center gap-1.5 text-muted-foreground text-xs">
<VideoIcon className="size-3.5" aria-hidden="true" />
45 min · Video call
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button size="sm" className="flex-1">
Join call
</Button>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="flex-1">
<CalendarClockIcon aria-hidden="true" />
Reschedule
</Button>
}
/>
<PopoverContent
align="end"
className="w-auto max-w-[calc(100vw-2rem)] gap-3 p-3"
>
<PopoverTitle className="px-1">Pick a new time</PopoverTitle>
<Calendar
mode="single"
selected={date}
onSelect={(next) => {
setDate(next);
setSlot(undefined);
}}
defaultMonth={meeting.date}
startMonth={new Date(2026, 9)}
endMonth={new Date(2026, 10)}
disabled={[
{ before: new Date(2026, 9, 1) },
{ dayOfWeek: [0, 6] },
]}
className="p-0"
/>
<div className="grid gap-2">
<p
id="popover-11-slots"
className="px-1 font-medium text-muted-foreground text-xs"
>
{date ? dateFormat.format(date) : "Select a day first"}
</p>
<ToggleGroup
variant="outline"
size="sm"
aria-labelledby="popover-11-slots"
className="grid w-full grid-cols-3"
value={slot ? [slot] : []}
onValueChange={(value) => setSlot(value[0])}
disabled={!date}
>
{slots.map((item) => (
<ToggleGroupItem
key={item}
value={item}
disabled={taken.includes(item)}
className="tabular-nums data-pressed:border-primary data-pressed:bg-primary data-pressed:text-primary-foreground"
>
{formatSlot(item)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="flex items-center justify-between gap-2 border-t pt-3">
<span className="flex items-center gap-1 text-muted-foreground text-xs">
<GlobeIcon className="size-3.5" aria-hidden="true" />
Berlin (CEST)
</span>
<Button
size="sm"
onClick={confirm}
disabled={!date || !slot || unchanged}
>
Confirm
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</article>
);
}
npx shadcn@latest add @sevenui/component/popover-11pnpm dlx shadcn@latest add @sevenui/component/popover-11yarn dlx shadcn@latest add @sevenui/component/popover-11bunx --bun shadcn@latest add @sevenui/component/popover-11"use client";
import * as React from "react";
import {
MinusIcon,
PlusIcon,
ShoppingBagIcon,
TrashIcon,
TruckIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
type CartItem = {
id: string;
name: string;
variant: string;
price: number;
quantity: number;
};
const freeShippingThreshold = 120;
const initialCart: CartItem[] = [
{
id: "c1",
name: "Merino crew sweater",
variant: "Oatmeal · M",
price: 68,
quantity: 1,
},
{
id: "c2",
name: "Waxed canvas tote",
variant: "Olive",
price: 42,
quantity: 1,
},
];
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
export default function Popover12() {
const [cart, setCart] = React.useState(initialCart);
const count = cart.reduce((sum, item) => sum + item.quantity, 0);
const subtotal = cart.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
const remaining = Math.max(freeShippingThreshold - subtotal, 0);
function updateQuantity(id: string, delta: number) {
setCart((current) =>
current
.map((item) =>
item.id === id
? { ...item, quantity: Math.min(item.quantity + delta, 9) }
: item,
)
.filter((item) => item.quantity > 0),
);
}
function remove(id: string) {
setCart((current) => current.filter((item) => item.id !== id));
}
return (
<div className="flex w-full max-w-md items-center justify-between gap-3 rounded-lg border bg-background px-4 py-2.5">
<span className="font-semibold text-sm tracking-tight">Fieldhouse</span>
<div className="hidden gap-4 text-muted-foreground text-sm sm:flex">
<span>Men</span>
<span>Women</span>
<span>Journal</span>
</div>
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="sm"
aria-label={`Shopping bag, ${count} ${count === 1 ? "item" : "items"}`}
>
<ShoppingBagIcon aria-hidden="true" />
<span className="tabular-nums">{count}</span>
</Button>
}
/>
<PopoverContent
align="end"
className="w-[min(22rem,calc(100vw-2rem))] gap-0 p-0"
>
<div className="flex items-baseline justify-between px-4 pt-4 pb-3">
<PopoverTitle>Your bag</PopoverTitle>
<span className="text-muted-foreground text-xs tabular-nums">
{count} {count === 1 ? "item" : "items"}
</span>
</div>
{cart.length === 0 ? (
<div className="flex flex-col items-center gap-1 border-t px-4 py-8 text-center">
<ShoppingBagIcon
className="mb-1 size-5 text-muted-foreground"
aria-hidden="true"
/>
<p className="font-medium text-sm">Your bag is empty</p>
<p className="text-muted-foreground text-sm">
Orders over {currency.format(freeShippingThreshold)} ship free.
</p>
</div>
) : (
<>
<div className="border-t px-4 py-3">
<p className="mb-2 flex items-center gap-1.5 text-xs">
<TruckIcon
className="size-3.5 text-muted-foreground"
aria-hidden="true"
/>
{remaining > 0 ? (
<span>
Add{" "}
<span className="font-medium tabular-nums">
{currency.format(remaining)}
</span>{" "}
for free shipping
</span>
) : (
<span className="font-medium">
You unlocked free shipping
</span>
)}
</p>
<Progress
value={Math.min((subtotal / freeShippingThreshold) * 100, 100)}
aria-label="Progress to free shipping"
/>
</div>
<ul className="divide-y border-t">
{cart.map((item) => (
<li key={item.id} className="flex gap-3 px-4 py-3">
<img
src="/placeholder.svg"
alt=""
className="size-14 shrink-0 rounded-md bg-muted object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium text-sm">
{item.name}
</p>
<p className="text-muted-foreground text-xs">
{item.variant}
</p>
</div>
<span className="text-sm tabular-nums">
{currency.format(item.price * item.quantity)}
</span>
</div>
<div className="flex items-center justify-between">
<fieldset className="flex items-center rounded-md border">
<legend className="sr-only">
Quantity of {item.name}
</legend>
<Button
variant="ghost"
size="icon-xs"
aria-label="Decrease quantity"
onClick={() => updateQuantity(item.id, -1)}
>
<MinusIcon aria-hidden="true" />
</Button>
<span
className="w-6 text-center text-xs tabular-nums"
aria-live="polite"
>
{item.quantity}
</span>
<Button
variant="ghost"
size="icon-xs"
aria-label="Increase quantity"
onClick={() => updateQuantity(item.id, 1)}
disabled={item.quantity >= 9}
>
<PlusIcon aria-hidden="true" />
</Button>
</fieldset>
<Button
variant="ghost"
size="icon-xs"
aria-label={`Remove ${item.name}`}
onClick={() => remove(item.id)}
>
<TrashIcon aria-hidden="true" />
</Button>
</div>
</div>
</li>
))}
</ul>
<div className="grid gap-3 border-t bg-muted/40 px-4 py-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="font-medium tabular-nums">
{currency.format(subtotal)}
</span>
</div>
<Button className="w-full">Checkout</Button>
</div>
</>
)}
</PopoverContent>
</Popover>
</div>
);
}
npx shadcn@latest add @sevenui/component/popover-12pnpm dlx shadcn@latest add @sevenui/component/popover-12yarn dlx shadcn@latest add @sevenui/component/popover-12bunx --bun shadcn@latest add @sevenui/component/popover-12$182.6K
+6.9%Last 30 days vs. previous period
"use client";
import * as React from "react";
import { cn } from "cn";
import {
SlidersHorizontalIcon,
TrendingDownIcon,
TrendingUpIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type Metric = "revenue" | "orders" | "aov";
type Period = "7d" | "30d" | "90d";
const metrics: {
value: Metric;
label: string;
format: (value: number) => string;
}[] = [
{
value: "revenue",
label: "Net revenue",
format: (value) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: 1,
}).format(value),
},
{
value: "orders",
label: "Orders",
format: (value) => new Intl.NumberFormat("en-US").format(value),
},
{
value: "aov",
label: "Average order value",
format: (value) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value),
},
];
const periods: { value: Period; label: string; long: string }[] = [
{ value: "7d", label: "7D", long: "Last 7 days" },
{ value: "30d", label: "30D", long: "Last 30 days" },
{ value: "90d", label: "90D", long: "Last 90 days" },
];
// Seven buckets per period: current and previous values for each metric.
const data: Record<
Period,
Record<Metric, { current: number[]; previous: number[] }>
> = {
"7d": {
revenue: {
current: [4200, 5100, 4800, 6100, 5900, 7200, 6800],
previous: [3900, 4700, 5000, 5200, 5600, 6100, 6400],
},
orders: {
current: [52, 61, 58, 74, 70, 86, 81],
previous: [49, 58, 63, 64, 69, 75, 77],
},
aov: {
current: [80.8, 83.6, 82.8, 82.4, 84.3, 83.7, 84],
previous: [79.6, 81, 79.4, 81.3, 81.2, 81.3, 83.1],
},
},
"30d": {
revenue: {
current: [21400, 23900, 22800, 26100, 27400, 29800, 31200],
previous: [22100, 22600, 24000, 23800, 25100, 26200, 27000],
},
orders: {
current: [262, 290, 281, 318, 330, 356, 371],
previous: [270, 276, 292, 289, 304, 318, 326],
},
aov: {
current: [81.7, 82.4, 81.1, 82.1, 83, 83.7, 84.1],
previous: [81.9, 81.9, 82.2, 82.4, 82.6, 82.4, 82.8],
},
},
"90d": {
revenue: {
current: [81000, 76500, 84200, 88900, 86100, 93400, 97800],
previous: [84500, 82100, 80300, 83800, 85600, 84900, 86200],
},
orders: {
current: [1004, 951, 1032, 1080, 1046, 1121, 1168],
previous: [1041, 1016, 992, 1030, 1049, 1038, 1051],
},
aov: {
current: [80.7, 80.4, 81.6, 82.3, 82.3, 83.3, 83.7],
previous: [81.2, 80.8, 80.9, 81.4, 81.6, 81.8, 82],
},
},
};
function total(metric: Metric, values: number[]) {
const sum = values.reduce((acc, value) => acc + value, 0);
return metric === "aov" ? sum / values.length : sum;
}
export default function Popover13() {
const [metric, setMetric] = React.useState<Metric>("revenue");
const [period, setPeriod] = React.useState<Period>("30d");
const [compare, setCompare] = React.useState(true);
const config = metrics.find((item) => item.value === metric) ?? metrics[0];
const periodConfig =
periods.find((item) => item.value === period) ?? periods[1];
const series = data[period][metric];
const current = total(metric, series.current);
const previous = total(metric, series.previous);
const change = ((current - previous) / previous) * 100;
const max = Math.max(...series.current, ...series.previous);
const up = change >= 0;
return (
<section
aria-labelledby="popover-13-title"
className="w-full max-w-sm rounded-lg border bg-card p-4 text-card-foreground"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3
id="popover-13-title"
className="truncate text-muted-foreground text-sm"
>
{config.label}
</h3>
<p className="mt-1 font-semibold text-2xl tracking-tight tabular-nums">
{config.format(current)}
</p>
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs">
{compare ? (
<span
className={cn(
"inline-flex items-center gap-1 font-medium tabular-nums",
up ? "text-success" : "text-destructive",
)}
>
{up ? (
<TrendingUpIcon className="size-3.5" aria-hidden="true" />
) : (
<TrendingDownIcon className="size-3.5" aria-hidden="true" />
)}
{up ? "+" : ""}
{change.toFixed(1)}%
</span>
) : null}
<span className="text-muted-foreground">
{periodConfig.long}
{compare ? " vs. previous period" : ""}
</span>
</p>
</div>
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Customize widget"
>
<SlidersHorizontalIcon aria-hidden="true" />
</Button>
}
/>
<PopoverContent align="end" className="w-64 gap-3 p-3">
<PopoverHeader>
<PopoverTitle>Customize widget</PopoverTitle>
<PopoverDescription>Only visible to you.</PopoverDescription>
</PopoverHeader>
<div className="grid gap-2">
<p
id="popover-13-metric"
className="font-medium text-muted-foreground text-xs"
>
Metric
</p>
<RadioGroup
aria-labelledby="popover-13-metric"
value={metric}
onValueChange={(value) => setMetric(value as Metric)}
className="gap-2.5"
>
{metrics.map((item) => (
// biome-ignore lint/a11y/noLabelWithoutControl: the radio renders inside the label
<label
key={item.value}
className="flex cursor-pointer items-center gap-2.5 text-sm"
>
<RadioGroupItem value={item.value} />
{item.label}
</label>
))}
</RadioGroup>
</div>
<Separator />
<div className="grid gap-2">
<p
id="popover-13-period"
className="font-medium text-muted-foreground text-xs"
>
Period
</p>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
className="w-full"
aria-labelledby="popover-13-period"
value={[period]}
onValueChange={(value) => {
if (value[0]) setPeriod(value[0] as Period);
}}
>
{periods.map((item) => (
<ToggleGroupItem
key={item.value}
value={item.value}
aria-label={item.long}
className="flex-1"
>
{item.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<Separator />
{/* biome-ignore lint/a11y/noLabelWithoutControl: the switch renders inside the label */}
<label className="flex cursor-pointer items-center justify-between gap-3 text-sm">
Compare to previous period
<Switch checked={compare} onCheckedChange={setCompare} />
</label>
</PopoverContent>
</Popover>
</div>
<div
role="img"
aria-label={`${config.label}, ${periodConfig.long}, seven intervals`}
className="mt-5 flex h-28 items-end gap-2"
>
{series.current.map((value, index) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length series
key={index}
className="flex h-full flex-1 items-end gap-0.5"
>
{compare ? (
<div
className="flex-1 rounded-t-sm bg-muted-foreground/25 transition-[height] duration-300 ease-out"
style={{ height: `${(series.previous[index] / max) * 100}%` }}
/>
) : null}
<div
className="flex-1 rounded-t-sm bg-chart-1 transition-[height] duration-300 ease-out"
style={{ height: `${(value / max) * 100}%` }}
/>
</div>
))}
</div>
{compare ? (
<div className="mt-3 flex gap-4 text-muted-foreground text-xs">
<span className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-chart-1"
aria-hidden="true"
/>
This period
</span>
<span className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-muted-foreground/25"
aria-hidden="true"
/>
Previous
</span>
</div>
) : null}
</section>
);
}
npx shadcn@latest add @sevenui/component/popover-13pnpm dlx shadcn@latest add @sevenui/component/popover-13yarn dlx shadcn@latest add @sevenui/component/popover-13bunx --bun shadcn@latest add @sevenui/component/popover-13"use client";
import * as React from "react";
import { cn } from "cn";
import {
ArrowUpIcon,
CreditCardIcon,
KeyRoundIcon,
MessageCircleIcon,
PackageIcon,
XIcon,
} from "lucide-react";
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
type Message = { id: number; from: "agent" | "user"; text: string };
const agents = [
{ name: "Ines", initials: "IG", image: "/placeholder.svg" },
{ name: "Theo", initials: "TW" },
{ name: "Ravi", initials: "RK", image: "/placeholder.svg" },
];
const topics = [
{
icon: PackageIcon,
label: "Where is my order?",
reply:
"Order 40821 shipped yesterday with DHL and is due Thursday. I have sent the tracking link to your inbox.",
},
{
icon: CreditCardIcon,
label: "Update payment method",
reply:
"You can swap cards under Settings → Billing. Your next charge of $24.00 is on October 3.",
},
{
icon: KeyRoundIcon,
label: "I can't sign in",
reply:
"I have sent a one-time sign-in link to j•••@hey.com. It stays valid for 15 minutes.",
},
];
const orders = [
{ id: "Order 40821", detail: "Trail runner · Size 10", status: "Shipped" },
{ id: "Order 40377", detail: "Rain shell · Slate", status: "Delivered" },
];
const greeting: Message = {
id: 0,
from: "agent",
text: "Hi Jordan! What can we help you with today?",
};
export default function Popover14() {
const [open, setOpen] = React.useState(false);
const [messages, setMessages] = React.useState<Message[]>([greeting]);
const [draft, setDraft] = React.useState("");
const listRef = React.useRef<HTMLOListElement>(null);
const nextId = React.useRef(1);
const started = messages.length > 1;
React.useEffect(() => {
const list = listRef.current;
if (list && messages.length > 1) list.scrollTop = list.scrollHeight;
}, [messages]);
function send(text: string, reply?: string) {
const trimmed = text.trim();
if (!trimmed) return;
const userMessage: Message = {
id: nextId.current++,
from: "user",
text: trimmed,
};
const agentMessage: Message = {
id: nextId.current++,
from: "agent",
text:
reply ??
"Thanks, that is with our team now. Ines will reply here, usually within 5 minutes.",
};
setMessages((current) => [...current, userMessage, agentMessage]);
setDraft("");
}
return (
<div className="relative flex h-[30rem] w-full max-w-sm flex-col justify-between rounded-xl border bg-muted/30 p-4">
<div>
<h3 className="font-medium text-sm">Your orders</h3>
<ul className="mt-3 divide-y rounded-lg border bg-background text-sm">
{orders.map((order) => (
<li
key={order.id}
className="flex items-center justify-between gap-3 px-3 py-2.5"
>
<span className="min-w-0">
<span className="block truncate font-medium">{order.id}</span>
<span className="block truncate text-muted-foreground text-xs">
{order.detail}
</span>
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{order.status}
</span>
</li>
))}
</ul>
</div>
<div className="flex justify-end">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
size="icon-lg"
className="size-11 rounded-full shadow-md"
aria-label={open ? "Close support chat" : "Open support chat"}
>
{open ? (
<XIcon aria-hidden="true" />
) : (
<MessageCircleIcon aria-hidden="true" />
)}
</Button>
}
/>
<PopoverContent
side="top"
align="end"
sideOffset={10}
className="w-[min(20rem,calc(100vw-2rem))] gap-0 overflow-hidden p-0"
>
<div className="flex items-center gap-3 border-b bg-muted/40 px-4 py-3">
<AvatarGroup>
{agents.map((agent) => (
<Avatar key={agent.name} size="sm">
{agent.image ? (
<AvatarImage src={agent.image} alt="" />
) : null}
<AvatarFallback>{agent.initials}</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<div className="min-w-0">
<PopoverTitle>Trailhead Support</PopoverTitle>
<PopoverDescription className="flex items-center gap-1.5 text-xs">
<span
className="size-1.5 rounded-full bg-success"
aria-hidden="true"
/>
Online · replies in about 5 min
</PopoverDescription>
</div>
</div>
<ol
ref={listRef}
aria-label="Conversation"
aria-live="polite"
className="flex max-h-64 min-h-40 flex-col gap-2 overflow-y-auto px-4 py-3"
>
{messages.map((message) => (
<li
key={message.id}
className={cn(
"flex items-end gap-2",
message.from === "user" && "justify-end",
)}
>
{message.from === "agent" ? (
<Avatar size="sm" className="shrink-0">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback>IG</AvatarFallback>
<AvatarBadge className="bg-success" />
</Avatar>
) : null}
<p
className={cn(
"max-w-[80%] rounded-2xl px-3 py-2 text-sm leading-snug",
message.from === "agent"
? "rounded-bl-sm bg-muted"
: "rounded-br-sm bg-primary text-primary-foreground",
)}
>
<span className="sr-only">
{message.from === "agent" ? "Ines: " : "You: "}
</span>
{message.text}
</p>
</li>
))}
</ol>
{started ? null : (
<div className="grid gap-1.5 px-4 pb-3">
{topics.map((topic) => (
<Button
key={topic.label}
variant="outline"
size="sm"
className="justify-start"
onClick={() => send(topic.label, topic.reply)}
>
<topic.icon aria-hidden="true" />
{topic.label}
</Button>
))}
</div>
)}
<form
className="border-t p-3"
onSubmit={(event) => {
event.preventDefault();
send(draft);
}}
>
<InputGroup>
<InputGroupInput
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Write a message…"
aria-label="Message"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-xs"
variant="default"
aria-label="Send message"
disabled={!draft.trim()}
>
<ArrowUpIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</PopoverContent>
</Popover>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/popover-14pnpm dlx shadcn@latest add @sevenui/component/popover-14yarn dlx shadcn@latest add @sevenui/component/popover-14bunx --bun shadcn@latest add @sevenui/component/popover-14