"use client"; import { useRef, type KeyboardEvent } from "react"; import { cn } from "@/lib/utils"; import { withAlpha } from "@/lib/item-status-ui"; export interface FilterTab { /** * Stable identifier — also the value `onChange` emits when this tab * is selected. Must be unique within a single `FilterTabs` instance. */ key: string; /** Human-friendly label rendered inside the pill. */ label: string; /** Hex colour (e.g. `#F2AF58`) used for the dot, fill, and shadows. */ color: string; /** * Count shown on the right of the label. Omit (or pass undefined) to * hide the badge — useful when the count isn't meaningful. */ count?: number; } interface FilterTabsProps { tabs: FilterTab[]; active: string; onChange: (key: string) => void; /** * Required for screen-reader announcement. Convention: * - /orders → "Filter orders by item status" * - /sprint → "Filter sprint items by category" */ ariaLabel: string; className?: string; } /** * Generic filter pill bar — one pill per `FilterTab`. Built as a proper * ARIA tablist (role=tablist / role=tab / aria-selected) with arrow-key * navigation matching the WAI-ARIA tabs-with-automatic-activation pattern. * * Roving tabindex (only the active tab is in the natural tab order; * arrow keys move focus + selection together) — same approach as the * /orders ItemStatusTabs before this refactor. * * Visual treatment: * - Inactive: muted bg, color dot, label, count badge in fg/10 * - Active: solid color fill, white text, count badge in white/20, * small drop shadow + ring at the color * * Caller is responsible for any "hide empty" filtering — drop those tabs * before passing them in. The component is otherwise a pure projection * of `tabs` and `active`. * * Replaces the role-specific `ItemStatusTabs` — the orders page now * calls this directly with the TabGroup data, and the new /sprint page * will call it with category data. */ export function FilterTabs({ tabs, active, onChange, ariaLabel, className, }: FilterTabsProps) { const refs = useRef<(HTMLButtonElement | null)[]>([]); const handleKey = (e: KeyboardEvent, idx: number) => { let nextIdx: number | null = null; const last = tabs.length - 1; if (e.key === "ArrowRight") nextIdx = idx === last ? 0 : idx + 1; else if (e.key === "ArrowLeft") nextIdx = idx === 0 ? last : idx - 1; else if (e.key === "Home") nextIdx = 0; else if (e.key === "End") nextIdx = last; if (nextIdx === null) return; e.preventDefault(); onChange(tabs[nextIdx].key); refs.current[nextIdx]?.focus(); }; return (
3 ? "scroll" : "wrap"} className={cn( // Mobile: horizontally scrollable single row so long category // names ("Husqvarna — Garden & Forestry") don't wrap onto a // second line and shove the list down. Desktop: flex-wrap so // the tabs fill the available width like a normal tab bar. "flex gap-2 overflow-x-auto sm:flex-wrap sm:overflow-visible", // Hide the scrollbar on mobile but keep it scrollable. "[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", // Right-edge fade mask — only on mobile scroll mode, and only // when content is actually clipped. Tells the user "more →" // exists when they can't see all the tabs. "sm:[mask-image:none] [mask-image:linear-gradient(to_right,black_calc(100%-24px),transparent)]", className, )} > {tabs.map((tab, idx) => { const isActive = active === tab.key; const count = tab.count ?? 0; const showBadge = tab.count !== undefined; return ( ); })}
); }