diff --git a/src/avatar/avatar-pair.module.css b/src/avatar/avatar-pair.module.css new file mode 100644 index 00000000..bb64c245 --- /dev/null +++ b/src/avatar/avatar-pair.module.css @@ -0,0 +1,229 @@ +/* + * AvatarPair renders two stacked Avatars: + * - First child (back): anchored bottom-right. + * - Last child (front): anchored top-left, painted on top. + * + * To make the back avatar read as a distinct element on *any* background, we + * cut a transparent hole through the front avatar wherever the back avatar + * peeks through, with a small `--mask-thickness` gap around it. Masks (not + * borders or box-shadow) are used because the parent background is unknown — + * masks make the region truly transparent, so whatever sits behind the pair + * shows through. + * + * Sizing is driven entirely by CSS variables. The `.avatarPairSize-{N}` + * classes (applied from JS) override the defaults per avatar size. + */ + +.avatarPair { + /* Inputs (overridden per size by .avatarPairSize-{N}) */ + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask-thickness: 2px; + --reactist-avatar-pair-rounded-radius: 5px; + + /* Derived (CSS-internal — not part of the JS contract) */ + --reactist-avatar-pair-rounded-mask-radius: calc( + var(--reactist-avatar-pair-rounded-radius) + var(--reactist-avatar-pair-mask-thickness) + ); + --reactist-avatar-pair-rounded-mask-start: calc( + var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask-thickness) + ); + --reactist-avatar-pair-first-center: calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + ); + + position: relative; + width: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); + height: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); +} + +.avatarPairSize-80 { + --reactist-avatar-pair-size: 80px; + --reactist-avatar-pair-spacing: 36px; + --reactist-avatar-pair-mask-thickness: 3px; + --reactist-avatar-pair-rounded-radius: 10px; +} + +.avatarPairSize-72 { + --reactist-avatar-pair-size: 72px; + --reactist-avatar-pair-spacing: 32px; + --reactist-avatar-pair-mask-thickness: 3px; + --reactist-avatar-pair-rounded-radius: 10px; +} + +.avatarPairSize-62 { + --reactist-avatar-pair-size: 62px; + --reactist-avatar-pair-spacing: 28px; + --reactist-avatar-pair-mask-thickness: 3px; + --reactist-avatar-pair-rounded-radius: 8.5px; +} + +.avatarPairSize-50 { + --reactist-avatar-pair-size: 50px; + --reactist-avatar-pair-spacing: 22px; + --reactist-avatar-pair-mask-thickness: 3px; + --reactist-avatar-pair-rounded-radius: 7px; +} + +.avatarPairSize-40 { + --reactist-avatar-pair-size: 40px; + --reactist-avatar-pair-spacing: 18px; + --reactist-avatar-pair-mask-thickness: 3px; + --reactist-avatar-pair-rounded-radius: 5.5px; +} + +.avatarPairSize-36 { + --reactist-avatar-pair-size: 36px; + --reactist-avatar-pair-spacing: 16px; + --reactist-avatar-pair-mask-thickness: 2.5px; + --reactist-avatar-pair-rounded-radius: 5px; +} + +.avatarPairSize-30 { + --reactist-avatar-pair-size: 30px; + --reactist-avatar-pair-spacing: 14px; + --reactist-avatar-pair-mask-thickness: 2.5px; + --reactist-avatar-pair-rounded-radius: 5px; +} + +.avatarPairSize-28 { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask-thickness: 2px; + --reactist-avatar-pair-rounded-radius: 5px; +} + +.avatarPairSize-24 { + --reactist-avatar-pair-size: 24px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask-thickness: 2px; + --reactist-avatar-pair-rounded-radius: 3.2px; +} + +.avatarPairSize-20 { + --reactist-avatar-pair-size: 20px; + --reactist-avatar-pair-spacing: 10px; + --reactist-avatar-pair-mask-thickness: 2px; + --reactist-avatar-pair-rounded-radius: 3px; +} + +.avatarPairSize-18 { + --reactist-avatar-pair-size: 18px; + --reactist-avatar-pair-spacing: 10px; + --reactist-avatar-pair-mask-thickness: 1.5px; + --reactist-avatar-pair-rounded-radius: 3px; +} + +.avatarPairSize-16 { + --reactist-avatar-pair-size: 16px; + --reactist-avatar-pair-spacing: 8px; + --reactist-avatar-pair-mask-thickness: 1.25px; + --reactist-avatar-pair-rounded-radius: 2px; +} + +.avatarPairSize-12 { + --reactist-avatar-pair-size: 12px; + --reactist-avatar-pair-spacing: 6px; + --reactist-avatar-pair-mask-thickness: 1px; + --reactist-avatar-pair-rounded-radius: 1.6px; +} + +.avatarPair > * { + position: absolute; +} + +/* Back avatar: anchored bottom-right, painted underneath. */ +.avatarPair > :first-child { + right: 0; + bottom: 0; +} + +/* Front avatar: anchored top-left, painted on top — receives the mask cutout. */ +.avatarPair > :last-child { + top: 0; + left: 0; +} + +/* + * Circle shape: a single radial-gradient cuts a circular hole centered on + * the back avatar, sized to leave a `--mask-thickness` gap around it. + */ +.avatarPairShape-circle > :last-child { + -webkit-mask-image: radial-gradient( + circle + calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask-thickness) + ) + at var(--reactist-avatar-pair-first-center) var(--reactist-avatar-pair-first-center), + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle + calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask-thickness) + ) + at var(--reactist-avatar-pair-first-center) var(--reactist-avatar-pair-first-center), + transparent 99%, + #000 100% + ); +} + +/* + * Rounded shape: CSS has no built-in "rounded-rectangle cutout" mask, so we + * compose the *visible* (un-cut) area of the front avatar from three mask + * layers. What's left over — the bottom-right rectangle with a rounded + * top-left corner — is the hole that reveals the back avatar. + * + * strip = spacing − mask-thickness (thickness of the L-shaped frame) + * corner = rounded-mask-radius (radius of the L's inner corner) + * + * Layer 1 — top strip of the L: 100% wide, strip tall, at (0, 0). + * Layer 2 — left strip of the L: strip wide, 100% tall, at (0, 0). + * Layer 3 — rounded inner corner fill: a corner × corner tile at + * (strip, strip), filled by a radial-gradient that's opaque + * outside the corner radius and transparent inside, so it rounds + * the L's inner ┘ corner where the strips meet. + */ +.avatarPairShape-rounded > :last-child { + /* The three mask layers below correspond 1:1 to the L-shape construction + * described above (layer 1 = top strip, layer 2 = left strip, layer 3 = + * rounded inner corner). The same ordering applies to mask-position and + * mask-size. */ + -webkit-mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + -webkit-mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + -webkit-mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} diff --git a/src/avatar/avatar-pair.stories.tsx b/src/avatar/avatar-pair.stories.tsx new file mode 100644 index 00000000..b636471c --- /dev/null +++ b/src/avatar/avatar-pair.stories.tsx @@ -0,0 +1,226 @@ +import * as React from 'react' + +import { Avatar, AvatarPair, Box, Inline, Stack, Text } from '../index' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +const contributors = [ + { name: 'pawel', githubUserId: '61894375' }, + { name: 'craig', githubUserId: '1305500' }, + { name: 'rui', githubUserId: '3165500' }, + { name: 'ricardo', githubUserId: '96476' }, + { name: 'scott', githubUserId: '25244878' }, + { name: 'francesca', githubUserId: '1509326' }, +] as const + +const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const + +function getContributor(index: number): (typeof contributors)[number] { + return contributors[index % contributors.length]! +} + +function getWorkspaceName(index: number): (typeof workspaceNames)[number] { + return workspaceNames[index % workspaceNames.length]! +} + +function getGithubAvatarUrl(githubUserId: string, width: number) { + return `https://avatars.githubusercontent.com/u/${githubUserId}?s=${width}` +} + +function getGithubSourceMap(githubUserId: string, width: number) { + return { + [width]: getGithubAvatarUrl(githubUserId, width), + [width * 2]: getGithubAvatarUrl(githubUserId, width * 2), + [width * 3]: getGithubAvatarUrl(githubUserId, width * 3), + } +} + +function StoryLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function StorySection({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) { + return ( + + + {title} + {description ? ( + + {description} + + ) : null} + + {children} + + ) +} + +function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {children} + + {label} + + + + ) +} + +function UserAvatar({ + contributor, + size, +}: { + contributor: (typeof contributors)[number] + size: React.ComponentProps['size'] +}) { + return ( + + ) +} + +function WorkspaceAvatar({ + name, + size, +}: { + name: string + size: React.ComponentProps['size'] +}) { + return +} + +const meta = { + title: 'Components/Avatar/AvatarPair', + component: AvatarPair, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const People = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Workspaces = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Sizes = { + render: () => ( + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + ), +} satisfies Story diff --git a/src/avatar/avatar-pair.test.tsx b/src/avatar/avatar-pair.test.tsx new file mode 100644 index 00000000..35aadc09 --- /dev/null +++ b/src/avatar/avatar-pair.test.tsx @@ -0,0 +1,108 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' + +import { Avatar } from './avatar' +import { AvatarPair } from './avatar-pair' + +describe('AvatarPair', () => { + it('renders direct Avatar children without wrappers', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('first')) + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('second')) + expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('pair')) + expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('pair')) + }) + + it('requires exactly two children at the type level', () => { + const invalidPair = ( + // @ts-expect-error AvatarPair children must be a tuple of two elements + + + + ) + expect(invalidPair).toBeTruthy() + }) + + it('applies the escape hatch class name', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveClass('custom-pair') + }) + + it('can render as a button', () => { + render( + + + + , + ) + + expect(screen.getByRole('button', { name: 'Open workspace pair' })).toBeVisible() + }) + + it('derives the root ref type from the element rendered with as', () => { + const anchorRef = React.createRef() + const buttonRef = React.createRef() + + render( + + + + , + ) + + expect(anchorRef.current).toBe(screen.getByTestId('pair')) + + const invalidRefElement = ( + // @ts-expect-error refs must match the element selected with as + + + + + ) + expect(invalidRefElement).toBeTruthy() + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + + + + + + + + + + , + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) +}) diff --git a/src/avatar/avatar-pair.tsx b/src/avatar/avatar-pair.tsx new file mode 100644 index 00000000..3fd1d7cf --- /dev/null +++ b/src/avatar/avatar-pair.tsx @@ -0,0 +1,98 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { Box } from '../box' +import { polymorphicComponent } from '../utils/polymorphism' + +import styles from './avatar-pair.module.css' + +import type { ObfuscatedClassName } from '../utils/common-types' +import type { PolymorphicComponentProps } from '../utils/polymorphism' +import type { AvatarShape, AvatarSize } from './utils' + +/** + * Props for the `AvatarPair` component. + */ +type AvatarPairOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The paired avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * Exactly two paired Avatar children. The first child is the foreground + * avatar (positioned bottom-right); the second is the diagonal overlay + * (positioned top-left, masked where it overlaps the first). + */ + children: readonly [React.ReactElement, React.ReactElement] + + /** + * Test identifier applied to the avatar pair root element. + */ + 'data-testid'?: string + + /** + * AvatarPair owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarPairProps = PolymorphicComponentProps< + ComponentType, + AvatarPairOwnProps, + 'omitClassName' +> + +/** + * Displays two Avatar children with the second avatar positioned diagonally + * above-left of the first avatar. + */ +const AvatarPair = polymorphicComponent<'div', AvatarPairOwnProps, 'omitClassName'>( + function AvatarPair( + { + as, + size, + shape = 'circle', + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + return ( + + {children} + + ) + }, +) + +export { AvatarPair } +export type { AvatarPairProps } diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 3bf4998f..6550f737 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -11,6 +11,7 @@ import { import * as AvatarStories from './avatar.stories' import * as AvatarGroupStories from './avatar-group.stories' +import * as AvatarPairStories from './avatar-pair.stories' @@ -37,6 +38,15 @@ conveys the count to assistive tech. +## Avatar pairs + +Use `AvatarPair` when a surface represents two related people or entities. Pass +the same `size` to the pair and its exactly two direct `Avatar` children. The +first child is the foreground avatar (positioned bottom-right); the second is +positioned diagonally above-left of the first child. + + + ## Migrating from the legacy API The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or @@ -191,6 +201,13 @@ the component props instead of overriding them directly. --reactist-avatar-group-mask-thickness: 2.5px; --reactist-avatar-group-rounded-radius: 5px; } + +.avatarPair { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 5px; +} ``` ## What the consumer owns diff --git a/src/avatar/index.ts b/src/avatar/index.ts index c57e70d1..1af786c8 100644 --- a/src/avatar/index.ts +++ b/src/avatar/index.ts @@ -1,2 +1,3 @@ export * from './avatar' export * from './avatar-group' +export * from './avatar-pair'