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:
parent
8e02c77ba1
commit
e7e492c568
2 changed files with 138 additions and 2 deletions
134
src/components/EmptyState.tsx
Normal file
134
src/components/EmptyState.tsx
Normal 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;
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue