@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.
@@ -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 };