Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

57 Tabs Example #60

Merged
merged 6 commits into from
Jun 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.11.1
v20.14.0
64 changes: 64 additions & 0 deletions src/components/Tabs/Tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common';
import classNames from 'classnames';

/**
* Properties for the `Tab` React component.
* @param {boolean} [isActive=false] - Optional. Indicates if this tab is the
* active tab.
* @param {string} label - The tab label.
* @param {function} [onClick] - Optional. A function to be invoked when the
* tab is clicked.
* @see {@link PropsWithClassName}
* @see {@link PropsWithTestId}
*/
export interface TabProps extends PropsWithClassName, PropsWithTestId {
isActive?: boolean;
label: string;
onClick?: () => void;
}

/**
* The `Tab` component renders a single tab for the display of tabbed content.
*
* A `Tab` is typically not rendered outside of the `Tabs` component, but rather
* the `TabProps` are supplied to the `Tabs` component so that the `Tabs` component
* may render one or more `Tab` components.
*
* @param {TabProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const Tab = ({
className,
isActive = false,
label,
onClick,
testId = 'tab',
}: TabProps): JSX.Element => {
/**
* Handle tab click events.
*/
const handleClick = () => {
onClick?.();
};

return (
<div
className={classNames(
'flex cursor-pointer items-center justify-center px-2 py-1 text-sm font-bold uppercase',
{
'border-b-2 border-b-blue-300 dark:border-b-blue-600': isActive,
},
{
'border-b-2 border-transparent': !isActive,
},
className,
)}
onClick={handleClick}
data-testid={testId}
>
{label}
</div>
);
};

export default Tab;
31 changes: 31 additions & 0 deletions src/components/Tabs/TabContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { PropsWithChildren } from 'react';
import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common';

/**
* Properties for the `TabContent` React component.
* @see {@link PropsWithChildren}
* @see {@link PropsWithClassName}
* @see {@link PropsWithTestId}
*/
export interface TabContentProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {}

/**
* The `TabContent` component renders a single block of tabbed content.
*
* A `TabContent` is typically not rendered outside of the `Tabs` component, but
* rather the `TabContentProps` are supplied to the `Tabs` component. The `Tabs`
* component renders one or more `TabContent` components.
*/
const TabContent = ({
children,
className,
testId = 'tab-content',
}: TabContentProps): JSX.Element => {
return (
<div className={className} data-testid={testId}>
{children}
</div>
);
};

export default TabContent;
96 changes: 96 additions & 0 deletions src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { PropsWithTestId } from '@leanstacks/react-common';
import { useSearchParams } from 'react-router-dom';
import classNames from 'classnames';

import { toNumberBetween } from 'utils/numbers';
import { SearchParam } from 'utils/constants';
import Tab, { TabProps } from './Tab';
import TabContent, { TabContentProps } from './TabContent';

/**
* The `TabVariant` describes variations of display behavior for `Tabs`.
*/
type TabVariant = 'fullWidth' | 'standard';

/**
* Properties for the `Tabs` React component.
* @param {TabProps[]} tabs - An array of `Tab` component properties.
* @param {TabConent[]} tabContents - An array of `TabContent` component properties.
* @param {TabVariant} [variant='standard'] - Optional. The tab display behavior.
* Default: `standard`.
* @see {@link PropsWithTestId}
*/
interface TabsProps extends PropsWithTestId {
tabs: Omit<TabProps, 'isActive' | 'onClick'>[];
tabContents: TabContentProps[];
variant?: TabVariant;
}

/**
* The `Tabs` component is a wrapper for rendering tabbed content.
*
* Supply one to many `TabProps` objects in the `tabs` property describing each
* `Tab` to render. Supply one to many `TabContentProps` objects in the `tabContents` property
* describing each `TabContent` to render.
*
* The number of `tabs` and `tabContents` items should be equal. The order of each array
* matters. The first item in the `tabs` array should correspond to content in the first
* item in the `tabContents` array and so on.
*
* *Example:*
* ```
* <Tabs
* tabs={[
* { label: 'List', testId: 'tab-list' },
* { label: 'Detail', testId: 'tab-detail' },
* ]}
* tabContents={[{ children: <MyList /> }, { children: <Outlet />, className: 'my-6' }]}
* />
* ```
* @param {TabsProps} - Component properties
* @returns {JSX.Element} JSX
*/
const Tabs = ({
tabs,
tabContents,
testId = 'tabs',
variant = 'standard',
}: TabsProps): JSX.Element => {
const [searchParams, setSearchParams] = useSearchParams();

// obtain activeTabIndex from query string
const activeTabIndex = toNumberBetween(searchParams.get(SearchParam.tab), 0, tabs.length - 1, 0);

/**
* Set the active tab index.
* @param {number} index - A tab index.
*/
const setTab = (index: number = 0): void => {
const tabIndex = toNumberBetween(index, 0, tabs.length - 1, 0);
if (tabIndex !== activeTabIndex) {
searchParams.set(SearchParam.tab, tabIndex.toString());
setSearchParams(searchParams);
}
};

return (
<div data-testid={testId}>
<div className="flex gap-4 border-b border-b-neutral-500/10" data-testid={`${testId}-tabs`}>
{tabs.map(({ className, ...tabProps }, index) => (
<Tab
{...tabProps}
className={classNames({ className, 'flex-grow': variant === 'fullWidth' })}
isActive={activeTabIndex === index}
onClick={() => setTab(index)}
key={index}
/>
))}
</div>
<div data-testid={`${testId}-content`}>
<TabContent {...tabContents[activeTabIndex]} />
</div>
</div>
);
};

export default Tabs;
66 changes: 66 additions & 0 deletions src/components/Tabs/__tests__/Tab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';

import { render, screen } from 'test/test-utils';

import Tab from '../Tab';

describe('Tab', () => {
it('should render successfully', async () => {
// ARRANGE
render(<Tab label="Label" />);
await screen.findByTestId('tab');

// ASSERT
expect(screen.getByTestId('tab')).toBeDefined();
});

it('should use custom testId', async () => {
// ARRANGE
render(<Tab label="Label" testId="custom-testId" />);
await screen.findByTestId('custom-testId');

// ASSERT
expect(screen.getByTestId('custom-testId')).toBeDefined();
});

it('should use custom className', async () => {
// ARRANGE
render(<Tab label="Label" className="custom-className" />);
await screen.findByTestId('tab');

// ASSERT
expect(screen.getByTestId('tab').classList).toContain('custom-className');
});

it('should render label', async () => {
// ARRANGE
render(<Tab label="Label" />);
await screen.findByTestId('tab');

// ASSERT
expect(screen.getByTestId('tab').textContent).toBe('Label');
});

it('should render active state', async () => {
// ARRANGE
render(<Tab label="Label" isActive />);
await screen.findByTestId('tab');

// ASSERT
expect(screen.getByTestId('tab').classList).toContain('border-b-blue-300');
});

it('should call click handler', async () => {
// ARRANGE
const mockClickFn = vi.fn();
render(<Tab label="Label" onClick={mockClickFn} />);
await screen.findByTestId('tab');

// ACT
await userEvent.click(screen.getByTestId('tab'));

// ASSERT
expect(mockClickFn).toHaveBeenCalledTimes(1);
});
});
47 changes: 47 additions & 0 deletions src/components/Tabs/__tests__/TabContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';

import { render, screen } from 'test/test-utils';

import TabContent from '../TabContent';

describe('TabContent', () => {
it('should render successfully', async () => {
// ARRANGE
render(<TabContent />);
await screen.findByTestId('tab-content');

// ASSERT
expect(screen.getByTestId('tab-content')).toBeDefined();
});

it('should use custom testId', async () => {
// ARRANGE
render(<TabContent testId="custom-testId" />);
await screen.findByTestId('custom-testId');

// ASSERT
expect(screen.getByTestId('custom-testId')).toBeDefined();
});

it('should use custom className', async () => {
// ARRANGE
render(<TabContent className="custom-className" />);
await screen.findByTestId('tab-content');

// ASSERT
expect(screen.getByTestId('tab-content').classList).toContain('custom-className');
});

it('should render children', async () => {
// ARRANGE
render(
<TabContent>
<div data-testid="tab-content-children"></div>
</TabContent>,
);
await screen.findByTestId('tab-content-children');

// ASSERT
expect(screen.getByTestId('tab-content-children')).toBeDefined();
});
});
67 changes: 67 additions & 0 deletions src/components/Tabs/__tests__/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';

import { render, screen } from 'test/test-utils';
import { TabProps } from '../Tab';
import { TabContentProps } from '../TabContent';

import Tabs from '../Tabs';

describe('Tabs', () => {
const tabs: TabProps[] = [
{ label: 'One', testId: 'tab-one' },
{ label: 'Two', testId: 'tab-two' },
];
const tabContents: TabContentProps[] = [
{
children: <div data-testid="tab-content-one"></div>,
},
{
children: <div data-testid="tab-content-two"></div>,
},
];

it('should render successfully', async () => {
// ARRANGE
render(<Tabs tabs={tabs} tabContents={tabContents} />);
await screen.findByTestId('tabs');

// ASSERT
expect(screen.getByTestId('tabs')).toBeDefined();
expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length);
expect(screen.getByTestId('tab-content-one')).toBeDefined();
});

it('should use custom testId', async () => {
// ARRANGE
render(<Tabs tabs={tabs} tabContents={tabContents} testId="custom-testId" />);
await screen.findByTestId('custom-testId');

// ASSERT
expect(screen.getByTestId('custom-testId')).toBeDefined();
});

it('should show tab content when tab is clicked', async () => {
// ARRANGE
render(<Tabs tabs={tabs} tabContents={tabContents} />);
await screen.findByTestId('tabs');

// ACT
await userEvent.click(screen.getByTestId('tab-two'));

// ASSERT
expect(screen.getByTestId('tab-content-two')).toBeDefined();
});

it('should render full width variant', async () => {
// ARRANGE
render(<Tabs tabs={tabs} tabContents={tabContents} variant="fullWidth" />);
await screen.findByTestId('tabs');

// ASSERT
expect(screen.getByTestId('tabs')).toBeDefined();
expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length);
expect(screen.getByTestId('tab-content-one')).toBeDefined();
expect(screen.getByTestId('tab-one').classList).toContain('flex-grow');
});
});
Loading