Skip to content

Commit

Permalink
Feat(web-react): Introduce UNSTABLE_Avatar component #DS-1324
Browse files Browse the repository at this point in the history
  • Loading branch information
crishpeen authored and pavelklibani committed Jun 21, 2024
1 parent 07a01cf commit dca5b50
Show file tree
Hide file tree
Showing 18 changed files with 539 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/web-react/scripts/entryPoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const entryPoints = [
{ dirs: ['components', 'TextFieldBase'] },
{ dirs: ['components', 'Toast'] },
{ dirs: ['components', 'Tooltip'] },
{ dirs: ['components', 'UNSTABLE_Avatar'] },
{ dirs: ['components', 'UNSTABLE_Divider'] },
{ dirs: ['components', 'VisuallyHidden'] },
];
Expand Down
114 changes: 114 additions & 0 deletions packages/web-react/src/components/UNSTABLE_Avatar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# UNSTABLE Avatar

⚠️ This component is UNSTABLE. It may significantly change at any point in the future.
Please use it with caution.

The `UNSTABLE_Avatar` component is used to display a user's profile picture or initials.

```jsx
import { UNSTABLE_Avatar } from '@lmc-eu/spirit-web-react';

<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>;
```

## Square

Add `isSquare` prop to make the avatar a square.

```jsx
<UNSTABLE_Avatar isSquare aria-label="Profile of Jiří Bárta">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>
```

## Sizes

The Avatar component is available in all [extended sizes][dictionary-size].

```jsx
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta" size="xsmall">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta" size="small">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta" size="medium">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta" size="large">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta" size="xlarge">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>
```

## Content

The content of the `UNSTABLE_Avatar` component can be an image, an icon, or a text string.

### Icon

Add an Icon with correct size.

```jsx
<UNSTABLE_Avatar size="xsmall" aria-label="Profile of Jiří Bárta">
<Icon name="profile" boxSize="16" />
</UNSTABLE_Avatar>
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta">
<Icon name="profile" boxSize="24" />
</UNSTABLE_Avatar>
```

ℹ️ Don't forget to add the `aria-label` attribute for accessible title.

### Image

Add an image, it will be resized to fit the avatar.

```jsx
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta">
<img src="https://picsum.photos/id/823/162/162" alt="Jiří Bárta" aria-hidden="true" />
</UNSTABLE_Avatar>
```

ℹ️ Don't forget to add the `aria-label` attribute for accessible title.
The image should have an `alt` attribute set and can be aria-hidden, because the `aria-label`
attribute is set on the container.

### Text

It is possible to use text as the content of the `UNSTABLE_Avatar` component.
This is useful when you want to display the initials of a user. You need to
take care of the text length and case. The rest is handled by the component.

```jsx
<UNSTABLE_Avatar aria-label="Profile of Jiří Bárta">
<span aria-hidden="true">JB</span>
</UNSTABLE_Avatar>
```

ℹ️ Don't forget to add the `aria-label` attribute for accessible title, especially when
using an abbreviation. The `aria-hidden` attribute is set on the text span, because the `aria-label`
attribute is set on the container and the abbreviation is not useful for screen readers.

## API

| Name | Type | Default | Required | Description |
| ------------- | ------------------------------------------- | -------- | -------- | ------------------------- |
| `children` | `ReactNode` | `null` || Content of the Avatar |
| `elementType` | `ElementType` | `button` || Type of element |
| `isSquare` | `bool` | `false` || If true, Avatar is square |
| `ref` | `ForwardedRef<HTMLButtonElement>` ||| Avatar element reference |
| `size` | [Size Extended dictionary][dictionary-size] | `medium` || Size of the Avatar |

The components accept [additional attributes][readme-additional-attributes].
If you need more control over the styling of a component, you can use [style props][readme-style-props]
and [escape hatches][readme-escape-hatches].

[dictionary-size]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#size
[readme-additional-attributes]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#additional-attributes
[readme-escape-hatches]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#escape-hatches
[readme-style-props]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#style-props
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import classNames from 'classnames';
import React, { ElementType, ForwardedRef, forwardRef } from 'react';
import { SizesExtended } from '../../constants';
import { useStyleProps } from '../../hooks';
import { SpiritAvatarProps } from '../../types';
import { useAvatarStyleProps } from './useAvatarStyleProps';

const defaultProps: Partial<SpiritAvatarProps> = {
elementType: 'div',
isSquare: false,
size: SizesExtended.MEDIUM,
};

/* We need an exception for components exported with forwardRef */
/* eslint no-underscore-dangle: ['error', { allow: ['_Avatar'] }] */
const _Avatar = <T extends ElementType = 'div', S = void>(
props: SpiritAvatarProps<T, S>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const propsWithDefaults = { ...defaultProps, ...props };
const {
elementType: ElementTag = defaultProps.elementType as ElementType,
children,
...restProps
} = propsWithDefaults;
const { classProps, props: modifiedProps } = useAvatarStyleProps(restProps);
const { styleProps, props: otherProps } = useStyleProps(modifiedProps);

return (
<ElementTag {...otherProps} {...styleProps} ref={ref} className={classNames(classProps, styleProps.className)}>
{children}
</ElementTag>
);
};

export const UNSTABLE_Avatar = forwardRef<HTMLDivElement, SpiritAvatarProps<ElementType>>(_Avatar);

export default UNSTABLE_Avatar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest';
import { sizeExtendedPropsTest } from '../../../../tests/providerTests/dictionaryPropsTest';
import { restPropsTest } from '../../../../tests/providerTests/restPropsTest';
import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest';
import { Icon } from '../../Icon';
import { UNSTABLE_Avatar } from '../UNSTABLE_Avatar';

describe('UNSTABLE_Avatar', () => {
classNamePrefixProviderTest(UNSTABLE_Avatar, 'UNSTABLE_Avatar');

sizeExtendedPropsTest(UNSTABLE_Avatar);

stylePropsTest(UNSTABLE_Avatar);

restPropsTest(UNSTABLE_Avatar, 'div');

it('should have default classname', () => {
render(<UNSTABLE_Avatar title="Jiří Bárta">JB</UNSTABLE_Avatar>);

expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar');
});

it('should have default classname with elementType', () => {
render(
<UNSTABLE_Avatar elementType="a" href="#" title="Jiří Bárta">
JB
</UNSTABLE_Avatar>,
);

expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar');
expect(screen.getByTitle('Jiří Bárta')).toHaveAttribute('href', '#');
});

it('should have square classname with isSquare', () => {
render(
<UNSTABLE_Avatar isSquare title="Jiří Bárta">
JB
</UNSTABLE_Avatar>,
);

expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar');
expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar--square');
});

it('should have size classname', () => {
render(
<UNSTABLE_Avatar size="xsmall" title="Jiří Bárta">
JB
</UNSTABLE_Avatar>,
);

expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar');
expect(screen.getByTitle('Jiří Bárta')).toHaveClass('UNSTABLE_Avatar--xsmall');
});

it('should render Icon', () => {
render(
<UNSTABLE_Avatar title="Jiří Bárta">
<Icon name="profile" boxSize="20" />
</UNSTABLE_Avatar>,
);

expect(screen.getByTitle('Jiří Bárta').querySelector('svg')).toBeInTheDocument();
});

it('should render text children', () => {
render(<UNSTABLE_Avatar title="Jiří Bárta">JB</UNSTABLE_Avatar>);

expect(screen.getByTitle('Jiří Bárta')).toHaveTextContent('JB');
});

it('should render image', () => {
render(
<UNSTABLE_Avatar title="Jiří Bárta">
<img src="https://via.placeholder.com/150" alt="" />
</UNSTABLE_Avatar>,
);

expect(screen.getByRole('img')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { renderHook } from '@testing-library/react-hooks';
import { SizesExtended } from '../../../constants/dictionaries';
import { useAvatarStyleProps } from '../useAvatarStyleProps';

describe('useAvatarStyleProps', () => {
const defaultProps = { size: SizesExtended.MEDIUM }; // default size passed to the hook from the component

it('should return defaults', () => {
const props = { ...defaultProps };
const { result } = renderHook(() => useAvatarStyleProps(props));

expect(result.current.classProps).toBe('UNSTABLE_Avatar UNSTABLE_Avatar--medium');
});

it('should return square avatar', () => {
const props = {
...defaultProps,
isSquare: true,
};
const { result } = renderHook(() => useAvatarStyleProps(props));

expect(result.current.classProps).toBe('UNSTABLE_Avatar UNSTABLE_Avatar--medium UNSTABLE_Avatar--square');
});

it.each(Object.values(SizesExtended))('should return %s size avatar', (size) => {
const props = { size };
const { result } = renderHook(() => useAvatarStyleProps(props));

expect(result.current.classProps).toBe(`UNSTABLE_Avatar UNSTABLE_Avatar--${size}`);
});
});
24 changes: 24 additions & 0 deletions packages/web-react/src/components/UNSTABLE_Avatar/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { SizesExtended } from '../../constants';

export const DEMO_SIZES = [
{
size: SizesExtended.XSMALL,
boxSize: 16,
},
{
size: SizesExtended.SMALL,
boxSize: 20,
},
{
size: SizesExtended.MEDIUM,
boxSize: 24,
},
{
size: SizesExtended.LARGE,
boxSize: 28,
},
{
size: SizesExtended.XLARGE,
boxSize: 32,
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { Icon } from '../../Icon';
import { DEMO_SIZES } from '../constants';
import { UNSTABLE_Avatar } from '../UNSTABLE_Avatar';

const AvatarIcon = () => (
<>
<div className="d-flex" style={{ gap: 'var(--spirit-space-400)' }}>
{DEMO_SIZES.map(({ size, boxSize }) => (
<UNSTABLE_Avatar key={`${size}`} elementType="a" href="#" size={size} aria-label="Profile of Jiří Bárta">
<Icon name="profile" boxSize={boxSize} />
</UNSTABLE_Avatar>
))}
</div>
<div className="d-flex" style={{ gap: 'var(--spirit-space-400)' }}>
{DEMO_SIZES.map(({ size, boxSize }) => (
<UNSTABLE_Avatar
key={`${size}`}
isSquare
elementType="a"
href="#"
size={size}
aria-label="Profile of Jiří Bárta"
>
<Icon name="profile" boxSize={boxSize} />
</UNSTABLE_Avatar>
))}
</div>
</>
);

export default AvatarIcon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { DEMO_SIZES } from '../constants';
import UNSTABLE_Avatar from '../UNSTABLE_Avatar';

const AvatarImage = () => (
<>
<div className="d-flex" style={{ gap: 'var(--spirit-space-400)' }}>
{DEMO_SIZES.map(({ size }) => (
<UNSTABLE_Avatar key={`${size}`} elementType="a" href="#" size={size} aria-label="Profile of Jiří Bárta">
<img src="https://picsum.photos/id/823/162/162" alt="Jiří Bárta" aria-hidden="true" />
</UNSTABLE_Avatar>
))}
</div>
<div className="d-flex" style={{ gap: 'var(--spirit-space-400)' }}>
{DEMO_SIZES.map(({ size }) => (
<UNSTABLE_Avatar
key={`${size}`}
isSquare
elementType="a"
href="#"
size={size}
aria-label="Profile of Jiří Bárta"
>
<img src="https://picsum.photos/id/823/162/162" alt="Jiří Bárta" aria-hidden="true" />
</UNSTABLE_Avatar>
))}
</div>
</>
);

export default AvatarImage;
Loading

0 comments on commit dca5b50

Please sign in to comment.