diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 514529b..f000c5a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -176,17 +176,18 @@ media, and the Toaster's typed toasts (`toast.success`, `toast.error`…). ### The Badge's status variants -`success`, `warning`, `info` and `destructive` share one recipe: the status's surface role, its ink -role, a focus ring at matching opacity, and a `/15` step of the ink for the anchor hover. `quiet` -is `muted` — a state that exists without asking for attention. Geometry is untouched by every -variant. +`success`, `warning` and `info` share one recipe: the status's surface role, its ink role, a +focus ring at matching opacity, and a `/15` step of the ink for the anchor hover. `destructive` +has no surface role: it wears the Button's destructive recipe — the role over `/10` of itself +(`/20` in dark), with a `/20` hover (`/30` in dark) — as does every other destructive variant or +state. `quiet` is `muted` — a state that exists without asking for attention. Geometry is +untouched by every variant. ## Action hierarchy Six Button variants, one intent each: `default` (the one dominant action in a view), `outline`, `secondary`, `ghost`, `destructive` (the action that ends something), `link`. Sizes are `default`, -`xs`, `sm`, `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg` and `inline` — the last one `h-auto p-0` -for a link button inside a sentence. +`xs`, `sm`, `lg`, `icon`, `icon-xs`, `icon-sm` and `icon-lg`. **A Button does not submit a form unless asked.** A native `{" "} - to begin. -

- ); -} diff --git a/examples/catalog/src/demos/context-menu/basic.tsx b/examples/catalog/src/demos/context-menu/basic.tsx index 18ad992..86c81a3 100644 --- a/examples/catalog/src/demos/context-menu/basic.tsx +++ b/examples/catalog/src/demos/context-menu/basic.tsx @@ -8,17 +8,23 @@ import { ContextMenuShortcut, ContextMenuTrigger, } from "@robomous/ui-core"; -import { CopyIcon, Trash2Icon } from "@robomous/ui-core/icons"; +import { CopyIcon, DownloadIcon, ImageIcon, Trash2Icon } from "@robomous/ui-core/icons"; /** The trigger is the area itself, not a button — right-click it to open the menu. */ export default function Basic() { return ( - - Right-click batch-0042.zip + +
+ +
+
+ frame-000184.jpg + 412 KB +
- - batch-0042.zip + + frame-000184.jpg @@ -26,6 +32,11 @@ export default function Basic() { Copy link ⌘C + + + Download + ⌘S + diff --git a/examples/catalog/src/demos/context-menu/checkbox-items.tsx b/examples/catalog/src/demos/context-menu/checkbox-items.tsx index fda8697..0278431 100644 --- a/examples/catalog/src/demos/context-menu/checkbox-items.tsx +++ b/examples/catalog/src/demos/context-menu/checkbox-items.tsx @@ -6,6 +6,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@robomous/ui-core"; +import { ImageIcon } from "@robomous/ui-core/icons"; import { useState } from "react"; /** Checked, unchecked, and indeterminate — a class kept visible on some cameras but not all. */ @@ -13,10 +14,16 @@ export default function CheckboxItems() { const [hidden, setHidden] = useState(true); return ( - - Right-click the frame + +
+ +
+
+ warehouse-cam-04 + 1920 × 1080 +
- + View options diff --git a/examples/catalog/src/demos/context-menu/submenu.tsx b/examples/catalog/src/demos/context-menu/submenu.tsx index 6f0bd6d..195db14 100644 --- a/examples/catalog/src/demos/context-menu/submenu.tsx +++ b/examples/catalog/src/demos/context-menu/submenu.tsx @@ -1,13 +1,16 @@ import { ContextMenu, ContextMenuContent, + ContextMenuItem, ContextMenuRadioGroup, ContextMenuRadioItem, + ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from "@robomous/ui-core"; +import { FileArchiveIcon, FolderInputIcon, PencilIcon } from "@robomous/ui-core/icons"; import { useState } from "react"; /** A submenu holding a radio group: one destination, chosen without leaving the row it opened from. */ @@ -15,15 +18,29 @@ export default function Submenu() { const [destination, setDestination] = useState("review"); return ( - - Right-click batch-0042.zip + +
+ +
+
+ batch-0042.zip + 1.8 GB +
- + + + + Rename + + - Move to + + + Move to + - Review + Review queue Archive Warehouse diff --git a/examples/catalog/src/demos/dialog/confirm.tsx b/examples/catalog/src/demos/dialog/confirm.tsx index 9d04fed..83ec7b7 100644 --- a/examples/catalog/src/demos/dialog/confirm.tsx +++ b/examples/catalog/src/demos/dialog/confirm.tsx @@ -18,11 +18,15 @@ export default function Confirm() { - Delete this dataset? + Delete warehouse-2024? - Eleven batches and their annotations go with it. This cannot be undone. + The dataset and everything in it will be removed. This cannot be undone. +
+ 11 batches + 4,812 annotations +
diff --git a/examples/catalog/src/demos/dialog/default.tsx b/examples/catalog/src/demos/dialog/default.tsx index 78f71a7..80eb287 100644 --- a/examples/catalog/src/demos/dialog/default.tsx +++ b/examples/catalog/src/demos/dialog/default.tsx @@ -7,6 +7,9 @@ import { DialogHeader, DialogTitle, DialogTrigger, + Field, + FieldLabel, + Input, } from "@robomous/ui-core"; /** DialogHeader stacks the title and description; DialogFooter right-aligns the actions. */ @@ -21,6 +24,10 @@ export default function Default() { New dataset Frames are grouped into batches as they arrive. + + Name + + diff --git a/examples/catalog/src/demos/dialog/form.tsx b/examples/catalog/src/demos/dialog/form.tsx index 86d86a5..315d7df 100644 --- a/examples/catalog/src/demos/dialog/form.tsx +++ b/examples/catalog/src/demos/dialog/form.tsx @@ -9,8 +9,14 @@ import { DialogTrigger, Field, FieldDescription, + FieldGroup, FieldLabel, Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@robomous/ui-core"; /** A dialog that collects something composes Field anatomy directly inside DialogContent. */ @@ -20,20 +26,35 @@ export default function Form() { - + New camera Connects to the ingest pipeline once saved. - - Name - - Lowercase, hyphens allowed. - + + + Name + + Lowercase, hyphens allowed. + + + Location + + + diff --git a/examples/catalog/src/demos/dialog/no-close-button.tsx b/examples/catalog/src/demos/dialog/no-close-button.tsx index 9071269..6def8b5 100644 --- a/examples/catalog/src/demos/dialog/no-close-button.tsx +++ b/examples/catalog/src/demos/dialog/no-close-button.tsx @@ -1,4 +1,5 @@ import { + Badge, Button, Dialog, DialogContent, @@ -9,7 +10,7 @@ import { DialogTrigger, } from "@robomous/ui-core"; -/** `showCloseButton={false}` on DialogContent removes the corner close affordance entirely. */ +/** `showCloseButton={false}` removes the corner close affordance entirely. */ export default function NoCloseButton() { return ( @@ -19,10 +20,18 @@ export default function NoCloseButton() { Retraining queued - - org/model-base will retrain against batch-0044 once ingest finishes. - + It starts once ingest finishes. +
+
Model
+
org/detector-base
+
Dataset
+
batch-0044
+
Status
+
+ Queued +
+
diff --git a/examples/catalog/src/demos/drawer/bottom.tsx b/examples/catalog/src/demos/drawer/bottom.tsx index 4b01d64..0a84206 100644 --- a/examples/catalog/src/demos/drawer/bottom.tsx +++ b/examples/catalog/src/demos/drawer/bottom.tsx @@ -9,8 +9,14 @@ import { DrawerTitle, DrawerTrigger, Field, + FieldGroup, FieldLabel, Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@robomous/ui-core"; /** The default direction; a drag handle appears above the content, so it can be pulled shut. */ @@ -21,22 +27,37 @@ export default function Bottom() { - - Filters - Narrow the batches shown. - -
- - Name contains - - +
+ + Filters + Narrow the batches shown. + + + + Name contains + + + + Status + + + + + + + + +
- - - - - - ); diff --git a/examples/catalog/src/demos/drawer/directions.tsx b/examples/catalog/src/demos/drawer/directions.tsx index 47ffab3..20c9f41 100644 --- a/examples/catalog/src/demos/drawer/directions.tsx +++ b/examples/catalog/src/demos/drawer/directions.tsx @@ -6,13 +6,26 @@ import { DrawerHeader, DrawerTitle, DrawerTrigger, + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, } from "@robomous/ui-core"; +const directions = ["top", "right", "bottom", "left"] as const; + +const batches = [ + ["batch-0042", "1,248 frames · approved"], + ["batch-0043", "980 frames · pending review"], + ["batch-0044", "1,102 frames · ingesting"], +]; + /** `direction` picks the edge the drawer opens from; only the bottom direction gets a drag handle. */ export default function Directions() { return (
- {(["top", "right", "bottom", "left"] as const).map((direction) => ( + {directions.map((direction) => ( + - + batch-0042 + Rename ⌘R - Duplicate + + + Duplicate + ⌘D + - View details + + + View details + ); diff --git a/examples/catalog/src/demos/dropdown-menu/checkbox-items.tsx b/examples/catalog/src/demos/dropdown-menu/checkbox-items.tsx index 40e1444..57c538b 100644 --- a/examples/catalog/src/demos/dropdown-menu/checkbox-items.tsx +++ b/examples/catalog/src/demos/dropdown-menu/checkbox-items.tsx @@ -21,7 +21,7 @@ export default function CheckboxItems() { - + Visible classes Vehicle diff --git a/examples/catalog/src/demos/dropdown-menu/destructive.tsx b/examples/catalog/src/demos/dropdown-menu/destructive.tsx index fae0a06..89d09f2 100644 --- a/examples/catalog/src/demos/dropdown-menu/destructive.tsx +++ b/examples/catalog/src/demos/dropdown-menu/destructive.tsx @@ -6,17 +6,27 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@robomous/ui-core"; -import { Trash2Icon } from "@robomous/ui-core/icons"; +import { ArchiveIcon, ChevronDownIcon, CopyIcon, Trash2Icon } from "@robomous/ui-core/icons"; /** `variant="destructive"` recolours the text and its icon before focus even lands. */ export default function Destructive() { return ( - + - - Duplicate + + + + Duplicate + + + + Archive + diff --git a/examples/catalog/src/demos/dropdown-menu/inset.tsx b/examples/catalog/src/demos/dropdown-menu/inset.tsx index f2dc104..73aedec 100644 --- a/examples/catalog/src/demos/dropdown-menu/inset.tsx +++ b/examples/catalog/src/demos/dropdown-menu/inset.tsx @@ -7,30 +7,31 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@robomous/ui-core"; -import { CopyIcon, PencilIcon, Trash2Icon } from "@robomous/ui-core/icons"; +import { ChevronDownIcon, CopyIcon, PencilIcon } from "@robomous/ui-core/icons"; /** `inset` lines a label's or an item's leading edge up with the icons in the rows around it. */ export default function Inset() { return ( - + - + batch-0042 - + Rename - + Duplicate - - - Delete - + View audit log + Copy batch ID ); diff --git a/examples/catalog/src/demos/dropdown-menu/radio-group.tsx b/examples/catalog/src/demos/dropdown-menu/radio-group.tsx index c1634dc..0c4574c 100644 --- a/examples/catalog/src/demos/dropdown-menu/radio-group.tsx +++ b/examples/catalog/src/demos/dropdown-menu/radio-group.tsx @@ -22,11 +22,11 @@ export default function RadioGroup() { Move to - + Destination - Review + Review queue Archive Warehouse diff --git a/examples/catalog/src/demos/dropdown-menu/submenu.tsx b/examples/catalog/src/demos/dropdown-menu/submenu.tsx index 0d4f215..a2affc1 100644 --- a/examples/catalog/src/demos/dropdown-menu/submenu.tsx +++ b/examples/catalog/src/demos/dropdown-menu/submenu.tsx @@ -9,26 +9,49 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@robomous/ui-core"; +import { + ArchiveIcon, + ChevronDownIcon, + CopyIcon, + DownloadIcon, + PencilIcon, +} from "@robomous/ui-core/icons"; /** A submenu opens on hover or the right arrow; it shares the parent menu's no-exit-animation rule. */ export default function Submenu() { return ( - + - - Rename - Duplicate + + + + Rename + + + + Duplicate + - Export as + + + Export as + COCO YOLO + Pascal VOC - Archive + + + Archive + ); diff --git a/examples/catalog/src/demos/hover-card/profile.tsx b/examples/catalog/src/demos/hover-card/profile.tsx index ec110f0..cd7811c 100644 --- a/examples/catalog/src/demos/hover-card/profile.tsx +++ b/examples/catalog/src/demos/hover-card/profile.tsx @@ -12,17 +12,30 @@ export default function Profile() { return ( - + - -
- - RO - -
- Robomous - Vision tooling. Joined 2026. + +
+
+ + ML + +
+ Maria Lopez + Lead annotator +
+

Reviews pedestrian and cyclist labels for the warehouse cameras.

+
+
+
Annotations
+
12,480
+
+
+
Reviews
+
342
+
+
diff --git a/examples/catalog/src/demos/hover-card/status.tsx b/examples/catalog/src/demos/hover-card/status.tsx index 7d786dd..707893b 100644 --- a/examples/catalog/src/demos/hover-card/status.tsx +++ b/examples/catalog/src/demos/hover-card/status.tsx @@ -1,4 +1,5 @@ -import { Button, HoverCard, HoverCardContent, HoverCardTrigger } from "@robomous/ui-core"; +import { Badge, Button, HoverCard, HoverCardContent, HoverCardTrigger } from "@robomous/ui-core"; +import { VideoIcon } from "@robomous/ui-core/icons"; /** Any focusable control can trigger a hover card; here a camera name previews its live status. */ export default function Status() { @@ -9,12 +10,23 @@ export default function Status() { warehouse-cam-04 - -
- warehouse-cam-04 - - Online since 03:12 · last frame 2 seconds ago. - + +
+
+ + + + warehouse-cam-04 + Online +
+
+
Resolution
+
1920 × 1080
+
Frame rate
+
30 fps
+
Last frame
+
2 s ago
+
diff --git a/examples/catalog/src/demos/popover/anchor.tsx b/examples/catalog/src/demos/popover/anchor.tsx index 826af29..88b3cae 100644 --- a/examples/catalog/src/demos/popover/anchor.tsx +++ b/examples/catalog/src/demos/popover/anchor.tsx @@ -1,4 +1,5 @@ import { + Badge, Button, Popover, PopoverAnchor, @@ -14,7 +15,7 @@ import { InfoIcon } from "@robomous/ui-core/icons"; export default function Anchor() { return ( -
+
warehouse-cam-04 @@ -24,11 +25,22 @@ export default function Anchor() {
- + - warehouse-cam-04 - Online since 03:12. Last frame 2 seconds ago. + + Camera status + Online + + Streaming to batch-0042 since 03:12. +
+
Resolution
+
1920 × 1080
+
Frame rate
+
30 fps
+
Last frame
+
2 s ago
+
); diff --git a/examples/catalog/src/demos/popover/rename.tsx b/examples/catalog/src/demos/popover/rename.tsx index 7067ec5..25d5fee 100644 --- a/examples/catalog/src/demos/popover/rename.tsx +++ b/examples/catalog/src/demos/popover/rename.tsx @@ -8,21 +8,30 @@ import { PopoverTitle, PopoverTrigger, } from "@robomous/ui-core"; +import { PencilIcon } from "@robomous/ui-core/icons"; /** PopoverHeader stacks PopoverTitle and PopoverDescription; the rest of Content is free-form. */ export default function Rename() { return ( - + - + - Rename + Rename batch Shown wherever this batch is listed. - - + +
+ + +
); diff --git a/examples/catalog/src/demos/sheet/filters.tsx b/examples/catalog/src/demos/sheet/filters.tsx index c718273..0b6d807 100644 --- a/examples/catalog/src/demos/sheet/filters.tsx +++ b/examples/catalog/src/demos/sheet/filters.tsx @@ -1,8 +1,14 @@ import { Button, + Checkbox, Field, + FieldGroup, FieldLabel, - Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, Sheet, SheetContent, SheetDescription, @@ -10,6 +16,9 @@ import { SheetHeader, SheetTitle, SheetTrigger, + Slider, + ToggleGroup, + ToggleGroupItem, } from "@robomous/ui-core"; /** SheetHeader and SheetFooter frame the panel; the body between them is free-form. */ @@ -19,19 +28,60 @@ export default function Filters() { - + Filters - Narrow the list to what you are looking for. + Narrow the frames in batch-0042. -
+ - Camera - + Review status + + Pending + Approved + Rejected + -
- - + + Camera + + + + Classes +
+ {["Vehicle", "Pedestrian", "Cyclist"].map((name) => ( + + + + {name} + + + ))} +
+
+ + Minimum confidence + + + + + +
diff --git a/examples/catalog/src/demos/sheet/no-close-button.tsx b/examples/catalog/src/demos/sheet/no-close-button.tsx index 5938ef8..65aa0d9 100644 --- a/examples/catalog/src/demos/sheet/no-close-button.tsx +++ b/examples/catalog/src/demos/sheet/no-close-button.tsx @@ -1,6 +1,16 @@ import { Button, + Checkbox, + Field, + FieldGroup, + FieldLabel, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, Sheet, + SheetClose, SheetContent, SheetDescription, SheetFooter, @@ -14,15 +24,44 @@ export default function NoCloseButton() { return ( - + - + - Export to COCO - Only annotated frames are included. + Export dataset + 4,812 annotations across 11 batches. - - + + + Format + + + + + + Only annotated frames + + + + + + Split into train and validation sets + + + + + + + diff --git a/examples/catalog/src/demos/sheet/sides.tsx b/examples/catalog/src/demos/sheet/sides.tsx index aced7ae..1056e84 100644 --- a/examples/catalog/src/demos/sheet/sides.tsx +++ b/examples/catalog/src/demos/sheet/sides.tsx @@ -1,5 +1,11 @@ import { Button, + Input, + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, Sheet, SheetContent, SheetDescription, @@ -7,23 +13,95 @@ import { SheetTitle, SheetTrigger, } from "@robomous/ui-core"; +import type { ReactNode } from "react"; + +const sides: Record< + "top" | "right" | "bottom" | "left", + { title: string; description: string; className: string; body: ReactNode } +> = { + top: { + title: "Search frames", + description: "Find a frame by name or camera.", + className: "", + body: ( +
+ +
+ ), + }, + right: { + title: "batch-0042", + description: "Ingested 2 hours ago from warehouse-cam-04.", + className: "data-[side=right]:sm:max-w-sm", + body: ( +
+ {[ + ["Frames", "1,248"], + ["Annotations", "4,812"], + ["Classes", "3"], + ["Reviewed", "86%"], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ), + }, + bottom: { + title: "Quick actions", + description: "For the 3 frames you selected.", + className: "", + body: ( +
+ + +
+ ), + }, + left: { + title: "Datasets", + description: "Switch to another dataset.", + className: "data-[side=left]:sm:max-w-xs", + body: ( + + {[ + ["Warehouse", "11 batches"], + ["Loading dock", "4 batches"], + ["Parking lot", "7 batches"], + ].map(([name, count]) => ( + + + + {name} + {count} + + + + ))} + + ), + }, +}; /** `side` picks the edge the panel slides from; right is the default. */ export default function Sides() { return (
- {(["top", "right", "bottom", "left"] as const).map((side) => ( + {Object.entries(sides).map(([side, { title, description, className, body }]) => ( - + - {side} sheet - Slides in from the {side} edge. + {title} + {description} + {body} ))} diff --git a/examples/catalog/src/demos/table/pagination.tsx b/examples/catalog/src/demos/table/pagination.tsx new file mode 100644 index 0000000..0419a1c --- /dev/null +++ b/examples/catalog/src/demos/table/pagination.tsx @@ -0,0 +1,51 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TablePagination, + TableRow, +} from "@robomous/ui-core"; +import { useState } from "react"; + +const JOBS = Array.from({ length: 125 }, (_, i) => 4128 - i); + +/** `TablePagination` is a ``: the range on the left, the pager on the right. */ +export default function Pagination() { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const pageCount = Math.ceil(JOBS.length / pageSize); + const rows = JOBS.slice((page - 1) * pageSize, page * pageSize); + + return ( + + + + Job + Model + + + + {rows.map((job) => ( + + {job} + rbm-det-3 + + ))} + + { + setPageSize(size); + setPage(1); + }} + /> +
+ ); +} diff --git a/examples/catalog/src/demos/tooltip/default.tsx b/examples/catalog/src/demos/tooltip/default.tsx index eeaf0b1..56852d1 100644 --- a/examples/catalog/src/demos/tooltip/default.tsx +++ b/examples/catalog/src/demos/tooltip/default.tsx @@ -1,23 +1,43 @@ import { Button, + ButtonGroup, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@robomous/ui-core"; +import { MaximizeIcon, ZoomInIcon, ZoomOutIcon } from "@robomous/ui-core/icons"; /** TooltipProvider owns the shared delayDuration (0 by default) for every Tooltip beneath it. */ export default function Default() { return ( - - - - - - Painted foreground-on-background, so it flips with the page. - - + + + + + + Zoom out + + + + + + Zoom in + + + + + + Fit to frame + + ); } diff --git a/examples/catalog/src/demos/tooltip/side.tsx b/examples/catalog/src/demos/tooltip/side.tsx index 834173e..cb4c1ff 100644 --- a/examples/catalog/src/demos/tooltip/side.tsx +++ b/examples/catalog/src/demos/tooltip/side.tsx @@ -1,21 +1,43 @@ import { Button, + ButtonGroup, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@robomous/ui-core"; +import { BoxIcon, HandIcon, MousePointer2Icon } from "@robomous/ui-core/icons"; /** `side` picks which edge the content opens from; the arrow and slide-in direction follow. */ export default function Side() { return ( - - - - - Offline since 02:14 - + + + + + + Select + + + + + + Bounding box + + + + + + Pan + + ); } diff --git a/examples/catalog/src/demos/tooltip/with-kbd.tsx b/examples/catalog/src/demos/tooltip/with-kbd.tsx index 6498063..6af25e3 100644 --- a/examples/catalog/src/demos/tooltip/with-kbd.tsx +++ b/examples/catalog/src/demos/tooltip/with-kbd.tsx @@ -1,25 +1,53 @@ import { Button, + ButtonGroup, Kbd, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@robomous/ui-core"; +import { RedoIcon, SaveIcon, UndoIcon } from "@robomous/ui-core/icons"; /** A Kbd inside TooltipContent restyles itself onto the painted background automatically. */ export default function WithKbd() { return ( - - - - - - Search - ⌘K - - + + + + + + + Undo + ⌘Z + + + + + + + + Redo + ⇧⌘Z + + + + + + + + Save + ⌘S + + + ); } diff --git a/examples/catalog/src/layouts/Docs.astro b/examples/catalog/src/layouts/Docs.astro index 37bac16..bcc4f28 100644 --- a/examples/catalog/src/layouts/Docs.astro +++ b/examples/catalog/src/layouts/Docs.astro @@ -22,19 +22,22 @@ const { prev, next } = neighbours(nav, current);
-
-
diff --git a/examples/catalog/src/pages/docs/[...slug].astro b/examples/catalog/src/pages/docs/[...slug].astro index dc57f54..d131b56 100644 --- a/examples/catalog/src/pages/docs/[...slug].astro +++ b/examples/catalog/src/pages/docs/[...slug].astro @@ -3,6 +3,7 @@ import type { CollectionEntry } from "astro:content"; import { getCollection, render } from "astro:content"; import Lede from "@/components/Lede.astro"; +import CodeBlock from "@/components/CodeBlock.astro"; import Preview from "@/components/Preview.astro"; import Docs from "@/layouts/Docs.astro"; import { plain } from "@/lib/nav"; @@ -26,6 +27,6 @@ const { Content, headings } = await render(entry);
- +
diff --git a/examples/catalog/src/pages/search.json.ts b/examples/catalog/src/pages/search.json.ts index bcc806a..31b5878 100644 --- a/examples/catalog/src/pages/search.json.ts +++ b/examples/catalog/src/pages/search.json.ts @@ -7,7 +7,7 @@ export const GET: APIRoute = async () => { const nav = await buildNav(); const entries = [ ...nav.sections.map((link) => ({ ...link, group: "Sections" })), - ...nav.components.map((link) => ({ ...link, group: "Components" })), + ...nav.components.map((link) => ({ ...link, group: link.group ?? "Components" })), ]; return new Response(JSON.stringify(entries), { headers: { "Content-Type": "application/json" }, diff --git a/examples/catalog/src/styles/global.css b/examples/catalog/src/styles/global.css index 8fa05b8..60e0b1c 100644 --- a/examples/catalog/src/styles/global.css +++ b/examples/catalog/src/styles/global.css @@ -27,13 +27,13 @@ /* MDX bodies: the few element styles a docs page needs, all on tokens. */ .docs-prose > h2 { - @apply mt-12 mb-4 scroll-mt-20 text-xl font-medium tracking-tight; + @apply mt-16 mb-4 scroll-mt-20 text-xl font-medium tracking-tight text-balance; } .docs-prose > h3 { - @apply mt-8 mb-3 scroll-mt-20 text-base font-medium; + @apply mt-10 mb-3 scroll-mt-20 text-base font-medium text-balance; } .docs-prose p:not([data-demo] *) { - @apply my-4 leading-7; + @apply my-4 leading-7 text-pretty; } .docs-prose a:not([data-demo] *) { @apply underline underline-offset-4 hover:text-foreground/80; diff --git a/src/components/alert.tsx b/src/components/alert.tsx index 74633f9..f24f29f 100644 --- a/src/components/alert.tsx +++ b/src/components/alert.tsx @@ -4,13 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "cn"; const alertVariants = cva( - "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + "group/alert relative grid min-h-11 w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-24 has-[>svg]:grid-cols-[1.25rem_1fr] has-[>svg]:gap-x-3 *:[svg]:row-span-2 *:[svg]:my-0.5 *:[svg]:self-start *:[svg]:justify-self-center *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-card text-card-foreground", + // The status recipe, as on Badge: the status's surface role under its ink + // role, the icon and the description in that same ink, border transparent. + info: "border-transparent bg-info-surface text-info *:data-[slot=alert-description]:text-info", + success: + "border-transparent bg-success-surface text-success *:data-[slot=alert-description]:text-success", + warning: + "border-transparent bg-warning-surface text-warning *:data-[slot=alert-description]:text-warning", destructive: - "border-transparent bg-destructive-surface text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + // The destructive tint, as on Button: the role has no surface of its own. + "border-transparent bg-destructive/10 text-destructive *:data-[slot=alert-description]:text-destructive dark:bg-destructive/20", }, }, defaultVariants: { @@ -19,6 +27,16 @@ const alertVariants = cva( }, ); +// A condition that needs attention is announced the moment it mounts; one that +// only informs waits its turn in a polite live region. +const alertRoles = { + default: "alert", + info: "status", + success: "status", + warning: "alert", + destructive: "alert", +} as const; + function Alert({ className, variant, @@ -27,7 +45,7 @@ function Alert({ return (
@@ -39,7 +57,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + "pt-px text-sm/4.5 font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className, )} {...props} @@ -52,7 +70,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">)
) function AttachmentAction({ className, variant, - size = "icon-xs", + size = "icon-sm", ...props }: React.ComponentProps) { return ( diff --git a/src/components/badge.tsx b/src/components/badge.tsx index 4da68cb..0c1d206 100644 --- a/src/components/badge.tsx +++ b/src/components/badge.tsx @@ -12,7 +12,7 @@ const badgeVariants = cva( default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", destructive: - "bg-destructive-surface text-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/15", + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20 dark:[a]:hover:bg-destructive/30", // The status recipe: the status's surface role under its ink role — a 50 // step under a 700 in light, a 950 under a 300 in dark. Both roles flip // between themes on their own, so no `dark:` restatement is needed. diff --git a/src/components/breadcrumb.tsx b/src/components/breadcrumb.tsx index 7a42255..d03d65f 100644 --- a/src/components/breadcrumb.tsx +++ b/src/components/breadcrumb.tsx @@ -44,7 +44,10 @@ function BreadcrumbLink({ return ( ); @@ -57,7 +60,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { role="link" aria-disabled="true" aria-current="page" - className={cn("font-normal text-foreground", className)} + className={cn("font-medium text-foreground", className)} {...props} /> ); @@ -69,7 +72,7 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" - className={cn("[&>svg]:size-3.5", className)} + className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)} {...props} > {children ?? } diff --git a/src/components/button-group.tsx b/src/components/button-group.tsx index b370a22..fccd2f4 100644 --- a/src/components/button-group.tsx +++ b/src/components/button-group.tsx @@ -51,7 +51,7 @@ function ButtonGroupText({ ) {
); @@ -171,7 +171,7 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { ) { return ( -
- +
+ - +
@@ -84,7 +84,7 @@ function CommandList({ className, ...props }: React.ComponentProps ); @@ -113,7 +113,7 @@ function CommandGroup({ ); @@ -143,7 +143,7 @@ function CommandItem({ ) - {children} + {/* Ahead of the call site's actions, so the primary one keeps the + trailing edge in a row and the top of the stack under sm. */} {showCloseButton && ( )} + {children}
); } diff --git a/src/components/dropdown-menu.tsx b/src/components/dropdown-menu.tsx index 165e131..8c74232 100644 --- a/src/components/dropdown-menu.tsx +++ b/src/components/dropdown-menu.tsx @@ -71,7 +71,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive-surface data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", + "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", className, )} {...props} diff --git a/src/components/empty.tsx b/src/components/empty.tsx index 6853740..6a7e913 100644 --- a/src/components/empty.tsx +++ b/src/components/empty.tsx @@ -7,7 +7,7 @@ function Empty({ className, ...props }: React.ComponentProps<"div">) {
) { return (
); @@ -59,7 +59,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); @@ -70,7 +70,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"div">)
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + "text-sm/5 text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", className, )} {...props} diff --git a/src/components/input-group.tsx b/src/components/input-group.tsx index efe3082..5a35c0d 100644 --- a/src/components/input-group.tsx +++ b/src/components/input-group.tsx @@ -12,7 +12,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="input-group" role="group" className={cn( - "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-muted has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", className, )} {...props} @@ -64,7 +64,7 @@ function InputGroupAddon({ const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", { variants: { size: { - xs: "h-6 gap-1 rounded-md px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + xs: "h-6 gap-1 rounded-md px-2 text-xs [&>svg:not([class*='size-'])]:size-3.5", sm: "", "icon-xs": "size-6 rounded-md p-0 has-[>svg]:p-0", "icon-sm": "size-8 p-0 has-[>svg]:p-0", diff --git a/src/components/input.tsx b/src/components/input.tsx index ab7dce9..5896414 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className, )} {...props} diff --git a/src/components/item.tsx b/src/components/item.tsx index 7fa0862..733c7cd 100644 --- a/src/components/item.tsx +++ b/src/components/item.tsx @@ -40,8 +40,8 @@ const itemVariants = cva( muted: "border-transparent bg-muted/50", }, size: { - default: "gap-2.5 px-3 py-2.5", - sm: "gap-2.5 px-3 py-2.5", + default: "gap-2 px-2.5 py-2", + sm: "gap-2 px-2.5 py-2", xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0", }, }, @@ -77,7 +77,7 @@ const itemMediaVariants = cva( variants: { variant: { default: "bg-transparent", - icon: "[&_svg:not([class*='size-'])]:size-4", + icon: "size-10 rounded-sm bg-muted group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 group-data-[variant=muted]/item:bg-background [&_svg:not([class*='size-'])]:size-4 group-data-[size=xs]/item:[&_svg:not([class*='size-'])]:size-3.5", image: "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover", }, @@ -108,7 +108,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
) { ) return (
    ); @@ -57,7 +57,7 @@ function PaginationPrevious({ @@ -75,7 +75,7 @@ function PaginationNext({ {text} @@ -90,7 +90,7 @@ function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span" aria-hidden data-slot="pagination-ellipsis" className={cn( - "flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4", + "flex size-8 items-center justify-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/src/components/popover.tsx b/src/components/popover.tsx index 1f9ccbc..46d6833 100644 --- a/src/components/popover.tsx +++ b/src/components/popover.tsx @@ -38,8 +38,22 @@ function PopoverContent({ ); } +// Radix's popper takes its anchor from a callback ref that only reports an +// attach. On the first commit the trigger is an anchor too and registers after +// this one; the trigger then remounts without its anchor wrapper once Radix +// learns a custom anchor exists, leaving the popper measuring a detached button +// — the content opens at the viewport's corner. Remounting this anchor once, +// in that same update, makes it register last. function PopoverAnchor({ ...props }: React.ComponentProps) { - return ; + const [registered, setRegistered] = React.useState(false); + React.useEffect(() => setRegistered(true), []); + return ( + + ); } function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { diff --git a/src/components/sheet.tsx b/src/components/sheet.tsx index 97624f1..45e4ac4 100644 --- a/src/components/sheet.tsx +++ b/src/components/sheet.tsx @@ -54,7 +54,7 @@ function SheetContent({ data-slot="sheet-content" data-side={side} className={cn( - "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", + "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-[520px] data-[side=right]:sm:max-w-[520px] data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", className, )} {...props} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 1a6d46d..8c7d4ed 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -389,7 +389,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
      ); diff --git a/src/components/sonner.tsx b/src/components/sonner.tsx index 9e322c9..9f76a3e 100644 --- a/src/components/sonner.tsx +++ b/src/components/sonner.tsx @@ -66,9 +66,11 @@ const Toaster = ({ ...props }: ToasterProps) => { "--warning-bg": "var(--warning-surface)", "--warning-text": "var(--warning)", "--warning-border": "var(--warning-surface)", - "--error-bg": "var(--destructive-surface)", + // Destructive has no surface role: the tint is mixed onto the toast's + // own background, under the role itself, as on Button. + "--error-bg": "color-mix(in oklch, var(--destructive) 10%, var(--popover))", "--error-text": "var(--destructive)", - "--error-border": "var(--destructive-surface)", + "--error-border": "color-mix(in oklch, var(--destructive) 10%, var(--popover))", "--border-radius": "var(--radius)", } as React.CSSProperties } diff --git a/src/components/switch.tsx b/src/components/switch.tsx index 51e78bf..9cf4d4e 100644 --- a/src/components/switch.tsx +++ b/src/components/switch.tsx @@ -14,7 +14,7 @@ function Switch({ data-slot="switch" data-size={size} className={cn( - "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50", + "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50", className, )} {...props} diff --git a/src/components/table.tsx b/src/components/table.tsx index 328e8c4..8cb1dc1 100644 --- a/src/components/table.tsx +++ b/src/components/table.tsx @@ -1,6 +1,18 @@ +"use client"; + import * as React from "react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, +} from "lucide-react"; import { cn } from "cn"; +import { Button } from "@/components/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/select"; +import { Skeleton } from "@/components/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/tooltip"; /** * A bordered, rounded frame around a native table. The frame is the scroll @@ -46,7 +58,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { tr]:last:border-b-0", + "border-t bg-muted/50 font-medium [&_td]:h-12 [&_td]:py-0 [&_tr]:hover:bg-transparent [&>tr]:last:border-b-0", className, )} {...props} @@ -103,4 +115,185 @@ function TableCaption({ className, ...props }: React.ComponentProps<"caption">) ); } -export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; +function PagerButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick?: () => void; + children: React.ReactNode; +}) { + return ( + + + + + {label} + + ); +} + +/** + * The table's footer when it pages: the range on the left; on the right Rows per page, the + * page position in a fixed lane, and first / previous / next / last. It is a ``, so it + * sits inside the table's own frame; `colSpan` should cover every column. With one page or + * none (`pageCount <= 1`) it is a count only (`total` and `unit`), and `loading` swaps the + * range and page number for skeletons and disables the pager. The range is a polite live + * region, so a page change announces itself. + */ +function TablePagination({ + className, + colSpan = 100, + page, + pageCount, + pageSize, + total, + unit = "rows", + pageSizeOptions = [10, 20, 50, 100], + onPageChange, + onPageSizeChange, + loading = false, + ...props +}: Omit, "children"> & { + colSpan?: number; + /** The current page, from 1. */ + page: number; + pageCount: number; + pageSize: number; + total: number; + /** The noun after the count when there is no pager: `3 runs`. */ + unit?: string; + pageSizeOptions?: number[]; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + loading?: boolean; +}) { + const from = total === 0 ? 0 : (page - 1) * pageSize + 1; + const to = Math.min(page * pageSize, total); + const paged = pageCount > 1; + const first = page <= 1; + const last = page >= pageCount; + + return ( + + + +
      +
      + {loading ? ( + + ) : paged ? ( + <> + + {from}–{to} + + of + {total} + + ) : ( + <> + {total} + {unit} + + )} +
      + {paged || loading ? ( + +
      +
      + Rows per page + +
      +
      + Page + {loading ? ( + + ) : ( + <> + {page} + of + {pageCount} + + )} +
      +
      + onPageChange?.(1)} + > + + + onPageChange?.(page - 1)} + > + + + onPageChange?.(page + 1)} + > + + + onPageChange?.(pageCount)} + > + + +
      +
      +
      + ) : null} +
      + + + + ); +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, + TablePagination, +}; diff --git a/src/components/tabs.tsx b/src/components/tabs.tsx index 6f4730f..5b6cf3b 100644 --- a/src/components/tabs.tsx +++ b/src/components/tabs.tsx @@ -20,12 +20,12 @@ function Tabs({ } const tabsListVariants = cva( - "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + "group/tabs-list inline-flex w-fit items-center justify-center gap-1 rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", { variants: { variant: { default: "bg-muted", - line: "gap-1 bg-transparent", + line: "bg-transparent p-0 group-data-horizontal/tabs:border-b group-data-horizontal/tabs:border-border", }, }, defaultVariants: { @@ -54,10 +54,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps) {