forked from jordanlambrecht/tracker-tracker
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTabBar.tsx
More file actions
116 lines (101 loc) · 3.25 KB
/
Copy pathTabBar.tsx
File metadata and controls
116 lines (101 loc) · 3.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// src/components/ui/TabBar.tsx
"use client"
import { cva } from "class-variance-authority"
import clsx from "clsx"
import { useEffect, useRef, useState } from "react"
const track = cva("relative flex bg-control-bg", {
variants: {
size: {
default: "nm-inset gap-2 p-2 rounded-nm-md",
compact: "nm-inset-sm gap-0.5 p-1 w-fit rounded-nm-sm",
},
},
defaultVariants: { size: "default" },
})
const pill = cva("absolute z-[1] bg-raised pointer-events-none", {
variants: {
size: {
default: "top-2.5 bottom-2.5 nm-raised-sm rounded-[4px]",
compact:
"top-[3px] bottom-1 bg-overlay rounded-[4px] ring-1 ring-black/[0.15] shadow-[2px_0_4px_-2px_rgba(0,0,0,0.4),-2px_0_4px_-2px_rgba(0,0,0,0.4)]",
},
},
defaultVariants: { size: "default" },
})
const tab = cva("relative z-10 transition-colors duration-150 cursor-pointer rounded-nm-sm", {
variants: {
size: {
default: "flex-1 px-4 py-2.5 text-sm font-sans font-medium",
compact: "px-2.5 py-1 text-xs font-mono",
},
active: {
true: "text-primary font-semibold",
false: "text-tertiary hover:text-secondary",
},
},
defaultVariants: { size: "default", active: false },
})
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface Tab<T extends string> {
key: T
label: string
}
interface TabBarProps<T extends string> {
tabs: Tab<T>[]
activeTab: T
onChange: (tab: T) => void
compact?: boolean
}
function TabBar<T extends string>({ tabs, activeTab, onChange, compact = false }: TabBarProps<T>) {
const containerRef = useRef<HTMLDivElement>(null)
const buttonRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
const [pillPos, setPillPos] = useState<{ left: number; width: number } | null>(null)
const size = compact ? "compact" : "default"
// biome-ignore lint/correctness/useExhaustiveDependencies: refs don't need to be deps
useEffect(() => {
const container = containerRef.current
const button = buttonRefs.current.get(activeTab)
if (!container || !button) return
const containerRect = container.getBoundingClientRect()
const buttonRect = button.getBoundingClientRect()
setPillPos({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
})
}, [activeTab, tabs])
return (
<div ref={containerRef} className={track({ size })} role="tablist">
{/* Sliding pill */}
{pillPos && (
<div
className={pill({ size })}
style={{
left: pillPos.left,
width: pillPos.width,
transition:
"left 250ms cubic-bezier(0.4, 0, 0.2, 1), width 250ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
)}
{tabs.map((t) => (
<button
key={t.key}
ref={(el) => {
if (el) buttonRefs.current.set(t.key, el)
}}
type="button"
onClick={() => onChange(t.key)}
className={clsx(tab({ size, active: activeTab === t.key }))}
aria-selected={activeTab === t.key}
role="tab"
>
{t.label}
</button>
))}
</div>
)
}
export type { TabBarProps }
export { TabBar }