Skip to content

Commit

Permalink
feat(component): add ButtonGroup component (#556)
Browse files Browse the repository at this point in the history
* feat(component): add ButtonGroup component

* fix: comments

* fix: grammatic
  • Loading branch information
golcinho authored Jul 7, 2021
1 parent 7706809 commit 2350481
Show file tree
Hide file tree
Showing 10 changed files with 419 additions and 0 deletions.
154 changes: 154 additions & 0 deletions packages/big-design/src/components/ButtonGroup/ButtonGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { MoreHorizIcon } from '@bigcommerce/big-design-icons';
import React, { createRef, HTMLAttributes, memo, useCallback, useEffect, useMemo, useState } from 'react';

import { useWindowResizeListener } from '../../hooks';
import { MarginProps } from '../../mixins';
import { ButtonProps } from '../Button';
import { Dropdown } from '../Dropdown';
import { Flex } from '../Flex';

import { StyledButton, StyledFlexItem } from './styled';

export interface ButtonGroupAction extends Omit<ButtonProps, 'children' | 'iconOnly' | 'iconRight' | 'iconLeft'> {
text: string;
icon?: React.ReactElement;
}

export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement>, MarginProps {
actions: ButtonGroupAction[];
}

const excludeIconProps = ({
iconOnly,
iconRight,
iconLeft,
...actionProps
}: ButtonProps & Pick<ButtonGroupAction, 'text'>): ButtonGroupAction => actionProps;

export const ButtonGroup: React.FC<ButtonGroupProps> = memo(({ actions, ...wrapperProps }) => {
const parentRef = createRef<HTMLDivElement>();
const dropdownRef = createRef<HTMLDivElement>();
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [actionsState, setActionsState] = useState(
actions.map((action) => ({ isVisible: true, action: excludeIconProps(action), ref: createRef<HTMLDivElement>() })),
);

const hideOverflowedActions = useCallback(() => {
const parentWidth = parentRef.current?.offsetWidth;
const dropdownWidth = dropdownRef.current?.offsetWidth;

if (!parentWidth || !dropdownWidth) {
return;
}

let remainingWidth = parentWidth;

const nextState = actionsState.map((stateObj) => {
const actionWidth = stateObj.ref.current?.offsetWidth;

if (!actionWidth) {
return stateObj;
}

if (stateObj.action.actionType === 'destructive') {
return { ...stateObj, isVisible: false };
}

if (remainingWidth - actionWidth > dropdownWidth) {
remainingWidth = remainingWidth - actionWidth;

return { ...stateObj, isVisible: true };
}

return { ...stateObj, isVisible: false };
});

const visibleActions = actionsState.filter(({ isVisible }) => isVisible);
const nextVisibleActions = nextState.filter(({ isVisible }) => isVisible);

if (visibleActions.length !== nextVisibleActions.length) {
setActionsState(nextState);
}
}, [actionsState, dropdownRef, parentRef]);

const renderedDropdown = useMemo(
() => (
<StyledFlexItem data-testid="buttongroup-dropdown" isVisible={isMenuVisible} ref={dropdownRef} role="listitem">
<Dropdown
items={actionsState
.filter(({ isVisible }) => !isVisible)
.map(({ action, ref }) => ({
actionType: action.actionType,
content: action.text,
disabled: action.disabled,
onItemClick: () => {
if (ref.current) {
ref.current.getElementsByTagName('button')[0].click();
}
},
hash: action.text.toLowerCase(),
icon: action.icon,
}))}
toggle={
<StyledButton
borderRadius={actionsState.every(({ isVisible }) => !isVisible)}
iconOnly={<MoreHorizIcon title="more" />}
variant="secondary"
/>
}
placement="bottom-end"
/>
</StyledFlexItem>
),
[actionsState, dropdownRef, isMenuVisible],
);

const renderedActions = useMemo(
() =>
[...actionsState]
.reverse()
.sort(({ isVisible }) => (isVisible ? -1 : 1))
.map(({ action, isVisible, ref }, key) => {
const { text, icon, ...buttonProps } = action;

return (
<StyledFlexItem data-testid="buttongroup-item" key={key} isVisible={isVisible} ref={ref} role="listitem">
<StyledButton {...buttonProps} variant="secondary">
{text}
</StyledButton>
</StyledFlexItem>
);
}),
[actionsState],
);

useEffect(() => {
const nextIsMenuVisible = actionsState.some(({ isVisible }) => !isVisible);

if (nextIsMenuVisible !== isMenuVisible) {
setIsMenuVisible(nextIsMenuVisible);
}
}, [actionsState, isMenuVisible]);

useEffect(() => {
hideOverflowedActions();
}, [actions, parentRef, hideOverflowedActions]);

useWindowResizeListener(() => {
hideOverflowedActions();
});

return actions.length > 0 ? (
<Flex
{...wrapperProps}
data-testid="buttongroup-wrapper"
flexDirection="row"
flexWrap="nowrap"
ref={parentRef}
role="list"
>
{renderedActions}
{renderedDropdown}
</Flex>
) : null;
});
1 change: 1 addition & 0 deletions packages/big-design/src/components/ButtonGroup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ButtonGroup';
107 changes: 107 additions & 0 deletions packages/big-design/src/components/ButtonGroup/spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { AddIcon } from '@bigcommerce/big-design-icons';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';

import { ButtonGroup } from './ButtonGroup';

const originalPrototype = Object.getOwnPropertyDescriptors(window.HTMLElement.prototype);

beforeAll(() => {
Object.defineProperties(window.HTMLElement.prototype, {
offsetWidth: {
get() {
if (this.dataset.testid === 'buttongroup-dropdown') {
return 50;
}

if (this.dataset.testid === 'buttongroup-item') {
return 100;
}

if (this.dataset.testid === 'buttongroup-wrapper') {
return 400;
}

return 0;
},
},
});
});

afterAll(() => Object.defineProperties(window.HTMLElement.prototype, originalPrototype));

test('renders given actions', () => {
render(<ButtonGroup actions={[{ text: 'button 1' }, { text: 'button 2' }, { text: 'button 3' }]} />);

expect(screen.getByRole('button', { name: /button 1/i })).toBeVisible();
expect(screen.getByRole('button', { name: /button 2/i })).toBeVisible();
expect(screen.getByRole('button', { name: /button 3/i })).toBeVisible();
});

test('renders dropdown if items do not fit', async () => {
render(
<ButtonGroup actions={[{ text: 'button 1' }, { text: 'button 2' }, { text: 'button 3' }, { text: 'button 4' }]} />,
);

expect(screen.getByText('button 4')).not.toBeVisible();

fireEvent.click(screen.getByTitle('more'));

expect(await screen.findByRole('option', { name: /button 4/i })).toBeVisible();
});

test('renders dropdown if some of items have destructive type', async () => {
render(
<ButtonGroup
actions={[{ actionType: 'destructive', text: 'button 1' }, { text: 'button 2' }, { text: 'button 3' }]}
/>,
);

expect(screen.getByText('button 1')).not.toBeVisible();

fireEvent.click(screen.getByTitle('more'));

expect(await screen.findByRole('option', { name: /button 1/i })).toBeVisible();
});

test('renders icon only with dropdown item', async () => {
render(
<ButtonGroup
actions={[
{ text: 'button 1' },
{ text: 'button 2' },
{ text: 'button 3', icon: <AddIcon title="button 3 icon" /> },
{ text: 'button 4', icon: <AddIcon title="button 4 icon" /> },
]}
/>,
);

expect(screen.queryByTitle('button 3 icon')).toBeNull();

fireEvent.click(screen.getByTitle('more'));

expect(await screen.findByTitle('button 4 icon')).toBeInTheDocument();
});

test('dropdown item on click callback receives synthetic event', async () => {
const mockOnClick = jest.fn();

render(
<ButtonGroup
actions={[
{ text: 'button 1' },
{ text: 'button 2' },
{ text: 'button 3' },
{ text: 'button 4', onClick: mockOnClick },
]}
/>,
);

fireEvent.click(screen.getByTitle('more'));

fireEvent.click(await screen.findByRole('option', { name: /button 4/i }));

expect(mockOnClick).toHaveBeenCalledWith(
expect.objectContaining({ target: await screen.findByRole('button', { name: /button 4/i }) }),
);
});
57 changes: 57 additions & 0 deletions packages/big-design/src/components/ButtonGroup/styled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { theme as defaultTheme } from '@bigcommerce/big-design-theme';
import styled, { css } from 'styled-components';

import { StyleableButton } from '../Button/private';
import { FlexItem } from '../Flex';

interface StyledButtonProps {
borderRadius?: boolean;
}

interface StyledFlexItemProps {
isVisible: boolean;
}

export const StyledButton = styled(StyleableButton)<StyledButtonProps>`
${({ borderRadius, theme }) =>
borderRadius
? css`
border-radius: ${theme.borderRadius.normal};
`
: css`
border-radius: ${theme.borderRadius.none};
`}
&:focus {
z-index: 1;
}
`;

export const StyledFlexItem = styled(FlexItem)<StyledFlexItemProps>`
margin-right: -1px;
&:last-of-type {
margin-right: 0;
}
&:first-of-type ${StyledButton} {
border-bottom-left-radius: ${({ theme }) => theme.borderRadius.normal};
border-top-left-radius: ${({ theme }) => theme.borderRadius.normal};
}
&:nth-last-of-type(-n + 2) ${StyledButton} {
border-bottom-right-radius: ${({ theme }) => theme.borderRadius.normal};
border-top-right-radius: ${({ theme }) => theme.borderRadius.normal};
}
${({ isVisible }) =>
!isVisible &&
css`
position: absolute;
visibility: hidden;
z-index: ${({ theme }) => -theme.zIndex.tooltip};
`}
`;

StyledButton.defaultProps = { theme: defaultTheme };
StyledFlexItem.defaultProps = { theme: defaultTheme };
1 change: 1 addition & 0 deletions packages/big-design/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './Alert';
export * from './Badge';
export * from './Box';
export * from './Button';
export * from './ButtonGroup';
export * from './Counter';
export * from './Checkbox';
export * from './Chip';
Expand Down
24 changes: 24 additions & 0 deletions packages/docs/PropTables/ButtonGroupPropTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';

import { Code, NextLink, Prop, PropTable, PropTableWrapper } from '../components';

const buttonGroupProps: Prop[] = [
{
name: 'actions',
types: 'object[]',
description: (
<>
Accepts an array of objects with{' '}
<NextLink href="/Button/ButtonPage" as="/button">
Button
</NextLink>{' '}
props and and additional <Code>text</Code> & <Code>icon</Code> prop. See example for usage.
</>
),
required: true,
},
];

export const ButtonGroupPropTable: React.FC<PropTableWrapper> = (props) => (
<PropTable title="ButtonGroup" propList={buttonGroupProps} {...props} />
);
1 change: 1 addition & 0 deletions packages/docs/PropTables/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './BadgePropTable';
export * from './BoxPropTable';
export * from './ButtonPropTable';
export * from './ButtonGroupPropTable';
export * from './CheckboxPropTable';
export * from './CollapsePropTable';
export * from './CounterPropTable';
Expand Down
3 changes: 3 additions & 0 deletions packages/docs/components/SideNav/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export const SideNav: React.FC = () => {
<SideNavLink href="/Button/ButtonPage" as="/button">
Button
</SideNavLink>
<SideNavLink href="/ButtonGroup/ButtonGroupPage" as="/button-group">
ButtonGroup
</SideNavLink>
<SideNavLink href="/Checkbox/CheckboxPage" as="/checkbox">
Checkbox
</SideNavLink>
Expand Down
1 change: 1 addition & 0 deletions packages/docs/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ module.exports = {
'/box': { page: '/Box/BoxPage' },
'/breakpoints': { page: '/Breakpoints/BreakpointsPage' },
'/button': { page: '/Button/ButtonPage' },
'/button-group': { page: '/ButtonGroup/ButtonGroupPage' },
'/checkbox': { page: '/Checkbox/CheckboxPage' },
'/collapse': { page: '/Collapse/CollapsePage' },
'/counter': { page: '/Counter/CounterPage' },
Expand Down
Loading

0 comments on commit 2350481

Please sign in to comment.