From c89d06dd7cad00a9357ba45316505f1dd22a3af4 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Thu, 20 Aug 2026 22:01:33 +0200 Subject: [PATCH 1/2] feat: slide the segmented control background to the active option --- .../tracker/ImplementationTracker.tsx | 158 +++++++----------- src/components/tracker/SegmentedControl.tsx | 110 ++++++++++++ 2 files changed, 174 insertions(+), 94 deletions(-) create mode 100644 src/components/tracker/SegmentedControl.tsx diff --git a/src/components/tracker/ImplementationTracker.tsx b/src/components/tracker/ImplementationTracker.tsx index 02bdb3b..66dc469 100644 --- a/src/components/tracker/ImplementationTracker.tsx +++ b/src/components/tracker/ImplementationTracker.tsx @@ -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; @@ -123,23 +124,16 @@ export default function ImplementationTracker() {

-
- setProgressMetric("surface")} - > - Surface area - - setProgressMetric("classes")} - > - Behaviors - -
+
@@ -185,21 +179,40 @@ export default function ImplementationTracker() { {/* Controls */}
- {/* Tabs */} -
- setTab("blocks")}> - - Blocks - - setTab("items")}> - - Items - - setTab("entities")}> - - Entities - -
+ + + Blocks + + ), + }, + { + value: "items", + content: ( + <> + + Items + + ), + }, + { + value: "entities", + content: ( + <> + + Entities + + ), + }, + ]} + /> {/* Search */}
@@ -213,22 +226,25 @@ export default function ImplementationTracker() { />
- {/* Status filter */} -
- setStatusFilter("all")}> - - All - - setStatusFilter("complete")}> - Complete - - setStatusFilter("partial")}> - Partial - - setStatusFilter("unimplemented")}> - Todo - -
+ + + All + + ), + }, + { value: "complete", content: "Complete" }, + { value: "partial", content: "Partial" }, + { value: "unimplemented", content: "Todo" }, + ]} + />
{/* Results count */} @@ -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 ( - - ); -} -function MetricButton({ - active, - onClick, - children, -}: { - active: boolean; - onClick: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} diff --git a/src/components/tracker/SegmentedControl.tsx b/src/components/tracker/SegmentedControl.tsx new file mode 100644 index 0000000..37e20d2 --- /dev/null +++ b/src/components/tracker/SegmentedControl.tsx @@ -0,0 +1,110 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; + +export interface Segment { + value: T; + content: ReactNode; +} + +interface Props { + label: string; + segments: Segment[]; + 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({ + label, + segments, + value, + onChange, + size = "md", +}: Props) { + const buttons = useRef(new Map()); + const wrapper = useRef(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 ( +
+ {indicator && ( +
+ ); +} From b735c173ab14d21b1a67981917422739445086b4 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Fri, 21 Aug 2026 09:44:30 +0200 Subject: [PATCH 2/2] feat: overshoot the segmented control indicator on its way to the active option --- src/components/tracker/SegmentedControl.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/tracker/SegmentedControl.tsx b/src/components/tracker/SegmentedControl.tsx index 37e20d2..d41feed 100644 --- a/src/components/tracker/SegmentedControl.tsx +++ b/src/components/tracker/SegmentedControl.tsx @@ -65,7 +65,7 @@ export default function SegmentedControl({ ref={wrapper} role="tablist" aria-label={label} - className={`relative flex shrink-0 bg-teal-100 dark:bg-white/5 border border-teal-200/40 dark:border-white/10 ${styles.wrapper}`} + 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 && ( ({ transform: `translateX(${indicator.left}px)`, width: `${indicator.width}px`, transitionProperty: "transform, width", - transitionDuration: reducedMotion ? "0ms" : "280ms", - transitionTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)", + // 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)", }} /> )}