add shadcn ui
This commit is contained in:
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user