Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Avatar component #1134

Merged
merged 31 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
eb33180
WIP: scaffold avatar component
RubenSibon Mar 14, 2024
56c5caf
feat(component): :sparkles: new avatar component
RubenSibon Mar 14, 2024
4116616
Merge branch 'develop' into feat/DES-653-avatar
RubenSibon Mar 14, 2024
44d7101
feat(component): :sparkles: picture support & default user icon
RubenSibon Mar 15, 2024
0633875
Merge remote-tracking branch 'origin/develop' into feat/DES-653-avatar
RubenSibon Mar 15, 2024
daf5ebd
fix(component): :ambulance: replace bg images & address feedback
RubenSibon Mar 15, 2024
d280398
Merge remote-tracking branch 'origin/develop' into feat/DES-653-avatar
RubenSibon Mar 15, 2024
c6d57b2
feat(story): :art: add example story with header
RubenSibon Mar 15, 2024
5e1ebbc
refactor(ts): :label: remove already implied types
RubenSibon Mar 15, 2024
5ea676f
fix(component): :lipstick: image position and clipping
RubenSibon Mar 15, 2024
49adfde
Use type for span element
VincentSmedinga Mar 18, 2024
a0b55d6
Tidy up Header story
VincentSmedinga Mar 18, 2024
f6b7f28
Use design token for aspect ratio
VincentSmedinga Mar 18, 2024
7131b44
Simplify slicing short labels
VincentSmedinga Mar 18, 2024
1eca268
Decrease padding to match space tokens scale
VincentSmedinga Mar 18, 2024
f833133
Display the fallback icon correctly
VincentSmedinga Mar 18, 2024
ff50d82
Split padding tokens
VincentSmedinga Mar 18, 2024
a98b87e
Simplify image sizing
VincentSmedinga Mar 18, 2024
d9b7844
Merge branch 'develop' into feat/DES-653-avatar
VincentSmedinga Mar 19, 2024
9d2af93
Remove useMemo, extract content rendering logic
alimpens Mar 19, 2024
187ce70
Add alt, remove redundant initials
alimpens Mar 19, 2024
75de96b
Hide visible text from screenreaders
alimpens Mar 19, 2024
98d7528
Fix invalid HTML
alimpens Mar 19, 2024
d25177e
Fix test
VincentSmedinga Mar 19, 2024
ce52b1a
Remove property setting the initial value
VincentSmedinga Mar 19, 2024
5dd40fd
Group components with their prop types
VincentSmedinga Mar 19, 2024
197f4e0
Use getByText in tests where possible
alimpens Mar 20, 2024
7f79270
Don't reuse aspect ratio component token
alimpens Mar 20, 2024
b8fd59e
Use rem instead of px
alimpens Mar 20, 2024
9f87473
Remove phantom white space when image doesn't load
alimpens Mar 20, 2024
c1f683d
Add comment
alimpens Mar 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 containing either up to two initials or a profile picture of the currently logged-in user.

The badge contains the personal login icon by default when no user is logged-in or no label is provided.

Interaction can be added by wrapping the avatar in a link.

## Design

The avatar contains either one or two initial letters from the user's full name, a picture or a generic user icon.

The default background colour is dark blue.
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: 1/1;
background-position: center;
alimpens marked this conversation as resolved.
Show resolved Hide resolved
background-repeat: no-repeat;
background-size: cover;
border-radius: 50%;
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
display: inline-flex;
font-family: var(--ams-avatar-font-family);
font-size: var(--ams-avatar-font-size);
justify-content: center;
line-height: var(--ams-avatar-line-height);
padding-block: var(--ams-avatar-padding);
padding-inline: var(--ams-avatar-padding);
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
width: calc(var(--ams-avatar-line-height) * var(--ams-avatar-font-size));
}

.ams-avatar--blue {
background-color: var(--ams-avatar-blue-background-color);
background-image: var(--ams-avatar-blue-background-image);
alimpens marked this conversation as resolved.
Show resolved Hide resolved
color: var(--ams-avatar-blue-color);
}

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

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

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

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

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

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

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

.ams-avatar--yellow {
background-color: var(--ams-avatar-yellow-background-color);
background-image: var(--ams-avatar-yellow-background-image);
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
94 changes: 94 additions & 0 deletions packages/react/src/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { render } from '@testing-library/react'
import { createRef } from 'react'
import { Avatar, avatarColors } from './Avatar'
import '@testing-library/jest-dom'

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

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

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('supports ForwardRef in React', () => {
const ref = createRef<HTMLElement>()

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

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

expect(ref.current).toBe(component)
})

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')
expect(component).toHaveAttribute('style', 'background-image: none;')
})

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

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

expect(component).toHaveTextContent('‏‏‎ ‎')
expect(component).toHaveAttribute('title', 'Niet-ingelogde gebruiker')
})

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('renders with a profile picture', () => {
const { container } = render(
<Avatar
label=""
imageUrl="https://web.archive.org/web/20230610011324im_/https://avatars.githubusercontent.com/u/7290629?v=4"
/>,
)

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

expect(component).toHaveTextContent('‏‏‎ ‎')
expect(component).toHaveAttribute(
'style',
'background-image: url(https://web.archive.org/web/20230610011324im_/https://avatars.githubusercontent.com/u/7290629?v=4);',
)
})
})
64 changes: 64 additions & 0 deletions packages/react/src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import clsx from 'clsx'
import { forwardRef, useMemo } from 'react'
import type { ForwardedRef, HTMLAttributes, ReactElement } from 'react'

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

type AvatarColor = (typeof avatarColors)[number]

export type AvatarProps = {
color?: AvatarColor
imageUrl?: string
alimpens marked this conversation as resolved.
Show resolved Hide resolved
label: string
} & HTMLAttributes<HTMLElement>
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved

export const Avatar = forwardRef(
({ label, imageUrl, className, color = 'dark-blue', ...restProps }: AvatarProps, ref: ForwardedRef<HTMLElement>) => {
const initials: string | ReactElement = useMemo(() => {
alimpens marked this conversation as resolved.
Show resolved Hide resolved
alimpens marked this conversation as resolved.
Show resolved Hide resolved
return (label.length > 2 ? label.slice(0, 2) : label).toUpperCase()
}, [label])

const title = useMemo(() => {
return !initials.length ? 'Niet-ingelogde gebruiker' : `Initialen gebruiker: ${initials.split('').join('.')}.`
alimpens marked this conversation as resolved.
Show resolved Hide resolved
alimpens marked this conversation as resolved.
Show resolved Hide resolved
alimpens marked this conversation as resolved.
Show resolved Hide resolved
}, [initials])

const backgroundImageValue: string | undefined = useMemo(() => {
if (imageUrl) {
return `url(${imageUrl})`
} else if (label.length) {
return 'none'
} else {
return undefined
}
}, [imageUrl, label])
alimpens marked this conversation as resolved.
Show resolved Hide resolved

return (
<span
{...restProps}
ref={ref}
className={clsx('ams-avatar', `ams-avatar--${color}`, className)}
style={{ backgroundImage: backgroundImageValue }}
title={title}
RubenSibon marked this conversation as resolved.
Show resolved Hide resolved
>
{backgroundImageValue !== 'none' ? '‏‏‎ ‎' : 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
74 changes: 74 additions & 0 deletions proprietary/tokens/src/components/ams/avatar.tokens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{
"ams": {
"avatar": {
"font-family": { "value": "{ams.text.font-family}" },
"font-size": { "value": "{ams.text.level.6.font-size}" },
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
"font-weight": { "value": "{ams.text.font-weight.normal}" },
"line-height": { "value": "{ams.text.level.6.line-height}" },
"padding": { "value": "5px" },
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
"blue": {
"background-color": { "value": "{ams.color.blue}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='black' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-black}" }
},
"dark-blue": {
"background-color": { "value": "{ams.color.primary-blue}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='white' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-white}" }
},
"dark-green": {
"background-color": { "value": "{ams.color.dark-green}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='white' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-white}" }
},
"green": {
"background-color": { "value": "{ams.color.green}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='black' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-black}" }
},
"magenta": {
"background-color": { "value": "{ams.color.magenta}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='white' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-white}" }
},
"orange": {
"background-color": { "value": "{ams.color.orange}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='black' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-black}" }
},
"purple": {
"background-color": { "value": "{ams.color.purple}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='white' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-white}" }
},
"red": {
"background-color": { "value": "{ams.color.primary-red}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='white' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-white}" }
},
"yellow": {
"background-color": { "value": "{ams.color.yellow}" },
"background-image": {
"value": "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path fill='black' fill-rule='evenodd' d='M31 32H1v-2.5c0-7.5 4.2-10 10-10h10c5.8 0 10 2.5 10 10V32zM16 17a7.6 7.6 0 0 0 7.5-7.5 7.5 7.5 0 0 0-15 0A7.6 7.6 0 0 0 16 17z'/></svg>\")"
},
"color": { "value": "{ams.color.primary-black}" }
}
}
}
}
25 changes: 25 additions & 0 deletions storybook/src/components/Avatar/Avatar.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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 />

### Without Label

Avatar without a label, i.e. no user is logged-in.

<Canvas of={AvatarStories.WithoutLabel} />

VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
### With Picture

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