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 c7aecb8c7af..0ab2d13a613 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -58,6 +58,7 @@ const docModules: Record> = { 'alert-dialog': dynamic(() => import('../stories/alert-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')), otp: dynamic(() => import('../stories/otp.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index b894198df1e..d41f4e358f2 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -54,6 +54,14 @@ import { Override as IconOverride, Sizes as IconSizes, } from '../stories/icon.stories'; +import { + BrandIcons as IconFrameBrandIcons, + CustomSurface as IconFrameCustomSurface, + Default as IconFrameDefault, + meta as iconFrameMeta, + Sizes as IconFrameSizes, + Treatments as IconFrameTreatments, +} from '../stories/icon-frame.stories'; import { Default, Disabled as InputDisabled, @@ -123,6 +131,7 @@ import { ConnectedAccounts as SectionConnectedAccounts, Default as SectionDefault, Destructive as SectionDestructive, + IconFrameMedia as SectionIconFrameMedia, meta as sectionMeta, MultipleEmailAndPhoneNumbers as SectionMultipleEmailAndPhoneNumbers, } from '../stories/section.stories'; @@ -218,6 +227,7 @@ const sectionModule: StoryModule = { Default: SectionDefault, MultipleEmailAndPhoneNumbers: SectionMultipleEmailAndPhoneNumbers, ConnectedAccounts: SectionConnectedAccounts, + IconFrameMedia: SectionIconFrameMedia, Destructive: SectionDestructive, }; const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; @@ -312,6 +322,15 @@ const iconModule: StoryModule = { Override: IconOverride, }; +const iconFrameModule: StoryModule = { + meta: iconFrameMeta, + Default: IconFrameDefault, + Sizes: IconFrameSizes, + Treatments: IconFrameTreatments, + CustomSurface: IconFrameCustomSurface, + 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. @@ -487,6 +506,7 @@ export const registry: StoryModule[] = [ alertDialogComponentModule, headingModule, iconModule, + iconFrameModule, menuComponentModule, otpComponentModule, popoverComponentModule, diff --git a/packages/swingset/src/stories/icon-frame.mdx b/packages/swingset/src/stories/icon-frame.mdx new file mode 100644 index 00000000000..ec98733d3b8 --- /dev/null +++ b/packages/swingset/src/stories/icon-frame.mdx @@ -0,0 +1,70 @@ +import * as IconFrameStories from './icon-frame.stories'; + +# IconFrame + +IconFrame centers an icon or other visual in a square Mosaic surface. `bordered` and `filled` are independent treatments, while `size` controls the frame dimensions. The default is a bordered 40px frame; the filled treatment uses `oklch(0.9702 0 0)` in light mode and `oklch(0.2393 0 0)` in dark mode. + +## Playground + + + +## Props + + ReactNode' }]} +/> + +## Usage + +```tsx +import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; + + + + +``` + +--- + +## Examples + +### Sizes + + + +`sm`, `md`, `lg`, and `xl` map to 28px, 32px, 36px, and 40px respectively. + +### Treatments + + + +`bordered` and `filled` can be used separately, together, or both disabled for a centered bare icon. + +### Custom surface + + + +### 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..5e6cbe781f3 --- /dev/null +++ b/packages/swingset/src/stories/icon-frame.stories.tsx @@ -0,0 +1,138 @@ +import type { IconFrameProps } from '@clerk/ui/mosaic/components/icon'; +import { Icon, IconFrame } from '@clerk/ui/mosaic/components/icon'; +import { colorVars, space } 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 ProviderLogo({ provider }: { provider: string }) { + return ( + + ); +} + +export const meta: StoryMeta = { + group: 'Components', + title: 'IconFrame', + source: 'packages/ui/src/mosaic/components/icon/icon-frame.tsx', + styles: { + _variants: { + bordered: { true: {}, false: {} }, + filled: { true: {}, false: {} }, + size: { sm: {}, md: {}, lg: {}, xl: {} }, + }, + _defaultVariants: { + bordered: true, + filled: false, + size: 'xl', + }, + }, +}; + +function knobsAsProps(props: Record) { + return props as unknown as IconFrameProps; +} + +export function Default(props: Record) { + return ( + + + + ); +} + +export function Sizes() { + return ( +
+ + + + + + + + + + + + +
+ ); +} + +export function Treatments() { + return ( +
+ + + + + + + + + + + + +
+ ); +} + +export function CustomSurface() { + 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 `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 @@ -353,6 +337,32 @@ export function ConnectedAccounts() { ); } +export function IconFrameMedia() { + return ( + + 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..8a1b94eb7c5 --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.styles.ts @@ -0,0 +1,35 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../tokens.stylex'; + +// Figma uses the 'page faded' token +const filledBackground = 'light-dark(oklch(0.9702 0 0), oklch(0.2393 0 0))'; + +export const styles = stylex.create({ + base: { + borderColor: 'transparent', + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + alignItems: 'center', + aspectRatio: '1 / 1', + backgroundColor: 'transparent', + display: 'inline-flex', + flexShrink: 0, + justifyContent: 'center', + }, + bordered: { + borderColor: colorVars['--cl-color-border'], + }, + filled: { + backgroundColor: filledBackground, + }, +}); + +export const sizes = stylex.create({ + sm: { height: space['7'] }, + md: { height: space['8'] }, + lg: { height: space['9'] }, + xl: { 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..0735e3ab18d --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.test.tsx @@ -0,0 +1,88 @@ +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'); + expect(frame).toHaveAttribute('data-bordered', ''); + expect(frame).not.toHaveAttribute('data-filled'); + expect(frame).toHaveAttribute('data-size', 'xl'); + }); + + it('reflects its treatment and size', () => { + render( + + Framed + , + ); + const frame = screen.getByText('Framed'); + expect(frame).not.toHaveAttribute('data-bordered'); + expect(frame).toHaveAttribute('data-filled', ''); + expect(frame).toHaveAttribute('data-size', 'sm'); + }); + + 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..3181cf78a2e --- /dev/null +++ b/packages/ui/src/mosaic/components/icon/icon-frame.tsx @@ -0,0 +1,34 @@ +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 '../../utils/reset.styles'; +import { sizes, styles } from './icon-frame.styles'; + +export interface IconFrameProps extends MosaicComponentProps<'span'> { + bordered?: boolean; + filled?: boolean; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +export const IconFrame = React.forwardRef(function MosaicIconFrame( + { bordered = true, filled = false, size = 'xl', render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('icon-frame', { bordered, filled, size }), + stylex.props(reset.base, styles.base, sizes[size], bordered && styles.bordered, filled && styles.filled), + 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 1c47fa8110e..c8919d9aeb7 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -59,9 +59,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'; 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 e6227392e30..b5e4bcd46e7 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 @@ -174,6 +174,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-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 539b9ee02a8..8a3cc17a27c 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 @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, radiusVars, space } from '../tokens.stylex'; +import { space } from '../tokens.stylex'; export const styles = stylex.create({ contactValue: { @@ -14,13 +14,6 @@ export const styles = stylex.create({ height: space['5'], width: space['5'], }, - providerMedia: { - borderColor: `light-dark(${colorVars['--cl-color-border-faded']}, ${colorVars['--cl-color-background']})`, - borderRadius: radiusVars['--cl-radius-lg'], - borderStyle: 'solid', - borderWidth: '1px', - backgroundColor: colorVars['--cl-color-background'], - }, root: { gap: space['4'], display: 'flex', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx b/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx index a200fe914b9..76a87718954 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx @@ -1,32 +1,30 @@ import * as stylex from '@stylexjs/stylex'; -import { Icon } from '../components/icon'; +import { Icon, IconFrame } from '../components/icon'; import { Section } from '../components/section'; import type { IconName } from '../icons/registry'; import { styles } from './user-profile-profile-panel.styles'; type UserProfileProviderIconProps = { iconUrl: string; name?: never } | { iconUrl?: never; name: IconName }; -// TODO: Replace this temporary user-profile wrapper with IconFrame. export function UserProfileProviderIcon(props: UserProfileProviderIconProps) { return ( - - {'iconUrl' in props ? ( - - ) : ( - - )} + + + {'iconUrl' in props ? ( + + ) : ( + + )} + ); }