From 39e36de4aaca0fca0587dea5cc1253248088d0a1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 14 Aug 2026 12:56:47 -0600 Subject: [PATCH 1/2] feat(ui): add Mosaic IconFrame --- .changeset/tidy-icons-frame.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 20 +++ packages/swingset/src/stories/icon-frame.mdx | 68 +++++++++ .../src/stories/icon-frame.stories.tsx | 133 ++++++++++++++++++ packages/swingset/src/stories/section.mdx | 11 ++ .../swingset/src/stories/section.stories.tsx | 28 +++- .../components/icon/icon-frame.styles.ts | 20 +++ .../components/icon/icon-frame.test.tsx | 69 +++++++++ .../src/mosaic/components/icon/icon-frame.tsx | 25 ++++ .../ui/src/mosaic/components/icon/index.ts | 2 + packages/ui/src/mosaic/styles/index.ts | 5 +- 12 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 .changeset/tidy-icons-frame.md create mode 100644 packages/swingset/src/stories/icon-frame.mdx create mode 100644 packages/swingset/src/stories/icon-frame.stories.tsx create mode 100644 packages/ui/src/mosaic/components/icon/icon-frame.styles.ts create mode 100644 packages/ui/src/mosaic/components/icon/icon-frame.test.tsx create mode 100644 packages/ui/src/mosaic/components/icon/icon-frame.tsx diff --git a/.changeset/tidy-icons-frame.md b/.changeset/tidy-icons-frame.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-icons-frame.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index ff0b0120dae..f0cc5272c8b 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -30,6 +30,7 @@ const docModules: Record> = { dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), + 'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), section: dynamic(() => import('../stories/section.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index db75fcefb1b..26efd75a8fc 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -40,6 +40,14 @@ import { Override as IconOverride, Sizes as IconSizes, } from '../stories/icon.stories'; +import { + BrandIcons as IconFrameBrandIcons, + CustomSurface as IconFrameCustomSurface, + Default as IconFrameDefault, + IconSizes as IconFrameIconSizes, + meta as iconFrameMeta, + MultipleTreatments as IconFrameMultipleTreatments, +} from '../stories/icon-frame.stories'; import { Default, Disabled as InputDisabled, @@ -77,6 +85,7 @@ import { ConnectedAccounts as SectionConnectedAccounts, Default as SectionDefault, Destructive as SectionDestructive, + IconFrameMedia as SectionIconFrameMedia, meta as sectionMeta, MultipleEmailAndPhoneNumbers as SectionMultipleEmailAndPhoneNumbers, } from '../stories/section.stories'; @@ -124,6 +133,7 @@ const sectionModule: StoryModule = { Default: SectionDefault, MultipleEmailAndPhoneNumbers: SectionMultipleEmailAndPhoneNumbers, ConnectedAccounts: SectionConnectedAccounts, + IconFrameMedia: SectionIconFrameMedia, Destructive: SectionDestructive, }; const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; @@ -196,6 +206,15 @@ const iconModule: StoryModule = { Override: IconOverride, }; +const iconFrameModule: StoryModule = { + meta: iconFrameMeta, + Default: IconFrameDefault, + IconSizes: IconFrameIconSizes, + CustomSurface: IconFrameCustomSurface, + MultipleTreatments: IconFrameMultipleTreatments, + BrandIcons: IconFrameBrandIcons, +}; + // Headless primitives carry just `meta` (no story functions). Like every component // they're documented as a single overview page; their live demos come from `` / // `` embeds in the MDX, which import the stories module directly. @@ -263,6 +282,7 @@ export const registry: StoryModule[] = [ dialogComponentModule, headingModule, iconModule, + iconFrameModule, menuComponentModule, popoverComponentModule, sectionModule, diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx new file mode 100644 index 00000000000..57137cf6e01 --- /dev/null +++ b/packages/swingset/src/stories/icon-frame.mdx @@ -0,0 +1,68 @@ +import * as IconFrameStories from './icon-frame.stories'; + +# IconFrame + +IconFrame places an icon or other visual in a square Mosaic surface. The child owns its size and artwork, while the frame adds padding, border, radius, centering, and a default background. Customize the surface through native `style` or `className` overrides. + +## Playground + + + +## Props + + ReactNode' }]} +/> + +## Usage + +```tsx +import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; +import { colorVars } from '@clerk/ui/mosaic/styles'; + + + + +``` + +--- + +## Examples + +### Icon sizes + + + +### Custom surface + + + +### Multiple treatments + + + +### Brand icons + + diff --git a/packages/swingset/src/stories/icon-frame.stories.tsx b/packages/swingset/src/stories/icon-frame.stories.tsx new file mode 100644 index 00000000000..4e571ff5d4a --- /dev/null +++ b/packages/swingset/src/stories/icon-frame.stories.tsx @@ -0,0 +1,133 @@ +import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; +import { colorVars } from '@clerk/ui/mosaic/styles'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './icon-frame.stories?raw'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; + +function ProviderIcon({ provider }: { provider: string }) { + return ( + + ); +} + +export const meta: StoryMeta = { + group: 'Components', + title: 'IconFrame', + source: 'packages/ui/src/mosaic/components/icon/icon-frame.tsx', + styleEngine: 'stylex', +}; + +export function Default() { + return ( + + + + ); +} + +export function IconSizes() { + return ( +
+ + + + + + + + + +
+ ); +} + +export function CustomSurface() { + return ( + + + + ); +} + +export function MultipleTreatments() { + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} + +export function BrandIcons() { + return ( +
+ + + + + + +
+ ); +} diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index c19ff9a638e..7061ecff1db 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -64,6 +64,17 @@ Use `Section.Items` for a nested value list beneath a row's header item. The row ]} /> +### Framed icon media + + + ### Destructive section + Team + + + + + + + + + + Engineering + 12 members + + + + + + ); +} + export function Destructive() { return ( diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts new file mode 100644 index 00000000000..c6094584aea --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts @@ -0,0 +1,20 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + base: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + alignItems: 'center', + aspectRatio: '1 / 1', + backgroundColor: colorVars['--cl-color-background'], + display: 'inline-flex', + flexShrink: 0, + justifyContent: 'center', + height: space['10'], + }, +}); diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx b/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx new file mode 100644 index 00000000000..75fa583da58 --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Icon } from './icon'; +import { IconFrame } from './icon-frame'; + +describe('Mosaic IconFrame', () => { + it('renders its children in a span by default', () => { + render(Framed); + const frame = screen.getByText('Framed'); + expect(frame.tagName).toBe('SPAN'); + expect(frame).toHaveClass('cl-icon-frame'); + }); + + it('composes with an Icon', () => { + const { container } = render( + + + , + ); + const frame = container.querySelector('.cl-icon-frame'); + expect(frame).toContainElement(container.querySelector('svg.cl-icon')); + }); + + it('forwards native span props and the ref', () => { + const ref = React.createRef(); + render( + , + ); + const frame = screen.getByTestId('frame'); + expect(ref.current).toBe(frame); + expect(frame).toHaveAttribute('aria-label', 'Status icon'); + }); + + it('lets the consumer className and style win', () => { + render( + , + ); + const frame = screen.getByTestId('frame'); + expect(frame).toHaveClass('cl-icon-frame', 'my-frame'); + expect(frame).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)' }); + }); + + it('renders a custom element via render, keeping styles and props', () => { + render( + } + > + Status + , + ); + const frame = screen.getByRole('link', { name: 'Status' }); + expect(frame.tagName).toBe('A'); + expect(frame).toHaveAttribute('href', '/status'); + expect(frame).toHaveAttribute('data-testid', 'frame'); + expect(frame).toHaveClass('cl-icon-frame'); + }); +}); diff --git a/packages/ui/src/mosaic/components/icon/icon-frame.tsx b/packages/ui/src/mosaic/components/icon/icon-frame.tsx new file mode 100644 index 00000000000..abb348a5c4a --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.tsx @@ -0,0 +1,25 @@ +import { useRender } from '@clerk/headless/utils'; +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 { styles } from './icon-frame.styles'; + +export type IconFrameProps = MosaicComponentProps<'span'>; + +export const IconFrame = React.forwardRef(function MosaicIconFrame( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps(themeProps('icon-frame'), stylex.props(reset.base, styles.base), className, style), + ...rest, + }, + }); +}); diff --git a/packages/ui/src/mosaic/components/icon/index.ts b/packages/ui/src/mosaic/components/icon/index.ts index 77a5eccba3b..38ab61e44ad 100644 --- a/packages/ui/src/mosaic/components/icon/index.ts +++ b/packages/ui/src/mosaic/components/icon/index.ts @@ -1,2 +1,4 @@ export { Icon } from './icon'; export type { IconProps } from './icon'; +export { IconFrame } from './icon-frame'; +export type { IconFrameProps } from './icon-frame'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 052d4940c6d..21a065e147c 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -32,9 +32,8 @@ export { Field } from '../components/field'; export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps } from '../components/field'; export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; -export { Icon } from '../components/icon'; -export type { IconProps } from '../components/icon'; -export type { MosaicIconOverride, MosaicIconOverrides } from '../icons/overrides'; +export { Icon, IconFrame } from '../components/icon'; +export type { IconFrameProps, IconProps } from '../components/icon'; export { Input } from '../components/input'; export type { InputProps } from '../components/input'; export { Item } from '../components/item'; From b8f51b10a7050c5fb002b7bae108d79a3d5baea3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 09:32:33 -0600 Subject: [PATCH 2/2] Use IconFrame --- .../src/stories/icon-frame.stories.tsx | 10 ++--- .../swingset/src/stories/section.stories.tsx | 40 ++++++------------- .../user-profile-profile-panel.view.test.tsx | 14 +++++++ ...rofile-connected-accounts-section.view.tsx | 19 +++++---- .../user-profile-profile-panel.styles.ts | 7 ---- ...user-profile-web3-wallets-section.view.tsx | 19 +++++---- 6 files changed, 49 insertions(+), 60 deletions(-) diff --git a/packages/swingset/src/stories/icon-frame.stories.tsx b/packages/swingset/src/stories/icon-frame.stories.tsx index 4e571ff5d4a..9f0943e43ec 100644 --- a/packages/swingset/src/stories/icon-frame.stories.tsx +++ b/packages/swingset/src/stories/icon-frame.stories.tsx @@ -1,5 +1,5 @@ import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; -import { colorVars } from '@clerk/ui/mosaic/styles'; +import { colorVars, space } from '@clerk/ui/mosaic/styles'; import type { StoryMeta } from '@/lib/types'; @@ -7,12 +7,12 @@ export { default as __source } from './icon-frame.stories?raw'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; -function ProviderIcon({ provider }: { provider: string }) { +function ProviderLogo({ provider }: { provider: string }) { return ( ); } @@ -123,10 +123,10 @@ export function BrandIcons() { return (
- + - +
); diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx index 0d660b8f596..b962636f0f0 100644 --- a/packages/swingset/src/stories/section.stories.tsx +++ b/packages/swingset/src/stories/section.stories.tsx @@ -3,7 +3,7 @@ import { Badge } from '@clerk/ui/mosaic/components/badge'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; import { Section } from '@clerk/ui/mosaic/components/section'; -import * as stylex from '@stylexjs/stylex'; +import { space } from '@clerk/ui/mosaic/styles'; import type { StoryMeta } from '@/lib/types'; @@ -11,32 +11,16 @@ export { default as __source } from './section.stories?raw'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; -const styles = stylex.create({ - providerMedia: { - backgroundColor: 'var(--cl-color-background)', - borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', - borderRadius: 'var(--cl-radius-lg)', - borderStyle: 'solid', - borderWidth: '1px', - }, - providerIcon: { - display: 'block', - height: '20px', - width: '20px', - }, -}); - -function ProviderIcon({ provider }: { provider: string }) { +function ProviderMedia({ provider }: { provider: string }) { return ( - - + + + + ); } @@ -308,7 +292,7 @@ export function ConnectedAccounts() { - + Google test@google.com @@ -328,7 +312,7 @@ export function ConnectedAccounts() { - + Apple diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index e8f831c4fd2..512535c62b3 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -101,6 +101,20 @@ describe('UserProfileProfilePanelView', () => { expect(onDeleteAccount).toHaveBeenCalledOnce(); }); + it('renders provider images in icon frames', () => { + const { container } = renderView({ + connectedAccounts: [{ id: 'google', provider: 'Google', iconUrl: '/google.svg' }], + web3Wallets: [{ id: 'metamask', provider: 'MetaMask', iconUrl: '/metamask.svg' }], + }); + + const frames = container.querySelectorAll('.cl-icon-frame'); + const images = container.querySelectorAll('img'); + expect(frames).toHaveLength(2); + expect(frames[0]).toContainElement(images[0]); + expect(frames[1]).toContainElement(images[1]); + frames.forEach(frame => expect(frame.closest('.cl-section-media')).toHaveAttribute('data-size', 'lg')); + }); + it('renders Web3 wallets and forwards wallet actions', async () => { const onConnectWeb3Wallet = vi.fn(); const onSetPrimaryWeb3Wallet = vi.fn(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx index f91bbbecfc0..c5df5ec457a 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import { Button } from '../components/button'; -import { Icon } from '../components/icon'; +import { Icon, IconFrame } from '../components/icon'; import { Section } from '../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; @@ -46,15 +46,14 @@ export function UserProfileConnectedAccountsSectionView({ {account.iconUrl ? ( - - + + + + ) : null} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 2f6a734164c..830b7b7e422 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -9,13 +9,6 @@ export const styles = stylex.create({ display: 'flex', minWidth: 0, }, - providerMedia: { - borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', - borderRadius: 'var(--cl-radius-lg)', - borderStyle: 'solid', - borderWidth: '1px', - backgroundColor: 'var(--cl-color-background)', - }, providerIcon: { display: 'block', height: space['5'], diff --git a/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx index 82c9b68df22..6a46ba64e85 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx @@ -2,7 +2,7 @@ import * as stylex from '@stylexjs/stylex'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; -import { Icon } from '../components/icon'; +import { Icon, IconFrame } from '../components/icon'; import { Section } from '../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; @@ -75,15 +75,14 @@ export function UserProfileWeb3WalletsSectionView({ {wallet.iconUrl ? ( - - + + + + ) : null}