Skip to content

Commit

Permalink
57 Tabs Example (#60)
Browse files Browse the repository at this point in the history
* #57 use node v20.14.0

* #57 initial tab components

* #57 docs

* #57 tests

* #57 tests

* #57 tabs variant
  • Loading branch information
mwarman authored Jun 16, 2024
1 parent 2f47fc2 commit dd52541
Show file tree
Hide file tree
Showing 16 changed files with 516 additions and 9 deletions.
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

0 comments on commit dd52541

Please sign in to comment.