Finish admin page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user