Wip stage M4

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 5386bc51d1
commit d5cacc1208
113 changed files with 9486 additions and 919 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "../config/tailwind/theme.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+2
View File
@@ -14,6 +14,8 @@
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "1.31.0",
"radix-ui": "1.6.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
+47
View File
@@ -0,0 +1,47 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* Status and merchandising labels.
*
* Kept on the raw ink/volt/semantic scales rather than the shadcn role tokens:
* a sale badge is a brand decision, not a surface role, and mapping it through
* `--primary` would make it change meaning the next time that token moves.
*/
const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center justify-center gap-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest ' +
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none ' +
"[&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-3",
{
variants: {
variant: {
neutral: 'bg-ink-100 text-ink-700',
solid: 'bg-ink-950 text-white',
sale: 'bg-sale text-white',
new: 'bg-volt-500 text-ink-950',
success: 'bg-success text-white',
warning: 'bg-warning text-ink-950',
outline: 'border border-ink-300 text-ink-700',
},
},
defaultVariants: { variant: 'neutral' },
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
return (
<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+110
View File
@@ -0,0 +1,110 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* The shadcn Button, tuned to this store's design language.
*
* Three deliberate departures from the registry default, each for a reason:
*
* - **Square, not rounded.** The sport/streetwear direction is hard-edged.
* `shape` exists so a pill can be asked for explicitly rather than becoming
* the default everywhere.
* - **Uppercase with tracking on the larger sizes.** This is the single
* strongest brand signal in the button and it belongs in the component, not
* repeated as `className` at ninety call sites.
* - **An `accent` variant on volt.** The registry has no equivalent, and volt
* is reserved for the one action that matters on a screen.
*
* Everything else — the variant names, `asChild`, the `data-slot` attributes,
* the focus ring on `--ring` — is left exactly as the registry writes it, so
* anything pasted from shadcn keeps working.
*/
const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-medium transition-all outline-none ' +
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] ' +
'disabled:pointer-events-none disabled:opacity-40 ' +
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 ' +
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
/**
* Transparent, not `bg-background`.
*
* The registry assumes an outline button sits on a light surface and
* gives it a white fill. Here they sit on the black hero as often as on
* a white page, and a white fill turned the label invisible. Letting the
* surface show through is what "outline" should mean.
*/
outline: 'border border-current bg-transparent hover:bg-foreground hover:text-background',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-foreground underline-offset-4 hover:underline',
destructive: 'bg-destructive text-white hover:bg-destructive/90',
/** Volt. One per screen — the primary conversion action. */
accent: 'bg-volt-500 text-ink-950 hover:bg-volt-400',
},
size: {
default: 'h-11 px-6 text-sm uppercase tracking-wide has-[>svg]:px-5',
sm: 'h-9 px-4 text-xs uppercase tracking-wide has-[>svg]:px-3',
lg: 'h-14 px-8 text-sm uppercase tracking-widest has-[>svg]:px-6',
/** Body-copy sizing — for inline text actions where caps would shout. */
inline: 'h-auto p-0 text-sm normal-case tracking-normal',
icon: 'size-11',
'icon-sm': 'size-9',
},
shape: {
square: 'rounded-none',
rounded: 'rounded-md',
pill: 'rounded-pill',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
shape: 'square',
},
},
);
function Button({
className,
variant = 'default',
size = 'default',
shape = 'square',
fullWidth,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
/**
* Renders the child element instead of a `<button>`, forwarding styles and
* behaviour onto it.
*
* This is what a Button wrapping a `<Link>` must use. `<Link><Button>`
* produces `<a><button>`, which is invalid HTML and gives screen readers
* two nested controls to announce.
*/
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, shape, fullWidth, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+145
View File
@@ -0,0 +1,145 @@
'use client';
import { XIcon } from 'lucide-react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
import { Button } from './button';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background 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 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg outline-none duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="rounded-xs ring-offset-background focus:ring-ring focus:outline-hidden data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold leading-none', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,228 @@
'use client';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-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 z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive! relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 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,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 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 className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'origin-(--radix-dropdown-menu-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 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+33
View File
@@ -0,0 +1,33 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* The shadcn Input at this store's proportions: taller (h-11, comfortable on
* touch), square, and on a solid background rather than transparent.
*
* The focus treatment is the registry's and is the reason to adopt it — the
* previous hand-rolled input set `focus:outline-none` and signalled focus with
* a border tint alone, which is close to invisible against `--border`. This
* draws a real 3px ring on `--ring`.
*/
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'border-input bg-background text-foreground h-11 w-full min-w-0 border px-4 py-1 text-sm outline-none transition-[color,box-shadow]',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
'file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
'disabled:bg-muted disabled:cursor-not-allowed disabled:opacity-60',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:ring-[3px]',
className,
)}
{...props}
/>
);
}
export { Input };
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { XIcon } from 'lucide-react';
import { Dialog as SheetPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=closed]:animate-out data-[state=open]:animate-in fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="rounded-xs ring-offset-background focus:ring-ring focus:outline-hidden data-[state=open]:bg-secondary absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
@@ -0,0 +1,11 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="skeleton" className={cn('bg-accent animate-pulse', className)} {...props} />
);
}
export { Skeleton };
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { cva, type VariantProps } from 'class-variance-authority';
import { Tabs as TabsPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '../../lib/utils';
function Tabs({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn('group/tabs flex gap-2 data-[orientation=horizontal]:flex-col', className)}
{...props}
/>
);
}
const tabsListVariants = cva(
'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-all focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent',
'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground',
'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100',
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+78 -20
View File
@@ -1,27 +1,85 @@
/**
* @sport/ui — the design system.
* @sport/ui — the shared component layer.
*
* WHAT BELONGS HERE
* Presentational primitives that are identical in the storefront and the
* admin: buttons, inputs, badges, skeletons, typography, layout helpers.
* Two tiers, and the split is the whole point:
*
* WHAT MUST NEVER BE HERE
* - Data fetching or any @sport/api-client import.
* - App state (cart, auth session, filters).
* - Domain components. A `<ProductCard>` knows about pricing, sale badges and
* variant swatches — that is storefront business UI and lives in
* `apps/storefront/src/features/product/`. Sharing it would couple two apps
* that must be free to diverge.
* `src/components/ui/` Infrastructure. Dialog, Sheet, Popover, Dropdown,
* Tabs, Button, Input. These are shadcn/ui components,
* owned as source in this repo. They carry no domain
* knowledge, look the same in the storefront and the
* admin, and are the things nobody should be rebuilding.
*
* NOT HERE Anything that carries the brand. Product cards,
* headers, heroes, mega menus, the PDP — those are
* built by hand in `apps/storefront/src/components/
* commerce/`, because they are the product, not
* plumbing. A `<ProductCard>` knows about sale badges,
* price ranges and colourways; sharing it would couple
* two apps that must stay free to diverge.
*
* Also never here: data fetching, `@sport/api-client`, app state (cart, auth,
* filters).
*
* The test: if the component would be meaningless in the admin dashboard, it
* does not go in this package.
* does not belong in this package.
*
* ---
* Adding a component from the registry:
*
* pnpm dlx shadcn@latest add <name> --cwd packages/ui
*
* then rewrite its `@/lib/utils` and `@/components/ui/*` imports to relative
* paths (a workspace package cannot rely on an app's tsconfig aliases) and
* re-export it below.
*/
export { cn } from './lib/cn';
export { Button, buttonVariants } from './primitives/button';
export type { ButtonProps } from './primitives/button';
export { Badge, badgeVariants } from './primitives/badge';
export type { BadgeProps } from './primitives/badge';
export { Input } from './primitives/input';
export type { InputProps } from './primitives/input';
export { Skeleton } from './primitives/skeleton';
export { cn } from './lib/utils';
export { Button, buttonVariants } from './components/ui/button';
export { Badge, badgeVariants } from './components/ui/badge';
export { Input } from './components/ui/input';
export { Skeleton } from './components/ui/skeleton';
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from './components/ui/dialog';
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from './components/ui/sheet';
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from './components/ui/dropdown-menu';
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants } from './components/ui/tabs';
-7
View File
@@ -1,7 +0,0 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Conditional classes + last-wins conflict resolution for Tailwind utilities. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+13
View File
@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Conditional classes + last-wins conflict resolution for Tailwind utilities.
*
* Lives at `@/lib/utils` because that is the path every component in the
* shadcn registry imports. Keeping it here means a component copied from the
* registry compiles unedited.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
-29
View File
@@ -1,29 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority';
import type { HTMLAttributes } from 'react';
import { cn } from '../lib/cn';
export const badgeVariants = cva(
'inline-flex items-center gap-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest',
{
variants: {
variant: {
neutral: 'bg-ink-100 text-ink-700',
solid: 'bg-ink-950 text-white',
sale: 'bg-sale text-white',
new: 'bg-volt-500 text-ink-950',
success: 'bg-success text-white',
warning: 'bg-warning text-ink-950',
outline: 'border border-ink-300 text-ink-700',
},
},
defaultVariants: { variant: 'neutral' },
},
);
export interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
-56
View File
@@ -1,56 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes, Ref } from 'react';
import { cn } from '../lib/cn';
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink-900 focus-visible:ring-offset-2 ' +
'disabled:pointer-events-none disabled:opacity-40',
{
variants: {
variant: {
primary: 'bg-ink-950 text-white hover:bg-ink-800',
secondary: 'bg-ink-100 text-ink-950 hover:bg-ink-200',
outline:
'border border-ink-950 bg-transparent text-ink-950 hover:bg-ink-950 hover:text-white',
ghost: 'bg-transparent text-ink-950 hover:bg-ink-100',
accent: 'bg-volt-500 text-ink-950 hover:bg-volt-400',
danger: 'bg-danger text-white hover:opacity-90',
},
size: {
sm: 'h-9 px-4 text-xs uppercase tracking-wide',
md: 'h-11 px-6 text-sm uppercase tracking-wide',
lg: 'h-14 px-8 text-sm uppercase tracking-widest',
icon: 'size-10',
},
shape: {
square: 'rounded-none',
rounded: 'rounded-card',
pill: 'rounded-pill',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
shape: 'square',
},
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
ref?: Ref<HTMLButtonElement>;
}
export function Button({ className, variant, size, shape, fullWidth, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, shape, fullWidth }), className)}
{...props}
/>
);
}
-24
View File
@@ -1,24 +0,0 @@
import type { InputHTMLAttributes, Ref } from 'react';
import { cn } from '../lib/cn';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
invalid?: boolean;
ref?: Ref<HTMLInputElement>;
}
export function Input({ className, invalid, ...props }: InputProps) {
return (
<input
aria-invalid={invalid || undefined}
className={cn(
'border-ink-300 text-ink-950 h-11 w-full border bg-white px-4 text-sm transition-colors',
'placeholder:text-ink-400 focus:border-ink-950 focus:outline-none',
'disabled:bg-ink-50 disabled:cursor-not-allowed',
invalid && 'border-danger focus:border-danger',
className,
)}
{...props}
/>
);
}
-7
View File
@@ -1,7 +0,0 @@
import type { HTMLAttributes } from 'react';
import { cn } from '../lib/cn';
export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('bg-ink-100 animate-pulse', className)} {...props} />;
}
+7 -1
View File
@@ -1,5 +1,11 @@
{
"extends": "@sport/config/typescript/react-library.json",
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}