Finish admin page

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent cda5d21d0d
commit 8fd23d162f
107 changed files with 6321 additions and 563 deletions
@@ -0,0 +1,80 @@
import type { ReactNode, ThHTMLAttributes, TdHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
/**
* The admin's table shell.
*
* 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 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.
*/
export function DataTable({
children,
className,
minWidth = '48rem',
}: {
children: ReactNode;
className?: string;
/** Below this the wrapper scrolls rather than 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>
</div>
);
}
export function DataTableHead({ children }: { children: ReactNode }) {
return (
<thead className="border-ink-200 bg-ink-50 border-b text-left">
<tr>{children}</tr>
</thead>
);
}
export function DataTableBody({ children }: { children: ReactNode }) {
return <tbody className="divide-ink-100 divide-y">{children}</tbody>;
}
export function DataTableRow({ children, className }: { children: ReactNode; className?: string }) {
return <tr className={cn('hover:bg-ink-50/60 transition-colors', className)}>{children}</tr>;
}
export function DataTableHeader({
children,
className,
...props
}: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
scope="col"
className={cn('text-eyebrow text-ink-500 px-4 py-3 uppercase', className)}
{...props}
>
{children}
</th>
);
}
export function DataTableCell({
children,
className,
...props
}: TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td className={cn('px-4 py-3 align-middle', className)} {...props}>
{children}
</td>
);
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/utils';
/**
* The "nothing here" state.
*
* Extracted from four hand-rolled copies that differed in padding, border
* style and text colour. An empty state is not a failure and should not look
* like one — no red, no icon of a broken thing. It says what is missing and,
* where there is one, offers the action that fills it.
*/
export function EmptyState({
title,
description,
action,
className,
}: {
title: string;
description?: string;
action?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'border-ink-200 rounded-card flex flex-col items-center border border-dashed px-6 py-12 text-center',
className,
)}
>
<p className="text-ink-700 text-sm font-medium">{title}</p>
{description ? <p className="text-ink-500 mt-1.5 max-w-sm text-sm">{description}</p> : null}
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useId, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
/**
* A labelled form field.
*
* The `<span class="text-eyebrow uppercase">` + control pairing appeared over
* seventy times across both apps, hand-written each time, which is how a
* hint ends up as `text-xs` in one form and `text-[0.625rem]` in the next —
* and how some controls ended up with no programmatic label at all.
*
* `useId` wires label, hint and error to the control via `htmlFor`,
* `aria-describedby` and `aria-invalid`, so the association survives however
* the control is rendered. Children receive the id as a render prop.
*/
export function Field({
label,
hint,
error,
required,
optionalLabel,
className,
children,
}: {
label: string;
hint?: string;
/** Presence flips the control to `aria-invalid` and shows the message. */
error?: string | null;
required?: boolean;
/** e.g. "(optional)" — marking the shorter list is the kinder default. */
optionalLabel?: string;
className?: string;
children: (props: {
id: string;
'aria-describedby'?: string;
'aria-invalid'?: boolean;
}) => ReactNode;
}) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [hint ? hintId : null, error ? errorId : null].filter(Boolean).join(' ');
return (
<div className={cn('space-y-1.5', className)}>
<label htmlFor={id} className="text-eyebrow text-ink-950 block uppercase">
{label}
{required ? (
<span aria-hidden className="text-danger ml-0.5">
*
</span>
) : null}
{optionalLabel ? (
<span className="text-ink-400 ml-1 font-normal normal-case tracking-normal">
{optionalLabel}
</span>
) : null}
</label>
{children({
id,
...(describedBy ? { 'aria-describedby': describedBy } : {}),
...(error ? { 'aria-invalid': true } : {}),
})}
{hint && !error ? (
<p id={hintId} className="text-ink-500 text-xs">
{hint}
</p>
) : null}
{error ? (
<p id={errorId} role="alert" className="text-danger text-xs">
{error}
</p>
) : null}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Star } from 'lucide-react';
import { cn } from '../../lib/utils';
const SIZES = { sm: 'size-3.5', md: 'size-4', lg: 'size-5' } as const;
/**
* A read-only star rating.
*
* Extracted from three copies — the admin moderation queue, the storefront
* review list and the review form — which had drifted to three different star
* sizes and two different fill colours for the same concept.
*
* The stars are `aria-hidden` and the value is exposed as text, because five
* separate star glyphs announced individually are noise; "4 out of 5" is the
* information. Rounding is the caller's business: a 4.5 average passed here
* fills four stars, and the numeral beside it carries the half.
*/
export function Rating({
value,
max = 5,
size = 'md',
className,
label,
}: {
value: number;
max?: number;
size?: keyof typeof SIZES;
className?: string;
/** Overrides the announced text, e.g. a localised "4 out of 5 stars". */
label?: string;
}) {
return (
<span
role="img"
aria-label={label ?? `${value}/${max}`}
className={cn('inline-flex items-center gap-0.5', className)}
>
{Array.from({ length: max }, (_, index) => index + 1).map((star) => (
<Star
key={star}
aria-hidden
className={cn(SIZES[size], star <= value ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
))}
</span>
);
}
@@ -0,0 +1,84 @@
'use client';
import { cn } from '../../lib/utils';
export interface SegmentedOption<T extends string> {
value: T;
label: string;
}
/**
* A row of mutually exclusive filters.
*
* 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.
*
* `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.
*/
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
size = 'md',
className,
}: {
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. */
label: string;
size?: 'sm' | 'md';
className?: string;
}) {
function handleKeyDown(event: React.KeyboardEvent, index: number) {
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (delta === 0) return;
event.preventDefault();
// Wraps, which is what a radio group does natively.
const next = options[(index + delta + options.length) % options.length];
if (next) onChange(next.value);
}
return (
<div
role="radiogroup"
aria-label={label}
className={cn('border-ink-200 rounded-card inline-flex overflow-hidden border', className)}
>
{options.map((option, index) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
// Only the selected option is tabbable; arrows move within the
// group. Otherwise a five-option filter costs five tab stops.
tabIndex={active ? 0 : -1}
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',
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',
)}
>
{option.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,27 @@
import { cn } from '../../lib/utils';
/**
* "Skip to content", for keyboard users.
*
* Visually hidden until focused. Without it, reaching the product grid on a
* storefront page means tabbing through the whole header — logo, three mega-menu
* triggers, language switcher, search, account, bag — on every single page.
*
* Deliberately the first focusable element in the DOM, and positioned rather
* than clipped: `sr-only` alone would keep it unreachable-looking when focused,
* which is the failure mode of most skip links that "exist" but are never seen.
*/
export function SkipLink({ href = '#main', children }: { href?: string; children: string }) {
return (
<a
href={href}
className={cn(
'bg-ink-950 rounded-card sr-only px-4 py-2 text-sm font-medium text-white',
'focus-visible:not-sr-only focus-visible:fixed focus-visible:left-4 focus-visible:top-4',
'focus-visible:z-toast focus-visible:outline-volt-400 focus-visible:outline-2 focus-visible:outline-offset-2',
)}
>
{children}
</a>
);
}
+22
View File
@@ -83,3 +83,25 @@ export {
} from './components/ui/dropdown-menu';
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants } from './components/ui/tabs';
/**
* ---- Commerce-agnostic patterns -------------------------------------------
*
* Not from the shadcn registry: these were extracted from duplicated markup
* that had already drifted across the storefront and the admin. They live here
* rather than in either app because both use them.
*/
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 { Field } from './components/ui/field';
export { SkipLink } from './components/ui/skip-link';
export {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeader,
DataTableRow,
} from './components/ui/data-table';
+40 -1
View File
@@ -1,5 +1,44 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { extendTailwindMerge } from 'tailwind-merge';
/**
* tailwind-merge, taught about this project's custom scales.
*
* Without the `extend` block, `cn('text-eyebrow', 'text-ink-500')` silently
* returns just `text-ink-500`. tailwind-merge resolves conflicts from a built-in
* class list; a custom `--text-*` token it has never heard of gets classified as
* a *text colour*, so the real colour that follows it looks like a conflict and
* wins. The class vanishes with no error, at build or at runtime — it simply is
* not in the DOM.
*
* That is exactly what happened to every table header in the admin: they
* rendered at the inherited 14px instead of the 10px eyebrow size, and the only
* way to notice was to read the computed style.
*/
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
// Each entry registers a custom token from theme.css into the class group
// it actually belongs to. Without this, `cn()` cannot override any of
// them: both classes survive the merge and the winner is decided by CSS
// source order rather than by the caller's intent.
'font-size': [{ text: ['eyebrow'] }],
rounded: [{ rounded: ['card', 'pill'] }],
'max-w': [{ 'max-w': ['page'] }],
p: [{ p: ['gutter'] }],
px: [{ px: ['gutter'] }],
py: [{ py: ['gutter'] }],
m: [{ m: ['gutter'] }],
mx: [{ mx: ['gutter'] }],
my: [{ my: ['gutter'] }],
gap: [{ gap: ['gutter'] }],
z: [{ z: ['sticky', 'dropdown', 'overlay', 'modal', 'toast'] }],
duration: [{ duration: ['fast', 'base'] }],
ease: [{ ease: ['out-quint'] }],
tracking: [{ tracking: ['display'] }],
},
},
});
/**
* Conditional classes + last-wins conflict resolution for Tailwind utilities.