Skip to content

Commit

Permalink
Add Avatar and ImageInput components (#922)
Browse files Browse the repository at this point in the history
* Create draft Avatar and ImageInput components

* Add SVG placeholders for object and identity variants

* Fix linting

* Update Avatar prop names

* Wrap Avatar in label in ImageInput instead of passing an as prop

* Add icons and logic for removing an image

* Clear file input via ref when the clear button is clicked

A file input is always uncontrolled so a ref is necessary here: https://reactjs.org/docs/uncontrolled-components.html\#the-file-input-tag

* Add @jsxRuntime pragma to enable usage with React 17

* Fix focus state UI bug where the outline appeared behind the ActionButton

* Add active state styles

* Update Avatar docs

This also renames the variant prop options to object/identity to match the design specs. Person/business was inaccurate because a business should actually use the identity variant with a business placeholder (in development).

* Switch Avatar default variant to object

This matches the ImageInput defaults and makes the two components more interchangeable

* Add onChange and onClear callbacks with loading and error states

* Add snapshot tests for Avatar

The Avatar is a purely visual component, so relying on snapshot testing makes sense here. It particularly ensures that the placeholder SVGs are set as expected wheen no image is provided.

* Fix types in ImageInput spec to pass CI

* Switch from brightness() to pseudo-elements and clean up styles

* Do not call onChange with a falsy file param

* Update ImageInput docs

* Add initial tests for ImageInput

Snapshots for main states (default, invalid, existing image) and functional test of an image upload via userEvent.upload

* Add business logic tests for ImageInput

* Rename ImageInput into AvatarInput

* Add test for the logic of clearing an uploaded avatar

* Properly export the components

* Add pointer-events:none to spinner to fix FF loading UI

* Address code review feedback

* Optimize SVGs

* Batched improvements (see commit description)

- the border-radius of the zetta size was increased to 12px, the value will be moves to design tokens and used in the next Circuit major version
- a bug on Chrome where uploading the same image twice would not fire onChange the second time was fixed by clearing the DOM input element on click
- the AvatarInput was adapted to accept any component as a visual element, the Avatar or something else. A new story "Custom Component" was added to document it. I will add tests for it and rename AvatarInput back to ImageInput in follow-up commits

* Rename AvatarInput back to ImageInput

Since the last commit, it accepts any visual element, although it is still optimized for the Avatar component. This will enable teams to use the ImageInput for use cases beyond an avatar upload.

* Move invalid border to inset

This is to ensure that the button still stands out well even with invalid styles (from a design feedback)

* Move add button out of label element

- This makes the component's implementation cleaner
- It makes the tests cleaner because there's only one matching label that we can query with getByLabelText
- This commit also adds missing prop descriptions

* Add snapshot test for rendering with a custom component

* Write docs

* Add changeset

* Address PR review comments

* Make the Avatar's alt prop required

The ImageInput also exposes it as part of the custom component's signature, but it defaults to  in that case.

Accessibility docs were also updated and an extra usage example with a custom component was added to the ImageInput stories.

* Update Avatar spec with required alt prop
  • Loading branch information
Robin Métral authored Jun 3, 2021
1 parent 1f86a58 commit feb6b32
Show file tree
Hide file tree
Showing 15 changed files with 2,428 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-bats-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/circuit-ui': minor
---

Added a new `ImageInput` component to allow users to upload images.
5 changes: 5 additions & 0 deletions .changeset/sharp-mugs-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/circuit-ui': minor
---

Added a new `Avatar` component to display identity or object images.
44 changes: 44 additions & 0 deletions packages/circuit-ui/components/Avatar/Avatar.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Status, Props, Story } from '../../../../.storybook/components';

# Avatar

<Status.Stable />

The Avatar component displays an identity or an object image. It can be passed to the [ImageInput](Forms/ImageInput) to allow users to upload an avatar.

<Story id="components-avatar--base" />

<Props />

## Usage guidelines

- **Do** use the right variant for your use case (see _Variants_ below).
- **Do** use the [ImageInput component](Forms/ImageInput) with the `component={Avatar}` prop to allow users to upload an avatar (note: the ImageInput only supports `variant="object"` for now).

## Accessibility

The Avatar has a required `alt` prop to ensure that it is accessible to visually impaired users.

Alt text can be fundamental for accessibility, especially in interfaces without textual elements. For example, if the Avatar is used to render a grid of products where the names are not shown, omitting alt text would make it inaccessible.

However, if the image is purely presentational, the alt text can be set to `""`. For example, if the Avatar is used as an illustrative element in a products list next to each product's name, using the product name as alt text would be redundant: assistive technology will already read out the names once. In this case, setting `alt=""` will effectively make the Avatar invisible to assistive technology.

## Variants

There are two variants and two sizes available for the component.

### Object variant

Use the object variant with a square shape for product item purposes (e.g. product catalogue).

<Story id="components-avatar--object" />

### Identity variant

Use the identity variant with a circle shape for identity account purposes (e.g. profile, contact, business).

<Story id="components-avatar--identity" />

### Sizes

<Story id="components-avatar--sizes" />
76 changes: 76 additions & 0 deletions packages/circuit-ui/components/Avatar/Avatar.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Copyright 2021, SumUp Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from 'react';

import { render, axe } from '../../util/test-utils';

import { Avatar, AvatarProps } from './Avatar';

const sizes = ['giga', 'yotta'] as const;
const variants = ['object', 'identity'] as const;
const images = {
object: 'https://source.unsplash.com/EcWFOYOpkpY/200x200',
identity: 'https://upload.wikimedia.org/wikipedia/en/8/86/Avatar_Aang.png',
};

describe('Avatar', () => {
function renderAvatar(props: AvatarProps = { alt: '' }, options = {}) {
return render(<Avatar {...props} />, options);
}

describe('styles', () => {
it('should render with default styles', () => {
const { container } = renderAvatar();
expect(container).toMatchSnapshot();
});

it.each(sizes)('should render the %s size', (size) => {
const { container } = renderAvatar({
size,
alt: '',
});
expect(container).toMatchSnapshot();
});

it.each(variants)(
'should render the %s variant with an image',
(variant) => {
const { container } = renderAvatar({
src: images[variant],
variant,
alt: '',
});
expect(container).toMatchSnapshot();
},
);

it.each(variants)('should render the %s variant placeholder', (variant) => {
const { container } = renderAvatar({
variant,
alt: '',
});
expect(container).toMatchSnapshot();
});
});

describe('accessibility', () => {
it('should meet accessibility guidelines', async () => {
const { container } = renderAvatar();
const actual = await axe(container);
expect(actual).toHaveNoViolations();
});
});
});
85 changes: 85 additions & 0 deletions packages/circuit-ui/components/Avatar/Avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Copyright 2021, SumUp Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React from 'react';

import { Stack } from '../../../../.storybook/components';

import { Avatar, AvatarProps } from './Avatar';
import docs from './Avatar.docs.mdx';

export default {
title: 'Components/Avatar',
component: Avatar,
parameters: {
docs: { page: docs },
},
};

export const Base = (args: AvatarProps): JSX.Element => <Avatar {...args} />;
Base.args = {
src: 'https://source.unsplash.com/EcWFOYOpkpY/200x200',
variant: 'object',
size: 'yotta',
};

export const ObjectVariant = (): JSX.Element => (
<Stack>
<Avatar
src="https://source.unsplash.com/EcWFOYOpkpY/200x200"
variant="object"
/>
<Avatar variant="object" />
</Stack>
);

export const IdentityVariant = (): JSX.Element => (
<Stack>
<Avatar
src="https://upload.wikimedia.org/wikipedia/en/8/86/Avatar_Aang.png"
variant="identity"
/>
<Avatar variant="identity" />
</Stack>
);

export const Sizes = (): JSX.Element => (
<Stack>
<Stack>
<Avatar
src="https://source.unsplash.com/EcWFOYOpkpY/200x200"
variant="object"
size="yotta"
/>
<Avatar
src="https://source.unsplash.com/EcWFOYOpkpY/200x200"
variant="object"
size="giga"
/>
</Stack>
<Stack>
<Avatar
src="https://upload.wikimedia.org/wikipedia/en/8/86/Avatar_Aang.png"
variant="identity"
size="yotta"
/>
<Avatar
src="https://upload.wikimedia.org/wikipedia/en/8/86/Avatar_Aang.png"
variant="identity"
size="giga"
/>
</Stack>
</Stack>
);
98 changes: 98 additions & 0 deletions packages/circuit-ui/components/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Copyright 2021, SumUp Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React, { HTMLAttributes } from 'react';
import { css } from '@emotion/core';
import isPropValid from '@emotion/is-prop-valid';

import styled, { StyleProps } from '../../styles/styled';

export interface AvatarProps extends HTMLAttributes<HTMLImageElement> {
/**
* The source URL of the Avatar image.
* Defaults to a placeholder illustration.
*/
src?: string;
/**
* Alt text for the Avatar image. Set it to "" if the image is presentational.
*/
alt: string;
/**
* The variant of the Avatar, either identity or object. Refer to the docs for usage guidelines.
* The variant also changes which placeholder is rendered when the `src` prop is not provided.
*/
variant?: 'object' | 'identity';
/**
* One of two available sizes for the Avatar, either giga or yotta.
*/
size?: 'giga' | 'yotta';
}

const avatarSizes = {
yotta: '96px',
giga: '48px',
};

const placeholders = {
object: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 96 96"><path fill="white" d="M30 25c0-4.9706 4.0294-9 9-9s9 4.0294 9 9-4.0294 9-9 9-9-4.0294-9-9zM41.1571 60.5691L30.6742 48.3905c-1.6438-1.9097-4.6225-1.8422-6.1782.1399L8 69.5483v12.4515c0 3.3137 2.6863 6 6 6h5.9592l21.1979-27.4307zM70.4856 32.878c1.5553-2.002 4.5569-2.0705 6.202-.1417l11.312 13.2623v36c0 3.3137-2.6863 6-6 6H27.6611L70.4856 32.878z"/></svg>`,
identity: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 96 96"><path fill="white" d="M48 18c-7.732 0-14 6.268-14 14s6.268 14 14 14 14-6.268 14-14-6.268-14-14-14zM47.9998 88C61.53 88 73.4913 81.2822 80.73 71c-7.2387-10.2822-19.2-17-32.7303-17-13.5302 0-25.4914 6.7178-32.7302 17 7.2388 10.2822 19.2 17 32.7303 17z"/></svg>`,
};

const baseStyles = ({
theme,
variant,
size = 'yotta',
}: AvatarProps & StyleProps) => css`
display: block;
width: ${avatarSizes[size]};
height: ${avatarSizes[size]};
box-shadow: 0 0 0 ${theme.borderWidth.kilo} rgba(0, 0, 0, 0.1);
background-color: ${theme.colors.n300};
border-radius: ${variant === 'identity'
? theme.borderRadius.circle
: /**
* @FIXME add this value to design tokens and upgrade in the next major
* to use it here and in the ImageInput
*/
'12px'};
object-fit: cover;
object-position: center;
`;

const StyledImage = styled('img', {
shouldForwardProp: (prop) => isPropValid(prop),
})<AvatarProps>(baseStyles);

/**
* The Avatar component displays an identity or an object image.
*/
export const Avatar = ({
src,
alt = '',
variant = 'object',
size,
...props
}: AvatarProps): JSX.Element => {
const placeholder = `data:image/svg+xml;utf8,${placeholders[variant]}`;
return (
<StyledImage
src={src || placeholder}
alt={alt}
variant={variant}
size={size}
{...props}
/>
);
};
Loading

1 comment on commit feb6b32

@vercel
Copy link

@vercel vercel bot commented on feb6b32 Jun 3, 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.