a11y: fix lime accent contrast (P3) + add shared EmptyState component (P4)

- Light-mode --accent-foreground changed from white (2.35:1, fails WCAG AA)
  to near-black 0 0% 10% (~9.5:1) so filled lime CTAs/badges are readable.
- Add src/components/EmptyState.tsx: a single reusable empty-state with
  icon/emoji, title, hint, action slot(s) and optional footer link, to
  consolidate the ~39 ad-hoc empty states over time. Action slot turns
  dead-ends into loops (e.g. "No crews nearby → Create one").

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-24 18:33:35 +07:00
parent 8e02c77ba1
commit e7e492c568
2 changed files with 138 additions and 2 deletions

View file

@ -0,0 +1,134 @@
import type { ComponentType, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
/**
* Shared empty-state component.
*
* Before this existed the app had ~39 ad-hoc empty states, each free to drift
* in spacing, tone and — crucially — whether they offered a way *out*. The
* `action` slot is the valuable part: "No crews nearby → Create one" turns a
* dead-end into a loop. Prefer this component over hand-rolling new ones.
*/
export interface EmptyStateAction {
/** Button label. */
label: string;
/** Internal route to navigate to (renders a <Link>). */
to?: string;
/** Click handler (renders a <button>). Ignored if `to` is set. */
onClick?: () => void;
/** Optional leading icon for the action button. */
icon?: ComponentType<{ className?: string }>;
/** Visual emphasis. `primary` = filled brand button, `secondary` = outline. */
variant?: 'primary' | 'secondary';
/** Extra classes for bespoke accent colors (e.g. teal crews, lime spots). */
className?: string;
}
export interface EmptyStateProps {
/** Lucide-style icon component rendered in the header tile. */
icon?: ComponentType<{ className?: string }>;
/** Emoji alternative to `icon` (used by some sections, e.g. 🛹). */
emoji?: string;
/** Headline. Kept short and human. */
title: string;
/** One or two sentences of guidance. */
hint?: ReactNode;
/** Primary + optional secondary actions. First is the emphasized CTA. */
actions?: EmptyStateAction[];
/** Optional footer link (e.g. "Have questions? Check the FAQ →"). */
footer?: { label: string; to: string };
/** Extra classes on the outer wrapper. */
className?: string;
}
function ActionButton({ action }: { action: EmptyStateAction }) {
const Icon = action.icon;
const isPrimary = action.variant !== 'secondary';
const classes = cn(
'inline-flex w-full items-center justify-center gap-2 rounded-xl px-5 py-2.5 text-sm font-black uppercase tracking-wide transition-all active:scale-95 sm:w-auto',
isPrimary
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-white/5 text-foreground hover:bg-white/10',
action.className,
);
const inner = (
<>
{Icon && <Icon className="h-4 w-4" />}
{action.label}
</>
);
if (action.to) {
return (
<Link to={action.to} className="w-full sm:w-auto">
<button type="button" className={classes}>
{inner}
</button>
</Link>
);
}
return (
<button type="button" onClick={action.onClick} className={classes}>
{inner}
</button>
);
}
export function EmptyState({
icon: Icon,
emoji,
title,
hint,
actions,
footer,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center px-4 py-16 text-center',
className,
)}
>
{(Icon || emoji) && (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5 text-3xl">
{emoji ? (
<span aria-hidden="true">{emoji}</span>
) : (
Icon && <Icon className="h-8 w-8 text-muted-foreground" />
)}
</div>
)}
<h3 className="mb-2 text-lg font-black text-foreground">{title}</h3>
{hint && (
<p className="mb-6 max-w-xs text-sm text-muted-foreground">{hint}</p>
)}
{actions && actions.length > 0 && (
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
{actions.map((action) => (
<ActionButton key={action.label} action={action} />
))}
</div>
)}
{footer && (
<Link
to={footer.to}
className="mt-5 text-xs text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground"
>
{footer.label}
</Link>
)}
</div>
);
}
export default EmptyState;

View file

@ -24,9 +24,11 @@
--muted: 0 0% 96%; --muted: 0 0% 96%;
--muted-foreground: 0 0% 40%; --muted-foreground: 0 0% 40%;
/* Darker neon lime for light mode */ /* Darker neon lime for light mode.
Foreground is near-black (~9.5:1 on lime) — white (2.35:1) fails WCAG AA,
and lime is the "go"/CTA color, so its filled state must stay readable. */
--accent: 75 80% 40%; --accent: 75 80% 40%;
--accent-foreground: 0 0% 100%; --accent-foreground: 0 0% 10%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;