Skip to content

Commit

Permalink
Button Group improvements (#1167)
Browse files Browse the repository at this point in the history
* add action config objects and styles

* add useMedia for switching variants on mobile

* add inline-grid to improve styles

* change implementation for button reorder on mobile

* move LoadingButton to Button

* update snapshots

* address PR comments

* fix button styles

* update snapshots

* add changesets

* update Button props

* address review comments
  • Loading branch information
amelako authored Oct 8, 2021
1 parent a2d7c3d commit 7b5c44c
Show file tree
Hide file tree
Showing 31 changed files with 4,561 additions and 1,603 deletions.
5 changes: 5 additions & 0 deletions .changeset/sharp-bees-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/circuit-ui': minor
---

Added a new `actions` prop to the ButtonGroup that replaces the `children` prop. The new API is more ergonomic and enables an improved look on mobile.
5 changes: 5 additions & 0 deletions .changeset/six-pumpkins-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/circuit-ui': minor
---

Merged the LoadingButton into the Button component.
31 changes: 31 additions & 0 deletions packages/circuit-ui/components/Button/Button.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ describe('Button', () => {
});
});

it('should render with loading styles', () => {
const wrapper = renderButton(create, {
...baseProps,
isLoading: true,
loadingLabel: 'Loading',
});
expect(wrapper).toMatchSnapshot();
});

describe('business logic', () => {
it('should render as a link when passed the href prop', () => {
const props = {
Expand All @@ -97,6 +106,18 @@ describe('Button', () => {
expect(buttonEl).toHaveAttribute('href');
});

it('should render loading button with loading label', () => {
const loadingLabel = 'Loading';
const props = {
...baseProps,
isLoading: true,
loadingLabel,
};

const { getByText } = renderButton(render, props);
expect(getByText(loadingLabel)).toBeVisible();
});

it('should call the onClick handler when clicked', () => {
const props = {
...baseProps,
Expand Down Expand Up @@ -145,5 +166,15 @@ describe('Button', () => {
const actual = await axe(wrapper);
expect(actual).toHaveNoViolations();
});

it('should meet accessibility guidelines for Loading button', async () => {
const wrapper = renderToHtml(
<Button isLoading={true} loadingLabel="Loading">
Button
</Button>,
);
const actual = await axe(wrapper);
expect(actual).toHaveNoViolations();
});
});
});
26 changes: 24 additions & 2 deletions packages/circuit-ui/components/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@

import { action } from '@storybook/addon-actions';
import { Plus } from '@sumup/icons';
import { useState } from 'react';

import { Stack } from '../../../../.storybook/components';
import ButtonGroup from '../ButtonGroup';
import LoadingButton from '../LoadingButton';
import IconButton from '../IconButton';
import CloseButton from '../CloseButton';

Expand All @@ -28,7 +28,7 @@ import docs from './Button.docs.mdx';
export default {
title: 'Components/Button',
component: Button,
subcomponents: { LoadingButton, IconButton, CloseButton, ButtonGroup },
subcomponents: { IconButton, CloseButton, ButtonGroup },
parameters: {
docs: { page: docs },
},
Expand Down Expand Up @@ -106,3 +106,25 @@ export const Tracking = (args: ButtonProps) => (
Track me
</Button>
);

export const Loading = (args: ButtonProps) => {
const [loading, setLoading] = useState(false);

const handleClick = () => {
setLoading(true);
setTimeout(() => {
setLoading(false);
}, 2000);
};

return (
<Button
isLoading={loading}
loadingLabel="Loading"
onClick={handleClick}
{...args}
>
Things take time
</Button>
);
};
98 changes: 94 additions & 4 deletions packages/circuit-ui/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ import {
typography,
disableVisually,
focusVisible,
hideVisually,
} from '../../styles/style-mixins';
import { ReturnType } from '../../types/return-type';
import { ClickEvent } from '../../types/events';
import { AsPropType } from '../../types/prop-types';
import { useComponents } from '../ComponentsContext';
import { useClickEvent, TrackingProps } from '../../hooks/useClickEvent';
import Spinner from '../Spinner';

export interface BaseProps {
'children': ReactNode;
Expand Down Expand Up @@ -83,6 +85,15 @@ export interface BaseProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
'ref'?: Ref<any>;
'data-testid'?: string;
/**
* Visually disables the button and shows a loading spinner.
*/
'isLoading'?: boolean;
/**
* Visually hidden label to communicate the loading state to visually
* impaired users.
*/
'loadingLabel'?: string;
}

type LinkElProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'onClick'>;
Expand Down Expand Up @@ -122,7 +133,6 @@ const SECONDARY_COLOR_MAP = {

const baseStyles = ({ theme }: StyleProps) => css`
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
height: auto;
Expand Down Expand Up @@ -263,6 +273,57 @@ const iconStyles = (theme: Theme) => css`
margin-right: ${theme.spacings.byte};
`;

const loadingStyles = css`
position: relative;
overflow: hidden;
`;

const spinnerBaseStyles = ({ theme }: StyleProps) => css`
position: absolute;
opacity: 0;
visibility: hidden;
transition: opacity ${theme.transitions.default},
visibility ${theme.transitions.default};
`;

const spinnerLoadingStyles = ({ isLoading }: { isLoading: boolean }) =>
isLoading &&
css`
opacity: 1;
visibility: visible;
`;

const LoadingIcon = styled(Spinner)<{ isLoading: boolean }>(
spinnerBaseStyles,
spinnerLoadingStyles,
);

const LoadingLabel = styled.span(hideVisually);

const contentStyles = ({ theme }: StyleProps) => css`
display: inline-flex;
align-items: center;
opacity: 1;
visibility: visible;
transform: scale3d(1, 1, 1);
transition: opacity ${theme.transitions.default},
transform ${theme.transitions.default},
visibility ${theme.transitions.default};
`;

const contentLoadingStyles = ({ isLoading }: { isLoading: boolean }) =>
isLoading &&
css`
opacity: 0;
visibility: hidden;
transform: scale3d(0, 0, 0);
`;

const Content = styled.span<{ isLoading: boolean }>(
contentStyles,
contentLoadingStyles,
);

const StyledButton = styled('button', {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== 'size',
})<ButtonProps>(
Expand All @@ -274,6 +335,7 @@ const StyledButton = styled('button', {
sizeStyles,
tertiaryStyles,
stretchStyles,
loadingStyles,
);

/**
Expand All @@ -282,9 +344,27 @@ const StyledButton = styled('button', {
*/
export const Button = forwardRef(
(
{ children, icon: Icon, tracking, ...props }: ButtonProps,
{
children,
isLoading,
loadingLabel,
icon: Icon,
tracking,
...props
}: ButtonProps,
ref?: BaseProps['ref'],
): ReturnType => {
if (
process.env.UNSAFE_DISABLE_ACCESSIBILITY_ERRORS !== 'true' &&
process.env.NODE_ENV !== 'production' &&
process.env.NODE_ENV !== 'test' &&
isLoading !== undefined &&
!loadingLabel
) {
throw new Error(
'The Button component has `isLoading` but is missing a `loadingLabel` prop. This is an accessibility requirement.',
);
}
const components = useComponents();
const Link = components.Link as AsPropType;

Expand All @@ -293,12 +373,22 @@ export const Button = forwardRef(
return (
<StyledButton
{...props}
{...(loadingLabel && {
'disabled': isLoading,
'aria-live': 'polite',
'aria-busy': isLoading,
})}
ref={ref}
as={props.href ? Link : 'button'}
onClick={handleClick}
>
{Icon && <Icon css={iconStyles} role="presentation" />}
{children}
<LoadingIcon isLoading={Boolean(isLoading)} size="byte">
<LoadingLabel>{loadingLabel}</LoadingLabel>
</LoadingIcon>
<Content isLoading={Boolean(isLoading)}>
{Icon && <Icon css={iconStyles} role="presentation" />}
{children}
</Content>
</StyledButton>
);
},
Expand Down
Loading

1 comment on commit 7b5c44c

@vercel
Copy link

@vercel vercel bot commented on 7b5c44c Oct 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.