WFDASH-WEBAPP/src/components/filter-tabs.tsx
SJCoetz cdaa2e2b25 fix, feat, style: standardize quote numbers, polish UI
- Standardize quote number format from Q-YYYY-MM-NNN to QYYYYMMNNN across all client, server, test and API code
- Add atomic backfill logic for quote counters to avoid sequence collisions with existing records
- Rearrange Delivery Notes and Repairs to the Operations sidebar section
- Add supplier count badge to pricelist catalogue and improve filter tab overflow styling
- Optimize font loading in root layout to skip preloading unused font weights
- Improve table styling for customer and company pages with better text wrapping and empty states
- Fix rep display logic in quotes page to show "Unassigned" when no rep is assigned
- Add supporting documentation, PocketBase counter schemas and setup migration scripts
- Remove embedded SupplierMap from suppliers page for cleaner layout
- Improve invoice card layout for company and customer info spacing
- Improve filter label styling and accessibility in collection map page
2026-07-10 22:21:50 +02:00

160 lines
No EOL
5.7 KiB
TypeScript

"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}
data-overflow={tabs.length > 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 (
<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(
"group inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium shrink-0 whitespace-nowrap",
"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>
);
}