diff --git a/.changeset/spotty-oranges-guess.md b/.changeset/spotty-oranges-guess.md new file mode 100644 index 0000000000..1415032986 --- /dev/null +++ b/.changeset/spotty-oranges-guess.md @@ -0,0 +1,6 @@ +--- +"@digdir/designsystemet-css": patch +"@digdir/designsystemet-react": patch +--- + +Avatar: new component diff --git a/packages/css/avatar.css b/packages/css/avatar.css new file mode 100644 index 0000000000..b0329102ce --- /dev/null +++ b/packages/css/avatar.css @@ -0,0 +1,98 @@ +.ds-avatar { + --dsc-avatar-background: var(--ds-color-accent-base-default); + --dsc-avatar-color: var(--ds-color-accent-contrast-default); + --dsc-avatar-size: var(--ds-sizing-12); + --dsc-avatar-padding: var(--ds-spacing-2); + --dsc-avatar-border-radius: 50%; + + background: var(--dsc-avatar-background); + height: var(--dsc-avatar-size); + aspect-ratio: 1 / 1; + color: var(--dsc-avatar-color); + border-radius: var(--dsc-avatar-border-radius); + overflow: hidden; + display: inline-flex; + justify-content: center; + align-items: center; + user-select: none; + text-transform: uppercase; + text-decoration: none; + + &:not(:has(> img)) { + padding: var(--dsc-avatar-padding); + } + + & img { + object-fit: cover; + width: 100%; + height: 100%; + } + + & svg { + max-width: 100%; + max-height: 100%; + } + + &:empty::before { + content: ''; + width: 100%; + height: 100%; + mask-repeat: no-repeat; + mask-size: contain; + background-color: currentcolor; + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' viewBox='0 0 24 24' focusable='false' role='img'%3E%3Cpath fill='currentColor' fill-rule='evenodd' d='M8.25 7.5a3.75 3.75 0 1 1 7.5 0 3.75 3.75 0 0 1-7.5 0M12 2.25a5.25 5.25 0 1 0 0 10.5 5.25 5.25 0 0 0 0-10.5M8.288 17.288A5.25 5.25 0 0 1 17.25 21a.75.75 0 0 0 1.5 0 6.75 6.75 0 0 0-13.5 0 .75.75 0 0 0 1.5 0 5.25 5.25 0 0 1 1.538-3.712' clip-rule='evenodd'%3E%3C/path%3E%3C/svg%3E"); + } + + &[data-ds-variant='circle'] { + --dsc-avatar-border-radius: var(--ds-border-radius-full); + } + + &[data-ds-variant='square'] { + --dsc-avatar-border-radius: var(--ds-border-radius-sm); + } + + &[data-ds-color='accent'] { + --dsc-avatar-background: var(--ds-color-accent-base-default); + --dsc-avatar-color: var(--ds-color-accent-contrast-default); + } + + &[data-ds-color='neutral'] { + --dsc-avatar-background: var(--ds-color-neutral-base-default); + --dsc-avatar-color: var(--ds-color-neutral-contrast-default); + } + + &[data-ds-color='brand1'] { + --dsc-avatar-background: var(--ds-color-brand1-base-default); + --dsc-avatar-color: var(--ds-color-brand1-contrast-default); + } + + &[data-ds-color='brand2'] { + --dsc-avatar-background: var(--ds-color-brand2-base-default); + --dsc-avatar-color: var(--ds-color-brand2-contrast-default); + } + + &[data-ds-color='brand3'] { + --dsc-avatar-background: var(--ds-color-brand3-base-default); + --dsc-avatar-color: var(--ds-color-brand3-contrast-default); + } + + &[data-ds-size='xs'] { + --dsc-avatar-size: var(--ds-sizing-7); + --dsc-avatar-padding: var(--ds-spacing-1); + } + + &[data-ds-size='sm'] { + --dsc-avatar-size: var(--ds-sizing-9); + --dsc-avatar-padding: var(--ds-spacing-1); + } + + &[data-ds-size='md'] { + --dsc-avatar-size: var(--ds-sizing-12); + --dsc-avatar-padding: var(--ds-spacing-2); + } + + &[data-ds-size='lg'] { + --dsc-avatar-size: var(--ds-sizing-15); + --dsc-avatar-padding: var(--ds-spacing-2); + } +} diff --git a/packages/css/index.css b/packages/css/index.css index df0f39837d..ef900c63f0 100644 --- a/packages/css/index.css +++ b/packages/css/index.css @@ -44,3 +44,4 @@ @import url('./combobox.css') layer(ds.components); @import url('./breadcrumbs.css') layer(ds.components); @import url('./badge.css') layer(ds.components); +@import url('./avatar.css') layer(ds.components); diff --git a/packages/react/src/components/Avatar/Avatar.mdx b/packages/react/src/components/Avatar/Avatar.mdx new file mode 100644 index 0000000000..7160500dd6 --- /dev/null +++ b/packages/react/src/components/Avatar/Avatar.mdx @@ -0,0 +1,75 @@ +import { Meta, Canvas, Controls, Primary } from '@storybook/blocks'; + +import * as AvatarStories from './Avatar.stories'; + + + +# Avatar + +`Avatar` er en komponent som viser et bilde, initialer eller ikon for en bruker eller profil. + + + + +## Bruk + +```tsx +import { Avatar } from '@digdir/designsystemet-react'; + +/* Med standard brukerikon */ + + +/* Med initialer */ +ON + +/* Med bilde */ + + + +``` + +## Standard ikon + +Sender du ikke inn `children` viser vi et standard brukerikon. + + + +## Størrelser + +`Avatar` kommer i flere størrelser. Tekst og ikon vil skalere, men det kan hende ikonet ditt må tilpasses. + + + +## Fargevarianter + +Du kan bruke alle tema fargene dine på `Avatar`. + + + +## Varianter + +Du kan endre mellom sirkel og firkantet form. Standard er sirkel. + + + +## Med bilde + +Skal du ha bilde, legger du dette som direkte barn av `Avatar`. + +Bilde (og andre child elements) får automatisk `aria-hidden="true"` for å unngå dobbel informasjon. + + + +## Komponering + +Du kan komponere `Avatar` inn i andre komponenter, som `DropdownMenu`, samt bruke komponenter som +`Badge` rundt for å vise status. + + + +## Som lenke + +Legg lenker eller knapper rundt `Avatar` for å gjøre det klikkbart. +Du må selv sette styling dersom du skal ha en interaktiv `Avatar`. + + diff --git a/packages/react/src/components/Avatar/Avatar.stories.tsx b/packages/react/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 0000000000..a6653f6cdb --- /dev/null +++ b/packages/react/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,129 @@ +import cat1 from '@assets/img/cats/Cat 3.jpg'; +import type { Meta, StoryFn } from '@storybook/react'; + +import { BriefcaseIcon } from '@navikt/aksel-icons'; +import { Avatar } from '.'; +import { Badge, DropdownMenu } from '../'; + +type Story = StoryFn; + +const meta: Meta = { + title: 'Komponenter/Avatar', + component: Avatar, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +export const Preview: Story = (args) => ; + +Preview.args = { + 'aria-label': 'Ola Nordmann', + color: 'accent', + size: 'md', + variant: 'circle', + children: '', +}; + +export const NoName: Story = () => ; + +export const Sizes: Story = () => ( + <> + + xs + + + + sm + + + + md + + + + lg + + + +); + +export const ColorVariants: Story = () => ( + <> + + + + + + +); + +export const ShapeVariants: Story = () => ( + <> + + + + ON + + + ON + + +); + +export const WithImage: Story = () => ( + + + +); + +export const InDropdownMenu: Story = () => ( + + + + ON + + Velg Profil + + + + + + + ON + + + Ola Nordmann + + + + + + Sogndal kommune + + + + +); + +export const AsLink: Story = () => ( + + + +); diff --git a/packages/react/src/components/Avatar/Avatar.test.tsx b/packages/react/src/components/Avatar/Avatar.test.tsx new file mode 100644 index 0000000000..3a52ac2d0c --- /dev/null +++ b/packages/react/src/components/Avatar/Avatar.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import { Avatar } from './'; + +describe('Avatar', () => { + it('should render correctly with default props', () => { + render(); + expect(screen.getByRole('img')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('data-ds-size', 'md'); + expect(screen.getByRole('img')).toHaveAttribute( + 'data-ds-variant', + 'circle', + ); + expect(screen.getByRole('img')).toHaveAttribute('data-ds-color', 'accent'); + }); + + it('should render correctly with custom props', () => { + render(); + expect(screen.getByRole('img')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('data-ds-size', 'lg'); + expect(screen.getByRole('img')).toHaveAttribute( + 'data-ds-variant', + 'square', + ); + }); + + it('should render initials when aria-label is set', () => { + render(ON); + expect(screen.getByText('ON')).toBeInTheDocument(); + }); + + it('should render children', () => { + render( + + ola nordmann + , + ); + /* look for image with correct id */ + expect(screen.getByTestId('child-image')).toBeInTheDocument(); + }); + + it('children should have aria-hidden', () => { + render( + + ola nordmann + , + ); + expect(screen.getByTestId('child-image')).toHaveAttribute( + 'aria-hidden', + 'true', + ); + }); +}); diff --git a/packages/react/src/components/Avatar/Avatar.tsx b/packages/react/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..6bba1129a3 --- /dev/null +++ b/packages/react/src/components/Avatar/Avatar.tsx @@ -0,0 +1,109 @@ +import { Slot } from '@radix-ui/react-slot'; +import cl from 'clsx/lite'; +import { + Fragment, + type HTMLAttributes, + type ReactNode, + forwardRef, +} from 'react'; + +export type AvatarProps = { + /** + * The name of the person the avatar represents. + */ + 'aria-label': string; + /** + * The color of the avatar. + * + * @default 'accent' + */ + color?: 'accent' | 'neutral' | 'brand1' | 'brand2' | 'brand3'; + /** + * The size of the avatar. + * + * @default 'md' + */ + size?: 'xs' | 'sm' | 'md' | 'lg'; + /** + * The shape of the avatar. + * + * @default 'circle' + */ + variant?: 'circle' | 'square'; + /** + * Image, icon or initials to display inside the avatar. + * + * Gets `aria-hidden="true"` + */ + children?: ReactNode; +} & Omit, 'aria-label'>; + +const fontSizeMap = { + xs: 'ds-paragraph--xs', + sm: 'ds-heading--2xs', + md: 'ds-heading--sm', + lg: 'ds-heading--md', +}; + +/** + * Avatars are used to represent people or entities. + * + * @example + * JD + * + * @example + * + * John Doe + * + * + * @example + * + * + * + */ +export const Avatar = forwardRef(function Avatar( + { + 'aria-label': ariaLabel, + color = 'accent', + size = 'md', + variant = 'circle', + className, + children, + ...rest + }, + ref, +) { + const Component = children && typeof children !== 'string' ? Slot : Fragment; + + return ( + + + {children} + + + ); +}); + +/** + * Gets initials using first and last word of a name. + */ +function getInitials(name: string | undefined): string | null { + // Leaving this function for perhaps later use + if (!name) return null; + const initials = []; + const segments = new Intl.Segmenter(document.documentElement.lang || 'no', { + granularity: 'word', + }).segment(name); + for (const segment of segments) + if (segment.isWordLike) initials.push(segment.segment); + return `${initials[0][0]}${initials.length > 1 ? initials[initials.length - 1][0] : ''}`; +} diff --git a/packages/react/src/components/Avatar/index.ts b/packages/react/src/components/Avatar/index.ts new file mode 100644 index 0000000000..4a3dd72a47 --- /dev/null +++ b/packages/react/src/components/Avatar/index.ts @@ -0,0 +1,2 @@ +export { Avatar } from './Avatar'; +export type { AvatarProps } from './Avatar'; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index f51a94c4ec..cf0d387c99 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,3 +1,4 @@ +export * from './Avatar'; export * from './Button'; export * from './Badge'; export * from './Breadcrumbs';