@purpurds/tabs 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LICENSE.txt +201 -0
- package/dist/__mocks__/intersectionObserverMock.d.ts +2 -0
- package/dist/__mocks__/intersectionObserverMock.d.ts.map +1 -0
- package/dist/styles.css +1 -0
- package/dist/tab-content.d.ts +21 -0
- package/dist/tab-content.d.ts.map +1 -0
- package/dist/tab-header.d.ts +13 -0
- package/dist/tab-header.d.ts.map +1 -0
- package/dist/tabs.cjs.js +10 -0
- package/dist/tabs.cjs.js.map +1 -0
- package/dist/tabs.d.ts +17 -0
- package/dist/tabs.d.ts.map +1 -0
- package/dist/tabs.es.js +988 -0
- package/dist/tabs.es.js.map +1 -0
- package/dist/tabs.system.js +10 -0
- package/dist/tabs.system.js.map +1 -0
- package/dist/tabs.utils.d.ts +12 -0
- package/dist/tabs.utils.d.ts.map +1 -0
- package/package.json +62 -0
- package/readme.mdx +68 -0
- package/src/__mocks__/intersectionObserverMock.ts +8 -0
- package/src/global.d.ts +4 -0
- package/src/tab-content.module.scss +20 -0
- package/src/tab-content.stories.tsx +43 -0
- package/src/tab-content.test.tsx +40 -0
- package/src/tab-content.tsx +67 -0
- package/src/tab-header.module.scss +121 -0
- package/src/tab-header.tsx +37 -0
- package/src/tabs.module.scss +158 -0
- package/src/tabs.stories.tsx +81 -0
- package/src/tabs.test.tsx +163 -0
- package/src/tabs.tsx +253 -0
- package/src/tabs.utils.ts +14 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
|
|
4
|
+
import { Tabs } from "./tabs";
|
|
5
|
+
import { tabsVariants } from "./tabs.utils";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof Tabs> = {
|
|
8
|
+
title: "Components/Tabs",
|
|
9
|
+
component: Tabs,
|
|
10
|
+
parameters: {
|
|
11
|
+
design: [
|
|
12
|
+
{
|
|
13
|
+
name: "Tabs",
|
|
14
|
+
type: "figma",
|
|
15
|
+
url: "https://www.figma.com/file/XEaIIFskrrxIBHMZDkIuIg/Purpur-DS---Component-library-%26-guidelines?type=design&node-id=2942-430&mode=design&t=zoKkl2HAtk3UuKoP-0",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
variant: { control: "select", options: tabsVariants },
|
|
21
|
+
["data-testid"]: { control: { type: "text" } },
|
|
22
|
+
className: { control: { type: "text" } },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
|
|
28
|
+
type Story = StoryObj<typeof Tabs>;
|
|
29
|
+
|
|
30
|
+
const tabId = "tab";
|
|
31
|
+
const name = "Tab name";
|
|
32
|
+
|
|
33
|
+
export const Showcase: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
variant: tabsVariants[0],
|
|
36
|
+
fullWidth: false,
|
|
37
|
+
children: [
|
|
38
|
+
<Tabs.Content
|
|
39
|
+
key="1"
|
|
40
|
+
tabId={`${tabId}-1`}
|
|
41
|
+
name={`${name}-1`}
|
|
42
|
+
style={{ padding: "var(--purpur-spacing-250" }}
|
|
43
|
+
>
|
|
44
|
+
<div>Content 1</div>
|
|
45
|
+
</Tabs.Content>,
|
|
46
|
+
<Tabs.Content
|
|
47
|
+
key="2"
|
|
48
|
+
tabId={`${tabId}-2`}
|
|
49
|
+
name={`${name}-2`}
|
|
50
|
+
style={{ padding: "var(--purpur-spacing-250" }}
|
|
51
|
+
>
|
|
52
|
+
<div>Content 2</div>
|
|
53
|
+
</Tabs.Content>,
|
|
54
|
+
<Tabs.Content
|
|
55
|
+
key="3"
|
|
56
|
+
tabId={`${tabId}-3`}
|
|
57
|
+
name={`${name}-3`}
|
|
58
|
+
style={{ padding: "var(--purpur-spacing-250" }}
|
|
59
|
+
>
|
|
60
|
+
<div>Content 3</div>
|
|
61
|
+
</Tabs.Content>,
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
render: ({ children, ...args }) => (
|
|
65
|
+
<div
|
|
66
|
+
style={{
|
|
67
|
+
padding: "var(--purpur-spacing-250)",
|
|
68
|
+
background: args.variant?.includes("negative")
|
|
69
|
+
? "var(--purpur-color-purple-900)"
|
|
70
|
+
: args.variant === "contained"
|
|
71
|
+
? "var(--purpur-color-gray-50)"
|
|
72
|
+
: "transparent",
|
|
73
|
+
color: `var(--purpur-color-text-default${
|
|
74
|
+
args.variant === "line-negative" ? "-negative" : ""
|
|
75
|
+
}`,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Tabs {...args}>{children}</Tabs>
|
|
79
|
+
</div>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import * as matchers from "@testing-library/jest-dom/matchers";
|
|
3
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { Tabs } from "./tabs";
|
|
7
|
+
import { TabContent } from "./tab-content";
|
|
8
|
+
import "./__mocks__/intersectionObserverMock";
|
|
9
|
+
|
|
10
|
+
const rootClassName = "purpur-tabs";
|
|
11
|
+
|
|
12
|
+
expect.extend(matchers);
|
|
13
|
+
|
|
14
|
+
describe("Tabs", () => {
|
|
15
|
+
afterEach(cleanup);
|
|
16
|
+
|
|
17
|
+
it("should render plain", () => {
|
|
18
|
+
render(
|
|
19
|
+
<Tabs data-testid="tabs-test">
|
|
20
|
+
<TabContent tabId="tab-1" name="Tab name 1">
|
|
21
|
+
<div>Content</div>
|
|
22
|
+
</TabContent>
|
|
23
|
+
<TabContent tabId="tab-2" name="Tab name 2">
|
|
24
|
+
<div>Content</div>
|
|
25
|
+
</TabContent>
|
|
26
|
+
</Tabs>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const tab = screen.getByTestId("tabs-test");
|
|
30
|
+
expect(tab).toBeInTheDocument();
|
|
31
|
+
expect(tab.classList.value).toContain(rootClassName);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should render header and content for each tab", () => {
|
|
35
|
+
const testId = "tab-content";
|
|
36
|
+
|
|
37
|
+
render(
|
|
38
|
+
<Tabs>
|
|
39
|
+
<TabContent tabId="tab-1" name="Tab name 1" data-testid={testId}>
|
|
40
|
+
<div>Content</div>
|
|
41
|
+
</TabContent>
|
|
42
|
+
<TabContent tabId="tab-2" name="Tab name 2" data-testid={testId}>
|
|
43
|
+
<div>Content</div>
|
|
44
|
+
</TabContent>
|
|
45
|
+
<TabContent tabId="tab-3" name="Tab name 3" data-testid={testId}>
|
|
46
|
+
<div>Content</div>
|
|
47
|
+
</TabContent>
|
|
48
|
+
</Tabs>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(screen.getAllByTestId(testId).length).toEqual(3);
|
|
52
|
+
expect(screen.getAllByTestId(`${testId}-header`).length).toEqual(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should not render children that are not TabContent components", () => {
|
|
56
|
+
const testId = "tab-content";
|
|
57
|
+
|
|
58
|
+
render(
|
|
59
|
+
<Tabs>
|
|
60
|
+
<h1 data-testid={testId}>Heading</h1>
|
|
61
|
+
<TabContent tabId="tab-1" name="Tab name 1" data-testid={testId}>
|
|
62
|
+
<div>Content</div>
|
|
63
|
+
</TabContent>
|
|
64
|
+
<TabContent tabId="tab-2" name="Tab name 2" data-testid={testId}>
|
|
65
|
+
<div>Content</div>
|
|
66
|
+
</TabContent>
|
|
67
|
+
<p data-testid={testId}>Paragraph</p>
|
|
68
|
+
</Tabs>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(screen.getAllByTestId(testId).length).toEqual(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should render two scroll buttons", () => {
|
|
75
|
+
render(
|
|
76
|
+
<Tabs data-testid="tabs-test">
|
|
77
|
+
<TabContent tabId="tab-1" name="Tab name 1">
|
|
78
|
+
<div>Content</div>
|
|
79
|
+
</TabContent>
|
|
80
|
+
<TabContent tabId="tab-2" name="Tab name 2">
|
|
81
|
+
<div>Content</div>
|
|
82
|
+
</TabContent>
|
|
83
|
+
</Tabs>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(screen.getAllByTestId("tabs-test-scroll-button").length).toEqual(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should render selected border for line variant", () => {
|
|
90
|
+
render(
|
|
91
|
+
<Tabs variant="line" data-testid="tabs-test">
|
|
92
|
+
<TabContent tabId="tab-1" name="Tab name 1">
|
|
93
|
+
<div>Content</div>
|
|
94
|
+
</TabContent>
|
|
95
|
+
<TabContent tabId="tab-2" name="Tab name 2">
|
|
96
|
+
<div>Content</div>
|
|
97
|
+
</TabContent>
|
|
98
|
+
</Tabs>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(screen.getByTestId("tabs-test-selected-border")).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should render selected border for line-negative variant", () => {
|
|
105
|
+
render(
|
|
106
|
+
<Tabs variant="line-negative" data-testid="tabs-test">
|
|
107
|
+
<TabContent tabId="tab-1" name="Tab name 1">
|
|
108
|
+
<div>Content</div>
|
|
109
|
+
</TabContent>
|
|
110
|
+
<TabContent tabId="tab-2" name="Tab name 2">
|
|
111
|
+
<div>Content</div>
|
|
112
|
+
</TabContent>
|
|
113
|
+
</Tabs>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(screen.getByTestId("tabs-test-selected-border")).toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should not render selected border for contained variant", () => {
|
|
120
|
+
render(
|
|
121
|
+
<Tabs variant="contained" data-testid="tabs-test">
|
|
122
|
+
<TabContent tabId="tab-1" name="Tab name 1">
|
|
123
|
+
<div>Content</div>
|
|
124
|
+
</TabContent>
|
|
125
|
+
<TabContent tabId="tab-2" name="Tab name 2">
|
|
126
|
+
<div>Content</div>
|
|
127
|
+
</TabContent>
|
|
128
|
+
</Tabs>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(screen.queryByTestId("tabs-test-selected-border")).not.toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should not render selected border for contained-negative variant", () => {
|
|
135
|
+
render(
|
|
136
|
+
<Tabs variant="contained-negative" data-testid="tabs-test">
|
|
137
|
+
<TabContent tabId="tab-1" name="Tab name 1">
|
|
138
|
+
<div>Content</div>
|
|
139
|
+
</TabContent>
|
|
140
|
+
<TabContent tabId="tab-2" name="Tab name 2">
|
|
141
|
+
<div>Content</div>
|
|
142
|
+
</TabContent>
|
|
143
|
+
</Tabs>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(screen.queryByTestId("tabs-test-selected-border")).not.toBeInTheDocument();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should throw error if duplicate tabId is found", () => {
|
|
150
|
+
expect(() => {
|
|
151
|
+
render(
|
|
152
|
+
<Tabs>
|
|
153
|
+
<TabContent tabId="tab" name="Tab name 1">
|
|
154
|
+
<div>Content</div>
|
|
155
|
+
</TabContent>
|
|
156
|
+
<TabContent tabId="tab" name="Tab name 2">
|
|
157
|
+
<div>Content</div>
|
|
158
|
+
</TabContent>
|
|
159
|
+
</Tabs>
|
|
160
|
+
);
|
|
161
|
+
}).toThrow("tabId must be unique");
|
|
162
|
+
});
|
|
163
|
+
});
|
package/src/tabs.tsx
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import React, { Children, ReactElement, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { chevronLeft, chevronRight, Icon } from "@purpurds/icon";
|
|
3
|
+
import { List, Root } from "@radix-ui/react-tabs";
|
|
4
|
+
import c from "classnames/bind";
|
|
5
|
+
|
|
6
|
+
import { isTabContent, TabContent } from "./tab-content";
|
|
7
|
+
import { TabHeader } from "./tab-header";
|
|
8
|
+
import styles from "./tabs.module.scss";
|
|
9
|
+
import { createTabChangeDetailEvent, TabChangeDetail, TabsCmp, TabsVariant } from "./tabs.utils";
|
|
10
|
+
|
|
11
|
+
type TabsProps = {
|
|
12
|
+
children: Array<ReactElement<typeof TabContent>> | ReactElement<typeof TabContent>;
|
|
13
|
+
variant?: TabsVariant;
|
|
14
|
+
fullWidth?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Event handler called when the value changes.
|
|
17
|
+
* */
|
|
18
|
+
onChange?: (event: CustomEvent<TabChangeDetail>) => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
"data-testid"?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const cx = c.bind(styles);
|
|
24
|
+
const rootClassName = "purpur-tabs";
|
|
25
|
+
|
|
26
|
+
const scrollToTarget = (target: HTMLElement, tabList?: HTMLDivElement | null) => {
|
|
27
|
+
/**
|
|
28
|
+
* scroll may not be available in test runner, so check existence before
|
|
29
|
+
* proceeding. We can't use scrollIntoView() because Safari doesn't support
|
|
30
|
+
* it fully.
|
|
31
|
+
*/
|
|
32
|
+
if (
|
|
33
|
+
typeof target?.getBoundingClientRect !== "function" ||
|
|
34
|
+
typeof tabList?.scroll !== "function"
|
|
35
|
+
) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const targetRect = target.getBoundingClientRect();
|
|
40
|
+
const wrapperRect = tabList.getBoundingClientRect();
|
|
41
|
+
const wrapperWidth = tabList.clientWidth;
|
|
42
|
+
const wrapperOffset = parseInt(getComputedStyle(tabList).borderLeftWidth?.split("px")[0], 10);
|
|
43
|
+
const offset = wrapperRect.left + (isNaN(wrapperOffset) ? 0 : wrapperOffset);
|
|
44
|
+
let left;
|
|
45
|
+
|
|
46
|
+
// Handle forward navigation
|
|
47
|
+
if (targetRect.right > wrapperRect.right) {
|
|
48
|
+
left = targetRect.left + tabList.scrollLeft;
|
|
49
|
+
left = left + targetRect.width - wrapperWidth + wrapperWidth * 0.1;
|
|
50
|
+
left = left - offset;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Handle backwards navigation
|
|
54
|
+
if (targetRect.left < wrapperRect.left) {
|
|
55
|
+
left = targetRect.left + tabList.scrollLeft;
|
|
56
|
+
left = left - wrapperWidth * 0.1;
|
|
57
|
+
left = left - offset;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (left !== undefined) {
|
|
61
|
+
tabList.scroll({ left, behavior: "smooth" });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Tabs: TabsCmp<TabsProps> = ({
|
|
66
|
+
children,
|
|
67
|
+
variant = "line",
|
|
68
|
+
fullWidth = false,
|
|
69
|
+
onChange,
|
|
70
|
+
className,
|
|
71
|
+
"data-testid": dataTestId,
|
|
72
|
+
...props
|
|
73
|
+
}) => {
|
|
74
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
75
|
+
const [scrollClasses, setScrollClasses] = useState<{ [key: string]: boolean }>({});
|
|
76
|
+
const [selectedTriggerOffset, setSelectedTriggerOffset] = useState(0);
|
|
77
|
+
const [selectedTriggerWidth, setSelectedTriggerWidth] = useState(0);
|
|
78
|
+
const tabContentChildren = Children.toArray(children).filter(isTabContent);
|
|
79
|
+
const tabList = useRef<HTMLDivElement | null>();
|
|
80
|
+
const tabChildren = useRef<HTMLButtonElement[]>(new Array(tabContentChildren.length));
|
|
81
|
+
const sideScrollAdjustmentSize = 200;
|
|
82
|
+
const isLineVariant = variant === "line" || variant === "line-negative";
|
|
83
|
+
|
|
84
|
+
const classNames = cx([
|
|
85
|
+
rootClassName,
|
|
86
|
+
`${rootClassName}--${variant}`,
|
|
87
|
+
{ [`${rootClassName}--fullWidth`]: fullWidth },
|
|
88
|
+
className,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const tabIds = Children.map(tabContentChildren, ({ props: { tabId } }) => tabId);
|
|
92
|
+
|
|
93
|
+
if (new Set(tabIds).size !== tabIds.length) {
|
|
94
|
+
throw new Error("tabId must be unique");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const getTestId = (name: string, id?: string) =>
|
|
98
|
+
id || dataTestId ? `${id || dataTestId}-${name}` : undefined;
|
|
99
|
+
|
|
100
|
+
const handleLinePosition = () => {
|
|
101
|
+
if (!isLineVariant) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const activeTabElement = tabChildren.current[activeIndex];
|
|
106
|
+
|
|
107
|
+
setSelectedTriggerOffset(activeTabElement?.offsetLeft || 0);
|
|
108
|
+
setSelectedTriggerWidth(activeTabElement?.getBoundingClientRect().width || 0);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleTabChange = (value: string) => {
|
|
112
|
+
if (isLineVariant) {
|
|
113
|
+
setActiveIndex(tabContentChildren.findIndex((child) => child.props.tabId === value));
|
|
114
|
+
}
|
|
115
|
+
onChange?.(createTabChangeDetailEvent(value));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleScrollButtonClick = (side: "left" | "right") => {
|
|
119
|
+
if (tabList?.current) {
|
|
120
|
+
const { scrollLeft } = tabList.current;
|
|
121
|
+
const modifier = side === "left" ? -sideScrollAdjustmentSize : sideScrollAdjustmentSize;
|
|
122
|
+
|
|
123
|
+
tabList.current.scroll({ left: scrollLeft + modifier, behavior: "smooth" });
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const ScrollButton = ({ side }: { side: "left" | "right" }) => (
|
|
128
|
+
<button
|
|
129
|
+
className={cx(`${rootClassName}__scroll-button`, `${rootClassName}__scroll-button--${side}`)}
|
|
130
|
+
onClick={() => handleScrollButtonClick(side)}
|
|
131
|
+
type="button"
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
tabIndex={-1}
|
|
134
|
+
data-testid={getTestId("scroll-button")}
|
|
135
|
+
>
|
|
136
|
+
<Icon svg={side === "left" ? chevronLeft : chevronRight} size="md" />
|
|
137
|
+
</button>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
window.addEventListener("resize", handleLinePosition);
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
window.removeEventListener("resize", handleLinePosition);
|
|
145
|
+
};
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
handleLinePosition();
|
|
150
|
+
}, [activeIndex, fullWidth, tabContentChildren, variant]);
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const onIntersection = (entries: IntersectionObserverEntry[]): void => {
|
|
154
|
+
const allEntriesVisible =
|
|
155
|
+
entries.every((entry) => entry.isIntersecting) &&
|
|
156
|
+
entries.length === tabContentChildren.length;
|
|
157
|
+
|
|
158
|
+
if (allEntriesVisible) {
|
|
159
|
+
setScrollClasses({});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
entries.forEach((entry) => {
|
|
164
|
+
const entryIndex = Number(entry.target.getAttribute("data-index"));
|
|
165
|
+
const isStartEntry = entryIndex === 0;
|
|
166
|
+
const isLastEntry = entryIndex === tabContentChildren.length - 1;
|
|
167
|
+
|
|
168
|
+
setScrollClasses((currentScrollClasses) => ({
|
|
169
|
+
...currentScrollClasses,
|
|
170
|
+
...(isStartEntry && {
|
|
171
|
+
[`${rootClassName}__wrapper--scroll-end`]: !entry.isIntersecting,
|
|
172
|
+
}),
|
|
173
|
+
...(isLastEntry && {
|
|
174
|
+
[`${rootClassName}__wrapper--scroll-start`]: !entry.isIntersecting,
|
|
175
|
+
}),
|
|
176
|
+
}));
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const observer = new IntersectionObserver(onIntersection, {
|
|
181
|
+
threshold: [0.99],
|
|
182
|
+
root: tabList.current,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
tabChildren.current.forEach((el) => observer.observe(el));
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
tabChildren.current.forEach((el) => observer.unobserve(el));
|
|
189
|
+
};
|
|
190
|
+
}, [tabContentChildren.length]);
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<Root
|
|
194
|
+
defaultValue={tabContentChildren[0].props.tabId}
|
|
195
|
+
onValueChange={handleTabChange}
|
|
196
|
+
data-testid={dataTestId}
|
|
197
|
+
className={classNames}
|
|
198
|
+
{...props}
|
|
199
|
+
>
|
|
200
|
+
<div className={cx(`${rootClassName}__container`)}>
|
|
201
|
+
<div className={cx([`${rootClassName}__wrapper`, scrollClasses])}>
|
|
202
|
+
<List
|
|
203
|
+
ref={(el) => {
|
|
204
|
+
tabList.current = el;
|
|
205
|
+
}}
|
|
206
|
+
className={cx(`${rootClassName}__list`)}
|
|
207
|
+
>
|
|
208
|
+
{Children.map(tabContentChildren, (child, index) => {
|
|
209
|
+
const { name, tabId, "data-testid": childDataTestId } = child.props;
|
|
210
|
+
return (
|
|
211
|
+
<TabHeader
|
|
212
|
+
data-testid={getTestId("header", childDataTestId)}
|
|
213
|
+
index={index}
|
|
214
|
+
tabId={tabId}
|
|
215
|
+
ref={(el) => {
|
|
216
|
+
if (el) {
|
|
217
|
+
tabChildren.current[index] = el;
|
|
218
|
+
}
|
|
219
|
+
}}
|
|
220
|
+
onFocus={(e) => {
|
|
221
|
+
scrollToTarget(e.target as HTMLElement, tabList.current);
|
|
222
|
+
}}
|
|
223
|
+
variant={variant}
|
|
224
|
+
>
|
|
225
|
+
{name}
|
|
226
|
+
</TabHeader>
|
|
227
|
+
);
|
|
228
|
+
})}
|
|
229
|
+
{isLineVariant && (
|
|
230
|
+
<div
|
|
231
|
+
className={cx(`${rootClassName}__selected-border`)}
|
|
232
|
+
style={{
|
|
233
|
+
width: selectedTriggerWidth,
|
|
234
|
+
transform: `translateX(${selectedTriggerOffset}px)`,
|
|
235
|
+
}}
|
|
236
|
+
data-testid={getTestId("selected-border")}
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
</List>
|
|
240
|
+
<ScrollButton side="left" />
|
|
241
|
+
<ScrollButton side="right" />
|
|
242
|
+
</div>
|
|
243
|
+
<div className={cx(`${rootClassName}__content-container`)}>
|
|
244
|
+
{Children.map(tabContentChildren, (child) => child)}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</Root>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
Tabs.Content = TabContent;
|
|
252
|
+
|
|
253
|
+
export * from "./tabs.utils";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TabContent } from "./tab-content";
|
|
2
|
+
|
|
3
|
+
export type TabsCmp<P> = React.FunctionComponent<P> & {
|
|
4
|
+
Content: typeof TabContent;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const tabsVariants = ["line", "line-negative", "contained", "contained-negative"] as const;
|
|
8
|
+
|
|
9
|
+
export type TabsVariant = (typeof tabsVariants)[number];
|
|
10
|
+
|
|
11
|
+
export const createTabChangeDetailEvent = (value: string) =>
|
|
12
|
+
new CustomEvent<TabChangeDetail>("tabChangeDetail", { detail: { value } });
|
|
13
|
+
|
|
14
|
+
export type TabChangeDetail = { value: string };
|