Skip to content

Commit de3c66b

Browse files
feat(ui): add AvatarButton block
1 parent c3ce60e commit de3c66b

13 files changed

Lines changed: 227 additions & 4 deletions

File tree

.changeset/avatar-button-block.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
2828
'organization-profile-delete-section': dynamic(() => import('../stories/organization-profile-delete-section.mdx')),
2929
},
3030
blocks: {
31+
'avatar-button': dynamic(() => import('../stories/avatar-button.mdx')),
3132
destructive: dynamic(() => import('../stories/destructive.mdx')),
3233
'settings-group': dynamic(() => import('../stories/settings-group.mdx')),
3334
},

packages/swingset/src/lib/registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import {
88
Shapes as AvatarShapes,
99
Sizes as AvatarSizes,
1010
} from '../stories/avatar.stories';
11+
import {
12+
Default as AvatarButtonDefault,
13+
Fallback as AvatarButtonFallback,
14+
meta as avatarButtonMeta,
15+
} from '../stories/avatar-button.stories';
1116
import {
1217
Colors as BadgeColors,
1318
meta as badgeMeta,
@@ -132,6 +137,11 @@ import { toSlug } from './slug';
132137
import type { StoryModule } from './types';
133138

134139
const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault };
140+
const avatarButtonModule: StoryModule = {
141+
meta: avatarButtonMeta,
142+
Default: AvatarButtonDefault,
143+
Fallback: AvatarButtonFallback,
144+
};
135145
const settingsGroupModule: StoryModule = {
136146
meta: settingsGroupMeta,
137147
Default: SettingsGroupDefault,
@@ -280,6 +290,7 @@ export const registry: StoryModule[] = [
280290
organizationProfileLeaveSectionModule,
281291
organizationProfileDeleteSectionModule,
282292
// Blocks
293+
avatarButtonModule,
283294
destructiveModule,
284295
settingsGroupModule,
285296
// Components
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as AvatarButtonStories from './avatar-button.stories';
2+
3+
# AvatarButton
4+
5+
An interactive profile avatar with a stable edit affordance, accessible button semantics, and initials fallback.
6+
7+
<Story name='Default' storyModule={AvatarButtonStories} composition={[{ name: 'Avatar', href: '/components/avatar', layer: 'Components' }, { name: 'Button', href: '/components/button', layer: 'Components' }]} />
8+
9+
## Fallback
10+
11+
<Story name='Fallback' storyModule={AvatarButtonStories} />
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { AvatarButton } from '@clerk/ui/mosaic/block/avatar-button';
3+
4+
import type { StoryMeta } from '@/lib/types';
5+
6+
export { default as __source } from './avatar-button.stories?raw';
7+
8+
export const meta: StoryMeta = {
9+
group: 'Blocks',
10+
title: 'AvatarButton',
11+
source: 'packages/ui/src/mosaic/block/avatar-button.tsx',
12+
styleEngine: 'stylex',
13+
};
14+
15+
export function Default() {
16+
return (
17+
<AvatarButton
18+
imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4'
19+
name='Preston Booth'
20+
onClick={() => undefined}
21+
/>
22+
);
23+
}
24+
25+
export function Fallback() {
26+
return (
27+
<AvatarButton
28+
name='Preston Booth'
29+
onClick={() => undefined}
30+
/>
31+
);
32+
}

packages/swingset/src/stories/icon.stories.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const meta: StoryMeta = {
1717
styleEngine: 'stylex',
1818
styles: {
1919
_variants: {
20-
size: { sm: {}, md: {}, lg: {} },
20+
size: { xs: {}, sm: {}, md: {}, lg: {} },
2121
},
2222
_defaultVariants: {
2323
size: 'md',
@@ -44,6 +44,11 @@ export function Default(props: Record<string, unknown>) {
4444
export function Sizes(props: Record<string, unknown>) {
4545
return (
4646
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
47+
<Icon
48+
{...knobsAsProps(props)}
49+
name='chevron-right'
50+
size='xs'
51+
/>
4752
<Icon
4853
{...knobsAsProps(props)}
4954
name='chevron-right'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { AvatarButton } from './avatar-button';
6+
7+
describe('AvatarButton', () => {
8+
it('renders an accessible avatar action with a stable edit treatment', () => {
9+
render(
10+
<AvatarButton
11+
imageUrl='avatar.png'
12+
name='Ada Lovelace'
13+
/>,
14+
);
15+
16+
const button = screen.getByRole('button', { name: 'Edit profile picture' });
17+
expect(button).toHaveClass('cl-avatar-button');
18+
expect(button).toHaveAttribute('data-shape', 'circle');
19+
expect(button).toHaveAttribute('data-size', 'lg');
20+
expect(button.querySelector('.cl-avatar')).toHaveAttribute('aria-hidden', 'true');
21+
expect(button.querySelector('.cl-avatar-button-edit-surface')).not.toBeNull();
22+
});
23+
24+
it('derives fallback initials and forwards button behavior', () => {
25+
const onClick = vi.fn();
26+
const ref = React.createRef<HTMLButtonElement>();
27+
render(
28+
<AvatarButton
29+
ref={ref}
30+
name='Ada Lovelace'
31+
onClick={onClick}
32+
/>,
33+
);
34+
35+
expect(screen.getByText('AL')).toBeInTheDocument();
36+
fireEvent.click(screen.getByRole('button', { name: 'Edit profile picture' }));
37+
expect(onClick).toHaveBeenCalledOnce();
38+
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
39+
});
40+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
import React from 'react';
3+
4+
import { Avatar } from '../components/avatar';
5+
import type { ButtonProps } from '../components/button';
6+
import { Button } from '../components/button';
7+
import { Icon } from '../components/icon';
8+
import { mergeStyleProps, themeProps } from '../props';
9+
import { colorVars, radiusVars } from '../tokens.stylex';
10+
11+
export interface AvatarButtonProps extends Omit<ButtonProps, 'children' | 'shape' | 'size'> {
12+
imageUrl?: string;
13+
name: string;
14+
fallback?: React.ReactNode;
15+
}
16+
17+
const styles = stylex.create({
18+
root: {
19+
borderWidth: 0,
20+
position: 'relative',
21+
},
22+
editSurface: {
23+
borderColor: colorVars['--cl-color-border'],
24+
borderRadius: radiusVars['--cl-radius-full'],
25+
borderStyle: 'solid',
26+
borderWidth: '1px',
27+
alignItems: 'center',
28+
backgroundColor: colorVars['--cl-color-card'],
29+
boxSizing: 'border-box',
30+
display: 'flex',
31+
insetInlineStart: '-4.5px',
32+
justifyContent: 'center',
33+
position: 'absolute',
34+
height: '20px',
35+
top: '25px',
36+
width: '20px',
37+
},
38+
});
39+
40+
export const AvatarButton = React.forwardRef<HTMLButtonElement, AvatarButtonProps>(function AvatarButton(
41+
{
42+
imageUrl,
43+
name,
44+
fallback,
45+
color = 'neutral',
46+
variant = 'ghost',
47+
className,
48+
style,
49+
'aria-label': ariaLabel = 'Edit profile picture',
50+
...rest
51+
},
52+
ref,
53+
) {
54+
const initials = name
55+
.split(/\s+/)
56+
.map(part => part[0])
57+
.join('')
58+
.slice(0, 2)
59+
.toUpperCase();
60+
61+
return (
62+
<Button
63+
ref={ref}
64+
aria-label={ariaLabel}
65+
color={color}
66+
shape='circle'
67+
size='lg'
68+
variant={variant}
69+
{...mergeStyleProps(themeProps('avatar-button'), stylex.props(styles.root), className, style)}
70+
{...rest}
71+
>
72+
<Avatar.Root
73+
aria-hidden
74+
size='md'
75+
>
76+
<Avatar.Image
77+
alt=''
78+
src={imageUrl}
79+
/>
80+
<Avatar.Fallback>{fallback ?? initials}</Avatar.Fallback>
81+
</Avatar.Root>
82+
<span
83+
aria-hidden
84+
{...mergeStyleProps(themeProps('avatar-button-edit-surface'), stylex.props(styles.editSurface))}
85+
>
86+
<Icon
87+
aria-hidden
88+
name='pen'
89+
size='xs'
90+
/>
91+
</span>
92+
</Button>
93+
);
94+
});

packages/ui/src/mosaic/components/icon/icon.styles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const styles = stylex.create({
2020
});
2121

2222
export const sizes = stylex.create({
23+
xs: { height: space['3'], width: space['3'] },
2324
sm: { height: space['3.5'], width: space['3.5'] },
2425
md: { height: space['4'], width: space['4'] },
2526
lg: { height: space['5'], width: space['5'] },

packages/ui/src/mosaic/components/icon/icon.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ describe('Mosaic Icon', () => {
4141
expect(svg).toHaveStyle({ marginTop: '8px' });
4242
});
4343

44+
it('renders the pen with its native 12px geometry', () => {
45+
const { container } = wrap(
46+
<Icon
47+
name='pen'
48+
size='xs'
49+
/>,
50+
);
51+
const svg = container.querySelector('svg');
52+
expect(svg).toHaveAttribute('data-size', 'xs');
53+
expect(svg).toHaveAttribute('viewBox', '0 0 12 12');
54+
expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor');
55+
});
56+
4457
it('emits no placement attribute when the icon is not placed', () => {
4558
const { container } = wrap(<Icon name='chevron-right' />);
4659
expect(container.querySelector('svg')).not.toHaveAttribute('data-icon');

0 commit comments

Comments
 (0)