Skip to content

Commit

Permalink
feat(component): Use theme in ListGroups, resolves #137 (#203)
Browse files Browse the repository at this point in the history
* feat(type): Add `ListGroup` to `FlowbiteTheme`

* feat(component): Use theme in `ListGroup`s, resolves #137

* refactor(docs): Update `ListGroup` examples to use themes

* test(component): Add numerous unit tests for `ListGroup`s
  • Loading branch information
tulup-conner authored Jun 8, 2022
1 parent b6fcf6a commit 360a723
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 99 deletions.
68 changes: 38 additions & 30 deletions src/docs/pages/ListGroupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,44 @@ const ListGroupPage: FC = () => {
{
title: 'Default list',
code: (
<ListGroup className="w-48">
<ListGroup.Item>Profile</ListGroup.Item>
<ListGroup.Item>Settings</ListGroup.Item>
<ListGroup.Item>Messages</ListGroup.Item>
<ListGroup.Item>Download</ListGroup.Item>
</ListGroup>
<div className="w-48">
<ListGroup>
<ListGroup.Item>Profile</ListGroup.Item>
<ListGroup.Item>Settings</ListGroup.Item>
<ListGroup.Item>Messages</ListGroup.Item>
<ListGroup.Item>Download</ListGroup.Item>
</ListGroup>
</div>
),
},
{
title: 'List group with links',
code: (
<ListGroup className="w-48">
<ListGroup.Item active href="/list-group">
Profile
</ListGroup.Item>
<ListGroup.Item href="/list-group">Settings</ListGroup.Item>
<ListGroup.Item href="/list-group">Messages</ListGroup.Item>
<ListGroup.Item href="/list-group">Download</ListGroup.Item>
</ListGroup>
<div className="w-48">
<ListGroup>
<ListGroup.Item active href="/list-group">
Profile
</ListGroup.Item>
<ListGroup.Item href="/list-group">Settings</ListGroup.Item>
<ListGroup.Item href="/list-group">Messages</ListGroup.Item>
<ListGroup.Item href="/list-group">Download</ListGroup.Item>
</ListGroup>
</div>
),
},
{
title: 'List group with buttons',
code: (
<ListGroup className="w-48">
<ListGroup.Item active onClick={() => alert('Profile clicked!')}>
Profile
</ListGroup.Item>
<ListGroup.Item>Settings</ListGroup.Item>
<ListGroup.Item>Messages</ListGroup.Item>
<ListGroup.Item>Download</ListGroup.Item>
</ListGroup>
<div className="w-48">
<ListGroup>
<ListGroup.Item active onClick={() => alert('Profile clicked!')}>
Profile
</ListGroup.Item>
<ListGroup.Item>Settings</ListGroup.Item>
<ListGroup.Item>Messages</ListGroup.Item>
<ListGroup.Item>Download</ListGroup.Item>
</ListGroup>
</div>
),
codeStringifierOptions: {
functionValue: (fn) => (fn.name === 'onClick' ? fn : fn.name),
Expand All @@ -50,14 +56,16 @@ const ListGroupPage: FC = () => {
{
title: 'List group with icons',
code: (
<ListGroup className="w-48">
<ListGroup.Item active icon={HiUserCircle}>
Profile
</ListGroup.Item>
<ListGroup.Item icon={HiOutlineAdjustments}>Settings</ListGroup.Item>
<ListGroup.Item icon={HiInbox}>Messages</ListGroup.Item>
<ListGroup.Item icon={HiCloudDownload}>Download</ListGroup.Item>
</ListGroup>
<div className="w-48">
<ListGroup>
<ListGroup.Item active icon={HiUserCircle}>
Profile
</ListGroup.Item>
<ListGroup.Item icon={HiOutlineAdjustments}>Settings</ListGroup.Item>
<ListGroup.Item icon={HiInbox}>Messages</ListGroup.Item>
<ListGroup.Item icon={HiCloudDownload}>Download</ListGroup.Item>
</ListGroup>
</div>
),
},
];
Expand Down
9 changes: 9 additions & 0 deletions src/lib/components/Flowbite/FlowbiteTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ export interface FlowbiteTheme {
base: string;
icon: string;
};
listGroup: {
base: string;
item: {
active: FlowbiteBoolean;
base: string;
href: FlowbiteBoolean;
icon: string;
};
};
modal: {
base: string;
show: FlowbiteBoolean;
Expand Down
159 changes: 139 additions & 20 deletions src/lib/components/ListGroup/ListGroup.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,150 @@
import type { RenderResult } from '@testing-library/react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { HiCloudDownload } from 'react-icons/hi';
import { describe, expect, it } from 'vitest';

import { ListGroup } from '.';
import { Flowbite } from '../Flowbite';

describe.concurrent('Components / List group', () => {
describe('items', () => {
describe('with a callback action (onClick)', () => {
describe('and user clicks the item', () => {
it('should run its callback', () => {
const { getAllByTestId } = render(<ListGroupTest />);
describe('Keyboard interactions', () => {
describe('`ListGroup.Item`', () => {
describe('`Enter`', () => {
it('should trigger `onClick`', () => {
const itemWithOnClick = getListGroupItems(render(<TestListGroup />))[0];

let item = getAllByTestId('list-group-item')[0];
userEvent.click(item);
userEvent.tab();
userEvent.keyboard('[Enter]');

item = getAllByTestId('list-group-item')[0];
expect(item).toHaveTextContent('Clicked');
expect(itemWithOnClick).toHaveAccessibleName('Clicked');
});
});

describe('`Space`', () => {
it('should trigger `onClick`', () => {
const itemWithOnClick = getListGroupItems(render(<TestListGroup />))[0];

userEvent.tab();
userEvent.keyboard('[Space]');

expect(itemWithOnClick).toHaveAccessibleName('Clicked');
});
});
});

describe('without a link', () => {
describe('`Tab`', () => {
it('should be possible to `Tab` out', () => {
const { getAllByRole, getByLabelText } = render(
<>
<TestListGroup />
<button aria-label="Outside">Outside</button>
</>,
);
const items = getListGroupItems({ getAllByRole });
const outsideButton = getByLabelText('Outside');

userEvent.tab();
items.forEach(() => userEvent.tab());

expect(outsideButton).toHaveFocus();
});
});
});

describe('Rendering', () => {
it('should render', () => {
const listGroup = getListGroup(render(<TestListGroup />));

expect(listGroup).toBeInTheDocument();
});

describe('`ListGroup.Item`', () => {
it('should render', () => {
const items = getListGroupItems(render(<TestListGroup />));

items.forEach((item) => expect(item).toBeInTheDocument());
});

it('should render a button', () => {
const { getAllByTestId } = render(<ListGroupTest />);
const buttons = getListGroupButtons(render(<TestListGroup />));

const item = getAllByTestId('list-group-item')[0];
expect(item).toBeInstanceOf(HTMLButtonElement);
buttons.forEach((button) => expect(button).toHaveAttribute('type', 'button'));
});

describe('`href=".."`', () => {
it('should render an anchor', () => {
const links = getListGroupLinks(render(<TestListGroup />));

links.forEach((link) => expect(link).toHaveAttribute('href', '#'));
});
});
});
});

describe('Theme', () => {
it('should use custom classes', () => {
const theme = {
listGroup: {
base: 'text-gray-100',
},
};

const listGroup = getListGroup(
render(
<Flowbite theme={{ theme }}>
<TestListGroup />
</Flowbite>,
),
);

expect(listGroup).toHaveClass('text-gray-100');
});

describe('with a link', () => {
it('should render an anchor', () => {
const { getAllByTestId } = render(<ListGroupTest />);
describe('`ListGroup.Item`', () => {
it('should use custom classes', () => {
const theme = {
listGroup: {
item: {
active: {
off: 'text-gray-400',
on: 'text-gray-200',
},
base: 'text-gray-100',
href: {
off: 'font-bold',
on: 'font-normal',
},
icon: 'text-gray-300',
},
},
};

const item = getAllByTestId('list-group-item')[1];
expect(item).toBeInstanceOf(HTMLAnchorElement);
expect(item).toHaveAttribute('href', '#');
const { getAllByRole, getAllByTestId } = render(
<Flowbite theme={{ theme }}>
<TestListGroup />
</Flowbite>,
);
const icons = getListGroupItemIcons({ getAllByTestId });
const items = getListGroupItems({ getAllByRole });
const activeItem = items[0];
const itemWithHref = items[1];

icons.forEach((icon) => expect(icon).toHaveClass('text-gray-300'));
items.forEach((item) => expect(item).toHaveClass('text-gray-100'));

[...items.filter((item) => item !== activeItem)].forEach((item) => expect(item).toHaveClass('text-gray-400'));
[...items.filter((item) => item !== itemWithHref)].forEach((item) => expect(item).toHaveClass('font-bold'));

expect(activeItem).toHaveClass('text-gray-200');
expect(itemWithHref).toHaveClass('font-normal');
});
});
});
});

const ListGroupTest = (): JSX.Element => {
const TestListGroup = (): JSX.Element => {
const [isClicked, setClicked] = useState(false);

return (
Expand All @@ -57,3 +158,21 @@ const ListGroupTest = (): JSX.Element => {
</ListGroup>
);
};

const getListGroup = ({ getByRole }: Pick<RenderResult, 'getByRole'>): HTMLElement => getByRole('list');

const getListGroupItemIcons = ({ getAllByTestId }: Pick<RenderResult, 'getAllByTestId'>): HTMLElement[] =>
getAllByTestId('flowbite-list-group-item-icon');

const getListGroupItems = ({ getAllByRole }: Pick<RenderResult, 'getAllByRole'>): HTMLElement[] =>
getAllByRole('listitem').map((li) => li.firstElementChild) as HTMLElement[];

const getListGroupButtons = ({ getAllByRole }: Pick<RenderResult, 'getAllByRole'>): HTMLElement[] =>
getAllByRole('listitem')
.map((li) => li.firstElementChild)
.filter((element) => element?.tagName.toLocaleLowerCase() === 'button') as HTMLButtonElement[];

const getListGroupLinks = ({ getAllByRole }: Pick<RenderResult, 'getAllByRole'>): HTMLElement[] =>
getAllByRole('listitem')
.map((li) => li.firstElementChild)
.filter((element) => element?.tagName.toLocaleLowerCase() === 'a') as HTMLAnchorElement[];
64 changes: 30 additions & 34 deletions src/lib/components/ListGroup/ListGroupItem.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,42 @@
import type { ComponentProps, FC, PropsWithChildren } from 'react';
import classNames from 'classnames';
import type { ComponentProps, FC, PropsWithChildren } from 'react';

export type ListGroupItemProps = PropsWithChildren<{
className?: string;
import { useTheme } from '../Flowbite/ThemeContext';

export interface ListGroupItemProps extends PropsWithChildren<Omit<ComponentProps<'a' | 'button'>, 'className'>> {
active?: boolean;
disabled?: boolean;
href?: string;
icon?: FC<ComponentProps<'svg'>>;
active?: boolean;
onClick?: () => void;
disabled?: boolean;
}>;
}

export const ListGroupItem: FC<ListGroupItemProps> = ({ children, className, href, onClick, active, icon: Icon }) => {
const Wrapper = ({ children, className }: PropsWithChildren<{ className?: string }>) =>
!href ? (
<button
className={classNames('text-left', className)}
data-testid="list-group-item"
export const ListGroupItem: FC<ListGroupItemProps> = ({
active: isActive,
children,
href,
icon: Icon,
onClick,
}): JSX.Element => {
const theme = useTheme().theme.listGroup.item;

const Component = typeof href === 'undefined' ? 'button' : 'a';

return (
<li>
<Component
className={classNames(
theme.active[isActive ? 'on' : 'off'],
theme.base,
theme.href[typeof href === 'undefined' ? 'off' : 'on'],
)}
href={href}
onClick={onClick}
type="button"
>
{Icon && <Icon aria-hidden className={theme.icon} data-testid="flowbite-list-group-item-icon" />}
{children}
</button>
) : (
<a className={className} data-testid="list-group-item" href={href}>
{children}
</a>
);

return (
<Wrapper
className={classNames(
'flex w-full cursor-pointer border-b border-gray-200 py-2 px-4 first:rounded-t-lg last:rounded-b-lg last:border-b-0 dark:border-gray-600',
{
'bg-blue-700 text-white dark:bg-gray-800': active,
'hover:bg-gray-100 hover:text-blue-700 focus:text-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-white dark:focus:text-white dark:focus:ring-gray-500':
!active,
},
className,
)}
>
{Icon && <Icon className="mr-2 h-4 w-4 fill-current" />}
{children}
</Wrapper>
</Component>
</li>
);
};
Loading

0 comments on commit 360a723

Please sign in to comment.