Skip to content

Commit dd52541

Browse files
authored
57 Tabs Example (#60)
* #57 use node v20.14.0 * #57 initial tab components * #57 docs * #57 tests * #57 tests * #57 tabs variant
1 parent 2f47fc2 commit dd52541

File tree

16 files changed

+516
-9
lines changed

16 files changed

+516
-9
lines changed

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v20.11.1
1+
v20.14.0

src/components/Tabs/Tab.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common';
2+
import classNames from 'classnames';
3+
4+
/**
5+
* Properties for the `Tab` React component.
6+
* @param {boolean} [isActive=false] - Optional. Indicates if this tab is the
7+
* active tab.
8+
* @param {string} label - The tab label.
9+
* @param {function} [onClick] - Optional. A function to be invoked when the
10+
* tab is clicked.
11+
* @see {@link PropsWithClassName}
12+
* @see {@link PropsWithTestId}
13+
*/
14+
export interface TabProps extends PropsWithClassName, PropsWithTestId {
15+
isActive?: boolean;
16+
label: string;
17+
onClick?: () => void;
18+
}
19+
20+
/**
21+
* The `Tab` component renders a single tab for the display of tabbed content.
22+
*
23+
* A `Tab` is typically not rendered outside of the `Tabs` component, but rather
24+
* the `TabProps` are supplied to the `Tabs` component so that the `Tabs` component
25+
* may render one or more `Tab` components.
26+
*
27+
* @param {TabProps} props - Component properties.
28+
* @returns {JSX.Element} JSX
29+
*/
30+
const Tab = ({
31+
className,
32+
isActive = false,
33+
label,
34+
onClick,
35+
testId = 'tab',
36+
}: TabProps): JSX.Element => {
37+
/**
38+
* Handle tab click events.
39+
*/
40+
const handleClick = () => {
41+
onClick?.();
42+
};
43+
44+
return (
45+
<div
46+
className={classNames(
47+
'flex cursor-pointer items-center justify-center px-2 py-1 text-sm font-bold uppercase',
48+
{
49+
'border-b-2 border-b-blue-300 dark:border-b-blue-600': isActive,
50+
},
51+
{
52+
'border-b-2 border-transparent': !isActive,
53+
},
54+
className,
55+
)}
56+
onClick={handleClick}
57+
data-testid={testId}
58+
>
59+
{label}
60+
</div>
61+
);
62+
};
63+
64+
export default Tab;

src/components/Tabs/TabContent.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { PropsWithChildren } from 'react';
2+
import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common';
3+
4+
/**
5+
* Properties for the `TabContent` React component.
6+
* @see {@link PropsWithChildren}
7+
* @see {@link PropsWithClassName}
8+
* @see {@link PropsWithTestId}
9+
*/
10+
export interface TabContentProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {}
11+
12+
/**
13+
* The `TabContent` component renders a single block of tabbed content.
14+
*
15+
* A `TabContent` is typically not rendered outside of the `Tabs` component, but
16+
* rather the `TabContentProps` are supplied to the `Tabs` component. The `Tabs`
17+
* component renders one or more `TabContent` components.
18+
*/
19+
const TabContent = ({
20+
children,
21+
className,
22+
testId = 'tab-content',
23+
}: TabContentProps): JSX.Element => {
24+
return (
25+
<div className={className} data-testid={testId}>
26+
{children}
27+
</div>
28+
);
29+
};
30+
31+
export default TabContent;

src/components/Tabs/Tabs.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { PropsWithTestId } from '@leanstacks/react-common';
2+
import { useSearchParams } from 'react-router-dom';
3+
import classNames from 'classnames';
4+
5+
import { toNumberBetween } from 'utils/numbers';
6+
import { SearchParam } from 'utils/constants';
7+
import Tab, { TabProps } from './Tab';
8+
import TabContent, { TabContentProps } from './TabContent';
9+
10+
/**
11+
* The `TabVariant` describes variations of display behavior for `Tabs`.
12+
*/
13+
type TabVariant = 'fullWidth' | 'standard';
14+
15+
/**
16+
* Properties for the `Tabs` React component.
17+
* @param {TabProps[]} tabs - An array of `Tab` component properties.
18+
* @param {TabConent[]} tabContents - An array of `TabContent` component properties.
19+
* @param {TabVariant} [variant='standard'] - Optional. The tab display behavior.
20+
* Default: `standard`.
21+
* @see {@link PropsWithTestId}
22+
*/
23+
interface TabsProps extends PropsWithTestId {
24+
tabs: Omit<TabProps, 'isActive' | 'onClick'>[];
25+
tabContents: TabContentProps[];
26+
variant?: TabVariant;
27+
}
28+
29+
/**
30+
* The `Tabs` component is a wrapper for rendering tabbed content.
31+
*
32+
* Supply one to many `TabProps` objects in the `tabs` property describing each
33+
* `Tab` to render. Supply one to many `TabContentProps` objects in the `tabContents` property
34+
* describing each `TabContent` to render.
35+
*
36+
* The number of `tabs` and `tabContents` items should be equal. The order of each array
37+
* matters. The first item in the `tabs` array should correspond to content in the first
38+
* item in the `tabContents` array and so on.
39+
*
40+
* *Example:*
41+
* ```
42+
* <Tabs
43+
* tabs={[
44+
* { label: 'List', testId: 'tab-list' },
45+
* { label: 'Detail', testId: 'tab-detail' },
46+
* ]}
47+
* tabContents={[{ children: <MyList /> }, { children: <Outlet />, className: 'my-6' }]}
48+
* />
49+
* ```
50+
* @param {TabsProps} - Component properties
51+
* @returns {JSX.Element} JSX
52+
*/
53+
const Tabs = ({
54+
tabs,
55+
tabContents,
56+
testId = 'tabs',
57+
variant = 'standard',
58+
}: TabsProps): JSX.Element => {
59+
const [searchParams, setSearchParams] = useSearchParams();
60+
61+
// obtain activeTabIndex from query string
62+
const activeTabIndex = toNumberBetween(searchParams.get(SearchParam.tab), 0, tabs.length - 1, 0);
63+
64+
/**
65+
* Set the active tab index.
66+
* @param {number} index - A tab index.
67+
*/
68+
const setTab = (index: number = 0): void => {
69+
const tabIndex = toNumberBetween(index, 0, tabs.length - 1, 0);
70+
if (tabIndex !== activeTabIndex) {
71+
searchParams.set(SearchParam.tab, tabIndex.toString());
72+
setSearchParams(searchParams);
73+
}
74+
};
75+
76+
return (
77+
<div data-testid={testId}>
78+
<div className="flex gap-4 border-b border-b-neutral-500/10" data-testid={`${testId}-tabs`}>
79+
{tabs.map(({ className, ...tabProps }, index) => (
80+
<Tab
81+
{...tabProps}
82+
className={classNames({ className, 'flex-grow': variant === 'fullWidth' })}
83+
isActive={activeTabIndex === index}
84+
onClick={() => setTab(index)}
85+
key={index}
86+
/>
87+
))}
88+
</div>
89+
<div data-testid={`${testId}-content`}>
90+
<TabContent {...tabContents[activeTabIndex]} />
91+
</div>
92+
</div>
93+
);
94+
};
95+
96+
export default Tabs;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { render, screen } from 'test/test-utils';
5+
6+
import Tab from '../Tab';
7+
8+
describe('Tab', () => {
9+
it('should render successfully', async () => {
10+
// ARRANGE
11+
render(<Tab label="Label" />);
12+
await screen.findByTestId('tab');
13+
14+
// ASSERT
15+
expect(screen.getByTestId('tab')).toBeDefined();
16+
});
17+
18+
it('should use custom testId', async () => {
19+
// ARRANGE
20+
render(<Tab label="Label" testId="custom-testId" />);
21+
await screen.findByTestId('custom-testId');
22+
23+
// ASSERT
24+
expect(screen.getByTestId('custom-testId')).toBeDefined();
25+
});
26+
27+
it('should use custom className', async () => {
28+
// ARRANGE
29+
render(<Tab label="Label" className="custom-className" />);
30+
await screen.findByTestId('tab');
31+
32+
// ASSERT
33+
expect(screen.getByTestId('tab').classList).toContain('custom-className');
34+
});
35+
36+
it('should render label', async () => {
37+
// ARRANGE
38+
render(<Tab label="Label" />);
39+
await screen.findByTestId('tab');
40+
41+
// ASSERT
42+
expect(screen.getByTestId('tab').textContent).toBe('Label');
43+
});
44+
45+
it('should render active state', async () => {
46+
// ARRANGE
47+
render(<Tab label="Label" isActive />);
48+
await screen.findByTestId('tab');
49+
50+
// ASSERT
51+
expect(screen.getByTestId('tab').classList).toContain('border-b-blue-300');
52+
});
53+
54+
it('should call click handler', async () => {
55+
// ARRANGE
56+
const mockClickFn = vi.fn();
57+
render(<Tab label="Label" onClick={mockClickFn} />);
58+
await screen.findByTestId('tab');
59+
60+
// ACT
61+
await userEvent.click(screen.getByTestId('tab'));
62+
63+
// ASSERT
64+
expect(mockClickFn).toHaveBeenCalledTimes(1);
65+
});
66+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { render, screen } from 'test/test-utils';
4+
5+
import TabContent from '../TabContent';
6+
7+
describe('TabContent', () => {
8+
it('should render successfully', async () => {
9+
// ARRANGE
10+
render(<TabContent />);
11+
await screen.findByTestId('tab-content');
12+
13+
// ASSERT
14+
expect(screen.getByTestId('tab-content')).toBeDefined();
15+
});
16+
17+
it('should use custom testId', async () => {
18+
// ARRANGE
19+
render(<TabContent testId="custom-testId" />);
20+
await screen.findByTestId('custom-testId');
21+
22+
// ASSERT
23+
expect(screen.getByTestId('custom-testId')).toBeDefined();
24+
});
25+
26+
it('should use custom className', async () => {
27+
// ARRANGE
28+
render(<TabContent className="custom-className" />);
29+
await screen.findByTestId('tab-content');
30+
31+
// ASSERT
32+
expect(screen.getByTestId('tab-content').classList).toContain('custom-className');
33+
});
34+
35+
it('should render children', async () => {
36+
// ARRANGE
37+
render(
38+
<TabContent>
39+
<div data-testid="tab-content-children"></div>
40+
</TabContent>,
41+
);
42+
await screen.findByTestId('tab-content-children');
43+
44+
// ASSERT
45+
expect(screen.getByTestId('tab-content-children')).toBeDefined();
46+
});
47+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from 'vitest';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { render, screen } from 'test/test-utils';
5+
import { TabProps } from '../Tab';
6+
import { TabContentProps } from '../TabContent';
7+
8+
import Tabs from '../Tabs';
9+
10+
describe('Tabs', () => {
11+
const tabs: TabProps[] = [
12+
{ label: 'One', testId: 'tab-one' },
13+
{ label: 'Two', testId: 'tab-two' },
14+
];
15+
const tabContents: TabContentProps[] = [
16+
{
17+
children: <div data-testid="tab-content-one"></div>,
18+
},
19+
{
20+
children: <div data-testid="tab-content-two"></div>,
21+
},
22+
];
23+
24+
it('should render successfully', async () => {
25+
// ARRANGE
26+
render(<Tabs tabs={tabs} tabContents={tabContents} />);
27+
await screen.findByTestId('tabs');
28+
29+
// ASSERT
30+
expect(screen.getByTestId('tabs')).toBeDefined();
31+
expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length);
32+
expect(screen.getByTestId('tab-content-one')).toBeDefined();
33+
});
34+
35+
it('should use custom testId', async () => {
36+
// ARRANGE
37+
render(<Tabs tabs={tabs} tabContents={tabContents} testId="custom-testId" />);
38+
await screen.findByTestId('custom-testId');
39+
40+
// ASSERT
41+
expect(screen.getByTestId('custom-testId')).toBeDefined();
42+
});
43+
44+
it('should show tab content when tab is clicked', async () => {
45+
// ARRANGE
46+
render(<Tabs tabs={tabs} tabContents={tabContents} />);
47+
await screen.findByTestId('tabs');
48+
49+
// ACT
50+
await userEvent.click(screen.getByTestId('tab-two'));
51+
52+
// ASSERT
53+
expect(screen.getByTestId('tab-content-two')).toBeDefined();
54+
});
55+
56+
it('should render full width variant', async () => {
57+
// ARRANGE
58+
render(<Tabs tabs={tabs} tabContents={tabContents} variant="fullWidth" />);
59+
await screen.findByTestId('tabs');
60+
61+
// ASSERT
62+
expect(screen.getByTestId('tabs')).toBeDefined();
63+
expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length);
64+
expect(screen.getByTestId('tab-content-one')).toBeDefined();
65+
expect(screen.getByTestId('tab-one').classList).toContain('flex-grow');
66+
});
67+
});

0 commit comments

Comments
 (0)