Basic Architecture of Sport Web
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @sport/ui — the design system.
|
||||
*
|
||||
* WHAT BELONGS HERE
|
||||
* Presentational primitives that are identical in the storefront and the
|
||||
* admin: buttons, inputs, badges, skeletons, typography, layout helpers.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The test: if the component would be meaningless in the admin dashboard, it
|
||||
* does not go in this package.
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -0,0 +1,7 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user