diff --git a/.changeset/avatar-button-block.md b/.changeset/avatar-button-block.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/avatar-button-block.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-avatar-interactive-styles.md b/.changeset/mosaic-avatar-interactive-styles.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-avatar-interactive-styles.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 6a69e33d0a8..e721dabd689 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -3,6 +3,7 @@ import { meta as accordionMeta } from '../stories/accordion.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { Fallback as AvatarFallbackStory, + Interactive as AvatarInteractive, meta as avatarMeta, Primary as AvatarPrimary, Shapes as AvatarShapes, @@ -175,6 +176,7 @@ const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: Car const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, + Interactive: AvatarInteractive, Fallback: AvatarFallbackStory, Sizes: AvatarSizes, Shapes: AvatarShapes, diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx index dce77c7f735..9deae63120c 100644 --- a/packages/swingset/src/stories/avatar.mdx +++ b/packages/swingset/src/stories/avatar.mdx @@ -2,7 +2,7 @@ import * as AvatarStories from './avatar.stories'; # Avatar -Avatar represents a user or entity as an image, falling back to a blank placeholder when the image is missing or fails to load. It is a compound component: `Avatar.Root` clips and sizes the box, `Avatar.Image` renders the picture once it loads, and `Avatar.Fallback` holds the space until then. +Avatar represents a user or entity as an image, falling back to a blank placeholder when the image is missing or fails to load. It is a compound component: `Avatar.Root` positions and sizes the box, `Avatar.Image` renders the picture once it loads, `Avatar.Fallback` holds the space until then, and `Avatar.Icon` adds an optional corner affordance. The fallback still takes children — initials, an icon — but never paints them, so an entity without a picture reads as an absent image rather than as two letters. They stay in the DOM, held in an `avatar-fallback-content` slot that is `visibility: hidden`: out of the page, the accessibility tree, and the tab order together, and overridable by a consumer who wants them back. @@ -37,10 +37,11 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar'; | Part | Slot (`.cl-*`) | Description | | ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `Avatar.Root` | `cl-avatar` | Owns `shape` / `size`, clips its children to the shape. | +| `Avatar.Root` | `cl-avatar` | Owns `shape` / `size`, positioning, and polymorphic rendering. | | `Avatar.Image` | `cl-avatar-image` | Renders an `` once the source loads; renders nothing until then. | | `Avatar.Fallback` | `cl-avatar-fallback` | Rendered while the image is pending or has failed. Carries `data-pending` while an image is still resolving. Optional `delayMs`. | | — | `cl-avatar-fallback-content` | Wraps whatever `Avatar.Fallback` was handed, hidden. Override its `visibility` to paint initials or an icon. | +| `Avatar.Icon` | `cl-avatar-icon` | Renders an optional icon surface over the avatar corner. | --- @@ -55,6 +56,15 @@ While an image is still resolving, the fallback carries `data-pending` and pulse storyModule={AvatarStories} /> +### Interactive + +Use `render` to compose the avatar onto a button or another trigger without introducing a separate wrapper component. The `lg` avatar and button are both 48×48. + + + ### Fallback Once the image fails, the pulse stops and the fallback settles into a plain fill. Nothing is on its way for an entity that has no picture, so its mark holds still rather than claiming to be loading one. diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index b14a0cea45e..bb5e9b39ff8 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @emotion/react */ import type { AvatarProps } from '@clerk/ui/mosaic/components/avatar'; import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; import type { StoryMeta } from '@/lib/types'; @@ -33,6 +34,7 @@ function knobsAsProps(props: Record) { } const IMAGE_SRC = 'https://github.com/clerk.png'; +const COLIN_IMAGE_SRC = 'https://avatars.githubusercontent.com/u/51144033?v=4'; export function Primary(props: Record) { return ( @@ -70,6 +72,30 @@ export function Fallback(props: Record) { ); } +export function Interactive(props: Record) { + return ( + + } + > + + CL + + + + + ); +} + export function Sizes(props: Record) { return (
diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts index c4961a0f156..787e69fda75 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -8,9 +8,8 @@ const pulse = stylex.keyframes({ }); export const styles = stylex.create({ - // root — clips its parts to the shape/size; fill comes from the image or fallback + // root — sizes and positions its parts; fill comes from the image or fallback base: { - overflow: 'hidden', alignItems: 'center', aspectRatio: '1 / 1', display: 'inline-flex', @@ -24,6 +23,22 @@ export const styles = stylex.create({ verticalAlign: 'middle', }, + interactive: { + background: 'transparent', + // An avatar used as a native button has no border shrinking the avatar inside it. + borderWidth: 0, + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, + }, + appearance: 'none', + cursor: { + default: 'pointer', + ':is(:disabled, [aria-disabled="true"])': 'not-allowed', + }, + outlineOffset: '2px', + }, + // Carries the root's radius rather than leaning on the clip alone, so a part that paints its own // fill rounds off cleanly instead of showing a corner. image: { @@ -63,6 +78,24 @@ export const styles = stylex.create({ }, animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)', }, + + icon: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + alignItems: 'center', + backgroundColor: colorVars['--cl-color-card'], + boxSizing: 'border-box', + display: 'flex', + insetBlockEnd: `calc(${space['2']} * -1)`, + insetInlineStart: `calc(${space['1']} * -1)`, + justifyContent: 'center', + position: 'absolute', + height: space['6'], + width: space['6'], + }, }); // shape — square shares its radius with Button; circle rounds fully diff --git a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx index 37d55b255d6..bdd628c92f0 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx @@ -4,6 +4,7 @@ import { flushSync } from 'react-dom'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Icon } from '../icon'; import { Avatar } from './avatar'; // React reads this off the global object and ships no typing for it. @@ -280,9 +281,37 @@ describe('Mosaic Avatar', () => { expect(avatar).toHaveStyle({ marginTop: '8px' }); }); + it('composes its root onto another element and renders an icon affordance', () => { + const ref = React.createRef(); + render( + + } + > + CN + + + + , + ); + + const button = screen.getByRole('button', { name: 'Edit profile picture' }); + expect(button).toHaveClass('cl-avatar'); + expect(button).toHaveAttribute('data-size', 'lg'); + expect(ref.current).toBe(button); + expect(button.querySelector('.cl-avatar-icon')).toHaveAttribute('aria-hidden', 'true'); + }); + it('throws when a part is rendered outside ', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(() => render(CN)).toThrow(/must be rendered inside /); + expect(() => render()).toThrow(/must be rendered inside /); spy.mockRestore(); }); }); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx index a679244d3d2..1a4d1c68d02 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx @@ -1,7 +1,9 @@ +import { useRender } from '@clerk/headless/utils'; import { useSafeLayoutEffect } from '@clerk/shared/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../reset.styles'; import { shapes, sizes, styles } from './avatar.styles'; @@ -23,34 +25,34 @@ function useAvatarContext(part: string): AvatarContextValue { return context; } -export interface AvatarProps extends React.ComponentPropsWithRef<'span'> { +export interface AvatarProps extends MosaicComponentProps<'span'> { shape?: 'circle' | 'square'; size?: 'fit' | 'lg' | 'md' | 'sm' | 'xs'; } const AvatarRoot = React.forwardRef(function MosaicAvatarRoot( - { shape = 'circle', size = 'md', className, style, children, ...rest }, + { shape = 'circle', size = 'md', render, className, style, ...rest }, ref, ) { const [status, setStatus] = React.useState('idle'); const value = React.useMemo(() => ({ status, onStatusChange: setStatus }), [status]); + const interactive = Boolean(render); + const element = useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('avatar', { shape, size }), + stylex.props(reset.base, styles.base, shapes[shape], sizes[size], interactive && styles.interactive), + className, + style, + ), + ...rest, + }, + }); - return ( - - - {children} - - - ); + return {element}; }); export type AvatarImageProps = React.ComponentPropsWithRef<'img'>; @@ -152,13 +154,33 @@ const AvatarFallback = React.forwardRef(fu ); }); +export type AvatarIconProps = React.ComponentPropsWithRef<'span'>; + +const AvatarIcon = React.forwardRef(function MosaicAvatarIcon( + { className, style, ...rest }, + ref, +) { + useAvatarContext('Avatar.Icon'); + + return ( + + ); +}); + /** - * Compound avatar. `Avatar.Root` clips and sizes the box; `Avatar.Image` renders + * Compound avatar. `Avatar.Root` positions and sizes the box; `Avatar.Image` renders * once its source loads; `Avatar.Fallback` holds the space until then, as a blank - * placeholder that pulses only while an image is actually on its way. + * placeholder that pulses only while an image is actually on its way; `Avatar.Icon` + * adds an optional corner affordance. */ export const Avatar = { Root: AvatarRoot, Image: AvatarImage, Fallback: AvatarFallback, + Icon: AvatarIcon, }; diff --git a/packages/ui/src/mosaic/components/avatar/index.ts b/packages/ui/src/mosaic/components/avatar/index.ts index 236ba032f57..363ec08e971 100644 --- a/packages/ui/src/mosaic/components/avatar/index.ts +++ b/packages/ui/src/mosaic/components/avatar/index.ts @@ -1,2 +1,2 @@ export { Avatar } from './avatar'; -export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from './avatar'; +export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from './avatar'; diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 5dcc15f012c..26a28cda818 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -9,11 +9,11 @@ type IconComponent = React.ForwardRefExoticComponent< * Builds a glyph from its inner `` markup. Glyphs omit `width`/`height` so the `Icon` recipe * controls size, and use `currentColor` so they inherit text color. Grow the set on demand. */ -function glyph(children: React.ReactNode): IconComponent { +function glyph(children: React.ReactNode, viewBox = '0 0 16 16'): IconComponent { return React.forwardRef>((props, ref) => ( , ); +const Pen = glyph( + , +); + const LogOut = glyph(