From 2350481ef113f317b5cd9db74a8337e63866bfcb Mon Sep 17 00:00:00 2001 From: Stanislav Holts Date: Wed, 7 Jul 2021 18:33:17 +0300 Subject: [PATCH] feat(component): add ButtonGroup component (#556) * feat(component): add ButtonGroup component * fix: comments * fix: grammatic --- .../components/ButtonGroup/ButtonGroup.tsx | 154 ++++++++++++++++++ .../src/components/ButtonGroup/index.ts | 1 + .../src/components/ButtonGroup/spec.tsx | 107 ++++++++++++ .../src/components/ButtonGroup/styled.tsx | 57 +++++++ packages/big-design/src/components/index.ts | 1 + .../docs/PropTables/ButtonGroupPropTable.tsx | 24 +++ packages/docs/PropTables/index.ts | 1 + packages/docs/components/SideNav/SideNav.tsx | 3 + packages/docs/next.config.js | 1 + .../pages/ButtonGroup/ButtonGroupPage.tsx | 70 ++++++++ 10 files changed, 419 insertions(+) create mode 100644 packages/big-design/src/components/ButtonGroup/ButtonGroup.tsx create mode 100644 packages/big-design/src/components/ButtonGroup/index.ts create mode 100644 packages/big-design/src/components/ButtonGroup/spec.tsx create mode 100644 packages/big-design/src/components/ButtonGroup/styled.tsx create mode 100644 packages/docs/PropTables/ButtonGroupPropTable.tsx create mode 100644 packages/docs/pages/ButtonGroup/ButtonGroupPage.tsx diff --git a/packages/big-design/src/components/ButtonGroup/ButtonGroup.tsx b/packages/big-design/src/components/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 000000000..8669233b2 --- /dev/null +++ b/packages/big-design/src/components/ButtonGroup/ButtonGroup.tsx @@ -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 { + text: string; + icon?: React.ReactElement; +} + +export interface ButtonGroupProps extends HTMLAttributes, MarginProps { + actions: ButtonGroupAction[]; +} + +const excludeIconProps = ({ + iconOnly, + iconRight, + iconLeft, + ...actionProps +}: ButtonProps & Pick): ButtonGroupAction => actionProps; + +export const ButtonGroup: React.FC = memo(({ actions, ...wrapperProps }) => { + const parentRef = createRef(); + const dropdownRef = createRef(); + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [actionsState, setActionsState] = useState( + actions.map((action) => ({ isVisible: true, action: excludeIconProps(action), ref: createRef() })), + ); + + 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( + () => ( + + !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={ + !isVisible)} + iconOnly={} + variant="secondary" + /> + } + placement="bottom-end" + /> + + ), + [actionsState, dropdownRef, isMenuVisible], + ); + + const renderedActions = useMemo( + () => + [...actionsState] + .reverse() + .sort(({ isVisible }) => (isVisible ? -1 : 1)) + .map(({ action, isVisible, ref }, key) => { + const { text, icon, ...buttonProps } = action; + + return ( + + + {text} + + + ); + }), + [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 ? ( + + {renderedActions} + {renderedDropdown} + + ) : null; +}); diff --git a/packages/big-design/src/components/ButtonGroup/index.ts b/packages/big-design/src/components/ButtonGroup/index.ts new file mode 100644 index 000000000..cd9c57b0a --- /dev/null +++ b/packages/big-design/src/components/ButtonGroup/index.ts @@ -0,0 +1 @@ +export * from './ButtonGroup'; diff --git a/packages/big-design/src/components/ButtonGroup/spec.tsx b/packages/big-design/src/components/ButtonGroup/spec.tsx new file mode 100644 index 000000000..36958afdf --- /dev/null +++ b/packages/big-design/src/components/ButtonGroup/spec.tsx @@ -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(); + + 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( + , + ); + + 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( + , + ); + + 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( + }, + { text: '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( + , + ); + + 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 }) }), + ); +}); diff --git a/packages/big-design/src/components/ButtonGroup/styled.tsx b/packages/big-design/src/components/ButtonGroup/styled.tsx new file mode 100644 index 000000000..b0f01bd4b --- /dev/null +++ b/packages/big-design/src/components/ButtonGroup/styled.tsx @@ -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)` + ${({ borderRadius, theme }) => + borderRadius + ? css` + border-radius: ${theme.borderRadius.normal}; + ` + : css` + border-radius: ${theme.borderRadius.none}; + `} + + &:focus { + z-index: 1; + } +`; + +export const StyledFlexItem = styled(FlexItem)` + 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 }; diff --git a/packages/big-design/src/components/index.ts b/packages/big-design/src/components/index.ts index 486d6a798..cb7c28858 100644 --- a/packages/big-design/src/components/index.ts +++ b/packages/big-design/src/components/index.ts @@ -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'; diff --git a/packages/docs/PropTables/ButtonGroupPropTable.tsx b/packages/docs/PropTables/ButtonGroupPropTable.tsx new file mode 100644 index 000000000..eab5957cd --- /dev/null +++ b/packages/docs/PropTables/ButtonGroupPropTable.tsx @@ -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{' '} + + Button + {' '} + props and and additional text & icon prop. See example for usage. + + ), + required: true, + }, +]; + +export const ButtonGroupPropTable: React.FC = (props) => ( + +); diff --git a/packages/docs/PropTables/index.ts b/packages/docs/PropTables/index.ts index e14016432..ed011ab2b 100644 --- a/packages/docs/PropTables/index.ts +++ b/packages/docs/PropTables/index.ts @@ -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'; diff --git a/packages/docs/components/SideNav/SideNav.tsx b/packages/docs/components/SideNav/SideNav.tsx index 82fb8be7e..a1dbd8c59 100644 --- a/packages/docs/components/SideNav/SideNav.tsx +++ b/packages/docs/components/SideNav/SideNav.tsx @@ -80,6 +80,9 @@ export const SideNav: React.FC = () => { Button + + ButtonGroup + Checkbox diff --git a/packages/docs/next.config.js b/packages/docs/next.config.js index 86463469e..b1b27996d 100644 --- a/packages/docs/next.config.js +++ b/packages/docs/next.config.js @@ -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' }, diff --git a/packages/docs/pages/ButtonGroup/ButtonGroupPage.tsx b/packages/docs/pages/ButtonGroup/ButtonGroupPage.tsx new file mode 100644 index 000000000..7e390e4d8 --- /dev/null +++ b/packages/docs/pages/ButtonGroup/ButtonGroupPage.tsx @@ -0,0 +1,70 @@ +import { Box, ButtonGroup, H0, H1, H2, Text } from '@bigcommerce/big-design'; +import { CheckIcon, InfoIcon } from '@bigcommerce/big-design-icons'; +import React from 'react'; + +import { Code, CodePreview } from '../../components'; +import { ButtonGroupPropTable } from '../../PropTables/ButtonGroupPropTable'; + +const ButtonGroupPage = () => ( + <> + Button Group + + + The Button Group component is used for grouping actions like Button. + Allows to save space and reduce visual overload when there are multiple actions available for the same entity. + + + + {/* jsx-to-string:start */} + + + + {/* jsx-to-string:end */} + + +

API

+ + +

Examples

+

Action type destructive

+ + By default action with actionsType: 'destructive' hides under the ellipsis. + + + + {/* jsx-to-string:start */} + + {/* jsx-to-string:end */} + + +

Icon property

+ Icon is available only for actions which is hidden under the ellipsis. + + {/* jsx-to-string:start */} + + , text: 'Button 1' }, + { text: 'Button 2' }, + { text: 'Button 3' }, + { icon: , text: 'Button 4' }, + { icon: , text: 'Button 5' }, + ]} + /> + + {/* jsx-to-string:end */} + + +); + +export default ButtonGroupPage;