Skip to content

Commit 4dcbd90

Browse files
committed
0.79.4 - Fix Tabs components replacing default styles when className is passed
TabsList, TabsTrigger, and TabsContent set the merged className first and then spread ...props (which still contained className) after it, so a consumer-supplied className overwrote the cn() merge and replaced the component's default styling entirely instead of extending it. Destructure className out of props before spreading, matching the Button convention. Consumer classes now merge on top of the defaults via cn()/tailwind-merge (e.g. h-auto overrides the default h-9). Adds a ClassNameMergesWithDefaults regression story with a play() interaction test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpKGc3TCkeEdNXmDSnuVCX
1 parent cc8e67a commit 4dcbd90

3 files changed

Lines changed: 87 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@schemavaults/ui",
3-
"version": "0.79.3",
3+
"version": "0.79.4",
44
"private": false,
55
"license": "UNLICENSED",
66
"description": "React.js UI components for SchemaVaults frontend applications",

src/components/ui/tabs/Tabs.stories.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Meta, StoryObj } from "@storybook/react";
2-
// import { fn } from "storybook/test";
2+
import { expect, waitFor } from "storybook/test";
33

44
import type { ReactElement } from "react";
55
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs";
@@ -96,3 +96,82 @@ type Story = StoryObj<typeof meta>;
9696
export const Example: Story = {
9797
args: {},
9898
};
99+
100+
/**
101+
* Regression test for the `className` override bug.
102+
*
103+
* `TabsList`, `TabsTrigger`, and `TabsContent` used to spread `...props` (which
104+
* still contained `className`) *after* setting the merged `className`, so a
105+
* consumer-supplied `className` replaced the component's default styling
106+
* entirely instead of extending it. For example `<TabsList className="flex-wrap
107+
* h-auto">` rendered with only `flex-wrap h-auto` — no `bg-muted`, no
108+
* `rounded-lg`, no padding.
109+
*
110+
* After destructuring `className` out of `props`, the custom classes are merged
111+
* on top of the defaults via `cn()`/tailwind-merge: non-conflicting defaults
112+
* survive and conflicting ones (e.g. `h-9`) are overridden by the custom class
113+
* (`h-auto`).
114+
*/
115+
export const ClassNameMergesWithDefaults: Story = {
116+
render: (): ReactElement => (
117+
<Tabs defaultValue="account" className="w-[400px]">
118+
<TabsList className="flex-wrap h-auto">
119+
<TabsTrigger value="account" className="uppercase">
120+
Account
121+
</TabsTrigger>
122+
<TabsTrigger value="password">Password</TabsTrigger>
123+
</TabsList>
124+
<TabsContent value="account" className="border">
125+
Account panel
126+
</TabsContent>
127+
<TabsContent value="password">Password panel</TabsContent>
128+
</Tabs>
129+
),
130+
play: async ({ canvasElement }): Promise<void> => {
131+
const list = await waitFor((): HTMLElement => {
132+
const el = canvasElement.querySelector<HTMLElement>('[role="tablist"]');
133+
if (el === null) {
134+
throw new Error("tablist has not rendered yet");
135+
}
136+
return el;
137+
});
138+
139+
// Default TabsList styles survive the custom className...
140+
for (const cls of [
141+
"inline-flex",
142+
"items-center",
143+
"justify-center",
144+
"rounded-lg",
145+
"bg-muted",
146+
"p-1",
147+
"text-muted-foreground",
148+
]) {
149+
expect(list.classList.contains(cls)).toBe(true);
150+
}
151+
// ...the custom classes are added...
152+
expect(list.classList.contains("flex-wrap")).toBe(true);
153+
expect(list.classList.contains("h-auto")).toBe(true);
154+
// ...and the conflicting default (`h-9`) is dropped by tailwind-merge.
155+
expect(list.classList.contains("h-9")).toBe(false);
156+
157+
// TabsTrigger merges the same way.
158+
const trigger = canvasElement.querySelector<HTMLElement>(
159+
'[role="tab"][data-state="active"]',
160+
);
161+
expect(trigger).not.toBeNull();
162+
expect(trigger!.classList.contains("uppercase")).toBe(true);
163+
for (const cls of ["inline-flex", "rounded-md", "text-sm", "font-medium"]) {
164+
expect(trigger!.classList.contains(cls)).toBe(true);
165+
}
166+
167+
// TabsContent merges the same way.
168+
const panel = canvasElement.querySelector<HTMLElement>(
169+
'[role="tabpanel"]',
170+
);
171+
expect(panel).not.toBeNull();
172+
expect(panel!.classList.contains("border")).toBe(true);
173+
for (const cls of ["mt-2", "ring-offset-background"]) {
174+
expect(panel!.classList.contains(cls)).toBe(true);
175+
}
176+
},
177+
};

src/components/ui/tabs/Tabs.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ const Tabs = TabsPrimitive.Root;
99

1010
export interface TabsListProps extends TabsPrimitive.TabsListProps {}
1111

12-
function TabsList(props: TabsListProps): ReactElement {
12+
function TabsList({ className, ...props }: TabsListProps): ReactElement {
1313
return (
1414
<TabsPrimitive.List
1515
className={cn(
1616
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
17-
props.className,
17+
className,
1818
)}
1919
{...props}
2020
/>
@@ -26,12 +26,12 @@ export interface TabsTriggerProps extends TabsPrimitive.TabsTriggerProps {
2626
className?: string;
2727
}
2828

29-
function TabsTrigger(props: TabsTriggerProps): ReactElement {
29+
function TabsTrigger({ className, ...props }: TabsTriggerProps): ReactElement {
3030
return (
3131
<TabsPrimitive.Trigger
3232
className={cn(
3333
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
34-
props.className,
34+
className,
3535
)}
3636
{...props}
3737
/>
@@ -41,12 +41,12 @@ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
4141

4242
export interface TabsContentProps extends TabsPrimitive.TabsContentProps {}
4343

44-
function TabsContent(props: TabsContentProps): ReactElement {
44+
function TabsContent({ className, ...props }: TabsContentProps): ReactElement {
4545
return (
4646
<TabsPrimitive.Content
4747
className={cn(
4848
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
49-
props.className,
49+
className,
5050
)}
5151
{...props}
5252
/>

0 commit comments

Comments
 (0)