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
+4
View File
@@ -0,0 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}
+1 -1
View File
@@ -1,5 +1,5 @@
import createNextIntlPlugin from 'next-intl/plugin';
import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
/** Registers the cookie-based locale resolver in src/i18n/request.ts. */
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
+2 -6
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { useEffect, useState, type FormEvent } from 'react';
import { isApiClientError } from '@sport/api-client';
import { Button, Input } from '@sport/ui';
import { Button, FormError, Input } from '@sport/ui';
import { loginSchema } from '@sport/validation';
import { useSession } from './session-provider';
@@ -91,11 +91,7 @@ export function LoginForm() {
/>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
<Button type="submit" fullWidth size="lg" disabled={submitting}>
{submitting ? t('signingIn') : t('signIn')}
@@ -13,7 +13,7 @@ import {
type ContentType,
type Locale,
} from '@sport/types';
import { Badge, Button, Input, Skeleton, cn } from '@sport/ui';
import { Badge, Button, FormError, Input, Skeleton, Textarea, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
@@ -275,12 +275,11 @@ export function ContentEditor({
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-widest">{t('body')}</span>
<textarea
<Textarea
value={fields.body}
onChange={(event) => setField('body', event.target.value)}
rows={22}
spellCheck
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 font-mono text-sm leading-relaxed outline-none"
/>
<span className="text-ink-500 text-xs">{t('bodyHint')}</span>
</label>
@@ -313,11 +312,7 @@ export function ContentEditor({
</div>
</details>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
<div className="border-ink-200 flex flex-wrap items-center gap-2 border-t pt-4">
<Button type="submit" variant="outline" disabled={saving}>
@@ -17,6 +17,7 @@ import {
DataTableHeader,
DataTableRow,
EmptyState,
FormError,
Input,
SegmentedControl,
Skeleton,
@@ -107,11 +108,7 @@ export function ContentTable() {
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!entries ? (
<div className="space-y-2" aria-busy="true">
@@ -20,6 +20,7 @@ import {
DialogHeader,
DialogTitle,
EmptyState,
FormError,
Input,
Skeleton,
} from '@sport/ui';
@@ -77,11 +78,7 @@ export function CustomersTable() {
aria-label={t('searchPlaceholder')}
/>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!customers ? (
<div className="space-y-2" aria-busy="true">
@@ -7,12 +7,14 @@ import { isApiClientError, type DiscountPayload } from '@sport/api-client';
import { DEFAULT_LOCALE, LOCALES, type AdminDiscount, type Locale } from '@sport/types';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
FormError,
Input,
cn,
} from '@sport/ui';
@@ -356,11 +358,7 @@ export function DiscountDialog({
</div>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
</form>
<DialogFooter className="sm:justify-between">
@@ -458,12 +456,10 @@ function Toggle({
return (
<div className="flex items-start gap-2.5">
<input
<Checkbox
id={id}
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
onCheckedChange={(checked) => onChange(checked === true)}
/>
<div className="space-y-0.5">
<label htmlFor={id} className="block text-sm font-medium">
@@ -16,6 +16,7 @@ import {
DataTableHeader,
DataTableRow,
EmptyState,
FormError,
Input,
SegmentedControl,
Skeleton,
@@ -114,11 +115,7 @@ export function DiscountsTable() {
</Button>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!discounts ? (
<div className="space-y-2" aria-busy="true">
@@ -14,6 +14,7 @@ import {
DataTableHead,
DataTableHeader,
DataTableRow,
FormError,
Input,
Skeleton,
cn,
@@ -106,11 +107,7 @@ export function InventoryTable() {
</label>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!levels ? (
<div className="space-y-2" aria-busy="true">
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type MediaAssetSummary } from '@sport/types';
import { Button, Skeleton } from '@sport/ui';
import { Button, FormError, Skeleton } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
@@ -145,11 +145,7 @@ export function MediaLibrary() {
</ul>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!assets ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6" aria-busy="true">
@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type Order, type OrderStatus } from '@sport/types';
import { Badge, Button, Input, Skeleton } from '@sport/ui';
import { Badge, Button, FormError, Input, Skeleton } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
@@ -103,11 +103,7 @@ export function OrderDetail({ orderId }: { orderId: string }) {
<Badge variant="outline">{t(`payment.${order.paymentStatus}`)}</Badge>
</header>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{can(PERMISSIONS.ORDER_UPDATE) && next.length > 0 ? (
<section className="border-ink-200 space-y-3 border bg-white p-6">
@@ -15,6 +15,7 @@ import {
DataTableHead,
DataTableHeader,
DataTableRow,
FormError,
Input,
Skeleton,
cn,
@@ -115,11 +116,7 @@ export function OrdersTable() {
<span className="text-ink-500 ml-auto text-xs">{t('count', { count: total })}</span>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!orders ? (
<div className="space-y-2" aria-busy="true">
@@ -11,7 +11,7 @@ import {
type AdminProductDetail,
type MediaAssetSummary,
} from '@sport/types';
import { Button, Skeleton, cn } from '@sport/ui';
import { Button, FormError, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
@@ -139,11 +139,7 @@ export function ImageManager({
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{slots.length === 0 ? (
<p className="text-ink-500 text-sm">{t('noImages')}</p>
@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminProductDetail } from '@sport/types';
import { Badge, Button, Skeleton, cn } from '@sport/ui';
import { Badge, Button, FormError, Skeleton, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
@@ -127,11 +127,7 @@ export function ProductEditor({ productId }: { productId: string }) {
) : null}
</header>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
<nav className="border-ink-200 flex gap-1 border-b">
{(['details', 'variants', 'images'] as const).map((value) => (
@@ -12,7 +12,7 @@ import {
type AdminProductDetail,
type Locale,
} from '@sport/types';
import { Badge, Button, Input, cn } from '@sport/ui';
import { Badge, Button, FormError, Input, Textarea, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
@@ -264,7 +264,7 @@ export function ProductForm({
<span className="text-xs font-semibold uppercase tracking-widest">
{t('description')}
</span>
<textarea
<Textarea
rows={5}
value={translations[locale].description}
onChange={(event) =>
@@ -347,11 +347,7 @@ export function ProductForm({
</section>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
<div className="flex items-center gap-3">
<Button size="lg" disabled={saving} onClick={() => void save()}>
@@ -16,6 +16,7 @@ import {
DataTableHead,
DataTableHeader,
DataTableRow,
FormError,
Input,
Skeleton,
cn,
@@ -141,11 +142,7 @@ export function ProductsTable() {
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!items ? (
<div className="space-y-2" aria-busy="true">
@@ -5,7 +5,7 @@ import { useState } from 'react';
import { isApiClientError, type VariantPatch } from '@sport/api-client';
import { PERMISSIONS, type AdminProductDetail, type AdminProductVariant } from '@sport/types';
import { Badge, Button, Input, cn } from '@sport/ui';
import { Badge, Button, FormError, Input, cn } from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
@@ -131,11 +131,7 @@ export function VariantGrid({
</span>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
<div className="border-ink-200 overflow-x-auto border bg-white">
<table className="min-w-3xl w-full text-sm">
@@ -5,7 +5,16 @@ import { useCallback, useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import { PERMISSIONS, type AdminReview, type ReviewStatus } from '@sport/types';
import { Badge, Button, EmptyState, Input, Rating, SegmentedControl, Skeleton } from '@sport/ui';
import {
Badge,
Button,
EmptyState,
FormError,
Input,
Rating,
SegmentedControl,
Skeleton,
} from '@sport/ui';
import { useSession } from '@/features/auth/session-provider';
import { browserApi } from '@/lib/api';
@@ -97,11 +106,7 @@ export function ReviewsQueue() {
/>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!reviews ? (
<div className="space-y-2" aria-busy="true">
@@ -23,6 +23,7 @@ import {
DialogTitle,
EmptyState,
Field,
FormError,
Input,
Skeleton,
cn,
@@ -171,11 +172,7 @@ export function TaxonomyManager<TRow extends TaxonomyRow>({
</div>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!rows ? (
<div className="space-y-2" aria-busy="true">
@@ -437,11 +434,7 @@ function TaxonomyDialog<TRow extends TaxonomyRow>({
</div>
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
</form>
<DialogFooter className="sm:justify-between">
+1 -2
View File
@@ -1,9 +1,8 @@
{
"extends": "@sport/config/typescript/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
}
},
"include": [
+1 -2
View File
@@ -1,9 +1,8 @@
{
"extends": "@sport/config/typescript/nestjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
},
"noEmit": true
},
@@ -5,7 +5,7 @@ import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl';
import { CART_NOTICE_REASONS } from '@sport/types';
import { Button, Skeleton } from '@sport/ui';
import { Button, FormError, Skeleton } from '@sport/ui';
import { useCart } from '@/features/cart/cart-provider';
import { Link } from '@/i18n/navigation';
@@ -76,11 +76,7 @@ export function CartView() {
</ul>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
<ul className="divide-ink-200 divide-y">
{cart.lines.map((line) => (
@@ -6,7 +6,7 @@ import { useState } from 'react';
import { isApiClientError, type ProductListQuery } from '@sport/api-client';
import type { Locale, ProductListItem } from '@sport/types';
import { Button } from '@sport/ui';
import { Button, FormError } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { browserApi } from '@/lib/api';
@@ -88,11 +88,7 @@ export function LoadMore({
{t('showing', { shown, total: totalCount })}
</p>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{cursor ? (
<Button variant="outline" size="lg" asChild>
@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import type { Locale, ReviewableItem } from '@sport/types';
import { Button, Input, cn } from '@sport/ui';
import { Button, FormError, Input, Textarea, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
@@ -155,21 +155,16 @@ function ItemReview({
aria-label={t('titlePlaceholder')}
maxLength={140}
/>
<textarea
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('bodyPlaceholder')}
aria-label={t('bodyPlaceholder')}
maxLength={2000}
rows={3}
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 text-sm outline-none"
/>
{error ? (
<p role="alert" className="text-danger text-xs">
{error}
</p>
) : null}
<FormError message={error} />
<Button size="sm" disabled={saving || !authorName.trim()} onClick={submit}>
{saving ? t('sending') : t('submit')}
@@ -12,7 +12,7 @@ import type {
OrderListItem,
ProductListItem,
} from '@sport/types';
import { Badge, Button, EmptyState, Input, Skeleton } from '@sport/ui';
import { Badge, Button, EmptyState, FormError, Input, Skeleton } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { browserApi } from '@/lib/api';
@@ -115,11 +115,7 @@ export function AccountOrders() {
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.orders')}</h1>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!data ? (
<Skeleton className="h-40 w-full" />
@@ -244,11 +240,7 @@ export function AccountAddresses() {
) : null}
</div>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{draft ? (
<form onSubmit={save} className="border-ink-200 space-y-4 border p-5">
@@ -301,11 +293,7 @@ export function AccountAddresses() {
<span className="text-sm">{t('makeDefault')}</span>
</label>
{formError ? (
<p role="alert" className="text-danger text-sm">
{formError}
</p>
) : null}
<FormError message={formError} />
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={busy}>
@@ -469,11 +457,7 @@ export function AccountProfile() {
<span className="text-ink-600 text-sm">{t('marketing')}</span>
</label>
{formError ? (
<p role="alert" className="text-danger text-sm">
{formError}
</p>
) : null}
<FormError message={formError} />
{saved ? <p className="text-success text-sm">{t('saved')}</p> : null}
<Button type="submit" disabled={busy}>
@@ -517,11 +501,7 @@ export function AccountWishlist() {
<div className="space-y-6">
<h1 className="text-2xl font-black uppercase tracking-tight">{t('nav.wishlist')}</h1>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
{!data ? (
<Skeleton className="h-32 w-full" />
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { useState, type FormEvent } from 'react';
import { isApiClientError } from '@sport/api-client';
import { Button, Input } from '@sport/ui';
import { Button, Checkbox, FormError, Input } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
@@ -152,22 +152,16 @@ export function AuthForm({ mode }: { mode: 'signIn' | 'register' }) {
</label>
<label className="flex items-start gap-2.5">
<input
type="checkbox"
<Checkbox
checked={acceptsMarketing}
onChange={(event) => setAcceptsMarketing(event.target.checked)}
className="border-ink-300 accent-ink-950 mt-0.5 size-4"
onCheckedChange={(checked) => setAcceptsMarketing(checked === true)}
/>
<span className="text-ink-600 text-sm">{t('marketing')}</span>
</label>
</>
) : null}
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
<FormError message={error} />
<Button type="submit" disabled={busy} className="w-full">
{busy ? t('working') : isRegister ? t('createAccount') : t('signIn')}
+1 -2
View File
@@ -1,9 +1,8 @@
{
"extends": "@sport/config/typescript/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
}
},
"include": [
+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 {
+34
View File
@@ -444,9 +444,15 @@ importers:
lucide-react:
specifier: 1.31.0
version: 1.31.0(react@19.2.8)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
radix-ui:
specifier: 1.6.7
version: 1.6.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
sonner:
specifier: ^2.0.8
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
tailwind-merge:
specifier: ^3.4.0
version: 3.6.0
@@ -5252,6 +5258,12 @@ packages:
typescript:
optional: true
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.3.0:
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
engines: {node: '>=20.9.0'}
@@ -5905,6 +5917,16 @@ packages:
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
sonner@2.0.8:
resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -11901,6 +11923,11 @@ snapshots:
transitivePeerDependencies:
- '@swc/helpers'
next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
next@16.3.0(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 16.3.0
@@ -12676,6 +12703,13 @@ snapshots:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
source-map-js@1.2.1: {}
source-map-support@0.5.13: