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-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-foreground: 0 0% 100%;
|
||||
--accent-foreground: 0 0% 10%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
|
|
|||
Loading…
Reference in a new issue