Skip to content

Commit fb894c2

Browse files
committed
refactor(ui): migrate CodeTabs from HeadlessUI to Radix UI
1 parent 5a24271 commit fb894c2

File tree

4 files changed

+354
-573
lines changed

4 files changed

+354
-573
lines changed

components/ui/mdx/code-tabs.tsx

Lines changed: 73 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
"use client";
22

3-
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
4-
import { AnimatePresence, motion } from "framer-motion";
3+
import {
4+
Content as TabsContent,
5+
List as TabsList,
6+
Root as TabsRoot,
7+
Trigger as TabsTrigger,
8+
} from "@radix-ui/react-tabs";
9+
import { motion } from "framer-motion";
510
import {
611
Children,
712
type ComponentType,
@@ -83,14 +88,28 @@ export const CodeTabs = ({
8388
onChange?: (index: number) => void;
8489
className?: string;
8590
}) => {
86-
const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
91+
const childrenArray = Children.toArray(children);
92+
const validChildren = childrenArray.filter(isValidElement);
93+
94+
const defaultLabel = validChildren[defaultIndex]
95+
? (validChildren[defaultIndex].props as { label: string }).label
96+
: undefined;
8797

88-
const handleChange = useCallback(
89-
(index: number) => {
90-
setSelectedIndex(index);
91-
onChange?.(index);
98+
const [selectedValue, setSelectedValue] = useState(defaultLabel);
99+
100+
const handleValueChange = useCallback(
101+
(value: string) => {
102+
setSelectedValue(value);
103+
if (onChange) {
104+
const index = validChildren.findIndex(
105+
(child) => (child.props as { label: string }).label === value
106+
);
107+
if (index !== -1) {
108+
onChange(index);
109+
}
110+
}
92111
},
93-
[onChange]
112+
[onChange, validChildren]
94113
);
95114

96115
return (
@@ -100,72 +119,62 @@ export const CodeTabs = ({
100119
className
101120
)}
102121
>
103-
<TabGroup onChange={handleChange} selectedIndex={selectedIndex}>
104-
<TabList
122+
<TabsRoot value={selectedValue} onValueChange={handleValueChange} defaultValue={defaultLabel}>
123+
<TabsList
105124
aria-label="Code language selection"
106125
className="flex space-x-1 border-gray-200 border-b bg-gray-50/50 p-2 dark:border-gray-800 dark:bg-gray-900/50"
107126
>
108-
{Children.map(children, (child) => {
109-
if (!isValidElement(child)) {
110-
return null;
111-
}
127+
{validChildren.map((child) => {
112128
const { label, disabled } = child.props as { label: string; disabled?: boolean };
113129
return (
114-
<Tab
115-
className={({ selected }) =>
116-
cn(
117-
"relative cursor-pointer rounded-lg px-4 py-2 font-medium text-sm outline-none transition-all duration-200",
118-
"disabled:cursor-not-allowed disabled:opacity-50",
119-
selected
120-
? "bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400"
121-
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
122-
)
123-
}
130+
<TabsTrigger
124131
disabled={disabled}
125132
key={label}
126-
>
127-
{() => (
128-
<span className="flex items-center gap-2">
129-
<LanguageIcon label={label} />
130-
{label}
131-
</span>
133+
value={label}
134+
className={cn(
135+
"relative cursor-pointer rounded-lg px-4 py-2 font-medium text-sm outline-none transition-all duration-200",
136+
"disabled:cursor-not-allowed disabled:opacity-50",
137+
"data-[state=active]:bg-blue-50 data-[state=active]:text-blue-600 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-400",
138+
"data-[state=inactive]:text-gray-600 data-[state=inactive]:hover:bg-gray-100 data-[state=inactive]:hover:text-gray-900 dark:data-[state=inactive]:text-gray-400 dark:data-[state=inactive]:hover:bg-gray-800 dark:data-[state=inactive]:hover:text-gray-200"
132139
)}
133-
</Tab>
140+
>
141+
<span className="flex items-center gap-2">
142+
<LanguageIcon label={label} />
143+
{label}
144+
</span>
145+
</TabsTrigger>
134146
);
135147
})}
136-
</TabList>
137-
<TabPanels>
138-
<AnimatePresence mode="wait">
139-
{Children.map(children, (child) => {
140-
if (!isValidElement(child)) {
141-
return null;
142-
}
143-
const { label, children: tabChildren } = child.props as {
144-
label: string;
145-
children: ReactNode;
146-
};
147-
return (
148-
<TabPanel
149-
className={cn(
150-
"p-4 ring-white/60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2",
151-
"bg-gray-50 dark:bg-black/20"
152-
)}
153-
key={label}
154-
>
155-
<motion.div
156-
animate={{ opacity: 1, x: 0 }}
157-
exit={{ opacity: 0, x: 10 }}
158-
initial={{ opacity: 0, x: -10 }}
159-
transition={{ duration: 0.2 }}
160-
>
161-
{tabChildren}
162-
</motion.div>
163-
</TabPanel>
164-
);
165-
})}
166-
</AnimatePresence>
167-
</TabPanels>
168-
</TabGroup>
148+
</TabsList>
149+
150+
{validChildren.map((child) => {
151+
const { label, children: tabChildren } = child.props as {
152+
label: string;
153+
children: ReactNode;
154+
};
155+
return (
156+
<TabsContent
157+
className={cn(
158+
"p-4 ring-white/60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2",
159+
"bg-gray-50 dark:bg-black/20"
160+
)}
161+
key={label}
162+
value={label}
163+
>
164+
<motion.div
165+
animate={{ opacity: 1, x: 0 }}
166+
// Exit animation is removed because Radix unmounts content immediately.
167+
// To support exit animations, we would need to control rendering manually outside of Tabs.Content
168+
// or use forceMount with standard AnimatePresence, but simple entry animation is usually sufficient.
169+
initial={{ opacity: 0, x: -10 }}
170+
transition={{ duration: 0.2 }}
171+
>
172+
{tabChildren}
173+
</motion.div>
174+
</TabsContent>
175+
);
176+
})}
177+
</TabsRoot>
169178
</div>
170179
);
171180
};

next.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const nextConfig = {
5656
compress: true,
5757
reactStrictMode: true,
5858
experimental: {
59-
optimizePackageImports: ["@headlessui/react", "framer-motion", "lucide-react"],
59+
optimizePackageImports: ["framer-motion", "lucide-react"],
6060
serverActions: {
6161
allowedOrigins: ["eternalcode.pl", "www.eternalcode.pl"],
6262
bodySizeLimit: "5mb",

0 commit comments

Comments
 (0)