Skip to content

Commit

Permalink
feat(component): adds list groups w/headers to Dropdown component (#288)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jordan Massingill authored Dec 4, 2019
1 parent b2cf7f7 commit ff031e9
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 33 deletions.
47 changes: 41 additions & 6 deletions packages/big-design/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { RefObject } from 'react';
import React, { Fragment, RefObject } from 'react';
import { Manager, Reference } from 'react-popper';
import scrollIntoView from 'scroll-into-view-if-needed';

Expand All @@ -7,10 +7,11 @@ import { Flex } from '../Flex';
import { FlexItem } from '../Flex/Item';
import { Link } from '../Link';
import { List } from '../List';
import { ListGroupHeader } from '../List/GroupHeader';
import { ListItem } from '../List/Item';
import { Tooltip, TooltipProps } from '../Tooltip';

import { DropdownLinkItem, DropdownOption, DropdownProps } from './types';
import { DropdownLinkItem, DropdownOption, DropdownOptionGroup, DropdownProps } from './types';

interface DropdownState {
highlightedItem: HTMLLIElement | null;
Expand Down Expand Up @@ -58,14 +59,35 @@ export class Dropdown<T extends any> extends React.PureComponent<DropdownProps<T
role="listbox"
{...aria}
>
{this.renderOptions()}
{this.renderItems()}
</List>
</Manager>
);
}

private renderOptions() {
private isGroup(item: DropdownOption<T> | DropdownOptionGroup<T>) {
return 'options' in item && !('content' in item);
}

private isOption(item: DropdownOption<T> | DropdownOptionGroup<T>) {
return 'content' in item && !('options' in item);
}

private renderItems() {
const { options } = this.props;

if (Array.isArray(options) && options.every(this.isGroup)) {
return (options as Array<DropdownOptionGroup<T>>).map((group, groupIndex) => this.renderGroup(group, groupIndex));
}

if (Array.isArray(options) && options.every(this.isOption)) {
return this.renderOptions(options as Array<DropdownOption<T>>);
}

return;
}

private renderOptions(options: Array<DropdownOption<T>>, groupIndex: number | null = null) {
const { highlightedItem } = this.state;

return (
Expand All @@ -75,7 +97,7 @@ export class Dropdown<T extends any> extends React.PureComponent<DropdownProps<T
return null;
}

const id = this.getItemId(option, index);
const id = this.getItemId(option, index, groupIndex);
const isHighlighted = Boolean(highlightedItem && id === highlightedItem.id);
const ref = React.createRef<HTMLLIElement>();

Expand Down Expand Up @@ -104,6 +126,15 @@ export class Dropdown<T extends any> extends React.PureComponent<DropdownProps<T
);
}

private renderGroup(group: DropdownOptionGroup<T>, groupIndex: number) {
return (
<Fragment key={groupIndex}>
<ListGroupHeader>{group.label}</ListGroupHeader>
{this.renderOptions(group.options, groupIndex)}
</Fragment>
);
}

private wrapInLink(option: DropdownLinkItem<T>, content: React.ReactChild) {
return (
<Link href={option.url} target={option.target}>
Expand Down Expand Up @@ -203,9 +234,13 @@ export class Dropdown<T extends any> extends React.PureComponent<DropdownProps<T
return id || this.uniqueDropdownId;
}

private getItemId(item: DropdownOption<T>, index: number) {
private getItemId(item: DropdownOption<T>, index: number, groupIndex: number | null = null) {
const { id } = item;

if (groupIndex !== null) {
return id || `${this.getDropdownId()}-group-${groupIndex}-item-${index}`;
}

return id || `${this.getDropdownId()}-item-${index}`;
}

Expand Down
119 changes: 96 additions & 23 deletions packages/big-design/src/components/Dropdown/spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,71 @@ import { Button } from '../Button';

import { Dropdown } from './Dropdown';

const onClick = jest.fn();
let onClick = jest.fn();

const DropdownMock = (
const DropdownMock = (click: jest.Mock) => (
<Dropdown
options={[
{ content: 'Option', type: 'string', value: '0' },
{ content: 'Option', type: 'string', value: '1', onClick },
{ content: 'Option', type: 'string', value: '1', onClick: click },
{ content: 'Option', type: 'string', value: '2', actionType: 'destructive' },
{ content: 'Option', type: 'string', value: '3', icon: <CheckCircleIcon /> },
]}
trigger={<Button>Button</Button>}
/>
);

const GroupedDropdownMock = (click: jest.Mock) => (
<Dropdown
options={[
{
label: 'Label 1',
options: [
{ content: 'Option 1', onClick: click },
{ content: 'Option 2', onClick: click },
{ content: 'Option 3', onClick: click },
],
},
{
label: 'Label 2',
options: [
{ content: 'Option 4', onClick: click },
{ content: 'Option 5', onClick: click },
{ content: 'Option 6', onClick: click },
],
},
]}
trigger={<Button>Button</Button>}
/>
);

beforeEach(() => {
onClick = jest.fn();
});

test('renders dropdown trigger', () => {
const { getByRole } = render(DropdownMock);
const { getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

expect(trigger).toBeInTheDocument();
});

test('dropdown trigger has an id', () => {
const { getByRole } = render(DropdownMock);
const { getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

expect(trigger.id).toBeDefined();
});

test('dropdown trigger has aria-haspopup', () => {
const { getByRole } = render(DropdownMock);
const { getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

expect(trigger.getAttribute('aria-haspopup')).toBe('true');
});

test('dropdown trigger has aria-expanded when dropdown menu is open', () => {
const { getByRole } = render(DropdownMock);
const { getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -52,13 +80,13 @@ test('dropdown trigger has aria-expanded when dropdown menu is open', () => {
});

test('renders the dropdown menu closed', () => {
const { queryByRole } = render(DropdownMock);
const { queryByRole } = render(DropdownMock(onClick));

expect(queryByRole('listbox')).not.toBeVisible();
});

test('opens/closes dropdown menu when trigger is clicked', () => {
const { getByRole, queryByRole } = render(DropdownMock);
const { getByRole, queryByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -69,7 +97,7 @@ test('opens/closes dropdown menu when trigger is clicked', () => {
});

test('dropdown menu has aria-labelledby', () => {
const { getByRole } = render(DropdownMock);
const { getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -78,7 +106,7 @@ test('dropdown menu has aria-labelledby', () => {
});

test('dropdown menu has aria-activedescendant', () => {
const { getAllByRole, getByRole } = render(DropdownMock);
const { getAllByRole, getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -89,14 +117,14 @@ test('dropdown menu has aria-activedescendant', () => {
});

test('dropdown menu should have 4 dropdown items', () => {
const { getAllByRole } = render(DropdownMock);
const { getAllByRole } = render(DropdownMock(onClick));

const options = getAllByRole('option');
expect(options.length).toBe(4);
});

test('first dropdown item should be selected when dropdown is opened', () => {
const { getByRole, getAllByRole } = render(DropdownMock);
const { getByRole, getAllByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -107,7 +135,7 @@ test('first dropdown item should be selected when dropdown is opened', () => {
});

test('up/down arrows should change dropdown item selection', () => {
const { getAllByRole, getByRole } = render(DropdownMock);
const { getAllByRole, getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -126,7 +154,7 @@ test('up/down arrows should change dropdown item selection', () => {
});

test('esc should close menu', () => {
const { getByRole, queryByRole } = render(DropdownMock);
const { getByRole, queryByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -137,7 +165,7 @@ test('esc should close menu', () => {
});

test('tab should close menu', () => {
const { getByRole, queryByRole } = render(DropdownMock);
const { getByRole, queryByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -148,7 +176,7 @@ test('tab should close menu', () => {
});

test('home should select first dropdown item', () => {
const { getAllByRole, getByRole } = render(DropdownMock);
const { getAllByRole, getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -167,7 +195,7 @@ test('home should select first dropdown item', () => {
});

test('end should select last dropdown item', () => {
const { getAllByRole, getByRole } = render(DropdownMock);
const { getAllByRole, getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -183,7 +211,7 @@ test('end should select last dropdown item', () => {
});

test('enter should trigger onClick', () => {
const { getAllByRole, getByRole } = render(DropdownMock);
const { getAllByRole, getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -199,7 +227,7 @@ test('enter should trigger onClick', () => {
});

test('space should trigger onClick', () => {
const { getAllByRole, getByRole } = render(DropdownMock);
const { getAllByRole, getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -215,7 +243,7 @@ test('space should trigger onClick', () => {
});

test('clicking on dropdown items should trigger onClick', () => {
const { getAllByRole, getByRole } = render(DropdownMock);
const { getAllByRole, getByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand All @@ -228,7 +256,7 @@ test('clicking on dropdown items should trigger onClick', () => {
});

test('dropdown items should be highlighted when moused over', () => {
const { getByRole, getAllByRole } = render(DropdownMock);
const { getByRole, getAllByRole } = render(DropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);
Expand Down Expand Up @@ -262,7 +290,7 @@ test('dropdown menu renders 4 link when passed options of type link', () => {
});

test('items renders icons', () => {
const { container } = render(DropdownMock);
const { container } = render(DropdownMock(onClick));

const svg = container.querySelectorAll('svg');
expect(svg.length).toBe(1);
Expand Down Expand Up @@ -347,3 +375,48 @@ test('no errors expected if all options are disabled', () => {
fireEvent.click(trigger);
}).not.toThrow();
});

test('dropdown should have 2 group labels', () => {
const { getAllByRole } = render(GroupedDropdownMock(onClick));

const labels = getAllByRole('group');

expect(labels.length).toBe(2);
});

test('group labels are grayed out', () => {
const { getAllByRole } = render(GroupedDropdownMock(onClick));

const labels = getAllByRole('group');

expect(labels[0]).toHaveStyle('color: #B4BAD1');
expect(labels[1]).toHaveStyle('color: #B4BAD1');
});

test('group labels are skipped over when using keyboard to navigate options', () => {
const { getAllByRole, getByRole } = render(GroupedDropdownMock(onClick));
const trigger = getByRole('button');

fireEvent.click(trigger);

const menu = getByRole('listbox');
const options = getAllByRole('option');

fireEvent.keyDown(menu, { key: 'ArrowDown' });
fireEvent.keyDown(menu, { key: 'ArrowDown' });
fireEvent.keyDown(menu, { key: 'ArrowDown' });

expect(options[3].dataset.highlighted).toBe('true');
});

test('clicking label does not call onClick', () => {
const { getAllByRole, getByRole } = render(GroupedDropdownMock(onClick));
const trigger = getByRole('button');
const labels = getAllByRole('group');

fireEvent.click(trigger);
fireEvent.mouseOver(labels[0]);
fireEvent.click(labels[0]);

expect(onClick).not.toHaveBeenCalled();
});
7 changes: 6 additions & 1 deletion packages/big-design/src/components/Dropdown/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type DropdownOption<T> = DropdownItem<T> | DropdownLinkItem<T>;

export interface DropdownProps<T> extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
maxHeight?: number;
options: Array<DropdownOption<T>>;
options: Array<DropdownOption<T>> | Array<DropdownOptionGroup<T>>;
placement?: Placement;
trigger: React.ReactElement;
}
Expand All @@ -28,3 +28,8 @@ export interface DropdownLinkItem<T> extends BaseItem<T> {
type: 'link';
url: string;
}

export interface DropdownOptionGroup<T> {
label: string;
options: Array<DropdownOption<T>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React, { memo } from 'react';

import { StyledGroupHeader } from './styled';

export const ListGroupHeader: React.FC<React.LiHTMLAttributes<
HTMLLIElement
>> = memo(({ className, style, value, ...rest }) => (
<StyledGroupHeader {...rest} tabIndex={-1} onMouseDown={preventFocus} role="group" />
));

function preventFocus(event: React.MouseEvent<HTMLLIElement, MouseEvent>) {
event.preventDefault();
}

ListGroupHeader.displayName = 'ListGroupHeader';
Loading

0 comments on commit ff031e9

Please sign in to comment.