Skip to content

Commit

Permalink
feat: Add Avatar component (#1134)
Browse files Browse the repository at this point in the history
Co-authored-by: Vincent Smedinga <v.smedinga@amsterdam.nl>
Co-authored-by: Aram Limpens <a.limpens@amsterdam.nl>
  • Loading branch information
3 people authored Mar 20, 2024
1 parent b423dfa commit 8dec2cf
Show file tree
Hide file tree
Showing 11 changed files with 415 additions and 0 deletions.
15 changes: 15 additions & 0 deletions packages/css/src/components/avatar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!-- @license CC0-1.0 -->

# Avatar

A circular badge representing a person.

## Design

The avatar contains 1 or 2 initial letters from the person's full name, a picture, or a generic icon.
The default background colour is dark blue.

## Usage

Display an avatar for the person currently using the application,
or to associate a person with a content item.
74 changes: 74 additions & 0 deletions packages/css/src/components/avatar/avatar.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

.ams-avatar {
aspect-ratio: var(--ams-avatar-aspect-ratio);
border-radius: 50%;
display: inline-flex;
font-family: var(--ams-avatar-font-family);
font-size: var(--ams-avatar-font-size);
line-height: var(--ams-avatar-line-height);
padding-block: var(--ams-avatar-padding-block);
padding-inline: var(--ams-avatar-padding-inline);
place-content: center;
width: calc(var(--ams-avatar-line-height) * var(--ams-avatar-font-size));

svg {
fill: currentColor;
}
}

.ams-avatar--has-image {
overflow: hidden;
padding-block: 0;
padding-inline: 0;
vertical-align: middle; /* Remove ‘phantom’ white space when image doesn’t load */
width: calc(var(--ams-avatar-line-height) * var(--ams-avatar-font-size) + 2 * var(--ams-avatar-padding-inline));
}

.ams-avatar--blue {
background-color: var(--ams-avatar-blue-background-color);
color: var(--ams-avatar-blue-color);
}

.ams-avatar--dark-blue {
background-color: var(--ams-avatar-dark-blue-background-color);
color: var(--ams-avatar-dark-blue-color);
}

.ams-avatar--dark-green {
background-color: var(--ams-avatar-dark-green-background-color);
color: var(--ams-avatar-dark-green-color);
}

.ams-avatar--green {
background-color: var(--ams-avatar-green-background-color);
color: var(--ams-avatar-green-color);
}

.ams-avatar--magenta {
background-color: var(--ams-avatar-magenta-background-color);
color: var(--ams-avatar-magenta-color);
}

.ams-avatar--orange {
background-color: var(--ams-avatar-orange-background-color);
color: var(--ams-avatar-orange-color);
}

.ams-avatar--purple {
background-color: var(--ams-avatar-purple-background-color);
color: var(--ams-avatar-purple-color);
}

.ams-avatar--red {
background-color: var(--ams-avatar-red-background-color);
color: var(--ams-avatar-red-color);
}

.ams-avatar--yellow {
background-color: var(--ams-avatar-yellow-background-color);
color: var(--ams-avatar-yellow-color);
}
1 change: 1 addition & 0 deletions packages/css/src/components/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

/* Append here */
@import "./avatar/avatar";
@import "./form-field-character-counter/form-field-character-counter";
@import "./row/row";
@import "./radio/radio";
Expand Down
95 changes: 95 additions & 0 deletions packages/react/src/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { Avatar, avatarColors } from './Avatar'
import '@testing-library/jest-dom'

describe('Avatar', () => {
it('renders', () => {
render(<Avatar label="NR" />)

const component = screen.getByText('Initialen gebruiker: NR')

expect(component).toBeInTheDocument()
expect(component).toBeVisible()
})

it('renders a design system BEM class name', () => {
const { container } = render(<Avatar label="RS" />)

const component = container.querySelector(':only-child')

expect(component).toHaveClass('ams-avatar')
})

it('renders an additional class name', () => {
const { container } = render(<Avatar label="VS" className="extra" />)

const component = container.querySelector(':only-child')

expect(component).toHaveClass('ams-avatar extra')
})

it('renders with a label consisting of no more than two, uppercase letters', () => {
const { container } = render(<Avatar label="Design System" />)

const component = container.querySelector(':only-child')

expect(component).toHaveTextContent('DE')
})

it('renders with default content and title', () => {
const { container } = render(<Avatar label="" />)

const component = screen.getByText('Gebruiker')
const svg = container.querySelector('svg')

expect(component).toBeVisible()
expect(svg).toBeVisible()
})

it('renders with a profile picture', () => {
const { container } = render(<Avatar label="RS" imageSrc="image-source" />)

const component = screen.getByText('Initialen gebruiker: RS')
const image = container.querySelector('[src="image-source"]')

expect(component).toBeVisible()
expect(image).toBeVisible()
})

it('shortens a label that is too long', () => {
const { container } = render(<Avatar label="ABC" />)

const component = container.querySelector(':only-child')

expect(component).toHaveTextContent('AB')
})

it('renders with default color', () => {
const { container } = render(<Avatar label="VS" />)

const component = container.querySelector(':only-child')

expect(component).toHaveClass('ams-avatar--dark-blue')
})

avatarColors.map((color) =>
it(`renders with ${color} color`, () => {
const { container } = render(<Avatar label="AL" color={color} />)

const component = container.querySelector(':only-child')

expect(component).toHaveClass(`ams-avatar--${color}`)
}),
)

it('supports ForwardRef in React', () => {
const ref = createRef<HTMLSpanElement>()

const { container } = render(<Avatar label="AL" ref={ref} />)

const component = container.querySelector(':only-child')

expect(ref.current).toBe(component)
})
})
73 changes: 73 additions & 0 deletions packages/react/src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import { PersonalLoginIcon } from '@amsterdam/design-system-react-icons'
import clsx from 'clsx'
import { forwardRef } from 'react'
import type { ForwardedRef, HTMLAttributes } from 'react'
import { Icon } from '../Icon'
import { Image } from '../Image'
import { VisuallyHidden } from '../VisuallyHidden'

export const avatarColors = [
'blue',
'dark-blue',
'dark-green',
'green',
'magenta',
'orange',
'purple',
'red',
'yellow',
] as const

type AvatarColor = (typeof avatarColors)[number]

type ContentProps = {
imageSrc?: string
initials: string
}

const Content = ({ imageSrc, initials }: ContentProps) => {
if (imageSrc) {
return <Image src={imageSrc} alt="" />
}

if (initials.length) {
return <span aria-hidden={true}>{initials}</span>
}

return <Icon svg={PersonalLoginIcon} size="level-6" />
}

export type AvatarProps = {
color?: AvatarColor
imageSrc?: string
label: string
} & HTMLAttributes<HTMLSpanElement>

export const Avatar = forwardRef(
(
{ label, imageSrc, className, color = 'dark-blue', ...restProps }: AvatarProps,
ref: ForwardedRef<HTMLSpanElement>,
) => {
const initials = label.slice(0, 2).toUpperCase()

const a11yLabel = initials.length === 0 ? 'Gebruiker' : `Initialen gebruiker: ${initials}`

return (
<span
{...restProps}
ref={ref}
className={clsx('ams-avatar', `ams-avatar--${color}`, imageSrc && 'ams-avatar--has-image', className)}
>
<VisuallyHidden>{a11yLabel}</VisuallyHidden>
<Content imageSrc={imageSrc} initials={initials} />
</span>
)
},
)

Avatar.displayName = 'Avatar'
5 changes: 5 additions & 0 deletions packages/react/src/Avatar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- @license CC0-1.0 -->

# React Avatar component

[Avatar documentation](../../../css/src/components/avatar/README.md)
2 changes: 2 additions & 0 deletions packages/react/src/Avatar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Avatar } from './Avatar'
export type { AvatarProps } from './Avatar'
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

/* Append here */
export * from './Avatar'
export * from './FormFieldCharacterCounter'
export * from './Row'
export * from './Radio'
Expand Down
49 changes: 49 additions & 0 deletions proprietary/tokens/src/components/ams/avatar.tokens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"ams": {
"avatar": {
"aspect-ratio": { "value": "{ams.proportion.square}" },
"font-family": { "value": "{ams.text.font-family}" },
"font-size": { "value": "{ams.text.level.6.font-size}" },
"font-weight": { "value": "{ams.text.font-weight.normal}" },
"line-height": { "value": "{ams.text.level.6.line-height}" },
"padding-block": { "value": "0.25rem" },
"padding-inline": { "value": "0.25rem" },
"blue": {
"background-color": { "value": "{ams.color.blue}" },
"color": { "value": "{ams.color.primary-black}" }
},
"dark-blue": {
"background-color": { "value": "{ams.color.primary-blue}" },
"color": { "value": "{ams.color.primary-white}" }
},
"dark-green": {
"background-color": { "value": "{ams.color.dark-green}" },
"color": { "value": "{ams.color.primary-white}" }
},
"green": {
"background-color": { "value": "{ams.color.green}" },
"color": { "value": "{ams.color.primary-black}" }
},
"magenta": {
"background-color": { "value": "{ams.color.magenta}" },
"color": { "value": "{ams.color.primary-white}" }
},
"orange": {
"background-color": { "value": "{ams.color.orange}" },
"color": { "value": "{ams.color.primary-black}" }
},
"purple": {
"background-color": { "value": "{ams.color.purple}" },
"color": { "value": "{ams.color.primary-white}" }
},
"red": {
"background-color": { "value": "{ams.color.primary-red}" },
"color": { "value": "{ams.color.primary-white}" }
},
"yellow": {
"background-color": { "value": "{ams.color.yellow}" },
"color": { "value": "{ams.color.primary-black}" }
}
}
}
}
32 changes: 32 additions & 0 deletions storybook/src/components/Avatar/Avatar.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks";
import * as AvatarStories from "./Avatar.stories.tsx";
import README from "../../../../packages/css/src/components/avatar/README.md?raw";

<Meta of={AvatarStories} />

<Markdown>{README}</Markdown>

## Stories

### Default

<Primary />

<Controls />

### With Picture

The Avatar can also display a photo or other image for the person.
Make sure to scale the image down to around 100 pixels to prevent unnecessary data transfers.

<Canvas of={AvatarStories.WithPicture} />

### Fallback Icon

A user icon displays if no label and image are provided.

<Canvas of={AvatarStories.FallbackIcon} />

### In a Header

<Canvas of={AvatarStories.InAHeader} />
Loading

0 comments on commit 8dec2cf

Please sign in to comment.