82 lines
2.7 KiB
TypeScript
82 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import type { CategoryDTO } from "@/types/board";
|
|
|
|
/**
|
|
* Mobile (< md) category rail: one chip per lane, shown under the top bar.
|
|
* Doubles as the board's position indicator while swiping between lanes
|
|
* (the active chip is highlighted, driven by the board's
|
|
* IntersectionObserver) and as quick navigation -- tapping a chip glides
|
|
* the lane pager to that lane.
|
|
*/
|
|
export function CategoryChips({
|
|
categories,
|
|
activeCategoryId,
|
|
onSelect,
|
|
}: {
|
|
categories: CategoryDTO[];
|
|
activeCategoryId: string | null;
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
|
|
// Keep the active chip in view if the user swipes several lanes at once
|
|
// (or a chip gets added/removed out of the strip's view).
|
|
useEffect(() => {
|
|
if (!activeCategoryId) return;
|
|
chipRefs.current
|
|
.get(activeCategoryId)
|
|
?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
|
|
}, [activeCategoryId]);
|
|
|
|
return (
|
|
<div
|
|
// -mx-3/px-3 bleeds the strip to the screen edges (the board's own
|
|
// p-3) so chips start and end flush with the viewport, the way a
|
|
// native tab strip does.
|
|
className="-mx-3 flex gap-2 overflow-x-auto px-3 pb-1 overscroll-x-contain"
|
|
>
|
|
{categories.map((category) => {
|
|
const active = category.id === activeCategoryId;
|
|
const openTodos = category.groups.reduce(
|
|
(n, g) => n + g.todos.filter((t) => !t.completed).length,
|
|
0
|
|
);
|
|
|
|
return (
|
|
<button
|
|
key={category.id}
|
|
ref={(el) => {
|
|
if (el) chipRefs.current.set(category.id, el);
|
|
else chipRefs.current.delete(category.id);
|
|
}}
|
|
type="button"
|
|
onClick={() => onSelect(category.id)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[13px] font-medium transition-colors",
|
|
active
|
|
? "border-transparent bg-primary text-primary-foreground shadow-sm"
|
|
: "border-border bg-lane text-muted-foreground active:bg-accent active:text-accent-foreground"
|
|
)}
|
|
>
|
|
{category.name}
|
|
<span
|
|
className={cn(
|
|
"rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
|
|
active ? "bg-primary-foreground/20" : "bg-foreground/8"
|
|
)}
|
|
aria-label={`${openTodos} open to-dos`}
|
|
>
|
|
{openTodos}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|