From bc132d837301c39ffda3403333d7e6be5f4a01a5 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Fri, 2 Oct 2026 17:39:50 -0700 Subject: [PATCH 1/2] feat: components follow the Paper design; destructive becomes a tint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every component was compared with its specimen and spec text on the "02 — Core Components" page of the design file, and the code moved to the design where they disagreed: - Alert gains `info`, `success` and `warning`; info and success announce as `role="status"`, warning and destructive as `role="alert"`. Padding, icon lane and type follow the specimen. Empty gets the specimen's room. - Table gains `TablePagination`, a `` pager with a live range, rows per page, page n of N and first/previous/next/last, with a loading and a count-only state. - Geometry and role fixes across Tabs, Breadcrumb, Pagination, Command, Combobox, Card, Item, Attachment, Dialog, Sheet, Input, Textarea, InputGroup, Switch, ButtonGroup, Toggle, ToggleGroup, Kbd and Sidebar. Theme: `--destructive` takes new values and loses its `-surface`; it gains `--destructive-foreground`. Every destructive variant or state now wears the Button's recipe — the role over /10 of itself (/20 in dark), a /20 hover (/30) — Alert, Badge, the menus' destructive items, Attachment's error tile and the error toast. Button drops the `inline` size; a link in prose is `variant="link"` with `h-auto p-0`. The dark `--input` stroke is neutral-200 at 12%. Fixes: - PopoverAnchor positioned nothing: Radix's popper takes its anchor from an attach-only callback ref, the trigger registers after the custom anchor and then remounts, and the content measured a detached button at the viewport's corner. The anchor now remounts once so it registers last. - DialogFooter's `showCloseButton` puts Close ahead of the call site's actions, so the primary one keeps the trailing edge. Tests, DESIGN.md, the component index and the affected catalog pages and demos are updated to match. --- docs/DESIGN.md | 17 +- docs/components/README.md | 6 +- examples/catalog/src/components/BuiltOn.tsx | 2 +- .../catalog/src/components/Swatches.astro | 6 +- .../catalog/src/content/components/alert.mdx | 13 +- .../catalog/src/content/components/button.mdx | 11 +- .../catalog/src/content/components/sheet.mdx | 2 +- .../catalog/src/content/components/table.mdx | 12 ++ examples/catalog/src/demos/alert/variants.tsx | 24 ++- examples/catalog/src/demos/button/inline.tsx | 14 -- .../catalog/src/demos/table/pagination.tsx | 51 +++++ src/components/alert.tsx | 28 ++- src/components/attachment.tsx | 14 +- src/components/badge.tsx | 2 +- src/components/breadcrumb.tsx | 9 +- src/components/button-group.tsx | 2 +- src/components/button.tsx | 16 +- src/components/card.tsx | 2 +- src/components/combobox.tsx | 8 +- src/components/command.tsx | 20 +- src/components/context-menu.tsx | 2 +- src/components/dialog.tsx | 8 +- src/components/dropdown-menu.tsx | 2 +- src/components/empty.tsx | 8 +- src/components/input-group.tsx | 4 +- src/components/input.tsx | 2 +- src/components/item.tsx | 8 +- src/components/kbd.tsx | 2 +- src/components/pagination.tsx | 8 +- src/components/popover.tsx | 16 +- src/components/sheet.tsx | 2 +- src/components/sidebar.tsx | 2 +- src/components/sonner.tsx | 6 +- src/components/switch.tsx | 2 +- src/components/table.tsx | 197 +++++++++++++++++- src/components/tabs.tsx | 8 +- src/components/textarea.tsx | 2 +- src/components/toggle-group.tsx | 6 +- src/components/toggle.tsx | 2 +- src/index.ts | 1 + src/theme/styles.css | 12 +- tests/components/button.test.tsx | 10 +- tests/components/components.test.tsx | 16 +- tests/components/sonner.test.tsx | 4 +- tests/components/table-pagination.test.tsx | 60 ++++++ tests/docs/api-extractor.test.ts | 1 - tests/theme/tokens.test.ts | 2 +- 47 files changed, 512 insertions(+), 140 deletions(-) delete mode 100644 examples/catalog/src/demos/button/inline.tsx create mode 100644 examples/catalog/src/demos/table/pagination.tsx create mode 100644 tests/components/table-pagination.test.tsx 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/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/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) {