2026-07-03 05:29:43 +00:00
|
|
|
"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<HTMLButtonElement>, 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 (
|
|
|
|
|
<div
|
|
|
|
|
role="tablist"
|
|
|
|
|
aria-label={ariaLabel}
|
2026-07-10 20:21:50 +00:00
|
|
|
data-overflow={tabs.length > 3 ? "scroll" : "wrap"}
|
2026-07-07 15:22:03 +00:00
|
|
|
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]",
|
2026-07-10 20:21:50 +00:00
|
|
|
// 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)]",
|
2026-07-07 15:22:03 +00:00
|
|
|
className,
|
|
|
|
|
)}
|
2026-07-03 05:29:43 +00:00
|
|
|
>
|
|
|
|
|
{tabs.map((tab, idx) => {
|
|
|
|
|
const isActive = active === tab.key;
|
|
|
|
|
const count = tab.count ?? 0;
|
|
|
|
|
const showBadge = tab.count !== undefined;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={tab.key}
|
|
|
|
|
ref={(el) => {
|
|
|
|
|
refs.current[idx] = el;
|
|
|
|
|
}}
|
|
|
|
|
type="button"
|
|
|
|
|
role="tab"
|
|
|
|
|
aria-selected={isActive}
|
|
|
|
|
tabIndex={isActive ? 0 : -1}
|
|
|
|
|
onClick={() => onChange(tab.key)}
|
|
|
|
|
onKeyDown={(e) => handleKey(e, idx)}
|
|
|
|
|
className={cn(
|
2026-07-07 15:22:03 +00:00
|
|
|
"group inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium shrink-0 whitespace-nowrap",
|
2026-07-03 05:29:43 +00:00
|
|
|
"transition-[background-color,box-shadow,transform] duration-150",
|
|
|
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
|
|
|
!isActive &&
|
|
|
|
|
"bg-muted/50 text-foreground/70 ring-1 ring-inset ring-border/40 hover:bg-muted hover:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
style={
|
|
|
|
|
isActive
|
|
|
|
|
? {
|
|
|
|
|
background: tab.color,
|
|
|
|
|
color: "white",
|
|
|
|
|
boxShadow: `0 1px 2px ${withAlpha(tab.color, 25)}, 0 0 0 1px ${withAlpha(tab.color, 40)}`,
|
|
|
|
|
}
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{!isActive && (
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="h-2 w-2 shrink-0 rounded-full"
|
|
|
|
|
style={{ background: tab.color }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<span>{tab.label}</span>
|
|
|
|
|
{showBadge && (
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className={cn(
|
|
|
|
|
"min-w-[1.5rem] rounded-full px-1.5 text-center text-xs tabular-nums leading-5",
|
|
|
|
|
isActive
|
|
|
|
|
? "bg-white/20 text-white"
|
|
|
|
|
: "bg-foreground/10 text-foreground/70",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{count}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|