-
Notifications
You must be signed in to change notification settings - Fork 65
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(component): add ButtonGroup component (#556)
* feat(component): add ButtonGroup component * fix: comments * fix: grammatic
- Loading branch information
Showing
10 changed files
with
419 additions
and
0 deletions.
There are no files selected for viewing
154 changes: 154 additions & 0 deletions
154
packages/big-design/src/components/ButtonGroup/ButtonGroup.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './ButtonGroup'; |
107 changes: 107 additions & 0 deletions
107
packages/big-design/src/components/ButtonGroup/spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }) }), | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.