add shadcn ui

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 57f44796ce
commit fee9523b35
50 changed files with 1331 additions and 211 deletions
+2
View File
@@ -15,7 +15,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "1.31.0",
"next-themes": "^0.4.6",
"radix-ui": "1.6.7",
"sonner": "^2.0.8",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
+60
View File
@@ -0,0 +1,60 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../lib/utils';
const alertVariants = cva(
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { Avatar as AvatarPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Avatar({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: 'default' | 'sm' | 'lg';
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 select-none overflow-hidden rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6',
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="avatar-badge"
className={cn(
'bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 z-10 inline-flex select-none items-center justify-center rounded-full ring-2',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group"
className={cn(
'group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2',
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group-count"
className={cn(
'bg-muted text-muted-foreground ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 [&>svg]:size-4',
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount };
@@ -0,0 +1,102 @@
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { Slot } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5',
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+75
View File
@@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('font-semibold leading-none', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('[.border-t]:pt-6 flex items-center px-6', className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
@@ -0,0 +1,29 @@
'use client';
import { CheckIcon } from 'lucide-react';
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'border-input shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary peer size-4 shrink-0 rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+24 -25
View File
@@ -1,20 +1,21 @@
import type { ReactNode, ThHTMLAttributes, TdHTMLAttributes } from 'react';
import type { ReactNode, TdHTMLAttributes, ThHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
/**
* The admin's table shell.
* The admin's table shell, on top of shadcn's `Table`.
*
* Seven screens had hand-written `<table>` markup with the same border, header
* background and eyebrow-cased column labels — and small divergences in each:
* different header text sizes, two different row hover colours, and one table
* that scrolled the whole page sideways on mobile instead of scrolling itself.
* These are not a replacement for the registry component — they *are* the
* registry component, with this project's three decisions baked in so seven
* screens stop repeating them: a bordered card that scrolls itself rather than
* the page, an ink-50 header row, and eyebrow-cased column labels.
*
* These are thin primitives rather than a generic `<DataTable columns={…}>`.
* A column-config table is the wrong abstraction here: every one of these
* screens renders bespoke cells (badges, stacked name+SKU, action buttons),
* and a config API would immediately grow a `render` escape hatch for each —
* at which point it is JSX with extra steps.
* Deliberately thin primitives rather than a generic `<DataTable columns={…}>`.
* Every screen here renders bespoke cells — badges, stacked name+SKU, action
* buttons — and a column-config API would immediately grow a `render` escape
* hatch for each, at which point it is JSX with extra steps.
*/
export function DataTable({
children,
@@ -23,32 +24,30 @@ export function DataTable({
}: {
children: ReactNode;
className?: string;
/** Below this the wrapper scrolls rather than the page. */
/** Below this the wrapper scrolls, not the page. */
minWidth?: string;
}) {
return (
<div className={cn('border-ink-200 rounded-card overflow-x-auto border bg-white', className)}>
<table className="w-full text-sm" style={{ minWidth }}>
{children}
</table>
<Table style={{ minWidth }}>{children}</Table>
</div>
);
}
export function DataTableHead({ children }: { children: ReactNode }) {
return (
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr>{children}</tr>
</thead>
<TableHeader className="border-ink-200 bg-ink-50 border-b">
<TableRow className="hover:bg-transparent">{children}</TableRow>
</TableHeader>
);
}
export function DataTableBody({ children }: { children: ReactNode }) {
return <tbody className="divide-ink-100 divide-y">{children}</tbody>;
return <TableBody className="divide-ink-100 divide-y">{children}</TableBody>;
}
export function DataTableRow({ children, className }: { children: ReactNode; className?: string }) {
return <tr className={cn('hover:bg-ink-50/60 transition-colors', className)}>{children}</tr>;
return <TableRow className={cn('hover:bg-ink-50/60', className)}>{children}</TableRow>;
}
export function DataTableHeader({
@@ -57,13 +56,13 @@ export function DataTableHeader({
...props
}: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
<TableHead
scope="col"
className={cn('text-eyebrow text-ink-500 px-4 py-3 uppercase', className)}
className={cn('text-eyebrow text-ink-500 h-auto px-4 py-3 uppercase', className)}
{...props}
>
{children}
</th>
</TableHead>
);
}
@@ -73,8 +72,8 @@ export function DataTableCell({
...props
}: TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td className={cn('px-4 py-3 align-middle', className)} {...props}>
<TableCell className={cn('px-4 py-3 align-middle', className)} {...props}>
{children}
</td>
</TableCell>
);
}
+4 -2
View File
@@ -4,6 +4,8 @@ import { useId, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { Label } from './label';
/**
* A labelled form field.
*
@@ -47,7 +49,7 @@ export function Field({
return (
<div className={cn('space-y-1.5', className)}>
<label htmlFor={id} className="text-eyebrow text-ink-950 block uppercase">
<Label htmlFor={id} className="text-eyebrow text-ink-950 block uppercase">
{label}
{required ? (
<span aria-hidden className="text-danger ml-0.5">
@@ -59,7 +61,7 @@ export function Field({
{optionalLabel}
</span>
) : null}
</label>
</Label>
{children({
id,
@@ -0,0 +1,27 @@
import { AlertCircle } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Alert, AlertDescription } from './alert';
/**
* The one way this product shows a form or request error.
*
* Twenty-seven files had grown their own `<p role="alert" className="text-danger
* text-sm">`, which is *almost* right — the role is there — but they differed in
* size, spacing and whether an icon appeared, so the same failure looked like
* three different severities depending on the screen.
*
* Renders nothing when there is no message, so callers can drop it in
* unconditionally instead of repeating a ternary.
*/
export function FormError({ message, className }: { message?: string | null; className?: string }) {
if (!message) return null;
return (
<Alert variant="destructive" className={cn('border-danger/30 bg-danger/5', className)}>
<AlertCircle />
<AlertDescription>{message}</AlertDescription>
</Alert>
);
}
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { Label as LabelPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };
@@ -0,0 +1,118 @@
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import * as React from 'react';
import { cn } from '../../lib/utils';
import { buttonVariants, type Button } from './button';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
// `children` is destructured rather than left in `...props` purely so
// jsx-a11y/anchor-has-content can see it — the registry spreads it, and the
// rule cannot follow that. Behaviour is unchanged.
function PaginationLink({
className,
isActive,
size = 'icon',
children,
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
>
{children}
</a>
);
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
+74
View File
@@ -0,0 +1,74 @@
'use client';
import { Popover as PopoverPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'origin-(--radix-popover-content-transform-origin) bg-popover text-popover-foreground outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 w-72 rounded-md border p-4 shadow-md',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="popover-header"
className={cn('flex flex-col gap-1 text-sm', className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />;
}
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="popover-description"
className={cn('text-muted-foreground', className)}
{...props}
/>
);
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
};
@@ -0,0 +1,45 @@
'use client';
import { CircleIcon } from 'lucide-react';
import { RadioGroup as RadioGroupPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute left-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
@@ -8,18 +8,25 @@ export interface SegmentedOption<T extends string> {
}
/**
* A row of mutually exclusive filters.
* A row of mutually exclusive filters, as one joined pill.
*
* Extracted from seven near-identical copies across the admin (discounts,
* reviews, content, orders, products) that had drifted: some used
* `aria-pressed`, some nothing; none had a focus ring; padding varied between
* them. A filter control is exactly the kind of thing that must look and
* behave the same everywhere, because an operator learns it once.
* Hand-rolled ARIA rather than shadcn's `RadioGroup`, and this is the third
* attempt — the first two are worth recording so nobody repeats them:
*
* `radiogroup` rather than a row of toggle buttons: the options are mutually
* exclusive, and that is what tells a screen reader "pick one of these" rather
* than "here are five independent switches". Arrow keys move between options,
* which is the native behaviour keyboard users expect from a radio group.
* 1. shadcn's `RadioGroupItem` hard-codes its children as a circular
* indicator and *ignores* anything passed to it. The labels rendered as
* blank 16px boxes.
* 2. Dropping to `RadioGroupPrimitive` fixed the labels, and then every item
* came out with `tabIndex="-1"` — Radix's roving focus did not initialise,
* so the whole control became unreachable by keyboard. Worse than what it
* replaced, and invisible unless you actually measure it.
*
* So the roving tabindex is explicit here. It is about fifteen lines, it is
* verifiable, and it does what a radio group is supposed to do: one tab stop
* for the group, arrows to move within it.
*
* This is the exception, not the pattern. Everything the registry covers well
* — dialogs, selects, tooltips, tables — uses the registry.
*/
export function SegmentedControl<T extends string>({
value,
@@ -32,8 +39,8 @@ export function SegmentedControl<T extends string>({
value: T;
onChange: (value: T) => void;
options: readonly SegmentedOption<T>[];
/** Names the group for assistive tech. Required — an unlabelled filter row
* is announced as a bare list of buttons. */
/** Names the group. Required — an unlabelled filter row is announced as a
* bare list of buttons. */
label: string;
size?: 'sm' | 'md';
className?: string;
@@ -43,7 +50,7 @@ export function SegmentedControl<T extends string>({
if (delta === 0) return;
event.preventDefault();
// Wraps, which is what a radio group does natively.
// Wraps, the way a native radio group does.
const next = options[(index + delta + options.length) % options.length];
if (next) onChange(next.value);
}
@@ -69,8 +76,8 @@ export function SegmentedControl<T extends string>({
onClick={() => onChange(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={cn(
'duration-(--duration-base) cursor-pointer transition-colors',
'focus-visible:outline-ink-950 focus-visible:z-10 focus-visible:outline-2',
'duration-(--duration-base) cursor-pointer whitespace-nowrap transition-colors',
'focus-visible:ring-ring/50 focus-visible:z-10 focus-visible:outline-none focus-visible:ring-[3px]',
size === 'sm' ? 'px-2.5 py-1.5 text-xs' : 'px-3 py-2 text-sm',
active ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
)}
+175
View File
@@ -0,0 +1,175 @@
'use client';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { Select as SelectPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='text-'])]:text-muted-foreground flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) bg-popover text-popover-foreground data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,28 @@
'use client';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };
+40
View File
@@ -0,0 +1,40 @@
'use client';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { Switch as SwitchPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Switch({
className,
size = 'default',
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: 'sm' | 'default';
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
'group/switch shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80 peer inline-flex shrink-0 items-center rounded-full border border-transparent outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=sm]:h-3.5 data-[size=default]:w-8 data-[size=sm]:w-6',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground pointer-events-none block rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3',
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+92
View File
@@ -0,0 +1,92 @@
'use client';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 whitespace-nowrap px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'whitespace-nowrap p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'field-sizing-content border-input shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Textarea };
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'origin-(--radix-tooltip-content-transform-origin) animate-in bg-foreground text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+83
View File
@@ -91,10 +91,93 @@ export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants } from './co
* that had already drifted across the storefront and the admin. They live here
* rather than in either app because both use them.
*/
/**
* ---- shadcn registry -------------------------------------------------------
*
* Added with `npx shadcn@latest add …` and kept as close to the registry as
* possible, so anything copied from ui.shadcn.com works unedited.
*
* TWO EDITS ARE APPLIED TO EVERY COMPONENT THE CLI WRITES, and they are not
* optional:
*
* 1. `@/lib/utils` becomes a relative import. These files are compiled by the
* *apps* via `transpilePackages`, where `@/*` resolves to the app's own
* `src` — so the alias silently points at the wrong file.
* 2. Quotes and semicolons are normalised by Prettier; the registry ships a
* different style.
*
* Re-running the CLI over an existing component OVERWRITES it. `button.tsx`
* carries deliberate departures (square corners, the volt `accent` variant, a
* transparent `outline` that stays legible on the dark hero) and was silently
* reverted once already. Use `--overwrite` only when you mean it, and re-check
* the diff afterwards.
*/
export { Alert, AlertDescription, AlertTitle } from './components/ui/alert';
export { Avatar, AvatarFallback, AvatarImage } from './components/ui/avatar';
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from './components/ui/breadcrumb';
export {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from './components/ui/card';
export { Checkbox } from './components/ui/checkbox';
export { Label } from './components/ui/label';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from './components/ui/pagination';
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './components/ui/popover';
export { RadioGroup, RadioGroupItem } from './components/ui/radio-group';
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from './components/ui/select';
export { Separator } from './components/ui/separator';
export { Toaster } from './components/ui/sonner';
export { Switch } from './components/ui/switch';
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from './components/ui/table';
export { Textarea } from './components/ui/textarea';
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './components/ui/tooltip';
export { SegmentedControl } from './components/ui/segmented-control';
export type { SegmentedOption } from './components/ui/segmented-control';
export { Rating } from './components/ui/rating';
export { EmptyState } from './components/ui/empty-state';
export { FormError } from './components/ui/form-error';
export { Field } from './components/ui/field';
export { SkipLink } from './components/ui/skip-link';
export {