Skip to content

Commit 7c4f3c3

Browse files
samejr0ski
authored andcommitted
ClientTabs now mirror the Tabs component
1 parent 73b2b0b commit 7c4f3c3

1 file changed

Lines changed: 164 additions & 20 deletions

File tree

Lines changed: 164 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,183 @@
11
"use client";
22

3+
import { motion } from "framer-motion";
34
import * as TabsPrimitive from "@radix-ui/react-tabs";
45
import * as React from "react";
56
import { cn } from "~/utils/cn";
7+
import { type Variants } from "./Tabs";
8+
9+
type ClientTabsContextValue = {
10+
value?: string;
11+
};
12+
13+
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
14+
15+
function useClientTabsContext() {
16+
return React.useContext(ClientTabsContext);
17+
}
618

719
const ClientTabs = React.forwardRef<
820
React.ElementRef<typeof TabsPrimitive.Root>,
921
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
10-
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
22+
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
23+
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
24+
25+
React.useEffect(() => {
26+
if (valueProp !== undefined) {
27+
setValue(valueProp);
28+
}
29+
}, [valueProp]);
30+
31+
const handleValueChange = React.useCallback(
32+
(nextValue: string) => {
33+
if (valueProp === undefined) {
34+
setValue(nextValue);
35+
}
36+
onValueChange?.(nextValue);
37+
},
38+
[onValueChange, valueProp]
39+
);
40+
41+
const controlledProps =
42+
valueProp !== undefined
43+
? { value: valueProp }
44+
: defaultValue !== undefined
45+
? { defaultValue }
46+
: {};
47+
48+
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
49+
50+
return (
51+
<ClientTabsContext.Provider value={contextValue}>
52+
<TabsPrimitive.Root
53+
ref={ref}
54+
onValueChange={handleValueChange}
55+
{...controlledProps}
56+
{...props}
57+
/>
58+
</ClientTabsContext.Provider>
59+
);
60+
});
1161
ClientTabs.displayName = TabsPrimitive.Root.displayName;
1262

1363
const ClientTabsList = React.forwardRef<
1464
React.ElementRef<typeof TabsPrimitive.List>,
15-
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
16-
>(({ className, ...props }, ref) => (
17-
<TabsPrimitive.List
18-
ref={ref}
19-
className={cn("inline-flex items-center justify-center transition duration-100", className)}
20-
{...props}
21-
/>
22-
));
65+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
66+
variant?: Variants;
67+
}
68+
>(({ className, variant = "pipe-divider", ...props }, ref) => {
69+
const variantClassName = (() => {
70+
switch (variant) {
71+
case "segmented":
72+
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
73+
case "underline":
74+
return "flex gap-x-6 border-b border-grid-bright";
75+
default:
76+
return "inline-flex items-center justify-center transition duration-100";
77+
}
78+
})();
79+
80+
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
81+
});
2382
ClientTabsList.displayName = TabsPrimitive.List.displayName;
2483

2584
const ClientTabsTrigger = React.forwardRef<
2685
React.ElementRef<typeof TabsPrimitive.Trigger>,
27-
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
28-
>(({ className, ...props }, ref) => (
29-
<TabsPrimitive.Trigger
30-
ref={ref}
31-
className={cn(
32-
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
33-
className
34-
)}
35-
{...props}
36-
/>
37-
));
86+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
87+
variant?: Variants;
88+
layoutId?: string;
89+
}
90+
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
91+
const context = useClientTabsContext();
92+
const activeValue = context?.value;
93+
const isActive = activeValue === props.value;
94+
95+
if (variant === "segmented") {
96+
return (
97+
<TabsPrimitive.Trigger
98+
ref={ref}
99+
className={cn(
100+
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
101+
"flex-1 basis-0",
102+
className
103+
)}
104+
{...props}
105+
>
106+
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
107+
<span
108+
className={cn(
109+
"text-sm transition duration-200",
110+
isActive ? "text-text-bright" : "text-text-dimmed transition hover:text-text-bright"
111+
)}
112+
>
113+
{children}
114+
</span>
115+
</div>
116+
{isActive ? (
117+
layoutId ? (
118+
<motion.div
119+
layoutId={layoutId}
120+
transition={{ duration: 0.4, type: "spring" }}
121+
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
122+
/>
123+
) : (
124+
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
125+
)
126+
) : null}
127+
</TabsPrimitive.Trigger>
128+
);
129+
}
130+
131+
if (variant === "underline") {
132+
return (
133+
<TabsPrimitive.Trigger
134+
ref={ref}
135+
className={cn(
136+
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
137+
className
138+
)}
139+
{...props}
140+
>
141+
<span
142+
className={cn(
143+
"text-sm transition duration-200",
144+
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
145+
)}
146+
>
147+
{children}
148+
</span>
149+
{layoutId ? (
150+
isActive ? (
151+
<motion.div
152+
layoutId={layoutId}
153+
transition={{ type: "spring", stiffness: 500, damping: 30 }}
154+
className="mt-1 h-0.5 w-full bg-indigo-500"
155+
/>
156+
) : (
157+
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
158+
)
159+
) : isActive ? (
160+
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
161+
) : (
162+
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
163+
)}
164+
</TabsPrimitive.Trigger>
165+
);
166+
}
167+
168+
return (
169+
<TabsPrimitive.Trigger
170+
ref={ref}
171+
className={cn(
172+
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
173+
className
174+
)}
175+
{...props}
176+
>
177+
{children}
178+
</TabsPrimitive.Trigger>
179+
);
180+
});
38181
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39182

40183
const ClientTabsContent = React.forwardRef<
@@ -60,6 +203,7 @@ export type TabsProps = {
60203
currentValue: string;
61204
className?: string;
62205
layoutId: string;
206+
variant?: Variants;
63207
};
64208

65209
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };

0 commit comments

Comments
 (0)