diff --git a/packages/eds-core-react/src/components/next/Avatar/Avatar.figma.tsx b/packages/eds-core-react/src/components/next/Avatar/Avatar.figma.tsx new file mode 100644 index 0000000000..943d050e07 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/Avatar.figma.tsx @@ -0,0 +1,83 @@ +import figma from '@figma/code-connect' +import { Avatar, AvatarNameLabel } from '.' + +figma.connect( + Avatar, + 'https://www.figma.com/design/dz0XQdc5j7AAtjXr1gTfVR/%F0%9F%94%B9-EDS-Core-Components?node-id=9319-5410', + { + props: { + size: figma.enum('Size', { + Small: 'sm', + Medium: 'md', + Large: 'lg', + }), + emphasis: figma.enum('Emphasis', { + High: 'high', + Low: 'low', + }), + isPhoto: figma.enum('Variant', { Photo: true }), + initial: figma.string('Initial'), + notification: figma.boolean('Notification'), + }, + example: ({ size, emphasis, isPhoto, initial, notification }) => + isPhoto ? ( + + ) : ( + + ), + }, +) + +figma.connect( + AvatarNameLabel, + 'https://www.figma.com/design/dz0XQdc5j7AAtjXr1gTfVR/%F0%9F%94%B9-EDS-Core-Components?node-id=9319-5429', + { + props: { + name: figma.string('Full Name'), + meta: figma.string('Email'), + layout: figma.enum('Layout', { + Horizontal: 'horizontal', + Vertical: 'vertical', + }), + size: figma.enum('Size', { + Small: 'sm', + Medium: 'md', + Large: 'lg', + }), + emphasis: figma.enum('Emphasis', { + High: 'high', + Low: 'low', + }), + notification: figma.boolean('Notification'), + isPhoto: figma.enum('Variant', { Photo: true }), + }, + example: ({ + name, + meta, + layout, + size, + emphasis, + notification, + isPhoto, + }) => ( + + ), + }, +) diff --git a/packages/eds-core-react/src/components/next/Avatar/Avatar.stories.tsx b/packages/eds-core-react/src/components/next/Avatar/Avatar.stories.tsx new file mode 100644 index 0000000000..bc60c6d2ed --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/Avatar.stories.tsx @@ -0,0 +1,518 @@ +import React from 'react' +import type { Meta, StoryFn } from '@storybook/react-vite' +import { more_vertical } from '@equinor/eds-icons' +import { Button } from '../Button' +import { Badge } from '../Badge' +import { Divider } from '../Divider' +import { Icon } from '../Icon' +import { + Avatar, + AvatarNameLabel, + type AvatarProps, + type AvatarNameLabelProps, +} from '.' + +const meta: Meta = { + title: 'EDS 2.0 (beta)/Data Display/Avatar', + component: Avatar, + tags: ['beta'], + argTypes: { + name: { + control: 'text', + description: + 'Full name of the person. Auto-derives initials and sets role="img" + aria-label for screen readers.', + table: { category: 'Core' }, + }, + initial: { + control: 'text', + description: + 'Override the auto-derived initial(s). 1–2 characters recommended.', + table: { category: 'Core' }, + }, + src: { + control: 'text', + description: + 'Profile photo URL. When provided, renders a circular photo instead of initials.', + table: { category: 'Appearance' }, + }, + alt: { + control: 'text', + description: + 'Alt text for the photo. Falls back to `name` if not provided.', + table: { category: 'Appearance' }, + }, + size: { + control: 'inline-radio', + options: ['sm', 'md', 'lg'], + description: 'Size of the avatar — sm (16px), md (24px), lg (32px).', + table: { category: 'Appearance', defaultValue: { summary: 'lg' } }, + }, + emphasis: { + control: 'inline-radio', + options: ['low', 'high'], + description: + 'low uses the muted accent background; high uses the emphasis background with white text.', + table: { category: 'Appearance', defaultValue: { summary: 'low' } }, + }, + notification: { + control: 'boolean', + description: 'Show a success-tone notification indicator dot.', + table: { category: 'States', defaultValue: { summary: 'false' } }, + }, + }, + parameters: { + docs: { + description: { + component: ` +⚠️ **Beta Component** — this component is under active development. + +Displays a user's initial(s) in a circular badge. Use \`AvatarNameLabel\` to pair it with a name and metadata. + +\`\`\`tsx +import { Avatar, AvatarNameLabel } from '@equinor/eds-core-react/next' +\`\`\` + `, + }, + }, + }, +} + +export default meta + +export const Introduction: StoryFn = (args) => +Introduction.args = { + name: 'Ada Lovelace', + size: 'lg', + emphasis: 'low', + notification: false, +} +Introduction.parameters = { + docs: { + description: { + story: + 'Pass `name` to make the avatar accessible — initials are derived automatically and the avatar is announced to screen readers as `role="img"`. Without `name`, the initial is purely decorative. Inside `AvatarNameLabel` this is handled automatically from `name`.', + }, + }, +} + +export const Sizes: StoryFn = () => ( +
+ + + +
+) +Sizes.parameters = { + docs: { + description: { + story: 'Three sizes: `sm` (16px), `md` (24px), `lg` (32px).', + }, + }, +} + +export const Emphasis: StoryFn = () => ( +
+ + +
+) +Emphasis.parameters = { + docs: { + description: { + story: + '`low` uses the muted accent background with dark text. `high` uses the emphasis accent background with white text.', + }, + }, +} + +export const WithNotification: StoryFn = () => ( +
+ + + + +
+) +WithNotification.parameters = { + docs: { + description: { + story: + 'The `notification` prop adds a success-tone indicator dot at the bottom-right corner.', + }, + }, +} + +export const Photo: StoryFn = () => ( +
+ + + + +
+) +Photo.parameters = { + docs: { + description: { + story: + 'Pass `src` to show a profile photo instead of initials. The image fills the circle with `object-fit: cover`. Use `name` for the accessible label — it becomes the `alt` text on the image.', + }, + }, +} + +export const NameLabelHorizontal: StoryFn = (args) => ( + +) +NameLabelHorizontal.args = { + name: 'Ada Lovelace', + meta: 'Senior Engineer', + layout: 'horizontal', +} +NameLabelHorizontal.argTypes = { + name: { + control: 'text', + description: 'Full name of the person.', + table: { category: 'Core' }, + }, + meta: { + control: 'text', + description: 'Secondary label — email, job title, or any short metadata.', + table: { category: 'Core' }, + }, + initial: { + control: 'text', + description: 'Override the auto-derived initial(s).', + table: { category: 'Core' }, + }, + src: { + control: 'text', + description: + 'Profile photo URL. When provided, renders a circular photo instead of initials.', + table: { category: 'Appearance' }, + }, + alt: { + control: 'text', + description: + 'Alt text for the photo. Falls back to `name` if not provided.', + table: { category: 'Appearance' }, + }, + layout: { + control: 'inline-radio', + options: ['horizontal', 'vertical'], + description: 'Layout of the name and metadata.', + table: { category: 'Appearance', defaultValue: { summary: 'horizontal' } }, + }, + size: { + control: 'inline-radio', + options: ['sm', 'md', 'lg'], + description: 'Size of the avatar.', + table: { category: 'Appearance', defaultValue: { summary: 'lg' } }, + }, + emphasis: { + control: 'inline-radio', + options: ['low', 'high'], + description: 'Colour emphasis of the avatar.', + table: { category: 'Appearance', defaultValue: { summary: 'low' } }, + }, + notification: { + control: 'boolean', + description: 'Show a notification dot on the avatar.', + table: { category: 'States', defaultValue: { summary: 'false' } }, + }, + children: { + table: { disable: true }, + }, +} +NameLabelHorizontal.parameters = { + docs: { + description: { + story: + 'Horizontal layout stacks the name and metadata vertically beside the avatar.', + }, + }, +} + +export const NameLabelVertical: StoryFn = (args) => ( + +) +NameLabelVertical.args = { + name: 'Ada Lovelace', + meta: 'ada@equinor.com', + layout: 'vertical', +} +NameLabelVertical.parameters = { + docs: { + description: { + story: + 'Vertical layout places the name and metadata on the same line — compact for use in wider contexts like headers or nav.', + }, + }, +} + +export const NameLabelWithNotification: StoryFn = () => ( + +) + +export const NameLabelWithPhoto: StoryFn = () => ( +
+ + + +
+) +NameLabelWithPhoto.parameters = { + docs: { + description: { + story: + 'Pass `src` to show a profile photo in the avatar. Works with both layouts and the notification dot.', + }, + }, +} + +export const NameLabelEdgeCases: StoryFn = () => ( +
+
+

+ Horizontal (default) +

+ + {[ + { + name: 'Bartholomew Featherstonehaugh', + meta: 'b.featherstonehaugh@equinor.com', + }, + { + name: 'Mary Jane Elizabeth Watson', + meta: 'mary.watson@equinor.com', + }, + ].map(({ name, meta }) => ( + + ))} + +
+
+

+ Vertical — designed for wider contexts (name + metadata on one line) +

+ + {[ + { + name: 'Bartholomew Featherstonehaugh', + meta: 'b.featherstonehaugh@equinor.com', + }, + { + name: 'Mary Jane Elizabeth Watson', + meta: 'mary.watson@equinor.com', + }, + ].map(({ name, meta }) => ( + + ))} + +
+
+) +NameLabelEdgeCases.parameters = { + docs: { + description: { + story: + 'The component fills its container. Long names wrap naturally — the row height grows with the content. **Vertical** layout keeps name and metadata on one line and is intended for wider contexts like headers or nav. Initials are always derived from the **first and last word**, regardless of how many names — `"Mary Jane Elizabeth Watson"` → `"MW"`.', + }, + }, +} + +export const NameLabelDensity: StoryFn = () => ( +
+
+

+ Spacious (default) +

+
+ + +
+
+
+

+ Comfortable +

+
+ + +
+
+
+) +NameLabelDensity.parameters = { + docs: { + description: { + story: + '`AvatarNameLabel` responds to `data-density` on a parent element — spacing between the avatar and name/metadata adapts automatically via spacing tokens.', + }, + }, +} + +const sectionLabel = { fontSize: '11px', color: '#6f6f6f', marginBottom: '4px' } + +const ListWithDividers = ({ + children, + maxWidth = '320px', +}: { + children: React.ReactNode[] + maxWidth?: string +}) => { + const items = React.Children.toArray(children) + return ( +
+ {items.map((child, i) => ( +
+
{child}
+ {i < items.length - 1 && } +
+ ))} +
+ ) +} + +export const NameLabelWithSlot: StoryFn = () => ( +
+
+

Overflow menu

+ + {[ + { + name: 'Ada Lovelace', + meta: 'Senior Engineer', + img: 'https://i.pravatar.cc/150?img=47', + }, + { + name: 'Mary Jane Watson', + meta: 'Reservoir Geoscientist', + img: 'https://i.pravatar.cc/150?img=32', + }, + ].map(({ name, meta, img }) => ( + + + + ))} + +
+ +
+

Role badge

+ + {[ + { + name: 'Ada Lovelace', + meta: 'ada@equinor.com', + role: 'Admin', + tone: 'info' as const, + img: 'https://i.pravatar.cc/150?img=47', + }, + { + name: 'Mary Jane Watson', + meta: 'mary@equinor.com', + role: 'Viewer', + tone: 'neutral' as const, + img: 'https://i.pravatar.cc/150?img=32', + }, + ].map(({ name, meta, role, tone, img }) => ( + + {role} + + ))} + +
+ +
+

Last active

+ + {[ + { + name: 'Ada Lovelace', + meta: 'Senior Engineer', + time: '2h ago', + img: 'https://i.pravatar.cc/150?img=47', + }, + { + name: 'Mary Jane Watson', + meta: 'Reservoir Geoscientist', + time: '3d ago', + img: 'https://i.pravatar.cc/150?img=32', + }, + ].map(({ name, meta, time, img }) => ( + + + {time} + + + ))} + +
+
+) +NameLabelWithSlot.parameters = { + docs: { + description: { + story: + 'The `children` prop renders into a trailing slot to the right of the name. It is open — use it for anything contextual to that person, like an action button, a role badge, or a timestamp. The examples here are just starting points.', + }, + }, +} diff --git a/packages/eds-core-react/src/components/next/Avatar/Avatar.test.tsx b/packages/eds-core-react/src/components/next/Avatar/Avatar.test.tsx new file mode 100644 index 0000000000..9b5e578710 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/Avatar.test.tsx @@ -0,0 +1,283 @@ +import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' +import { Avatar } from './Avatar' +import { AvatarNameLabel } from './AvatarNameLabel' + +describe('Avatar (next)', () => { + describe('Rendering', () => { + it('renders with default props', () => { + render() + expect(screen.getByTestId('eds-avatar')).toBeInTheDocument() + }) + + it('renders the initial letter', () => { + render() + expect(screen.getByText('C')).toBeInTheDocument() + }) + + it('renders an image when src is provided', () => { + render() + expect( + screen.getByRole('img', { name: 'Ada Lovelace' }), + ).toBeInTheDocument() + }) + + it('does not render initials when src is provided', () => { + render() + expect(screen.queryByText('AL')).not.toBeInTheDocument() + }) + + it('does not render initial span when initial is not provided', () => { + render() + expect(screen.getByTestId('eds-avatar')).toBeEmptyDOMElement() + }) + + it('initial is aria-hidden', () => { + render() + expect(screen.getByText('C')).toHaveAttribute('aria-hidden', 'true') + }) + + it('applies custom className', () => { + render() + expect(screen.getByTestId('eds-avatar')).toHaveClass( + 'eds-avatar', + 'custom', + ) + }) + + it('forwards ref', () => { + const ref = { current: null as HTMLDivElement | null } + render() + expect(ref.current).toBeInstanceOf(HTMLDivElement) + }) + + it('spreads additional props', () => { + render() + expect(screen.getByTestId('test')).toHaveAttribute('data-custom', 'value') + }) + }) + + describe('Variants', () => { + it('applies size attribute', () => { + render() + expect(screen.getByTestId('eds-avatar')).toHaveAttribute( + 'data-size', + 'sm', + ) + }) + + it('applies emphasis attribute', () => { + render() + expect(screen.getByTestId('eds-avatar')).toHaveAttribute( + 'data-emphasis', + 'high', + ) + }) + + it('always sets data-color-appearance to accent', () => { + render() + expect(screen.getByTestId('eds-avatar')).toHaveAttribute( + 'data-color-appearance', + 'accent', + ) + }) + }) + + describe('Notification', () => { + it('renders notification dot when notification=true', () => { + render() + expect(screen.getByTestId('eds-avatar')).toBeInTheDocument() + }) + + it('does not render notification dot when notification=false', () => { + render() + expect(screen.getByTestId('eds-avatar')).toBeEmptyDOMElement() + }) + + it('notification is merged into avatar aria-label when name is provided', () => { + render( + , + ) + expect(screen.getByTestId('eds-avatar')).toHaveAttribute( + 'aria-label', + 'Ada Lovelace, notification', + ) + }) + }) + + describe('Accessibility', () => { + it('has no accessibility violations with no label (default render)', async () => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) + + it('auto-derives initial from name when initial is not provided', () => { + render() + expect(screen.getByText('AL')).toBeInTheDocument() + }) + + it('name prop sets role="img" and aria-label', () => { + render( + , + ) + const el = screen.getByTestId('eds-avatar') + expect(el).toHaveAttribute('role', 'img') + expect(el).toHaveAttribute('aria-label', 'Ada Lovelace') + }) + + it('has no accessibility violations with name prop', async () => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) + + it('has no accessibility violations with notification', async () => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) + }) +}) + +describe('AvatarNameLabel (next)', () => { + describe('Rendering', () => { + it('renders name', () => { + render() + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + }) + + it('renders meta when provided', () => { + render() + expect(screen.getByText('Senior Engineer')).toBeInTheDocument() + }) + + it('does not render meta when not provided', () => { + render() + expect(screen.queryByText('Senior Engineer')).not.toBeInTheDocument() + }) + + it('derives initials from first and last word of name', () => { + render() + expect(screen.getByText('AL')).toBeInTheDocument() + }) + + it('derives single initial when name is one word', () => { + render() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('uses provided initial over derived one', () => { + render() + expect(screen.getByText('X')).toBeInTheDocument() + }) + + it('renders slot right when children provided', () => { + render( + + icon + , + ) + expect(screen.getByTestId('slot-content')).toBeInTheDocument() + }) + + it('does not render slot right when no children', () => { + render() + expect(screen.queryByTestId('slot-content')).not.toBeInTheDocument() + }) + + it('forwards ref', () => { + const ref = { current: null as HTMLDivElement | null } + render() + expect(ref.current).toBeInstanceOf(HTMLDivElement) + }) + }) + + describe('Avatar pass-throughs', () => { + it('renders without error when notification is passed', () => { + render() + expect(screen.getByText('Ada')).toBeInTheDocument() + }) + + // size and emphasis flow to the inner Avatar via props — verified visually + // and by TypeScript. Testing Library's no-node-access rule prevents querying + // the inner div's data attributes directly; the type system enforces the wiring. + it('renders with size prop without error', () => { + render() + expect(screen.getByText('Ada')).toBeInTheDocument() + }) + + it('renders with emphasis prop without error', () => { + render() + expect(screen.getByText('Ada')).toBeInTheDocument() + }) + }) + + describe('Layout', () => { + it('applies horizontal layout by default', () => { + render() + expect(screen.getByTestId('label')).toHaveAttribute( + 'data-layout', + 'horizontal', + ) + }) + + it('applies vertical layout', () => { + render( + , + ) + expect(screen.getByTestId('label')).toHaveAttribute( + 'data-layout', + 'vertical', + ) + }) + }) + + describe('Accessibility', () => { + it('has no accessibility violations', async () => { + const { container } = render( + , + ) + expect(await axe(container)).toHaveNoViolations() + }) + + it('has no accessibility violations with vertical layout', async () => { + const { container } = render( + , + ) + expect(await axe(container)).toHaveNoViolations() + }) + + it('announces notification via visually-hidden text', () => { + render() + expect(screen.getByText('Notification')).toBeInTheDocument() + }) + + it('has no accessibility violations with notification', async () => { + const { container } = render( + , + ) + expect(await axe(container)).toHaveNoViolations() + }) + + it('has no accessibility violations with photo — image is decorative', async () => { + const { container } = render( + , + ) + expect(await axe(container)).toHaveNoViolations() + }) + }) +}) diff --git a/packages/eds-core-react/src/components/next/Avatar/Avatar.tsx b/packages/eds-core-react/src/components/next/Avatar/Avatar.tsx new file mode 100644 index 0000000000..c8eae44a8f --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/Avatar.tsx @@ -0,0 +1,62 @@ +import { forwardRef } from 'react' +import type { AvatarProps } from './Avatar.types' + +function deriveInitials(name: string): string { + const words = name.trim().split(/\s+/) + if (!words[0]) return '' + if (words.length === 1) return words[0][0].toUpperCase() + return (words[0][0] + words[words.length - 1][0]).toUpperCase() +} + +export const Avatar = forwardRef(function Avatar( + { + size = 'lg', + emphasis = 'low', + initial, + name, + src, + alt, + notification = false, + className, + ...rest + }, + ref, +) { + const classes = ['eds-avatar', className].filter(Boolean).join(' ') + const resolvedInitial = initial ?? (name ? deriveInitials(name) : undefined) + // When src is provided the element carries the accessible name — skip role/aria-label on the div + const label = + !src && name ? (notification ? `${name}, notification` : name) : undefined + const a11yProps = label ? { role: 'img' as const, 'aria-label': label } : {} + + return ( +
+ {src ? ( + {alt + ) : ( + resolvedInitial && ( + + ) + )} + {notification && ( +
+ ) +}) + +Avatar.displayName = 'Avatar' diff --git a/packages/eds-core-react/src/components/next/Avatar/Avatar.types.ts b/packages/eds-core-react/src/components/next/Avatar/Avatar.types.ts new file mode 100644 index 0000000000..e698092e07 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/Avatar.types.ts @@ -0,0 +1,33 @@ +import type { HTMLAttributes } from 'react' + +export type AvatarSize = 'sm' | 'md' | 'lg' +export type AvatarEmphasis = 'high' | 'low' + +export type AvatarProps = { + /** Size of the avatar — sm (16px), md (24px), lg (32px) */ + size?: AvatarSize + /** Colour emphasis — low uses muted background, high uses emphasis background */ + emphasis?: AvatarEmphasis + /** + * Initial(s) displayed in the avatar — intended to be 1–2 characters (e.g. + * `"A"` or `"AL"`). The span is `aria-hidden`; provide `name` to give + * screen readers an accessible label. Longer strings are clipped by the circle. + */ + initial?: string + /** + * Full name of the person. Sets `role="img"` and `aria-label` on the avatar + * so screen readers announce the person's name instead of skipping it. + * Required when using Avatar standalone — not needed inside `AvatarNameLabel` + * where the adjacent name text provides context. + */ + name?: string + /** + * Image source URL. When provided, renders a circular photo instead of initials. + * Pair with `name` or `alt` for accessibility. + */ + src?: string + /** Alt text for the image. Falls back to `name` if not provided. Pass `""` explicitly for decorative images. */ + alt?: string + /** Show a success-tone notification indicator dot. When `name` is set, "notification" is appended to the avatar `aria-label` (e.g. `"Ada Lovelace, notification"`). The dot is `aria-hidden` and silent when no `name` is provided. */ + notification?: boolean +} & HTMLAttributes diff --git a/packages/eds-core-react/src/components/next/Avatar/AvatarNameLabel.tsx b/packages/eds-core-react/src/components/next/Avatar/AvatarNameLabel.tsx new file mode 100644 index 0000000000..37946c13b1 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/AvatarNameLabel.tsx @@ -0,0 +1,60 @@ +import { forwardRef } from 'react' +import type { AvatarNameLabelProps } from './AvatarNameLabel.types' +import { Avatar } from './Avatar' + +function deriveInitials(name: string): string { + const words = name.trim().split(/\s+/) + if (!words[0]) return '' + if (words.length === 1) return words[0][0].toUpperCase() + return (words[0][0] + words[words.length - 1][0]).toUpperCase() +} + +export const AvatarNameLabel = forwardRef( + function AvatarNameLabel( + { + name, + meta, + layout = 'horizontal', + initial, + src, + alt, + size = 'lg', + emphasis = 'low', + notification = false, + children, + className, + ...rest + }, + ref, + ) { + const derivedInitial = initial ?? deriveInitials(name) + const classes = ['eds-avatar-name-label', className] + .filter(Boolean) + .join(' ') + + return ( +
+
+ +
+ {name} + {meta && {meta}} + {notification && ( + Notification + )} +
+
+ {children &&
{children}
} +
+ ) + }, +) + +AvatarNameLabel.displayName = 'AvatarNameLabel' diff --git a/packages/eds-core-react/src/components/next/Avatar/AvatarNameLabel.types.ts b/packages/eds-core-react/src/components/next/Avatar/AvatarNameLabel.types.ts new file mode 100644 index 0000000000..98d5359ea8 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/AvatarNameLabel.types.ts @@ -0,0 +1,37 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import type { AvatarEmphasis, AvatarSize } from './Avatar.types' + +export type AvatarNameLabelLayout = 'horizontal' | 'vertical' + +export type AvatarNameLabelProps = { + /** Name of the person, displayed as the primary label. Also auto-derives the avatar initial. */ + name: string + /** Secondary label — email, job title, or any short metadata string */ + meta?: string + /** + * Layout variant for the name/email block. + * + * - `'horizontal'` — name and email are **stacked** (column). Use in vertical + * lists where each row shows one person. + * - `'vertical'` — name and email are **inline** (row). Use in horizontal + * lists or compact contexts where a single line per person is needed. + * + * Note: the naming follows the Figma component naming convention and refers + * to the orientation of the containing list, not the internal text direction. + */ + layout?: AvatarNameLabelLayout + /** Override the initial shown in the avatar (defaults to first letter of name) */ + initial?: string + /** Profile photo URL. When provided, renders a circular photo instead of initials. */ + src?: string + /** Alt text for the photo. Defaults to `""` (decorative) so the visible name is not announced twice. Pass a value to override. */ + alt?: string + /** Size of the avatar */ + size?: AvatarSize + /** Colour emphasis of the avatar */ + emphasis?: AvatarEmphasis + /** Show a notification dot on the avatar */ + notification?: boolean + /** Optional slot for content to the right (e.g. an icon or action button) */ + children?: ReactNode +} & Omit, 'children'> diff --git a/packages/eds-core-react/src/components/next/Avatar/avatar.css b/packages/eds-core-react/src/components/next/Avatar/avatar.css new file mode 100644 index 0000000000..cb7b6b4a04 --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/avatar.css @@ -0,0 +1,177 @@ +@layer eds-components { + .eds-avatar { + /* Size + font tokens — overridden per data-size */ + --_size: var(--eds-sizing-icon-2xl); /* 32px — Large */ + --_font-size: var(--eds-typography-ui-body-md-font-size); + --_line-height: var(--eds-typography-ui-body-md-line-height-default); + + /* Dot sizes have no token equivalent in the design — hardcoded per Figma spec */ + --_dot-size: 12px; + + /* Colour tokens — overridden for high emphasis; --_color consumed by .initial */ + --_bg: var(--eds-color-bg-fill-muted-default); + --_color: var(--eds-color-text-strong); + + position: relative; + + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: var(--_size); + height: var(--_size); + border-radius: var(--eds-spacing-border-radius-pill); + + background-color: var(--_bg); + + &[data-size='sm'] { + --_size: var(--eds-sizing-icon-xs); /* 16px */ + --_font-size: var(--eds-typography-ui-body-xs-font-size); + --_line-height: var(--eds-typography-ui-body-xs-line-height-default); + --_dot-size: 8px; + } + + &[data-size='md'] { + --_size: var(--eds-sizing-icon-lg); /* 24px */ + --_font-size: var(--eds-typography-ui-body-sm-font-size); + --_line-height: var(--eds-typography-ui-body-sm-line-height-default); + --_dot-size: 10px; + } + + &[data-emphasis='high'] { + --_bg: var(--eds-color-bg-fill-emphasis-default); + --_color: var(--eds-color-text-strong-on-emphasis); + } + + & .photo { + position: absolute; + inset: 0; + + width: 100%; + height: 100%; + border-radius: inherit; + + object-fit: cover; + } + + & .initial { + overflow: hidden; + + font-family: var(--eds-typography-ui-body-font-family); + font-size: var(--_font-size); + font-feature-settings: 'lnum', 'tnum'; + line-height: var(--_line-height); + color: var(--_color); + + @supports (text-box: trim-both cap alphabetic) { + text-box: trim-both cap alphabetic; + } + } + + & .notification { + position: absolute; + + /* -2px overlap — no token equivalent, intentional per Figma spec */ + right: -2px; + bottom: -2px; + + width: var(--_dot-size); + height: var(--_dot-size); + border: var(--eds-sizing-stroke-thin) solid var(--eds-color-bg-surface); + border-radius: var(--eds-spacing-border-radius-pill); + + background-color: var(--eds-color-bg-fill-emphasis-default); + } + } + + .eds-avatar-name-label { + display: flex; + + /* Gap between avatar+content block and slot-right — horizontal arrangement */ + gap: var(--eds-container-gap-horizontal); + align-items: center; + font-family: var(--eds-typography-ui-body-font-family); + + & .content { + display: flex; + flex: 1 0 0; + gap: var(--eds-selectable-space-horizontal); + align-items: center; + + min-width: 0; + } + + & .names { + display: flex; + flex: 1 0 0; + flex-direction: column; + + /* Column layout: use vertical spacing between stacked name and email */ + gap: var(--eds-selectable-space-vertical); + justify-content: center; + + min-width: 0; + + font-size: var(--eds-typography-ui-body-xs-font-size); + font-feature-settings: 'lnum', 'tnum'; + line-height: var(--eds-typography-ui-body-xs-line-height-default); + } + + &[data-layout='vertical'] { + & .content { + flex: initial; + flex-shrink: 0; + } + + & .names { + flex: initial; + flex-direction: row; + flex-shrink: 0; + + /* Row layout: use horizontal spacing between inline name and email */ + gap: var(--eds-selectable-space-horizontal); + align-items: center; + + white-space: nowrap; + } + } + + & .full-name, + & .meta { + @supports (text-box: trim-both cap alphabetic) { + text-box: trim-both cap alphabetic; + } + } + + & .full-name { + color: var(--eds-color-text-strong); + overflow-wrap: break-word; + } + + & .meta { + color: var(--eds-color-text-subtle); + overflow-wrap: break-word; + } + + & .notification-label { + position: absolute; + + overflow: hidden; + + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + + white-space: nowrap; + + clip: rect(0, 0, 0, 0); + } + + & .slot-right { + flex-shrink: 0; + } + } +} diff --git a/packages/eds-core-react/src/components/next/Avatar/index.ts b/packages/eds-core-react/src/components/next/Avatar/index.ts new file mode 100644 index 0000000000..9da42ff29c --- /dev/null +++ b/packages/eds-core-react/src/components/next/Avatar/index.ts @@ -0,0 +1,8 @@ +export { Avatar } from './Avatar' +export type { AvatarProps, AvatarSize, AvatarEmphasis } from './Avatar.types' + +export { AvatarNameLabel } from './AvatarNameLabel' +export type { + AvatarNameLabelProps, + AvatarNameLabelLayout, +} from './AvatarNameLabel.types' diff --git a/packages/eds-core-react/src/components/next/index.css b/packages/eds-core-react/src/components/next/index.css index 3f5985d407..acfaffc843 100644 --- a/packages/eds-core-react/src/components/next/index.css +++ b/packages/eds-core-react/src/components/next/index.css @@ -39,5 +39,6 @@ @import './Autocomplete/autocomplete.css'; @import './Select/select.css'; @import './Badge/badge.css'; +@import './Avatar/avatar.css'; @import './Dialog/dialog.css'; diff --git a/packages/eds-core-react/src/components/next/index.ts b/packages/eds-core-react/src/components/next/index.ts index 13d42704ee..5e0c6aaab4 100644 --- a/packages/eds-core-react/src/components/next/index.ts +++ b/packages/eds-core-react/src/components/next/index.ts @@ -86,6 +86,15 @@ export type { BadgeVariant, } from './Badge' +export { Avatar, AvatarNameLabel } from './Avatar' +export type { + AvatarProps, + AvatarSize, + AvatarEmphasis, + AvatarNameLabelProps, + AvatarNameLabelLayout, +} from './Avatar' + export { Dialog } from './Dialog' export type { DialogActionsProps,