Skip to content

Commit

Permalink
feature(component): Allow Sidebars to hide completely, resolves #81 (
Browse files Browse the repository at this point in the history
…#170)

* refactor(component): Prepend `boolean` fields with `is`

* fix(story): Update old color name in `Sidebar` story

* fix(a11y): Add unique labels to `Sidebar` examples

* feature(component): Allow `Sidebar`s to hide completely, resolves #81

`Sidebar`s can now be created with:

```js
<Sidebar collapseBehavior="hide">...</Sidebar>
```

To completely hide the `Sidebar` when collapsed,
instead of displaying icons. This is useful if you
are not adding icons to yours.

* test(component): Add numerous unit tests for `Sidebar`s
  • Loading branch information
tulup-conner authored Jun 2, 2022
1 parent 2c39deb commit 44a29e7
Show file tree
Hide file tree
Showing 11 changed files with 294 additions and 160 deletions.
10 changes: 5 additions & 5 deletions src/docs/pages/SidebarPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const SidebarPage: FC = () => {
{
title: 'Default sidebar',
code: (
<Sidebar className="!bg-gray-50" collapsed={false}>
<Sidebar aria-label="Default sidebar example" className="!bg-gray-50 dark:!bg-gray-900">
<Sidebar.Items>
<Sidebar.ItemGroup>
<Sidebar.Item href="#" icon={HiChartPie}>
Expand Down Expand Up @@ -41,7 +41,7 @@ const SidebarPage: FC = () => {
{
title: 'Multi-level dropdown',
code: (
<Sidebar className="!bg-gray-50" collapsed={false}>
<Sidebar aria-label="Sidebar with multi-level dropdown example" className="!bg-gray-50 dark:!bg-gray-900">
<Sidebar.Items>
<Sidebar.ItemGroup>
<Sidebar.Item href="#" icon={HiChartPie}>
Expand Down Expand Up @@ -73,7 +73,7 @@ const SidebarPage: FC = () => {
{
title: 'Content separator',
code: (
<Sidebar className="!bg-gray-50" collapsed={false}>
<Sidebar aria-label="Sidebar with content separator example" className="!bg-gray-50 dark:!bg-gray-900">
<Sidebar.Items>
<Sidebar.ItemGroup>
<Sidebar.Item href="#" icon={HiChartPie}>
Expand Down Expand Up @@ -116,7 +116,7 @@ const SidebarPage: FC = () => {
{
title: 'CTA button',
code: (
<Sidebar className="!bg-gray-50" collapsed={false}>
<Sidebar aria-label="Sidebar with call to action button example" className="!bg-gray-50 dark:!bg-gray-900">
<Sidebar.Items>
<Sidebar.ItemGroup>
<Sidebar.Item href="#" icon={HiChartPie}>
Expand Down Expand Up @@ -177,7 +177,7 @@ const SidebarPage: FC = () => {
{
title: 'Logo branding',
code: (
<Sidebar className="!bg-gray-50" collapsed={false}>
<Sidebar aria-label="Sidebar with logo branding example" className="!bg-gray-50 dark:!bg-gray-900">
<Sidebar.Logo href="#" img="favicon.png" imgAlt="Flowbite logo">
Flowbite
</Sidebar.Logo>
Expand Down
278 changes: 191 additions & 87 deletions src/lib/components/Sidebar/Sidebar.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,126 +1,230 @@
import { render, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { HiChartPie, HiInbox, HiUser, HiShoppingBag, HiArrowSmRight, HiTable } from 'react-icons/hi';
import { render, RenderResult, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HiChartPie, HiInbox, HiShoppingBag } from 'react-icons/hi';
import { Sidebar, SidebarProps } from '.';
import { Badge } from '../Badge';
import { Button } from '../Button';

describe('Sidebar', () => {
describe('when collapsed', () => {
describe('logo', () => {
it('should not display its text label', () => {
const { getByTestId } = render(<SidebarTestComponent collapsed />);
expect(getByTestId('sidebar-logo')).toHaveClass('sr-only');

describe('Components / Sidebar', () => {
describe('A11y', () => {
it('should use `aria-label` if provided', () => {
const { getByLabelText } = render(<TestSidebar aria-label="My differently labelled sidebar" />);
const sidebar = getByLabelText('My differently labelled sidebar');

expect(sidebar).toHaveAccessibleName('My differently labelled sidebar');
});

describe('`Sidebar.Collapse` and `Sidebar.Item`', () => {
it('should use text content as accessible name', () => {
const itemLabels = ['Dashboard', 'E-commerce', 'Products', 'Services', 'Inbox', 'My heading'];

const { getAllByRole } = render(<TestSidebar />);
const collapseButtons = getSidebarCollapseButtons({ getAllByRole });

collapseButtons.forEach((button) => userEvent.click(button));

const items = getSidebarItems({ getAllByRole });

items.forEach((item, i) => {
expect(item.firstElementChild).toHaveAccessibleName(itemLabels[i]);
});
});
});

describe('items', () => {
it('should not display their text content', () => {
const { queryAllByTestId } = render(<SidebarTestComponent collapsed />);
expect(queryAllByTestId('sidebar-item-content')).toHaveLength(0);
describe('`Sidebar.Logo`', () => {
it('should use text content as accessible name', () => {
const logo = getSidebarLogo(render(<TestSidebar />));

expect(logo).toHaveAccessibleName('Flowbite');
});
it('should not display their label', () => {
const { queryAllByTestId } = render(<SidebarTestComponent collapsed />);
expect(queryAllByTestId('sidebar-item-label')).toHaveLength(0);

describe('`img=".."` and `imgAlt=".."`', () => {
it('should use `imgAlt` as alternative text for image', () => {
const { getByAltText } = render(<TestSidebar />);
const logoImg = getByAltText('Flowbite logo');

expect(logoImg).toHaveAccessibleName('Flowbite logo');
});
});
it('should display a tooltip', async () => {
const { queryAllByTestId } = render(<SidebarTestComponent collapsed />);
expect(queryAllByTestId('sidebar-item-tooltip')).not.toHaveLength(0);
});
});

describe('Keyboard interactions', () => {
describe('`Space`', () => {
describe('`Sidebar.Collapse`', () => {
it('should expand/collapse', () => {
const { getAllByRole } = render(<TestSidebar />);
const collapseButton = getSidebarCollapseButtons({ getAllByRole })[0];

userEvent.click(collapseButton);

const collapse = getSidebarCollapses({ getAllByRole })[0];

expect(collapse).toBeVisible();
});
});

describe('`Sidebar.Item`', () => {
describe('`href=".."`', () => {
it('should follow link', () => {
const { getAllByRole } = render(<TestSidebar />);
const link = getAllByRole('link')[1];

expect(link).toHaveAccessibleName('Dashboard');
expect(link).toHaveAttribute('href', '#');
});
});
});
});

describe('CTA', () => {
it('should not be displayed', () => {
const { queryByTestId } = render(<SidebarTestComponent collapsed />);
expect(queryByTestId('sidebar-cta')).toBeNull();
describe('`Tab`', () => {
it('should be possible to `Tab` out', async () => {
const { getByText } = render(
<>
<TestSidebar />
<button role="checkbox">Outside</button>
</>,
);
const outside = getByText('Outside');

await waitFor(() => {
userEvent.tab();

expect(outside).toHaveFocus();
});
});
});
});

describe('with a collapsable item', () => {
describe('that is closed', () => {
it('should expand when clicked', async () => {
const { getByTestId } = render(<SidebarTestComponent />);
act(() => {
getByTestId('sidebar-collapse-button').click();
});
await waitFor(() => expect(getByTestId('sidebar-collapse')).not.toHaveClass('hidden'));
describe('Props', () => {
describe('`collapseBehavior="hide"`', () => {
it("shouldn't display anything", () => {
const { queryByLabelText } = render(<TestSidebar collapseBehavior="hide" collapsed />);
const sidebar = queryByLabelText('Sidebar');

expect(sidebar).not.toBeVisible();
});
});

describe('that is open', () => {
it('should collapse when clicked', async () => {
const { getByTestId } = render(<SidebarTestComponent />);
act(() => {
getByTestId('sidebar-collapse-button').click();
describe('`collapsed={true}`', () => {
describe('`Sidebar.CTA`', () => {
it("shouldn't be visible", () => {
const cta = getSidebarCTA(render(<TestSidebar collapsed />));

expect(cta).not.toBeVisible();
});
act(() => {
getByTestId('sidebar-collapse-button').click();
});

describe('`Sidebar.Collapse` and `Sidebar.Item`', () => {
it('should display tooltip', () => {
const items = getSidebarItems(render(<TestSidebar collapsed />));

items.forEach((item) => {
expect(item.firstElementChild).toHaveAttribute('data-testid', 'tooltip-target');
});
});

it("shouldn't display text content", () => {
const items = getSidebarItemContents(render(<TestSidebar collapsed />));

items.forEach((item) => expect(item).toHaveClass('hidden'));
});
await waitFor(() => expect(getByTestId('sidebar-collapse')).toHaveClass('hidden'));
});

describe('`Sidebar.Item`', () => {
it("shouldn't display `label`", () => {
const labels = getSidebarLabels(render(<TestSidebar collapsed />));

labels.forEach((label) => expect(label).not.toBeVisible());
});
});

describe('`Sidebar.Logo`', () => {
it("shouldn't display text content", () => {
const logo = getSidebarLogo(render(<TestSidebar collapsed />));

expect(logo.lastElementChild).toHaveClass('hidden');
});
});
});

describe('`Sidebar.Item`', () => {
describe('`as=""`', () => {
it('should use that HTML element', () => {
const { getByLabelText } = render(<TestSidebar />);
const asItem = getByLabelText('My heading');

expect(asItem.tagName.toLocaleLowerCase()).toEqual('h3');
});
});
});
});

describe('Rendering', () => {
it('should render', () => {
const sidebar = getSidebar(render(<TestSidebar />));

expect(sidebar).toBeInTheDocument();
});

describe('`collapseBehavior="hide"`', () => {
it('should render', () => {
const sidebar = getSidebar(render(<TestSidebar collapseBehavior="hide" collapsed />));

expect(sidebar).toBeInTheDocument();
});
});

describe('`collapsed={true}`', () => {
it('should render', () => {
const sidebar = getSidebar(render(<TestSidebar collapsed />));

expect(sidebar).toBeInTheDocument();
});
});
});
});

const SidebarTestComponent = ({ collapsed }: SidebarProps): JSX.Element => (
<Sidebar collapsed={collapsed}>
const TestSidebar = ({ ...props }: SidebarProps): JSX.Element => (
<Sidebar {...props}>
<Sidebar.Logo href="#" img="favicon.png" imgAlt="Flowbite logo">
Flowbite
</Sidebar.Logo>
<Sidebar.Items>
<Sidebar.ItemGroup>
<Sidebar.Item href="#" icon={HiChartPie}>
<Sidebar.Item href="#" icon={HiChartPie} label="3" labelColor="success">
Dashboard
</Sidebar.Item>
<Sidebar.Collapse icon={HiShoppingBag} label="E-commerce">
<Sidebar.Collapse aria-label="E-commerce" icon={HiShoppingBag}>
<Sidebar.Item href="#">Products</Sidebar.Item>
<Sidebar.Item href="#">Services</Sidebar.Item>
</Sidebar.Collapse>
<Sidebar.Item href="#" icon={HiInbox}>
Inbox
</Sidebar.Item>
<Sidebar.Item href="#" icon={HiUser}>
Users
</Sidebar.Item>
<Sidebar.Item href="#" icon={HiShoppingBag}>
Something else
</Sidebar.Item>
<Sidebar.Item href="#" icon={HiArrowSmRight}>
Sign In
</Sidebar.Item>
<Sidebar.Item href="#" icon={HiTable}>
Sign Up
</Sidebar.Item>
<Sidebar.Item as="h3">My heading</Sidebar.Item>
</Sidebar.ItemGroup>
</Sidebar.Items>
<Sidebar.CTA>
<div className="mb-3 flex items-center">
<Badge color="yellow">Beta</Badge>
<Button
aria-label="Close"
className="-mx-1.5 -my-1.5 ml-auto !h-6 !w-6 bg-transparent !p-1 text-blue-900 hover:bg-blue-200 dark:!bg-blue-900 dark:text-blue-200 dark:hover:!bg-blue-800"
data-collapse-toggle="dropdown-cta"
>
<span className="sr-only">Close</span>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
></path>
</svg>
</Button>
</div>
<p className="mb-3 text-sm text-blue-900 dark:text-blue-400">
Preview the new Flowbite dashboard navigation! You can turn the new navigation off for a limited time in your
profile.
</p>
<a
className="text-sm text-blue-900 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
href="#"
>
Turn new navigation off
</a>
</Sidebar.CTA>
<Sidebar.CTA>Some content</Sidebar.CTA>
</Sidebar>
);

const getSidebar = ({ getByLabelText }: Pick<RenderResult, 'getByLabelText'>): HTMLElement => getByLabelText('Sidebar');

const getSidebarCollapseButtons = ({ getAllByRole }: Pick<RenderResult, 'getAllByRole'>): HTMLElement[] =>
getAllByRole('button');

const getSidebarCollapses = ({ getAllByRole }: Pick<RenderResult, 'getAllByRole'>): HTMLElement[] =>
getAllByRole('list').slice(1);

const getSidebarCTA = ({ getByText }: Pick<RenderResult, 'getByText'>): HTMLElement => getByText('Some content');

const getSidebarItemContents = ({ getAllByTestId }: Pick<RenderResult, 'getAllByTestId'>): HTMLElement[] =>
getAllByTestId('flowbite-sidebar-item-content');

const getSidebarItems = ({ getAllByRole }: Pick<RenderResult, 'getAllByRole'>): HTMLElement[] =>
getAllByRole('listitem');

const getSidebarLabels = ({ queryAllByTestId }: Pick<RenderResult, 'queryAllByTestId'>): HTMLElement[] =>
queryAllByTestId('flowbite-sidebar-label');

const getSidebarLogo = ({ getByLabelText }: Pick<RenderResult, 'getByLabelText'>): HTMLElement =>
getByLabelText('Flowbite');
2 changes: 1 addition & 1 deletion src/lib/components/Sidebar/Sidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Default.args = {
<Sidebar.Item href="#" icon={HiChartPie}>
Dashboard
</Sidebar.Item>
<Sidebar.Item href="#" icon={HiViewBoards} label="Pro" labelColor="alternative">
<Sidebar.Item href="#" icon={HiViewBoards} label="Pro" labelColor="gray">
Kanban
</Sidebar.Item>
<Sidebar.Item href="#" icon={HiInbox} label="3">
Expand Down
11 changes: 5 additions & 6 deletions src/lib/components/Sidebar/SidebarCTA.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,15 @@ const colorClasses: Record<Color, string> = {
purple: 'bg-purple-50 dark:bg-purple-900',
};

const SidebarCTA: FC<SidebarCTAProps> = ({ children, className, color = 'blue', ...rest }) => {
const { collapsed } = useSidebarContext();
const SidebarCTA: FC<SidebarCTAProps> = ({ children, className, color = 'blue', ...theirProps }) => {
const { isCollapsed } = useSidebarContext();

return collapsed ? (
<></>
) : (
return (
<div
className={classNames('mt-6 rounded-lg p-4', colorClasses[color], className)}
data-testid="sidebar-cta"
{...rest}
hidden={isCollapsed}
{...theirProps}
>
{children}
</div>
Expand Down
Loading

0 comments on commit 44a29e7

Please sign in to comment.