Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 64 additions & 94 deletions src/components/tracker/ImplementationTracker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from "react";
import { Search, ChevronDown, Blocks, Sword, Filter, PawPrint } from "lucide-react";
import SegmentedControl from "./SegmentedControl";

interface ClassGroup {
implemented: boolean;
Expand Down Expand Up @@ -123,23 +124,16 @@ export default function ImplementationTracker() {
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div
className="flex rounded-lg bg-teal-100 dark:bg-white/5 border border-teal-200/40 dark:border-white/10 p-0.5"
aria-label="Progress calculation"
>
<MetricButton
active={progressMetric === "surface"}
onClick={() => setProgressMetric("surface")}
>
Surface area
</MetricButton>
<MetricButton
active={progressMetric === "classes"}
onClick={() => setProgressMetric("classes")}
>
Behaviors
</MetricButton>
</div>
<SegmentedControl
label="Progress calculation"
size="sm"
value={progressMetric}
onChange={setProgressMetric}
segments={[
{ value: "surface", content: "Surface area" },
{ value: "classes", content: "Behaviors" },
]}
/>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<span className="inline-block size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
Expand Down Expand Up @@ -185,21 +179,40 @@ export default function ImplementationTracker() {

{/* Controls */}
<div className="flex flex-col md:flex-row gap-3 mb-6">
{/* Tabs */}
<div className="flex rounded-xl bg-teal-100 dark:bg-white/5 border border-teal-200/40 dark:border-white/10 p-1">
<TabButton active={tab === "blocks"} onClick={() => setTab("blocks")}>
<Blocks className="size-3.5" />
Blocks
</TabButton>
<TabButton active={tab === "items"} onClick={() => setTab("items")}>
<Sword className="size-3.5" />
Items
</TabButton>
<TabButton active={tab === "entities"} onClick={() => setTab("entities")}>
<PawPrint className="size-3.5" />
Entities
</TabButton>
</div>
<SegmentedControl
label="Category"
value={tab}
onChange={setTab}
segments={[
{
value: "blocks",
content: (
<>
<Blocks className="size-3.5" />
Blocks
</>
),
},
{
value: "items",
content: (
<>
<Sword className="size-3.5" />
Items
</>
),
},
{
value: "entities",
content: (
<>
<PawPrint className="size-3.5" />
Entities
</>
),
},
]}
/>

{/* Search */}
<div className="relative flex-1">
Expand All @@ -213,22 +226,25 @@ export default function ImplementationTracker() {
/>
</div>

{/* Status filter */}
<div className="flex rounded-xl bg-teal-100 dark:bg-white/5 border border-teal-200/40 dark:border-white/10 p-1">
<TabButton active={statusFilter === "all"} onClick={() => setStatusFilter("all")}>
<Filter className="size-3.5" />
All
</TabButton>
<TabButton active={statusFilter === "complete"} onClick={() => setStatusFilter("complete")}>
Complete
</TabButton>
<TabButton active={statusFilter === "partial"} onClick={() => setStatusFilter("partial")}>
Partial
</TabButton>
<TabButton active={statusFilter === "unimplemented"} onClick={() => setStatusFilter("unimplemented")}>
Todo
</TabButton>
</div>
<SegmentedControl
label="Status filter"
value={statusFilter}
onChange={setStatusFilter}
segments={[
{
value: "all",
content: (
<>
<Filter className="size-3.5" />
All
</>
),
},
{ value: "complete", content: "Complete" },
{ value: "partial", content: "Partial" },
{ value: "unimplemented", content: "Todo" },
]}
/>
</div>

{/* Results count */}
Expand Down Expand Up @@ -364,50 +380,4 @@ function StatCard({ label, value, color = "default" }: { label: string; value: n
);
}

function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all cursor-pointer ${
active
? "bg-white dark:bg-white/10 text-teal-950 dark:text-white shadow-sm"
: "text-teal-600 dark:text-white/50 hover:text-teal-950 dark:hover:text-white"
}`}
>
{children}
</button>
);
}

function MetricButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-all cursor-pointer ${
active
? "bg-white dark:bg-white/10 text-teal-950 dark:text-white shadow-sm"
: "text-teal-600 dark:text-white/45 hover:text-teal-950 dark:hover:text-white"
}`}
>
{children}
</button>
);
}
114 changes: 114 additions & 0 deletions src/components/tracker/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

export interface Segment<T extends string> {
value: T;
content: ReactNode;
}

interface Props<T extends string> {
label: string;
segments: Segment<T>[];
value: T;
onChange: (value: T) => void;
size?: "md" | "sm";
}

const SIZES = {
md: {
wrapper: "p-1 rounded-xl",
button: "gap-1.5 px-3 py-1.5 text-sm rounded-lg",
indicator: "rounded-lg",
},
sm: {
wrapper: "p-0.5 rounded-lg",
button: "gap-1 px-2.5 py-1 text-xs rounded-md",
indicator: "rounded-md",
},
};

export default function SegmentedControl<T extends string>({
label,
segments,
value,
onChange,
size = "md",
}: Props<T>) {
const buttons = useRef(new Map<T, HTMLButtonElement>());
const wrapper = useRef<HTMLDivElement>(null);
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
const styles = SIZES[size];

// global.css sets an unlayered `transition` on div/span/button, which outranks any
// utility class, so the sliding transition has to be declared inline.
const reducedMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// The indicator is positioned from the active button's box, so it keeps up with
// label changes, font loading and container resizes rather than a fixed width.
useLayoutEffect(() => {
const measure = () => {
const active = buttons.current.get(value);
if (!active) return;
setIndicator({ left: active.offsetLeft, width: active.offsetWidth });
};
measure();

const observer = new ResizeObserver(measure);
if (wrapper.current) observer.observe(wrapper.current);
for (const button of buttons.current.values()) observer.observe(button);
return () => observer.disconnect();
}, [value, segments]);

return (
<div
ref={wrapper}
role="tablist"
aria-label={label}
className={`relative flex shrink-0 overflow-hidden bg-teal-100 dark:bg-white/5 border border-teal-200/40 dark:border-white/10 ${styles.wrapper}`}
>
{indicator && (
<span
aria-hidden="true"
className={`absolute left-0 bg-white dark:bg-white/10 shadow-sm ${styles.indicator}`}
style={{
top: size === "sm" ? 2 : 4,
bottom: size === "sm" ? 2 : 4,
transform: `translateX(${indicator.left}px)`,
width: `${indicator.width}px`,
transitionProperty: "transform, width",
// The slide overshoots slightly then settles; the width stays on a plain
// ease-out so the pill does not visibly stretch past its target.
transitionDuration: reducedMotion ? "0ms, 0ms" : "420ms, 260ms",
transitionTimingFunction: reducedMotion
? "linear, linear"
: "cubic-bezier(0.34, 1.35, 0.5, 1), cubic-bezier(0.22, 1, 0.36, 1)",
}}
/>
)}

{segments.map((segment) => (
<button
key={segment.value}
ref={(node) => {
if (node) buttons.current.set(segment.value, node);
else buttons.current.delete(segment.value);
}}
type="button"
role="tab"
aria-selected={segment.value === value}
onClick={() => onChange(segment.value)}
className={`relative z-10 flex items-center font-medium cursor-pointer transition-colors ${
styles.button
} ${
segment.value === value
? "text-teal-950 dark:text-white"
: "text-teal-600 dark:text-white/50 hover:text-teal-950 dark:hover:text-white"
}`}
>
{segment.content}
</button>
))}
</div>
);
}