From 6d675c9b26a84e204d2f9a1347adfa24adac25bf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 11:25:38 -0600 Subject: [PATCH 01/18] feat(ui): extend Section layouts --- .../ui/src/mosaic/components/section/index.ts | 1 + .../components/section/section.styles.ts | 23 ++++++++++++- .../components/section/section.test.tsx | 23 +++++++++++++ .../src/mosaic/components/section/section.tsx | 32 +++++++++++++++---- 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/mosaic/components/section/index.ts b/packages/ui/src/mosaic/components/section/index.ts index 8b920fdc6fe..d220b70a895 100644 --- a/packages/ui/src/mosaic/components/section/index.ts +++ b/packages/ui/src/mosaic/components/section/index.ts @@ -11,5 +11,6 @@ export type { SectionMediaSize, SectionRootProps, SectionRowProps, + SectionRowVariant, SectionTitleProps, } from './section'; diff --git a/packages/ui/src/mosaic/components/section/section.styles.ts b/packages/ui/src/mosaic/components/section/section.styles.ts index df51f9e5472..62ce9e2c63c 100644 --- a/packages/ui/src/mosaic/components/section/section.styles.ts +++ b/packages/ui/src/mosaic/components/section/section.styles.ts @@ -9,10 +9,11 @@ export const styles = stylex.create({ root: { display: 'flex', flexDirection: 'column', - rowGap: space['2'], + rowGap: space['3'], width: '100%', }, title: { + color: colorVars['--cl-color-neutral'], fontWeight: fontWeightVars['--cl-font-medium'], }, group: { @@ -46,6 +47,11 @@ export const styles = stylex.create({ minHeight: `calc(${space['18.5']} + 1px)`, width: 'auto', }, + rowList: { + paddingBlock: 0, + rowGap: 0, + minHeight: 0, + }, items: { display: 'flex', flexDirection: 'column', @@ -62,6 +68,21 @@ export const styles = stylex.create({ nestedItem: { paddingBlock: space['1'], }, + listHeader: { + paddingBlock: space['3'], + borderBlockEndColor: colorVars['--cl-color-border'], + borderBlockEndStyle: 'solid', + borderBlockEndWidth: '1px', + }, + listItem: { + paddingBlock: space['4'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { + default: '1px', + ':first-child': '0px', + }, + }, mediaBase: { alignItems: 'center', alignSelf: 'center', diff --git a/packages/ui/src/mosaic/components/section/section.test.tsx b/packages/ui/src/mosaic/components/section/section.test.tsx index 9a31513a612..ba33c07c823 100644 --- a/packages/ui/src/mosaic/components/section/section.test.tsx +++ b/packages/ui/src/mosaic/components/section/section.test.tsx @@ -88,6 +88,29 @@ describe('Section', () => { expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); }); + it('supports a divided list row', () => { + render( + + + + Email + + one@example.com + two@example.com + + + + , + ); + + expect(screen.getByTestId('row')).toHaveAttribute('data-variant', 'list'); + expect(screen.getByText('one@example.com')).toHaveAttribute('data-nested'); + expect(screen.getByText('two@example.com')).toHaveAttribute('data-nested'); + }); + it('lets consumer props win and forwards refs and custom elements', () => { const rootRef = React.createRef(); const groupRef = React.createRef(); diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 944166d2a8e..8a9c45c177d 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -14,7 +14,8 @@ import { styles } from './section.styles'; export type SectionRootProps = Omit, 'title'>; export type SectionTitleProps = Omit; export type SectionGroupProps = MosaicComponentProps<'div'>; -export type SectionRowProps = MosaicComponentProps<'div'>; +export type SectionRowVariant = 'default' | 'list'; +export type SectionRowProps = MosaicComponentProps<'div'> & { variant?: SectionRowVariant }; export type SectionItemsProps = MosaicComponentProps<'div'>; export type SectionItemProps = MosaicComponentProps<'div'>; export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; @@ -31,8 +32,14 @@ const mediaSizes = { xl: styles.mediaXl, }; +const rowVariants = { + default: null, + list: styles.rowList, +}; + const SectionTitleContext = React.createContext> | null>(null); const SectionItemsContext = React.createContext(false); +const SectionRowVariantContext = React.createContext('default'); const Root = React.forwardRef(function SectionRoot( { render, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, @@ -77,7 +84,7 @@ const Title = React.forwardRef(function S ref={ref} id={id} render={render ?? (props =>

)} - size='sm' + size='base' {...mergeStyleProps(themeProps('section-title'), stylex.props(styles.title), className, style)} {...rest} /> @@ -100,18 +107,25 @@ const Group = React.forwardRef(function Secti }); const Row = React.forwardRef(function SectionRow( - { render, className, style, ...rest }, + { variant = 'default', render, className, style, ...rest }, ref, ) { - return useRender({ + const element = useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('section-row'), stylex.props(reset.base, styles.row), className, style), + ...mergeStyleProps( + themeProps('section-row', { variant }), + stylex.props(reset.base, styles.row, rowVariants[variant]), + className, + style, + ), ...rest, }, }); + + return {element}; }); const Items = React.forwardRef(function SectionItems( @@ -141,6 +155,7 @@ const Item = React.forwardRef(function Section ref, ) { const nested = React.useContext(SectionItemsContext); + const rowVariant = React.useContext(SectionRowVariantContext); return useRender({ defaultTagName: 'div', @@ -149,7 +164,12 @@ const Item = React.forwardRef(function Section props: { ...mergeStyleProps( themeProps('section-item', { nested }), - stylex.props(reset.base, styles.item, nested && styles.nestedItem), + stylex.props( + reset.base, + styles.item, + nested && styles.nestedItem, + rowVariant === 'list' && (nested ? styles.listItem : styles.listHeader), + ), className, style, ), From 123bf5f93813d344aaec3c874df92c6c1ab62977 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 11:25:53 -0600 Subject: [PATCH 02/18] feat(ui): refine user profile account sections --- .../user-profile-profile-panel.view.test.tsx | 84 ++++- .../user-profile-account-section.view.tsx | 353 +++++++++++------- ...rofile-connected-accounts-section.view.tsx | 17 +- .../user-profile-profile-panel.styles.ts | 17 +- .../user-profile-profile-panel.view.tsx | 2 +- .../user-profile-provider-icon.tsx | 19 + ...user-profile-web3-wallets-section.view.tsx | 14 +- 7 files changed, 323 insertions(+), 183 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx 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..ed78bf86d52 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 @@ -31,8 +31,7 @@ describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { renderView({ onEditProfilePicture: vi.fn(), onNameChange: vi.fn(), onUsernameChange: vi.fn() }); - expect(screen.queryByRole('heading', { name: 'Account' })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Account' })).toContainElement( document.querySelector('.cl-section-group'), ); @@ -44,14 +43,14 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('button', { name: 'Edit username' })).toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); - expect(within(screen.getByRole('region', { name: 'Email' })).getByText('Primary')).toBeInTheDocument(); + expect(screen.getByText('item1@clerk.dev').closest('.cl-section-item')).toHaveTextContent('Primary'); expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); expect(screen.getByText('Profile picture')).toHaveClass('cl-section-label'); expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toHaveClass('cl-section-description'); expect(screen.getByText('Email')).toHaveClass('cl-section-label'); expect(screen.getByText('Phone')).toHaveClass('cl-section-label'); expect(screen.getByText('item1@clerk.dev').closest('.cl-section-description')).not.toBeNull(); - expect(screen.getByRole('button', { name: 'Edit profile picture' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); const profilePicture = screen.getByText('Profile picture').closest('.cl-section-item'); expect(profilePicture?.querySelector('.cl-section-media')).toHaveAttribute('data-size', 'lg'); expect(profilePicture?.querySelector('.cl-avatar')).toHaveAttribute('data-size', 'fit'); @@ -59,16 +58,78 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); }); - it('edits the profile picture when the avatar is clicked', async () => { + it('edits the profile picture when Upload is clicked', async () => { const onEditProfilePicture = vi.fn(); const user = userEvent.setup(); renderView({ onEditProfilePicture }); - await user.click(screen.getByRole('button', { name: 'Edit profile picture' })); + await user.click(screen.getByRole('button', { name: 'Upload' })); expect(onEditProfilePicture).toHaveBeenCalledOnce(); }); + it('breaks out both contact types when either has multiple entries', () => { + renderView({ onAddEmail: vi.fn(), onAddPhone: vi.fn() }); + + const accountSection = screen.getByRole('region', { name: 'Account' }); + const emailSection = screen.getByRole('region', { name: 'Email' }); + const phoneSection = screen.getByRole('region', { name: 'Phone' }); + + expect(accountSection).not.toContainElement(emailSection); + expect(accountSection).not.toContainElement(phoneSection); + expect(emailSection).toHaveTextContent('item1@clerk.dev'); + expect(emailSection).toHaveTextContent('item2@clerk.dev'); + expect(phoneSection).toHaveTextContent('+1 801-888-8181'); + expect(within(emailSection).getByRole('button', { name: 'Add email' })).toHaveTextContent('Add'); + expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toHaveTextContent('Add'); + }); + + it('keeps both contact types inside Account when neither has multiple entries', () => { + renderView({ + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], + onManageEmail: vi.fn(), + onManagePhone: vi.fn(), + }); + + const accountSection = screen.getByRole('region', { name: 'Account' }); + + expect(accountSection).toHaveTextContent('item1@clerk.dev'); + expect(accountSection).toHaveTextContent('+1 801-888-8181'); + expect(within(accountSection).getByRole('button', { name: 'Update email' })).toBeInTheDocument(); + expect(within(accountSection).getByRole('button', { name: 'Update phone number' })).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Phone' })).not.toBeInTheDocument(); + }); + + it('forwards inline contact update and add actions', async () => { + const onAddEmail = vi.fn(); + const onManagePhone = vi.fn(); + const user = userEvent.setup(); + renderView({ + emails: [], + onAddEmail, + onManagePhone, + }); + + expect(screen.getByText('No email addresses added')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Add email' })); + await user.click(screen.getByRole('button', { name: 'Update phone number' })); + + expect(onAddEmail).toHaveBeenCalledOnce(); + expect(onManagePhone).toHaveBeenCalledWith('phone_1'); + }); + + it('renders an actionable empty state when no phone number exists', () => { + renderView({ phones: [], onAddPhone: vi.fn() }); + + const phoneSection = screen.getByRole('region', { name: 'Phone' }); + const emptyState = within(phoneSection).getByText('No phone numbers added'); + + expect(emptyState.closest('.cl-section-items')).not.toBeNull(); + expect(emptyState.closest('.cl-section-item')).not.toContainElement(within(phoneSection).getByText('Phone')); + expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument(); + }); + it('renders connected accounts and the danger zone when provided', async () => { const onConnectAccount = vi.fn(); const onManageConnectedAccount = vi.fn(); @@ -76,7 +137,7 @@ describe('UserProfileProfilePanelView', () => { const user = userEvent.setup(); renderView({ connectedAccounts: [ - { id: 'google', provider: 'Google', identifier: 'test@google.com' }, + { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' }, { id: 'apple', provider: 'Apple', connected: false }, ], onConnectAccount, @@ -85,6 +146,9 @@ describe('UserProfileProfilePanelView', () => { }); expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); + expect( + screen.getByRole('region', { name: 'Connected accounts' }).querySelector('.cl-section-media[data-size="lg"] img'), + ).toHaveAttribute('src', 'https://example.com/google.svg'); expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); expect(screen.getByText('Permanently delete this profile and all its data. This cannot be undone.')).toHaveClass( @@ -112,6 +176,7 @@ describe('UserProfileProfilePanelView', () => { id: 'primary', address: '0x1234567890abcdef1234567890abcdef12345678', provider: 'MetaMask', + iconUrl: 'https://example.com/metamask.svg', isPrimary: true, isVerified: true, }, @@ -134,6 +199,9 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument(); expect(screen.getByText('MetaMask')).toBeInTheDocument(); + expect( + screen.getByRole('region', { name: 'Web3 wallets' }).querySelector('.cl-section-media[data-size="lg"] img'), + ).toHaveAttribute('src', 'https://example.com/metamask.svg'); expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); expect(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); @@ -185,7 +253,7 @@ describe('UserProfileProfilePanelView', () => { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Edit name' })); - await user.click(within(screen.getByRole('region', { name: 'Email' })).getByRole('button', { name: 'Add email' })); + await user.click(screen.getByRole('button', { name: 'Add email' })); await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); expect(onManageEmail).not.toHaveBeenCalled(); await user.click(screen.getByRole('menuitem', { name: 'Manage' })); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 34243e4e9fc..c60ff82e15e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -74,83 +74,103 @@ export function UserProfileAccountSectionView({ .toUpperCase(); const updateName = onNameChange ? () => onNameChange(name) : undefined; const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined; + const shouldBreakOutContacts = emails.length > 1 || phones.length > 1; return ( - - - - - - - ) : undefined - } - > - - {initials} - {onEditProfilePicture ? ( - - - - ) : null} - - - - Profile picture - Recommend size 1:1, up to 10MB. - - - - - - - Name - {name} - - {updateName ? ( - - - - ) : null} - - - - - - Username - {username} - - {updateUsername ? ( - - - - ) : null} - - +
+ + Profile + + + + + + + {initials} + + + + Profile picture + Recommend size 1:1, up to 10MB. + + {onEditProfilePicture ? ( + + + + ) : null} + + + + + + Name + {name} + + {updateName ? ( + + + + ) : null} + + + + + + Username + {username} + + {updateUsername ? ( + + + + ) : null} + + + {!shouldBreakOutContacts ? ( + + ) : null} + {!shouldBreakOutContacts ? ( + + ) : null} + + + {shouldBreakOutContacts ? ( + ) : null} + {shouldBreakOutContacts ? ( - - + ) : null} +
); } -function ContactSection({ - kind, - label, - items, - onAdd, - onManage, - onVerify, - onSetPrimary, - onRemove, -}: { +interface ContactSectionProps { kind: 'email' | 'phone'; label: string; items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; @@ -194,80 +207,142 @@ function ContactSection({ onVerify?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void; -}) { - const labelId = `user-profile-profile-panel-${label.toLowerCase()}`; +} +function ContactSection(props: ContactSectionProps) { return ( - ( -
- )} - > + + + + + + ); +} + +function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { + const item = items[0]; + const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; + const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; + const actionLabel = item + ? kind === 'email' + ? 'Update email' + : 'Update phone number' + : kind === 'email' + ? 'Add email' + : 'Add phone number'; + + return ( + - {label} + {label} + {item ? ( + + {item.value} + {item.isDefault ? Primary : null} + + ) : ( + {emptyDescription} + )} + + {onClick ? ( + + + + ) : null} + + + ); +} + +function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimary, onRemove }: ContactSectionProps) { + const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; + + return ( + + + + {label} {onAdd ? ( ) : null} - {items.map(item => { - const actions: UserProfileMenuAction[] = []; - const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + {items.length === 0 ? ( + + + {emptyDescription} + + + ) : ( + items.map(item => { + const actions: UserProfileMenuAction[] = []; + const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); - if (item.isVerified === false && onVerify) { - actions.push({ - label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', - onClick: () => onVerify(item.id), - }); - } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { - actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); - } + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); + } - if (onRemove && item.canRemove !== false) { - actions.push({ - label: kind === 'email' ? 'Remove email' : 'Remove phone number', - color: 'negative', - onClick: () => onRemove(item.id), - }); - } + if (onRemove && item.canRemove !== false) { + actions.push({ + label: kind === 'email' ? 'Remove email' : 'Remove phone number', + color: 'negative', + onClick: () => onRemove(item.id), + }); + } - if (!hasExplicitActions && onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); - } + if (!hasExplicitActions && onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); + } - return ( - - - - {item.value} - {item.isDefault ? Primary : null} - - - {actions.length > 0 ? ( - - - - ) : null} - - ); - })} + return ( + + + + {item.value} + {item.isDefault ? Primary : null} + + + {actions.length > 0 ? ( + + + + ) : null} + + ); + }) + )} ); 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..94b4760ed65 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,11 +1,9 @@ -import * as stylex from '@stylexjs/stylex'; - import { Button } from '../components/button'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; -import { styles } from './user-profile-profile-panel.styles'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; export interface UserProfileConnectedAccount { id: string; @@ -45,18 +43,7 @@ export function UserProfileConnectedAccountsSectionView({ return ( - {account.iconUrl ? ( - - - - ) : null} + {account.iconUrl ? : null} {account.provider} {account.identifier ? {account.identifier} : 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..47fb979d2fc 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 { space } from '../tokens.stylex'; +import { colorVars, radiusVars, space } from '../tokens.stylex'; export const styles = stylex.create({ contactValue: { @@ -9,18 +9,18 @@ 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'], width: space['5'], }, + providerMedia: { + borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', + borderRadius: radiusVars['--cl-radius-lg'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: colorVars['--cl-color-background'], + }, root: { gap: space['4'], display: 'flex', @@ -30,5 +30,6 @@ export const styles = stylex.create({ gap: space['8'], display: 'flex', flexDirection: 'column', + width: '100%', }, }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 79139febcdf..42947b075ea 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -67,7 +67,7 @@ export function UserProfileProfilePanelView({ render={props =>

} size='2xl' > - Profile + Account
+ + + ); +} 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..0ccf9bfd4da 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 @@ -7,6 +7,7 @@ import { Section } from '../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { styles } from './user-profile-profile-panel.styles'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; export interface UserProfileWeb3Wallet { id: string; @@ -74,18 +75,7 @@ export function UserProfileWeb3WalletsSectionView({ return ( - {wallet.iconUrl ? ( - - - - ) : null} + {wallet.iconUrl ? : null} From e18d1dff48d82709d62615a0a85d9e40c26e6452 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 11:26:08 -0600 Subject: [PATCH 03/18] chore(swingset): make profile stories interactive --- .../user-profile-account-section.stories.tsx | 40 ++++++++++++++---- .../user-profile-profile-panel.stories.tsx | 41 +++++++++++++++---- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 71494230b16..889f1a065b4 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,4 +1,9 @@ +import type { + UserProfileEmail, + UserProfilePhone, +} from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -11,21 +16,42 @@ export const meta: StoryMeta = { }; export function Default() { + const [emails, setEmails] = useState([ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + ]); + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); + return ( undefined} - onAddPhone={() => undefined} + onAddEmail={() => + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]) + } + onAddPhone={() => + setPhones(current => [ + ...current, + { + id: `phone_${Date.now()}`, + value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, + isVerified: true, + }, + ]) + } onEditProfilePicture={() => undefined} onManageEmail={() => undefined} onManagePhone={() => undefined} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onNameChange={() => undefined} onUsernameChange={() => undefined} /> diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index b69cbefa42c..567d029e673 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -1,4 +1,6 @@ +import type { UserProfileEmail, UserProfilePhone } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -14,12 +16,17 @@ export const meta: StoryMeta = { }; export function Default(_args: Record) { + const [emails, setEmails] = useState([ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + ]); + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); + return ( ) { ]} imageUrl={profileImageUrl} name='Preston Booth' - phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]} + phones={phones} username='prestonxyz' - onAddEmail={() => undefined} - onAddPhone={() => undefined} + onAddEmail={() => + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]) + } + onAddPhone={() => + setPhones(current => [ + ...current, + { + id: `phone_${Date.now()}`, + value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, + isVerified: true, + }, + ]) + } onConnectAccount={() => undefined} onDeleteAccount={() => undefined} onEditProfilePicture={() => undefined} + onManageEmail={() => undefined} + onManagePhone={() => undefined} onRemoveConnectedAccount={() => undefined} - onRemoveEmail={() => undefined} - onRemovePhone={() => undefined} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onConnectWeb3Wallet={() => undefined} onRemoveWeb3Wallet={() => undefined} onSetPrimaryWeb3Wallet={() => undefined} From 291cf0d816d53d38c6c50e916049a64eb8a4e86b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 13:08:40 -0600 Subject: [PATCH 04/18] fix(ui): isolate Section list row spacing --- packages/ui/src/mosaic/components/section/section.styles.ts | 4 +++- packages/ui/src/mosaic/components/section/section.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/components/section/section.styles.ts b/packages/ui/src/mosaic/components/section/section.styles.ts index 62ce9e2c63c..3466f44fe69 100644 --- a/packages/ui/src/mosaic/components/section/section.styles.ts +++ b/packages/ui/src/mosaic/components/section/section.styles.ts @@ -35,6 +35,9 @@ export const styles = stylex.create({ }, display: 'flex', flexDirection: 'column', + width: 'auto', + }, + rowDefault: { paddingBlockEnd: { default: space['4'], [stylex.when.descendant('[data-nested]', sectionItemsMarker)]: space['1'], @@ -45,7 +48,6 @@ export const styles = stylex.create({ [stylex.when.descendant('[data-nested]', sectionItemsMarker)]: space['3'], }, minHeight: `calc(${space['18.5']} + 1px)`, - width: 'auto', }, rowList: { paddingBlock: 0, diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 8a9c45c177d..9da6fa7f55e 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -33,7 +33,7 @@ const mediaSizes = { }; const rowVariants = { - default: null, + default: styles.rowDefault, list: styles.rowList, }; From 48aa225e5e116c0673656d27e10cb5be18f07786 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 13:08:59 -0600 Subject: [PATCH 05/18] feat(ui): add user profile security panel --- .changeset/user-profile-security-panel.md | 2 + .../src/mosaic/components/icon/icon.test.tsx | 19 ++ packages/ui/src/mosaic/icons/registry.tsx | 122 ++++++++++++ .../user-profile-profile-panel.view.test.tsx | 2 +- .../user-profile-security-panel.view.test.tsx | 177 ++++++++++++++++++ ...er-profile-active-devices-section.view.tsx | 139 ++++++++++++++ .../user-profile-delete-section.view.tsx | 2 +- .../user-profile-mfa-section.view.tsx | 127 +++++++++++++ .../user-profile-passkeys-section.view.tsx | 70 +++++++ .../user-profile-password-section.view.tsx | 40 ++++ .../user-profile-security-icon.tsx | 34 ++++ .../user-profile-security-list.tsx | 71 +++++++ .../user-profile-security-panel.styles.ts | 44 +++++ .../user-profile-security-panel.view.tsx | 102 ++++++++++ 14 files changed, 949 insertions(+), 2 deletions(-) create mode 100644 .changeset/user-profile-security-panel.md create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-security-icon.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-security-panel.styles.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx diff --git a/.changeset/user-profile-security-panel.md b/.changeset/user-profile-security-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-security-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/components/icon/icon.test.tsx b/packages/ui/src/mosaic/components/icon/icon.test.tsx index dc9483eb2bd..74e65c5d64e 100644 --- a/packages/ui/src/mosaic/components/icon/icon.test.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.test.tsx @@ -19,6 +19,25 @@ describe('Mosaic Icon', () => { expect(svg?.querySelector('path')).not.toBeNull(); }); + it.each(['security-phone', 'security-lock-square'] as const)('renders the %s glyph on its 18px canvas', name => { + const { container } = wrap(); + const svg = container.querySelector('svg'); + + expect(svg).toHaveAttribute('viewBox', '0 0 18 18'); + expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor'); + }); + + it.each([ + ['device-phone', ['#646464', '#646464', '#343434', '#575757', '#171717', 'black']], + ['device-laptop', ['black', '#575757', 'black', '#444444', '#171717']], + ] as const)('preserves the supplied %s palette', (name, palette) => { + const { container } = wrap(); + const paths = Array.from(container.querySelectorAll('path')); + + expect(container.querySelector('svg')).toHaveAttribute('viewBox', '0 0 18 18'); + expect(paths.map(path => path.getAttribute('fill'))).toEqual(palette); + }); + it('applies the default size when none is passed', () => { const { container } = wrap(); expect(container.querySelector('svg')).toHaveAttribute('data-size', 'md'); diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index a652c87c706..03b3e38a9f0 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -94,6 +94,122 @@ const Plus = glyph( />, ); +const SecurityPasskey = glyph( + <> + + + , + '0 0 14.604 13.511', +); + +const SecurityPhone = glyph( + <> + + + , + '0 0 18 18', +); + +const SecurityLockSquare = glyph( + , + '0 0 18 18', +); + +const DevicePhone = glyph( + <> + + + + + + + , + '0 0 18 18', +); + +const DeviceLaptop = glyph( + <> + + + + + + , + '0 0 18 18', +); + const ArrowRightTop = glyph( ; 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 ed78bf86d52..33a98752cd1 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 @@ -151,7 +151,7 @@ describe('UserProfileProfilePanelView', () => { ).toHaveAttribute('src', 'https://example.com/google.svg'); expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); - expect(screen.getByText('Permanently delete this profile and all its data. This cannot be undone.')).toHaveClass( + expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( 'cl-section-description', ); await user.click(screen.getByRole('button', { name: 'Manage Google' })); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx new file mode 100644 index 00000000000..ce92e2056ea --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -0,0 +1,177 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; +import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; + +const props: UserProfileSecurityPanelViewProps = { + hasPassword: true, + passkeys: [ + { + id: 'passkey_1', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ], + mfaMethods: [ + { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, + { id: 'totp_1', type: 'authenticator' }, + { id: 'backup_1', type: 'backup-codes' }, + ], + devices: [ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, + ], +}; + +function renderView(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('UserProfileSecurityPanelView', () => { + it('composes authentication, active devices, and the danger zone', () => { + renderView({ onDeleteAccount: vi.fn() }); + + expect(screen.getByRole('heading', { level: 3, name: 'Security' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Active devices' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Password')).toHaveClass('cl-section-label'); + expect(screen.getByText('Passkeys')).toHaveClass('cl-section-label'); + expect(screen.getByText('2-step verification')).toHaveClass('cl-section-label'); + expect(screen.getByRole('region', { name: 'Passkeys' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: '2-step verification' })).toBeInTheDocument(); + expect(screen.getByText('This device')).toBeInTheDocument(); + expect(screen.getByText('2 other devices')).toBeInTheDocument(); + expect( + screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), + ).toBeInTheDocument(); + }); + + it('forwards security actions', async () => { + const onChangePassword = vi.fn(); + const onAddPasskey = vi.fn(); + const onManagePasskey = vi.fn(); + const onRemovePasskey = vi.fn(); + const onAddMfaMethod = vi.fn(); + const onSignOutDevice = vi.fn(); + const onSignOutAllOtherDevices = vi.fn(); + const onDeleteAccount = vi.fn(); + const user = userEvent.setup(); + + renderView({ + mfaMethods: [ + { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup_1', type: 'backup-codes' }, + ], + onChangePassword, + onAddPasskey, + onManagePasskey, + onRemovePasskey, + onAddMfaMethod, + onSignOutDevice, + onSignOutAllOtherDevices, + onDeleteAccount, + }); + + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.click(screen.getByRole('button', { name: 'Add passkey' })); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + expect(screen.queryByRole('menuitem', { name: 'SMS verification' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Authenticator app' })); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + + await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); + await user.click(screen.getByRole('menuitem', { name: 'Rename' })); + await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); + + const otherDevices = screen.getByRole('region', { name: 'Other devices' }); + await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + + expect(onChangePassword).toHaveBeenCalledOnce(); + expect(onAddPasskey).toHaveBeenCalledOnce(); + expect(onManagePasskey).toHaveBeenCalledWith('passkey_1'); + expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1'); + expect(onAddMfaMethod).toHaveBeenCalledWith('authenticator'); + expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); + expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); + expect(onDeleteAccount).toHaveBeenCalledOnce(); + }); + + it('keeps supported empty authentication methods actionable', () => { + renderView({ + hasPassword: false, + passkeys: [], + mfaMethods: [], + devices: [], + onAddPasskey: vi.fn(), + onAddMfaMethod: vi.fn(), + }); + + expect(screen.getByText('No passkeys added')).toBeInTheDocument(); + expect(screen.getByText('No verification methods added')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add passkey' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add verification method' })).toBeInTheDocument(); + expect(screen.getByText('No current device available')).toBeInTheDocument(); + expect(screen.queryByText('Password')).not.toBeInTheDocument(); + }); + + it('only shows backup codes with another verification method and only allows regeneration', async () => { + const onRegenerateBackupCodes = vi.fn(); + const onRemoveMfaMethod = vi.fn(); + const backupCodes = { id: 'backup_1', type: 'backup-codes' as const }; + const backupOnlyView = renderView({ + mfaMethods: [backupCodes], + onRegenerateBackupCodes, + onRemoveMfaMethod, + }); + + expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + backupOnlyView.unmount(); + + const user = userEvent.setup(); + renderView({ + mfaMethods: [{ id: 'sms_1', type: 'sms' }, backupCodes], + onRegenerateBackupCodes, + onRemoveMfaMethod, + }); + + expect(screen.getByText('Backup codes')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + expect(screen.queryByRole('menuitem', { name: 'Remove method' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + + expect(onRegenerateBackupCodes).toHaveBeenCalledOnce(); + expect(onRemoveMfaMethod).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx new file mode 100644 index 00000000000..16cb56c5c57 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx @@ -0,0 +1,139 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../components/button'; +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileSecurityIcon } from './user-profile-security-icon'; +import { styles } from './user-profile-security-panel.styles'; + +export interface UserProfileDevice { + id: string; + name: string; + description?: string; + type: 'desktop' | 'mobile'; + isCurrent?: boolean; +} + +export interface UserProfileActiveDevicesSectionViewProps { + devices: UserProfileDevice[]; + onManageDevice?: (id: string) => void; + onSignOutDevice?: (id: string) => void; + onSignOutAllOtherDevices?: () => void; +} + +export function UserProfileActiveDevicesSectionView({ + devices, + onManageDevice, + onSignOutDevice, + onSignOutAllOtherDevices, +}: UserProfileActiveDevicesSectionViewProps) { + const currentDevices = devices.filter(device => device.isCurrent); + const otherDevices = devices.filter(device => !device.isCurrent); + + return ( +
+ + Active devices + + {currentDevices.length > 0 ? ( + currentDevices.map(device => ( + + + + )) + ) : ( + + + + No current device available + + + + )} + + + {otherDevices.length > 0 ? ( + + + + + + + {otherDevices.length} other {otherDevices.length === 1 ? 'device' : 'devices'} + + + {onSignOutAllOtherDevices ? ( + + + + ) : null} + + + {otherDevices.map(device => ( + + ))} + + + + + ) : null} +
+ ); +} + +function DeviceItem({ + device, + onManage, + onSignOut, +}: { + device: UserProfileDevice; + onManage?: (id: string) => void; + onSignOut?: (id: string) => void; +}) { + const actions: UserProfileMenuAction[] = []; + + if (onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(device.id) }); + } + if (onSignOut) { + actions.push({ label: 'Sign out', color: 'negative', onClick: () => onSignOut(device.id) }); + } + + return ( + + + + {device.name} + {device.isCurrent || device.description ? ( + + {device.isCurrent ? This device : null} + {device.isCurrent && device.description ? · : null} + {device.description ? {device.description} : null} + + ) : null} + + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx index ba2e5cb5b6b..8ace3e6e9c6 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx @@ -15,7 +15,7 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect Delete account - Permanently delete this profile and all its data. This cannot be undone. + Permanently delete this account and all its data. This cannot be undone. diff --git a/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx new file mode 100644 index 00000000000..cae1c4da689 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx @@ -0,0 +1,127 @@ +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Menu } from '../components/menu'; +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileSecurityIcon } from './user-profile-security-icon'; +import { UserProfileSecurityList } from './user-profile-security-list'; + +export interface UserProfileMfaMethod { + id: string; + type: 'sms' | 'authenticator' | 'backup-codes'; + label?: string; + description?: string; +} + +export type UserProfileMfaAddableMethod = Extract; + +export interface UserProfileMfaSectionViewProps { + methods: UserProfileMfaMethod[]; + sectionTitle?: string; + onAdd?: (type: UserProfileMfaAddableMethod) => void; + onManage?: (id: string) => void; + onRegenerateBackupCodes?: () => void; + onRemove?: (id: string) => void; +} + +const labels: Record = { + sms: 'SMS verification', + authenticator: 'Authenticator app', + 'backup-codes': 'Backup codes', +}; + +const addableMethods: UserProfileMfaAddableMethod[] = ['sms', 'authenticator']; + +export function UserProfileMfaSectionView({ + methods, + sectionTitle, + onAdd, + onManage, + onRegenerateBackupCodes, + onRemove, +}: UserProfileMfaSectionViewProps) { + const availableMethods = addableMethods.filter(type => !methods.some(method => method.type === type)); + const hasConfiguredMethod = methods.some(method => method.type === 'sms' || method.type === 'authenticator'); + const visibleMethods = methods.filter(method => method.type !== 'backup-codes' || hasConfiguredMethod); + + return ( + 0 ? ( + + ( + + + ) : null} +
+
+ + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-icon.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-icon.tsx new file mode 100644 index 00000000000..cbdae19132b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-icon.tsx @@ -0,0 +1,34 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { mergeStyleProps } from '../props'; +import { space } from '../tokens.stylex'; +import { styles } from './user-profile-security-panel.styles'; + +export type UserProfileSecurityIconName = 'authenticator' | 'backup-codes' | 'desktop' | 'mobile' | 'passkey' | 'sms'; + +const icons = { + authenticator: 'security-lock-square', + 'backup-codes': 'security-phone', + desktop: 'device-laptop', + mobile: 'device-phone', + passkey: 'security-passkey', + sms: 'security-phone', +} as const; + +export function UserProfileSecurityIcon({ name }: { name: UserProfileSecurityIconName }) { + return ( + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx new file mode 100644 index 00000000000..417b85f0e3d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; + +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; + +export function UserProfileSecurityList({ + sectionTitle, + label, + addLabel, + emptyLabel, + hasItems, + onAdd, + addControl, + children, +}: { + sectionTitle?: string; + label: string; + addLabel: string; + emptyLabel: string; + hasItems: boolean; + onAdd?: () => void; + addControl?: ReactNode; + children: ReactNode; +}) { + return ( + + {sectionTitle ? {sectionTitle} : null} + + + + + {label} + + {addControl ? ( + {addControl} + ) : onAdd ? ( + + + + ) : null} + + + {hasItems ? ( + children + ) : ( + + + {emptyLabel} + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.styles.ts new file mode 100644 index 00000000000..6d4ba165d7d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.styles.ts @@ -0,0 +1,44 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + currentDevice: { + color: colorVars['--cl-color-positive'], + }, + descriptionLine: { + columnGap: space['1'], + display: 'flex', + flexWrap: 'wrap', + }, + icon: { + color: colorVars['--cl-color-neutral-faded'], + display: 'block', + height: space['4.5'], + width: space['4.5'], + }, + media: { + borderColor: colorVars['--cl-color-border-faded'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: colorVars['--cl-color-background'], + }, + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + }, + sectionCards: { + gap: space['3'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + sections: { + gap: space['10'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx new file mode 100644 index 00000000000..28d542a2110 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx @@ -0,0 +1,102 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import type { + UserProfileActiveDevicesSectionViewProps, + UserProfileDevice, +} from './user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; +import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; +import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; +import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; +import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; +import { styles } from './user-profile-security-panel.styles'; + +export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; + +export interface UserProfileSecurityPanelViewProps extends Omit { + hasPassword?: boolean; + passkeys?: UserProfilePasskey[]; + mfaMethods?: UserProfileMfaMethod[]; + devices?: UserProfileDevice[]; + onChangePassword?: () => void; + onAddPasskey?: () => void; + onManagePasskey?: (id: string) => void; + onRemovePasskey?: (id: string) => void; + onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; + onManageMfaMethod?: (id: string) => void; + onRegenerateBackupCodes?: () => void; + onRemoveMfaMethod?: (id: string) => void; + onDeleteAccount?: () => void; +} + +export function UserProfileSecurityPanelView({ + hasPassword = false, + passkeys, + mfaMethods, + devices, + onChangePassword, + onAddPasskey, + onManagePasskey, + onRemovePasskey, + onAddMfaMethod, + onManageMfaMethod, + onRegenerateBackupCodes, + onRemoveMfaMethod, + onManageDevice, + onSignOutDevice, + onSignOutAllOtherDevices, + onDeleteAccount, +}: UserProfileSecurityPanelViewProps): ReactElement { + const hasAuthentication = hasPassword || passkeys !== undefined || mfaMethods !== undefined; + + return ( +
+

} + size='2xl' + > + Security + +
+ {hasAuthentication ? ( +
+ {hasPassword ? : null} + {passkeys !== undefined ? ( + + ) : null} + {mfaMethods !== undefined ? ( + + ) : null} +
+ ) : null} + {devices ? ( + + ) : null} + {onDeleteAccount ? : null} +
+

+ ); +} From f09b9bdfeb6674dfe38823cd0e42d194d3aec4dd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 13:09:16 -0600 Subject: [PATCH 06/18] feat(swingset): organize user component navigation --- .../swingset/src/components/app-sidebar.tsx | 124 ++++++++++++++---- packages/swingset/src/lib/types.ts | 5 + .../src/stories/user-button.stories.tsx | 2 + .../user-profile-account-section.stories.tsx | 2 + ...ile-connected-accounts-section.stories.tsx | 2 + .../user-profile-delete-section.stories.tsx | 2 + .../user-profile-profile-panel.stories.tsx | 2 + ...r-profile-web3-wallets-section.stories.tsx | 2 + 8 files changed, 113 insertions(+), 28 deletions(-) diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index eba4369ff05..4a68f409fec 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -17,9 +17,79 @@ import { SidebarRail, } from '@/components/ui/sidebar'; import { getSidebarGroups } from '@/lib/registry'; +import type { StoryModule } from '@/lib/types'; const groups = getSidebarGroups(); +type SidebarEntry = { mod: StoryModule; componentSlug: string }; + +function getNavigationFamilies(components: SidebarEntry[]) { + const families = new Map>(); + + for (const component of components) { + const family = component.mod.meta.navigation?.family ?? ''; + const category = component.mod.meta.navigation?.category ?? ''; + const categories = families.get(family) ?? new Map(); + const entries = categories.get(category) ?? []; + + entries.push(component); + categories.set(category, entries); + families.set(family, categories); + } + + return Array.from(families, ([family, categories]) => ({ + family, + categories: Array.from(categories, ([category, components]) => ({ + category, + components: components.sort( + (a, b) => + (a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) - + (b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER), + ), + })), + })); +} + +function SidebarEntryLink({ + entry, + groupSlug, + pathname, +}: { + entry: SidebarEntry; + groupSlug: string; + pathname: string; +}) { + const { mod, componentSlug } = entry; + const href = `/${groupSlug}/${componentSlug}`; + const usage = mod.meta.label + ? mod.meta.label + : mod.meta.group === 'Hooks' + ? `${mod.meta.title}()` + : mod.meta.group === 'Styles' + ? mod.meta.title + : `<${mod.meta.title} />`; + + return ( + + } + > + + {usage} + + + + ); +} + export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname(); @@ -68,34 +138,32 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {group} - - {components.map(({ mod, componentSlug }) => { - const href = `/${groupSlug}/${componentSlug}`; - // How an entry is USED differs by layer, so the label follows the layer rather - // than a guess at the title: hooks are called, atomic styles are a set of - // exports with no single call form worth privileging, and everything else is a - // component rendered as JSX. - const usage = - mod.meta.group === 'Hooks' - ? `${mod.meta.title}()` - : mod.meta.group === 'Styles' - ? mod.meta.title - : `<${mod.meta.title} />`; - return ( - - } - > - - {usage} - - - - ); - })} - + {getNavigationFamilies(components).map(({ family, categories }) => ( +
+ {family ? ( +
{family}
+ ) : null} + {categories.map(({ category, components }) => ( +
+ {category ? ( +
+ {category} +
+ ) : null} + + {components.map(entry => ( + + ))} + +
+ ))} +
+ ))}
))} diff --git a/packages/swingset/src/lib/types.ts b/packages/swingset/src/lib/types.ts index e031a80e6cd..837177928fc 100644 --- a/packages/swingset/src/lib/types.ts +++ b/packages/swingset/src/lib/types.ts @@ -43,6 +43,11 @@ export interface StoryMeta { * (which still drives the slug and the `` tag). */ label?: string; + navigation?: { + family?: string; + category?: string; + order?: number; + }; /** * Path to the file that exports the documented component, relative to the monorepo * root (e.g. `packages/ui/src/mosaic/components/button.tsx`). Rendered as a "View diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index 06e1b30a79b..784be217083 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -19,6 +19,8 @@ export { default as __source } from './user-button.stories?raw'; export const meta: StoryMeta = { group: 'User', title: 'UserButton', + label: 'User button', + navigation: { family: 'User button', category: 'Compositions', order: 10 }, source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 889f1a065b4..653eb5b9ab4 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -12,6 +12,8 @@ export { default as __source } from './user-profile-account-section.stories?raw' export const meta: StoryMeta = { group: 'User', title: 'UserProfileAccountSection', + label: 'Account', + navigation: { family: 'User profile', category: 'Sections', order: 10 }, source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx index 189e78f123d..12dbba0267d 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx @@ -7,6 +7,8 @@ export { default as __source } from './user-profile-connected-accounts-section.s export const meta: StoryMeta = { group: 'User', title: 'UserProfileConnectedAccountsSection', + label: 'Connected accounts', + navigation: { family: 'User profile', category: 'Sections', order: 60 }, source: 'packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index 9c316bf4ed8..e9f3f65d4b9 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -7,6 +7,8 @@ export { default as __source } from './user-profile-delete-section.stories?raw'; export const meta: StoryMeta = { group: 'User', title: 'UserProfileDeleteSection', + label: 'Danger zone', + navigation: { family: 'User profile', category: 'Sections', order: 80 }, source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 567d029e673..0cf5d47d924 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -12,6 +12,8 @@ export { default as __source } from './user-profile-profile-panel.stories?raw'; export const meta: StoryMeta = { group: 'User', title: 'UserProfileProfilePanel', + label: 'Profile panel', + navigation: { family: 'User profile', category: 'Compositions', order: 10 }, source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 21964f25d18..ba03cc6280c 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -7,6 +7,8 @@ export { default as __source } from './user-profile-web3-wallets-section.stories export const meta: StoryMeta = { group: 'User', title: 'UserProfileWeb3WalletsSection', + label: 'Web3 wallets', + navigation: { family: 'User profile', category: 'Sections', order: 70 }, source: 'packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx', }; From 22539b72a7d9d1be6beb4b03b3e42113f21d48f5 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:09:36 -0600 Subject: [PATCH 07/18] feat(swingset): add user profile security examples --- .../swingset/src/components/DocsViewer.tsx | 5 + packages/swingset/src/lib/registry.ts | 49 +++++++++ .../user-profile-active-devices-section.mdx | 11 ++ ...profile-active-devices-section.stories.tsx | 48 +++++++++ .../src/stories/user-profile-mfa-section.mdx | 17 +++ .../user-profile-mfa-section.stories.tsx | 85 +++++++++++++++ .../stories/user-profile-passkeys-section.mdx | 17 +++ .../user-profile-passkeys-section.stories.tsx | 59 +++++++++++ .../stories/user-profile-password-section.mdx | 11 ++ .../user-profile-password-section.stories.tsx | 17 +++ .../stories/user-profile-security-panel.mdx | 11 ++ .../user-profile-security-panel.stories.tsx | 100 ++++++++++++++++++ 12 files changed, 430 insertions(+) create mode 100644 packages/swingset/src/stories/user-profile-active-devices-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-mfa-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-mfa-section.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-passkeys-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-password-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-password-section.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-security-panel.mdx create mode 100644 packages/swingset/src/stories/user-profile-security-panel.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 34ad4f04993..081e64e2e27 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -13,7 +13,12 @@ const docModules: Record<string, Record<string, React.ComponentType>> = { user: { 'user-button': dynamic(() => import('../stories/user-button.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), + 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), + 'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')), + 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), + 'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')), + 'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')), 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), ), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 5aab8aeb2cd..cc5090c8fb5 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -105,6 +105,10 @@ import { Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, } from '../stories/user-profile-account-section.stories'; +import { + Default as UserProfileActiveDevicesSectionDefault, + meta as userProfileActiveDevicesSectionMeta, +} from '../stories/user-profile-active-devices-section.stories'; import { Default as UserProfileConnectedAccountsSectionDefault, meta as userProfileConnectedAccountsSectionMeta, @@ -113,10 +117,28 @@ import { Default as UserProfileDeleteSectionDefault, meta as userProfileDeleteSectionMeta, } from '../stories/user-profile-delete-section.stories'; +import { + Default as UserProfileMfaSectionDefault, + Empty as UserProfileMfaSectionEmpty, + meta as userProfileMfaSectionMeta, +} from '../stories/user-profile-mfa-section.stories'; +import { + Default as UserProfilePasskeysSectionDefault, + Empty as UserProfilePasskeysSectionEmpty, + meta as userProfilePasskeysSectionMeta, +} from '../stories/user-profile-passkeys-section.stories'; +import { + Default as UserProfilePasswordSectionDefault, + meta as userProfilePasswordSectionMeta, +} from '../stories/user-profile-password-section.stories'; import { Default as UserProfileProfilePanelDefault, meta as userProfileProfilePanelMeta, } from '../stories/user-profile-profile-panel.stories'; +import { + Default as UserProfileSecurityPanelDefault, + meta as userProfileSecurityPanelMeta, +} from '../stories/user-profile-security-panel.stories'; import { Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, @@ -243,6 +265,28 @@ const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, Default: UserProfileProfilePanelDefault, }; +const userProfileSecurityPanelModule: StoryModule = { + meta: userProfileSecurityPanelMeta, + Default: UserProfileSecurityPanelDefault, +}; +const userProfilePasswordSectionModule: StoryModule = { + meta: userProfilePasswordSectionMeta, + Default: UserProfilePasswordSectionDefault, +}; +const userProfilePasskeysSectionModule: StoryModule = { + meta: userProfilePasskeysSectionMeta, + Default: UserProfilePasskeysSectionDefault, + Empty: UserProfilePasskeysSectionEmpty, +}; +const userProfileMfaSectionModule: StoryModule = { + meta: userProfileMfaSectionMeta, + Default: UserProfileMfaSectionDefault, + Empty: UserProfileMfaSectionEmpty, +}; +const userProfileActiveDevicesSectionModule: StoryModule = { + meta: userProfileActiveDevicesSectionMeta, + Default: UserProfileActiveDevicesSectionDefault, +}; const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, @@ -260,7 +304,12 @@ export const registry: StoryModule[] = [ // User userButtonModule, userProfileProfilePanelModule, + userProfileSecurityPanelModule, userProfileAccountSectionModule, + userProfilePasswordSectionModule, + userProfilePasskeysSectionModule, + userProfileMfaSectionModule, + userProfileActiveDevicesSectionModule, userProfileConnectedAccountsSectionModule, userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.mdx b/packages/swingset/src/stories/user-profile-active-devices-section.mdx new file mode 100644 index 00000000000..6aa7f659b7e --- /dev/null +++ b/packages/swingset/src/stories/user-profile-active-devices-section.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-active-devices-section.stories'; + +# UserProfileActiveDevicesSection + +The current device and other active sessions composed with `Section`. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx new file mode 100644 index 00000000000..c39c3ef0f8d --- /dev/null +++ b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx @@ -0,0 +1,48 @@ +import type { UserProfileDevice } from '@clerk/ui/mosaic/user-profile/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-active-devices-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-active-devices-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileActiveDevicesSection', + label: 'Active devices', + navigation: { family: 'User profile', category: 'Sections', order: 50 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx', +}; + +export function Default() { + const [devices, setDevices] = useState<UserProfileDevice[]>([ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, + ]); + + return ( + <UserProfileActiveDevicesSectionView + devices={devices} + onManageDevice={() => undefined} + onSignOutAllOtherDevices={() => setDevices(current => current.filter(device => device.isCurrent))} + onSignOutDevice={id => setDevices(current => current.filter(device => device.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx new file mode 100644 index 00000000000..c915eceedaa --- /dev/null +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-profile-mfa-section.stories'; + +# UserProfileMfaSection + +Two-step verification methods composed with the shared Security list treatment. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> + +<Story + name='Empty' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx new file mode 100644 index 00000000000..ad2e1b242dc --- /dev/null +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -0,0 +1,85 @@ +import type { UserProfileMfaMethod } from '@clerk/ui/mosaic/user-profile/user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-mfa-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-mfa-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileMfaSection', + label: '2-step verification', + navigation: { family: 'User profile', category: 'Sections', order: 40 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx', +}; + +export function Default() { + const [methods, setMethods] = useState<UserProfileMfaMethod[]>([ + { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup', type: 'backup-codes' }, + ]); + + return ( + <UserProfileMfaSectionView + methods={methods} + sectionTitle='Authentication' + onAdd={type => + setMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }) + } + onManage={() => undefined} + onRegenerateBackupCodes={() => + setMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ) + } + onRemove={id => setMethods(current => current.filter(method => method.id !== id))} + /> + ); +} + +export function Empty() { + const [methods, setMethods] = useState<UserProfileMfaMethod[]>([]); + + return ( + <UserProfileMfaSectionView + methods={methods} + sectionTitle='Authentication' + onAdd={type => + setMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }) + } + onRegenerateBackupCodes={() => + setMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ) + } + onRemove={id => setMethods(current => current.filter(method => method.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx new file mode 100644 index 00000000000..b4ba13cebb8 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-profile-passkeys-section.stories'; + +# UserProfilePasskeysSection + +Passkey management composed with the shared Security list treatment. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> + +<Story + name='Empty' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx new file mode 100644 index 00000000000..36b8db7ea07 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -0,0 +1,59 @@ +import type { UserProfilePasskey } from '@clerk/ui/mosaic/user-profile/user-profile-passkeys-section.view'; +import { UserProfilePasskeysSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-passkeys-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-passkeys-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfilePasskeysSection', + label: 'Passkeys', + navigation: { family: 'User profile', category: 'Sections', order: 30 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx', +}; + +export function Default() { + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([ + { + id: 'passkey', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ]); + + return ( + <UserProfilePasskeysSectionView + passkeys={passkeys} + sectionTitle='Authentication' + onAdd={() => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]) + } + onManage={() => undefined} + onRemove={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + /> + ); +} + +export function Empty() { + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([]); + + return ( + <UserProfilePasskeysSectionView + passkeys={passkeys} + sectionTitle='Authentication' + onAdd={() => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]) + } + onRemove={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx new file mode 100644 index 00000000000..3f36536dac4 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-password-section.stories'; + +# UserProfilePasswordSection + +Password management composed with `Section`. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx new file mode 100644 index 00000000000..ea87582a5ac --- /dev/null +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -0,0 +1,17 @@ +import { UserProfilePasswordSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-password-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-password-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfilePasswordSection', + label: 'Password', + navigation: { family: 'User profile', category: 'Sections', order: 20 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx', +}; + +export function Default() { + return <UserProfilePasswordSectionView onChangePassword={() => undefined} />; +} diff --git a/packages/swingset/src/stories/user-profile-security-panel.mdx b/packages/swingset/src/stories/user-profile-security-panel.mdx new file mode 100644 index 00000000000..ba87eecb59d --- /dev/null +++ b/packages/swingset/src/stories/user-profile-security-panel.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-security-panel.stories'; + +# UserProfileSecurityPanel + +Authentication methods, active devices, and the danger zone composed without the surrounding navigation shell. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx new file mode 100644 index 00000000000..c00c070ed2a --- /dev/null +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -0,0 +1,100 @@ +import type { + UserProfileDevice, + UserProfileMfaMethod, + UserProfilePasskey, +} from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import { UserProfileSecurityPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-security-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileSecurityPanel', + label: 'Security panel', + navigation: { family: 'User profile', category: 'Compositions', order: 20 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx', +}; + +export function Default() { + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([ + { + id: 'passkey', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ]); + const [mfaMethods, setMfaMethods] = useState<UserProfileMfaMethod[]>([ + { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup', type: 'backup-codes' }, + ]); + const [devices, setDevices] = useState<UserProfileDevice[]>([ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, + ]); + + return ( + <UserProfileSecurityPanelView + devices={devices} + hasPassword + mfaMethods={mfaMethods} + passkeys={passkeys} + onAddMfaMethod={type => + setMfaMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }) + } + onAddPasskey={() => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]) + } + onChangePassword={() => undefined} + onDeleteAccount={() => undefined} + onManageDevice={() => undefined} + onManageMfaMethod={() => undefined} + onManagePasskey={() => undefined} + onRegenerateBackupCodes={() => + setMfaMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ) + } + onRemoveMfaMethod={id => setMfaMethods(current => current.filter(method => method.id !== id))} + onRemovePasskey={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + onSignOutAllOtherDevices={() => setDevices(current => current.filter(device => device.isCurrent))} + onSignOutDevice={id => setDevices(current => current.filter(device => device.id !== id))} + /> + ); +} From 5a8037387a2dd8a9b3249b938ce951a489115d0a Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:13:15 -0600 Subject: [PATCH 08/18] fix(ui): update passkey security glyph --- .../ui/src/mosaic/components/icon/icon.test.tsx | 17 ++++++++++------- packages/ui/src/mosaic/icons/registry.tsx | 10 +++++++--- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/mosaic/components/icon/icon.test.tsx b/packages/ui/src/mosaic/components/icon/icon.test.tsx index 74e65c5d64e..574b726c327 100644 --- a/packages/ui/src/mosaic/components/icon/icon.test.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.test.tsx @@ -19,13 +19,16 @@ describe('Mosaic Icon', () => { expect(svg?.querySelector('path')).not.toBeNull(); }); - it.each(['security-phone', 'security-lock-square'] as const)('renders the %s glyph on its 18px canvas', name => { - const { container } = wrap(<Icon name={name} />); - const svg = container.querySelector('svg'); - - expect(svg).toHaveAttribute('viewBox', '0 0 18 18'); - expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor'); - }); + it.each(['security-phone', 'security-lock-square', 'security-passkey'] as const)( + 'renders the %s glyph on its 18px canvas', + name => { + const { container } = wrap(<Icon name={name} />); + const svg = container.querySelector('svg'); + + expect(svg).toHaveAttribute('viewBox', '0 0 18 18'); + expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor'); + }, + ); it.each([ ['device-phone', ['#646464', '#646464', '#343434', '#575757', '#171717', 'black']], diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 03b3e38a9f0..98ac742a10d 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -97,15 +97,19 @@ const Plus = glyph( const SecurityPasskey = glyph( <> <path - d='M6.189 2.813a1.125 1.125 0 1 1-2.25 0 1.125 1.125 0 0 1 2.25 0m1.688 0A2.813 2.813 0 1 1 2.252 2.8a2.813 2.813 0 0 1 5.625.013M5.064 6.75c.624 0 1.224.124 1.773.34a.844.844 0 0 1-.616 1.57 3.2 3.2 0 0 0-1.157-.223c-1.539 0-2.824 1.013-3.141 2.37l-.232.987a.1.1 0 0 0 .055.019H6.53a.844.844 0 0 1 0 1.687H1.746c-1.063 0-1.962-.96-1.7-2.078l.234-1C.788 8.249 2.798 6.75 5.064 6.75' + d='M8.43754 5.0625C8.43754 4.44118 7.93386 3.9375 7.31254 3.9375C6.69122 3.9375 6.18754 4.44118 6.18754 5.0625C6.18754 5.68382 6.69122 6.1875 7.31254 6.1875C7.93386 6.1875 8.43754 5.68382 8.43754 5.0625ZM10.125 5.0625C10.125 6.6158 8.86584 7.875 7.31254 7.875C5.75924 7.875 4.50004 6.6158 4.50004 5.0625C4.50004 3.5092 5.75924 2.25 7.31254 2.25C8.86584 2.25 10.125 3.5092 10.125 5.0625Z' fill='currentColor' /> <path - d='M12.916 10.23H9.388v1.312c0 .155.126.281.282.281h2.965a.281.281 0 0 0 .281-.28zm-.948-1.997a.815.815 0 0 0-1.631 0v.31h1.631zm1.688.31h.104c.466 0 .844.378.844.844v2.155a1.97 1.97 0 0 1-1.969 1.969H9.67a1.97 1.97 0 0 1-1.969-1.969V9.387c0-.466.378-.844.844-.844h.104v-.31a2.504 2.504 0 0 1 5.007 0z' + d='M7.31254 9C7.93629 9 8.53595 9.12402 9.08573 9.33948C9.51942 9.5095 9.73343 9.99887 9.56364 10.4326C9.39361 10.8665 8.90326 11.0806 8.4694 10.9105C8.1027 10.7669 7.71165 10.6875 7.31254 10.6875C5.77377 10.6875 4.48879 11.6999 4.17155 13.0562L3.93974 14.0438C3.94311 14.0471 3.94803 14.0515 3.95512 14.0548C3.96327 14.0586 3.97581 14.0625 3.99467 14.0625H8.77812C9.24395 14.0627 9.62187 14.4404 9.62187 14.9062C9.62187 15.3721 9.24395 15.7498 8.77812 15.75H3.99467C2.9311 15.7498 2.03268 14.7896 2.29399 13.6725L2.52799 12.6716C3.03625 10.4987 5.04597 9 7.31254 9Z' + fill='currentColor' + /> + <path + d='M15.1645 12.4805H11.6368V13.7922C11.6368 13.9476 11.7627 14.0735 11.918 14.0735H14.8832C15.0385 14.0735 15.1645 13.9476 15.1645 13.7922V12.4805ZM14.2163 10.4832C14.2161 10.0331 13.8513 9.66823 13.4012 9.66797C12.9508 9.66797 12.5851 10.0329 12.5849 10.4832V10.793H14.2163V10.4832ZM15.9038 10.793H16.0082C16.4742 10.793 16.852 11.1707 16.852 11.6367V13.7922C16.852 14.8795 15.9705 15.761 14.8832 15.761H11.918C10.8307 15.761 9.94926 14.8795 9.94926 13.7922V11.6367C9.94926 11.1707 10.327 10.793 10.793 10.793H10.8974V10.4832C10.8976 9.10091 12.0189 7.98047 13.4012 7.98047C14.7832 7.98073 15.9036 9.10107 15.9038 10.4832V10.793Z' fill='currentColor' /> </>, - '0 0 14.604 13.511', + '0 0 18 18', ); const SecurityPhone = glyph( From 9d0c83b99d5fc60b26bafe86c36aac3f873095f4 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:14:13 -0600 Subject: [PATCH 09/18] fix(ui): hide current device actions --- .../user-profile-security-panel.view.test.tsx | 10 ++++++++++ .../user-profile-active-devices-section.view.tsx | 5 +---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx index ce92e2056ea..cb8d1901c92 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -146,6 +146,16 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByText('Password')).not.toBeInTheDocument(); }); + it('does not render actions for the current device', () => { + renderView({ + onManageDevice: vi.fn(), + onSignOutDevice: vi.fn(), + }); + + expect(screen.queryByRole('button', { name: 'Manage Safari on macOS' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toBeInTheDocument(); + }); + it('only shows backup codes with another verification method and only allows regeneration', async () => { const onRegenerateBackupCodes = vi.fn(); const onRemoveMfaMethod = vi.fn(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx index 16cb56c5c57..e1084ded966 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx @@ -39,10 +39,7 @@ export function UserProfileActiveDevicesSectionView({ {currentDevices.length > 0 ? ( currentDevices.map(device => ( <Section.Row key={device.id}> - <DeviceItem - device={device} - onManage={onManageDevice} - /> + <DeviceItem device={device} /> </Section.Row> )) ) : ( From 76bf81114ba6638a981cf31ca3d51ab435730b1f Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:16:31 -0600 Subject: [PATCH 10/18] fix(ui): limit MFA method actions --- .../src/stories/user-profile-mfa-section.stories.tsx | 1 - .../stories/user-profile-security-panel.stories.tsx | 1 - .../user-profile-security-panel.view.test.tsx | 5 ++++- .../user-profile/user-profile-mfa-section.view.tsx | 11 ++--------- .../user-profile/user-profile-security-panel.view.tsx | 3 --- 5 files changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index ad2e1b242dc..088aafcb23c 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -40,7 +40,6 @@ export function Default() { ]; }) } - onManage={() => undefined} onRegenerateBackupCodes={() => setMethods(current => current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx index c00c070ed2a..10ed084ee17 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -84,7 +84,6 @@ export function Default() { onChangePassword={() => undefined} onDeleteAccount={() => undefined} onManageDevice={() => undefined} - onManageMfaMethod={() => undefined} onManagePasskey={() => undefined} onRegenerateBackupCodes={() => setMfaMethods(current => diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx index cb8d1901c92..5892b8c8cd8 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -177,11 +177,14 @@ describe('UserProfileSecurityPanelView', () => { }); expect(screen.getByText('Backup codes')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); + expect(screen.queryByRole('menuitem', { name: 'Manage' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); expect(screen.queryByRole('menuitem', { name: 'Remove method' })).not.toBeInTheDocument(); await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + expect(onRemoveMfaMethod).toHaveBeenCalledWith('sms_1'); expect(onRegenerateBackupCodes).toHaveBeenCalledOnce(); - expect(onRemoveMfaMethod).not.toHaveBeenCalled(); }); }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx index cae1c4da689..b298e1f4001 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx @@ -20,7 +20,6 @@ export interface UserProfileMfaSectionViewProps { methods: UserProfileMfaMethod[]; sectionTitle?: string; onAdd?: (type: UserProfileMfaAddableMethod) => void; - onManage?: (id: string) => void; onRegenerateBackupCodes?: () => void; onRemove?: (id: string) => void; } @@ -37,7 +36,6 @@ export function UserProfileMfaSectionView({ methods, sectionTitle, onAdd, - onManage, onRegenerateBackupCodes, onRemove, }: UserProfileMfaSectionViewProps) { @@ -97,13 +95,8 @@ export function UserProfileMfaSectionView({ onClick: onRegenerateBackupCodes, }); } - } else { - if (onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(method.id) }); - } - if (onRemove) { - actions.push({ label: 'Remove method', color: 'negative', onClick: () => onRemove(method.id) }); - } + } else if (onRemove) { + actions.push({ label: 'Remove method', color: 'negative', onClick: () => onRemove(method.id) }); } return ( diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx index 28d542a2110..2ed2b1deadf 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx @@ -28,7 +28,6 @@ export interface UserProfileSecurityPanelViewProps extends Omit<UserProfileActiv onManagePasskey?: (id: string) => void; onRemovePasskey?: (id: string) => void; onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; - onManageMfaMethod?: (id: string) => void; onRegenerateBackupCodes?: () => void; onRemoveMfaMethod?: (id: string) => void; onDeleteAccount?: () => void; @@ -44,7 +43,6 @@ export function UserProfileSecurityPanelView({ onManagePasskey, onRemovePasskey, onAddMfaMethod, - onManageMfaMethod, onRegenerateBackupCodes, onRemoveMfaMethod, onManageDevice, @@ -80,7 +78,6 @@ export function UserProfileSecurityPanelView({ methods={mfaMethods} sectionTitle={!hasPassword && passkeys === undefined ? 'Authentication' : undefined} onAdd={onAddMfaMethod} - onManage={onManageMfaMethod} onRegenerateBackupCodes={onRegenerateBackupCodes} onRemove={onRemoveMfaMethod} /> From 21a185daf476799241d04cdf1586a3d32825c7c2 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:51:41 -0600 Subject: [PATCH 11/18] feat(ui): add Mosaic billing profile panel --- .changeset/user-profile-billing-panel.md | 2 + .../swingset/src/components/DocsViewer.tsx | 5 + packages/swingset/src/lib/registry.ts | 29 +++++ .../stories/user-profile-billing-panel.mdx | 11 ++ .../user-profile-billing-panel.stories.tsx | 60 +++++++++ .../user-profile-payment-methods-section.mdx | 17 +++ ...rofile-payment-methods-section.stories.tsx | 55 +++++++++ .../user-profile-subscription-section.mdx | 11 ++ ...r-profile-subscription-section.stories.tsx | 30 +++++ packages/ui/src/mosaic/icons/registry.tsx | 8 ++ .../user-profile-billing-panel.view.test.tsx | 88 +++++++++++++ .../user-profile-billing-panel.styles.ts | 24 ++++ .../user-profile-billing-panel.view.tsx | 53 ++++++++ ...r-profile-payment-methods-section.view.tsx | 116 ++++++++++++++++++ .../user-profile-provider-icon.tsx | 24 +++- ...user-profile-subscription-section.view.tsx | 61 +++++++++ 16 files changed, 588 insertions(+), 6 deletions(-) create mode 100644 .changeset/user-profile-billing-panel.md create mode 100644 packages/swingset/src/stories/user-profile-billing-panel.mdx create mode 100644 packages/swingset/src/stories/user-profile-billing-panel.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-payment-methods-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-subscription-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-subscription-section.stories.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-billing-panel.styles.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx diff --git a/.changeset/user-profile-billing-panel.md b/.changeset/user-profile-billing-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-billing-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 081e64e2e27..5ad282bc9b7 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -14,11 +14,16 @@ const docModules: Record<string, Record<string, React.ComponentType>> = { 'user-button': dynamic(() => import('../stories/user-button.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), + 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), 'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')), 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), 'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')), 'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')), + 'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')), + 'user-profile-payment-methods-section': dynamic( + () => import('../stories/user-profile-payment-methods-section.mdx'), + ), 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), ), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index cc5090c8fb5..d995ffa4e23 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -109,6 +109,10 @@ import { Default as UserProfileActiveDevicesSectionDefault, meta as userProfileActiveDevicesSectionMeta, } from '../stories/user-profile-active-devices-section.stories'; +import { + Default as UserProfileBillingPanelDefault, + meta as userProfileBillingPanelMeta, +} from '../stories/user-profile-billing-panel.stories'; import { Default as UserProfileConnectedAccountsSectionDefault, meta as userProfileConnectedAccountsSectionMeta, @@ -131,6 +135,11 @@ import { Default as UserProfilePasswordSectionDefault, meta as userProfilePasswordSectionMeta, } from '../stories/user-profile-password-section.stories'; +import { + Default as UserProfilePaymentMethodsSectionDefault, + Empty as UserProfilePaymentMethodsSectionEmpty, + meta as userProfilePaymentMethodsSectionMeta, +} from '../stories/user-profile-payment-methods-section.stories'; import { Default as UserProfileProfilePanelDefault, meta as userProfileProfilePanelMeta, @@ -139,6 +148,10 @@ import { Default as UserProfileSecurityPanelDefault, meta as userProfileSecurityPanelMeta, } from '../stories/user-profile-security-panel.stories'; +import { + Default as UserProfileSubscriptionSectionDefault, + meta as userProfileSubscriptionSectionMeta, +} from '../stories/user-profile-subscription-section.stories'; import { Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, @@ -269,6 +282,10 @@ const userProfileSecurityPanelModule: StoryModule = { meta: userProfileSecurityPanelMeta, Default: UserProfileSecurityPanelDefault, }; +const userProfileBillingPanelModule: StoryModule = { + meta: userProfileBillingPanelMeta, + Default: UserProfileBillingPanelDefault, +}; const userProfilePasswordSectionModule: StoryModule = { meta: userProfilePasswordSectionMeta, Default: UserProfilePasswordSectionDefault, @@ -287,6 +304,15 @@ const userProfileActiveDevicesSectionModule: StoryModule = { meta: userProfileActiveDevicesSectionMeta, Default: UserProfileActiveDevicesSectionDefault, }; +const userProfileSubscriptionSectionModule: StoryModule = { + meta: userProfileSubscriptionSectionMeta, + Default: UserProfileSubscriptionSectionDefault, +}; +const userProfilePaymentMethodsSectionModule: StoryModule = { + meta: userProfilePaymentMethodsSectionMeta, + Default: UserProfilePaymentMethodsSectionDefault, + Empty: UserProfilePaymentMethodsSectionEmpty, +}; const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, @@ -305,11 +331,14 @@ export const registry: StoryModule[] = [ userButtonModule, userProfileProfilePanelModule, userProfileSecurityPanelModule, + userProfileBillingPanelModule, userProfileAccountSectionModule, userProfilePasswordSectionModule, userProfilePasskeysSectionModule, userProfileMfaSectionModule, userProfileActiveDevicesSectionModule, + userProfileSubscriptionSectionModule, + userProfilePaymentMethodsSectionModule, userProfileConnectedAccountsSectionModule, userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, diff --git a/packages/swingset/src/stories/user-profile-billing-panel.mdx b/packages/swingset/src/stories/user-profile-billing-panel.mdx new file mode 100644 index 00000000000..2483eedd431 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-panel.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-billing-panel.stories'; + +# UserProfileBillingPanel + +Subscription and payment methods composed without the surrounding navigation shell. Billing history is intentionally deferred. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx new file mode 100644 index 00000000000..2231914f738 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx @@ -0,0 +1,60 @@ +import type { + UserProfilePaymentMethod, + UserProfileSubscription, +} from '@clerk/ui/mosaic/user-profile/user-profile-billing-panel.view'; +import { UserProfileBillingPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-billing-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-billing-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileBillingPanel', + label: 'Billing panel', + navigation: { family: 'User profile', category: 'Compositions', order: 30 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx', +}; + +const initialSubscription: UserProfileSubscription = { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', +}; + +const initialPaymentMethods: UserProfilePaymentMethod[] = [ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + { id: 'mastercard', label: 'Mastercard •••• 1212', expiryLabel: 'Expires 02/2029' }, +]; + +export function Default() { + const [subscription, setSubscription] = useState(initialSubscription); + const [paymentMethods, setPaymentMethods] = useState(initialPaymentMethods); + + return ( + <UserProfileBillingPanelView + paymentMethods={paymentMethods} + subscription={subscription} + onAddPaymentMethod={() => + setPaymentMethods(current => [ + ...current, + { id: `card-${Date.now()}`, label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030' }, + ]) + } + onChangePlan={() => + setSubscription({ + planName: 'Pro Plan', + priceLabel: '$25 / Month', + totalDueLabel: '$25.00', + renewsAtLabel: 'Renews Aug 26', + }) + } + onMakeDefaultPaymentMethod={id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))) + } + onRemovePaymentMethod={id => setPaymentMethods(current => current.filter(method => method.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-payment-methods-section.mdx b/packages/swingset/src/stories/user-profile-payment-methods-section.mdx new file mode 100644 index 00000000000..1f49f3646fe --- /dev/null +++ b/packages/swingset/src/stories/user-profile-payment-methods-section.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-profile-payment-methods-section.stories'; + +# UserProfilePaymentMethodsSection + +Saved payment methods with default and removal actions. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> + +<Story + name='Empty' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx b/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx new file mode 100644 index 00000000000..bd5fa8581e3 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx @@ -0,0 +1,55 @@ +import type { UserProfilePaymentMethod } from '@clerk/ui/mosaic/user-profile/user-profile-payment-methods-section.view'; +import { UserProfilePaymentMethodsSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-payment-methods-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-payment-methods-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfilePaymentMethodsSection', + label: 'Payment methods', + navigation: { family: 'User profile', category: 'Billing sections', order: 20 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx', +}; + +export function Default() { + const [paymentMethods, setPaymentMethods] = useState<UserProfilePaymentMethod[]>([ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + { id: 'mastercard', label: 'Mastercard •••• 1212', expiryLabel: 'Expires 02/2029' }, + ]); + + return ( + <UserProfilePaymentMethodsSectionView + paymentMethods={paymentMethods} + onAdd={() => + setPaymentMethods(current => [ + ...current, + { id: `card-${Date.now()}`, label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030' }, + ]) + } + onMakeDefault={id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))) + } + onRemove={id => setPaymentMethods(current => current.filter(method => method.id !== id))} + /> + ); +} + +export function Empty() { + const [paymentMethods, setPaymentMethods] = useState<UserProfilePaymentMethod[]>([]); + + return ( + <UserProfilePaymentMethodsSectionView + paymentMethods={paymentMethods} + onAdd={() => + setPaymentMethods([{ id: 'visa', label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030', isDefault: true }]) + } + onMakeDefault={id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))) + } + onRemove={id => setPaymentMethods(current => current.filter(method => method.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-subscription-section.mdx b/packages/swingset/src/stories/user-profile-subscription-section.mdx new file mode 100644 index 00000000000..f8c99e74780 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-subscription-section.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-subscription-section.stories'; + +# UserProfileSubscriptionSection + +Current plan, renewal date, total due, and plan-change action. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx b/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx new file mode 100644 index 00000000000..b865ff7b82f --- /dev/null +++ b/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx @@ -0,0 +1,30 @@ +import { UserProfileSubscriptionSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-subscription-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-subscription-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileSubscriptionSection', + label: 'Subscription', + navigation: { family: 'User profile', category: 'Billing sections', order: 10 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx', +}; + +export function Default() { + const [isPro, setIsPro] = useState(false); + + return ( + <UserProfileSubscriptionSectionView + subscription={{ + planName: isPro ? 'Pro Plan' : 'Basic Plan', + priceLabel: isPro ? '$25 / Month' : '$12 / Month', + totalDueLabel: isPro ? '$25.00' : '$12.00', + renewsAtLabel: 'Renews Aug 26', + }} + onChangePlan={() => setIsPro(value => !value)} + /> + ); +} diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 98ac742a10d..1d16a1ac3f6 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -94,6 +94,13 @@ const Plus = glyph( />, ); +const CreditCard = glyph( + <path + d='M2.75 6.75V10.25C2.75 11.3546 3.64543 12.25 4.75 12.25H11.25C12.3546 12.25 13.25 11.3546 13.25 10.25V6.75M2.75 6.75V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H11.25C12.3546 3.75 13.25 4.64543 13.25 5.75V6.75M2.75 6.75H13.25M5.75 9.25H6.25' + {...strokeProps} + />, +); + const SecurityPasskey = glyph( <> <path @@ -277,6 +284,7 @@ export const iconRegistry = { 'chevron-up-down': ChevronUpDown, check: Check, close: Close, + 'credit-card': CreditCard, ellipsis: Ellipsis, pen: Pen, plus: Plus, diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx new file mode 100644 index 00000000000..3de2fe29a60 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileBillingPanelView } from '../user-profile-billing-panel.view'; + +const subscription = { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', +}; + +const paymentMethods = [ + { + id: 'visa', + label: 'Visa •••• 0644', + expiryLabel: 'Expires 02/2029', + isDefault: true, + }, + { + id: 'mastercard', + label: 'Mastercard •••• 1212', + expiryLabel: 'Expires 02/2029', + }, +]; + +function renderView(overrides: Partial<React.ComponentProps<typeof UserProfileBillingPanelView>> = {}) { + return render( + <MosaicProvider> + <UserProfileBillingPanelView + paymentMethods={paymentMethods} + subscription={subscription} + {...overrides} + /> + </MosaicProvider>, + ); +} + +describe('UserProfileBillingPanelView', () => { + it('composes subscription and payment methods without history', () => { + renderView(); + + expect(screen.getByRole('heading', { level: 3, name: 'Billing' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Subscription' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Payment methods' })).toBeInTheDocument(); + expect(screen.getByText('Basic Plan')).toBeInTheDocument(); + expect(screen.getByText('$12.00')).toBeInTheDocument(); + expect(screen.getByText('Visa •••• 0644')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + expect(screen.queryByText('History')).not.toBeInTheDocument(); + }); + + it('forwards subscription and payment method actions', async () => { + const onChangePlan = vi.fn(); + const onAdd = vi.fn(); + const onMakeDefault = vi.fn(); + const onRemove = vi.fn(); + const user = userEvent.setup(); + + renderView({ + onChangePlan, + onAddPaymentMethod: onAdd, + onMakeDefaultPaymentMethod: onMakeDefault, + onRemovePaymentMethod: onRemove, + }); + + await user.click(screen.getByRole('button', { name: 'Change plan' })); + await user.click(screen.getByRole('button', { name: 'Add payment method' })); + await user.click(screen.getByRole('button', { name: 'Manage Mastercard •••• 1212' })); + await user.click(screen.getByRole('menuitem', { name: 'Make default' })); + await user.click(screen.getByRole('button', { name: 'Manage Mastercard •••• 1212' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove payment method' })); + + expect(onChangePlan).toHaveBeenCalledOnce(); + expect(onAdd).toHaveBeenCalledOnce(); + expect(onMakeDefault).toHaveBeenCalledWith('mastercard'); + expect(onRemove).toHaveBeenCalledWith('mastercard'); + }); + + it('keeps an empty payment method list actionable', () => { + renderView({ paymentMethods: [], onAddPaymentMethod: vi.fn() }); + + expect(screen.getByText('No payment methods added')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add payment method' })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.styles.ts new file mode 100644 index 00000000000..13c1602d4aa --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.styles.ts @@ -0,0 +1,24 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + amount: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-base-size'], + fontWeight: fontWeightVars['--cl-font-semibold'], + lineHeight: typeScaleVars['--cl-text-base-leading'], + }, + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + sections: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx new file mode 100644 index 00000000000..8be1c2bd520 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './user-profile-billing-panel.styles'; +import type { UserProfilePaymentMethod } from './user-profile-payment-methods-section.view'; +import { UserProfilePaymentMethodsSectionView } from './user-profile-payment-methods-section.view'; +import type { UserProfileSubscription } from './user-profile-subscription-section.view'; +import { UserProfileSubscriptionSectionView } from './user-profile-subscription-section.view'; + +export type { UserProfilePaymentMethod, UserProfileSubscription }; + +export interface UserProfileBillingPanelViewProps { + subscription: UserProfileSubscription; + paymentMethods: UserProfilePaymentMethod[]; + onChangePlan?: () => void; + onAddPaymentMethod?: () => void; + onMakeDefaultPaymentMethod?: (id: string) => void; + onRemovePaymentMethod?: (id: string) => void; +} + +export function UserProfileBillingPanelView({ + subscription, + paymentMethods, + onChangePlan, + onAddPaymentMethod, + onMakeDefaultPaymentMethod, + onRemovePaymentMethod, +}: UserProfileBillingPanelViewProps): ReactElement { + return ( + <div {...mergeStyleProps(themeProps('user-profile-billing-panel'), stylex.props(styles.root))}> + <Heading + render={props => <h3 {...props} />} + size='2xl' + > + Billing + </Heading> + <div {...stylex.props(styles.sections)}> + <UserProfileSubscriptionSectionView + subscription={subscription} + onChangePlan={onChangePlan} + /> + <UserProfilePaymentMethodsSectionView + paymentMethods={paymentMethods} + onAdd={onAddPaymentMethod} + onMakeDefault={onMakeDefaultPaymentMethod} + onRemove={onRemovePaymentMethod} + /> + </div> + </div> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx new file mode 100644 index 00000000000..8354a4428ea --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx @@ -0,0 +1,116 @@ +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; + +export interface UserProfilePaymentMethod { + id: string; + label: string; + expiryLabel?: string; + isDefault?: boolean; + isRemovable?: boolean; +} + +export interface UserProfilePaymentMethodsSectionViewProps { + paymentMethods: UserProfilePaymentMethod[]; + onAdd?: () => void; + onMakeDefault?: (id: string) => void; + onRemove?: (id: string) => void; +} + +export function UserProfilePaymentMethodsSectionView({ + paymentMethods, + onAdd, + onMakeDefault, + onRemove, +}: UserProfilePaymentMethodsSectionViewProps) { + return ( + <Section.Root aria-label='Payment methods'> + <Section.Group> + <Section.Row variant='list'> + <Section.Item> + <Section.Content> + <Section.Label>Payment methods</Section.Label> + </Section.Content> + {onAdd ? ( + <Section.Actions> + <Button + aria-label='Add payment method' + color='neutral' + size='sm' + variant='outline' + onClick={onAdd} + > + <Icon + name='plus' + placement='inline-start' + size='sm' + /> + Add + </Button> + </Section.Actions> + ) : null} + </Section.Item> + <Section.Items> + {paymentMethods.length > 0 ? ( + paymentMethods.map(paymentMethod => ( + <PaymentMethodItem + key={paymentMethod.id} + paymentMethod={paymentMethod} + onMakeDefault={onMakeDefault} + onRemove={onRemove} + /> + )) + ) : ( + <Section.Item> + <Section.Content> + <Section.Description>No payment methods added</Section.Description> + </Section.Content> + </Section.Item> + )} + </Section.Items> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} + +function PaymentMethodItem({ + paymentMethod, + onMakeDefault, + onRemove, +}: { + paymentMethod: UserProfilePaymentMethod; + onMakeDefault?: (id: string) => void; + onRemove?: (id: string) => void; +}) { + const actions: UserProfileMenuAction[] = []; + + if (!paymentMethod.isDefault && onMakeDefault) { + actions.push({ label: 'Make default', onClick: () => onMakeDefault(paymentMethod.id) }); + } + if (paymentMethod.isRemovable !== false && onRemove) { + actions.push({ label: 'Remove payment method', color: 'negative', onClick: () => onRemove(paymentMethod.id) }); + } + + return ( + <Section.Item> + <UserProfileProviderIcon name='credit-card' /> + <Section.Content> + <Section.Label> + {paymentMethod.label} {paymentMethod.isDefault ? <Badge color='neutral'>Default</Badge> : null} + </Section.Label> + {paymentMethod.expiryLabel ? <Section.Description>{paymentMethod.expiryLabel}</Section.Description> : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${paymentMethod.label}`} + /> + </Section.Actions> + </Section.Item> + ); +} 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 962449e80b6..768ddfcb3fe 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,19 +1,31 @@ import * as stylex from '@stylexjs/stylex'; +import { Icon } from '../components/icon'; import { Section } from '../components/section'; +import type { IconName } from '../icons/registry'; import { styles } from './user-profile-profile-panel.styles'; -export function UserProfileProviderIcon({ iconUrl }: { iconUrl: string }) { +type UserProfileProviderIconProps = { iconUrl: string; name?: never } | { iconUrl?: never; name: IconName }; + +export function UserProfileProviderIcon(props: UserProfileProviderIconProps) { return ( <Section.Media size='lg' {...stylex.props(styles.providerMedia)} > - <img - alt='' - src={iconUrl} - {...stylex.props(styles.providerIcon)} - /> + {'iconUrl' in props ? ( + <img + alt='' + src={props.iconUrl} + {...stylex.props(styles.providerIcon)} + /> + ) : ( + <Icon + aria-hidden + name={props.name} + {...stylex.props(styles.providerIcon)} + /> + )} </Section.Media> ); } diff --git a/packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx new file mode 100644 index 00000000000..188d7d213e7 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx @@ -0,0 +1,61 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../components/button'; +import { Section } from '../components/section'; +import { styles } from './user-profile-billing-panel.styles'; + +export interface UserProfileSubscription { + planName: string; + priceLabel: string; + totalDueLabel: string; + renewsAtLabel: string; +} + +export interface UserProfileSubscriptionSectionViewProps { + subscription: UserProfileSubscription; + onChangePlan?: () => void; +} + +export function UserProfileSubscriptionSectionView({ + subscription, + onChangePlan, +}: UserProfileSubscriptionSectionViewProps) { + return ( + <Section.Root> + <Section.Title>Subscription</Section.Title> + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>{subscription.planName}</Section.Label> + <Section.Description>{subscription.priceLabel}</Section.Description> + </Section.Content> + {onChangePlan ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onChangePlan} + > + Change plan + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Total due</Section.Label> + <Section.Description>{subscription.renewsAtLabel}</Section.Description> + </Section.Content> + <Section.Actions> + <span {...stylex.props(styles.amount)}>{subscription.totalDueLabel}</span> + </Section.Actions> + </Section.Item> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} From 951af3cd17ed33ff816e20b6134dc32518b580fa Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 14:08:07 -0600 Subject: [PATCH 12/18] feat(ui): add Mosaic billing history table --- .../swingset/src/components/DocsViewer.tsx | 3 + packages/swingset/src/lib/registry.ts | 11 ++ .../user-profile-billing-history-section.mdx | 20 ++ ...rofile-billing-history-section.stories.tsx | 56 ++++++ .../stories/user-profile-billing-panel.mdx | 2 +- .../user-profile-billing-panel.stories.tsx | 58 ++++++ .../user-profile-billing-panel.view.test.tsx | 41 +++- ...-profile-billing-history-section.styles.ts | 121 ++++++++++++ ...r-profile-billing-history-section.view.tsx | 178 ++++++++++++++++++ .../user-profile-billing-panel.view.tsx | 29 ++- 10 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 packages/swingset/src/stories/user-profile-billing-history-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.styles.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 5ad282bc9b7..429af10c58a 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -20,6 +20,9 @@ const docModules: Record<string, Record<string, React.ComponentType>> = { 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), 'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')), 'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')), + 'user-profile-billing-history-section': dynamic( + () => import('../stories/user-profile-billing-history-section.mdx'), + ), 'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')), 'user-profile-payment-methods-section': dynamic( () => import('../stories/user-profile-payment-methods-section.mdx'), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index d995ffa4e23..2060572b80d 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -109,6 +109,11 @@ import { Default as UserProfileActiveDevicesSectionDefault, meta as userProfileActiveDevicesSectionMeta, } from '../stories/user-profile-active-devices-section.stories'; +import { + Default as UserProfileBillingHistorySectionDefault, + Empty as UserProfileBillingHistorySectionEmpty, + meta as userProfileBillingHistorySectionMeta, +} from '../stories/user-profile-billing-history-section.stories'; import { Default as UserProfileBillingPanelDefault, meta as userProfileBillingPanelMeta, @@ -286,6 +291,11 @@ const userProfileBillingPanelModule: StoryModule = { meta: userProfileBillingPanelMeta, Default: UserProfileBillingPanelDefault, }; +const userProfileBillingHistorySectionModule: StoryModule = { + meta: userProfileBillingHistorySectionMeta, + Default: UserProfileBillingHistorySectionDefault, + Empty: UserProfileBillingHistorySectionEmpty, +}; const userProfilePasswordSectionModule: StoryModule = { meta: userProfilePasswordSectionMeta, Default: UserProfilePasswordSectionDefault, @@ -339,6 +349,7 @@ export const registry: StoryModule[] = [ userProfileActiveDevicesSectionModule, userProfileSubscriptionSectionModule, userProfilePaymentMethodsSectionModule, + userProfileBillingHistorySectionModule, userProfileConnectedAccountsSectionModule, userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, diff --git a/packages/swingset/src/stories/user-profile-billing-history-section.mdx b/packages/swingset/src/stories/user-profile-billing-history-section.mdx new file mode 100644 index 00000000000..d3269e1b413 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-history-section.mdx @@ -0,0 +1,20 @@ +import * as Stories from './user-profile-billing-history-section.stories'; + +# UserProfileBillingHistorySection + +Billing history rendered as a section-local semantic table. + +<Story + name='Default' + storyModule={Stories} + composition={[ + { name: 'Section', href: '/components/section', layer: 'Components' }, + { name: 'Badge', href: '/components/badge', layer: 'Components' }, + { name: 'Button', href: '/components/button', layer: 'Components' }, + ]} +/> + +<Story + name='Empty' + storyModule={Stories} +/> diff --git a/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx b/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx new file mode 100644 index 00000000000..af3cc9c4a75 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx @@ -0,0 +1,56 @@ +import type { UserProfileBillingHistoryItem } from '@clerk/ui/mosaic/user-profile/user-profile-billing-history-section.view'; +import { UserProfileBillingHistorySectionView } from '@clerk/ui/mosaic/user-profile/user-profile-billing-history-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-billing-history-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileBillingHistorySection', + label: 'Billing history', + navigation: { family: 'User profile', category: 'Billing sections', order: 30 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx', +}; + +const items: UserProfileBillingHistoryItem[] = [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202606_0644', + dateLabel: 'Jun 3, 2026', + invoiceLabel: 'stmt_202606_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202607_0644', + dateLabel: 'Jun 10, 2026', + invoiceLabel: 'stmt_202607_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, +]; + +export function Default() { + const [pageSize, setPageSize] = useState(10); + + return ( + <UserProfileBillingHistorySectionView + items={items} + pagination={{ page: 1, pageCount: 1, pageSize }} + onPageSizeChange={setPageSize} + onView={() => {}} + /> + ); +} + +export function Empty() { + return <UserProfileBillingHistorySectionView items={[]} />; +} diff --git a/packages/swingset/src/stories/user-profile-billing-panel.mdx b/packages/swingset/src/stories/user-profile-billing-panel.mdx index 2483eedd431..90b9ec8fd02 100644 --- a/packages/swingset/src/stories/user-profile-billing-panel.mdx +++ b/packages/swingset/src/stories/user-profile-billing-panel.mdx @@ -2,7 +2,7 @@ import * as Stories from './user-profile-billing-panel.stories'; # UserProfileBillingPanel -Subscription and payment methods composed without the surrounding navigation shell. Billing history is intentionally deferred. +Subscription, payment methods, and an inline billing history table composed without the surrounding navigation shell. <Story name='Default' diff --git a/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx index 2231914f738..2f645f5dea8 100644 --- a/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx @@ -1,4 +1,5 @@ import type { + UserProfileBillingHistoryItem, UserProfilePaymentMethod, UserProfileSubscription, } from '@clerk/ui/mosaic/user-profile/user-profile-billing-panel.view'; @@ -29,12 +30,67 @@ const initialPaymentMethods: UserProfilePaymentMethod[] = [ { id: 'mastercard', label: 'Mastercard •••• 1212', expiryLabel: 'Expires 02/2029' }, ]; +const historyItems: UserProfileBillingHistoryItem[] = [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202606_0644', + dateLabel: 'Jun 3, 2026', + invoiceLabel: 'stmt_202606_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202607_0644', + dateLabel: 'Jun 10, 2026', + invoiceLabel: 'stmt_202607_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202608_0644', + dateLabel: 'Jun 18, 2026', + invoiceLabel: 'stmt_202608_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202609_0644', + dateLabel: 'Jul 1, 2026', + invoiceLabel: 'stmt_202609_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202610_0644', + dateLabel: 'Jul 9, 2026', + invoiceLabel: 'stmt_202610_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202611_0644', + dateLabel: 'Jul 23, 2026', + invoiceLabel: 'stmt_202611_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, +]; + export function Default() { const [subscription, setSubscription] = useState(initialSubscription); const [paymentMethods, setPaymentMethods] = useState(initialPaymentMethods); + const [historyPageSize, setHistoryPageSize] = useState(10); return ( <UserProfileBillingPanelView + historyItems={historyItems} + historyPagination={{ page: 1, pageCount: 1, pageSize: historyPageSize }} paymentMethods={paymentMethods} subscription={subscription} onAddPaymentMethod={() => @@ -55,6 +111,8 @@ export function Default() { setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))) } onRemovePaymentMethod={id => setPaymentMethods(current => current.filter(method => method.id !== id))} + onBillingHistoryPageSizeChange={setHistoryPageSize} + onViewInvoice={() => {}} /> ); } diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx index 3de2fe29a60..75ef66de7fe 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx @@ -26,10 +26,21 @@ const paymentMethods = [ }, ]; +const historyItems = [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, +]; + function renderView(overrides: Partial<React.ComponentProps<typeof UserProfileBillingPanelView>> = {}) { return render( <MosaicProvider> <UserProfileBillingPanelView + historyItems={historyItems} paymentMethods={paymentMethods} subscription={subscription} {...overrides} @@ -39,7 +50,7 @@ function renderView(overrides: Partial<React.ComponentProps<typeof UserProfileBi } describe('UserProfileBillingPanelView', () => { - it('composes subscription and payment methods without history', () => { + it('composes subscription, payment methods, and billing history', () => { renderView(); expect(screen.getByRole('heading', { level: 3, name: 'Billing' })).toBeInTheDocument(); @@ -49,7 +60,9 @@ describe('UserProfileBillingPanelView', () => { expect(screen.getByText('$12.00')).toBeInTheDocument(); expect(screen.getByText('Visa •••• 0644')).toBeInTheDocument(); expect(screen.getByText('Default')).toBeInTheDocument(); - expect(screen.queryByText('History')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'History' })).toBeInTheDocument(); + expect(screen.getByText('May 26, 2026')).toBeInTheDocument(); + expect(screen.getByText('Paid')).toBeInTheDocument(); }); it('forwards subscription and payment method actions', async () => { @@ -57,6 +70,7 @@ describe('UserProfileBillingPanelView', () => { const onAdd = vi.fn(); const onMakeDefault = vi.fn(); const onRemove = vi.fn(); + const onViewInvoice = vi.fn(); const user = userEvent.setup(); renderView({ @@ -64,6 +78,7 @@ describe('UserProfileBillingPanelView', () => { onAddPaymentMethod: onAdd, onMakeDefaultPaymentMethod: onMakeDefault, onRemovePaymentMethod: onRemove, + onViewInvoice, }); await user.click(screen.getByRole('button', { name: 'Change plan' })); @@ -72,11 +87,13 @@ describe('UserProfileBillingPanelView', () => { await user.click(screen.getByRole('menuitem', { name: 'Make default' })); await user.click(screen.getByRole('button', { name: 'Manage Mastercard •••• 1212' })); await user.click(screen.getByRole('menuitem', { name: 'Remove payment method' })); + await user.click(screen.getByRole('button', { name: 'View' })); expect(onChangePlan).toHaveBeenCalledOnce(); expect(onAdd).toHaveBeenCalledOnce(); expect(onMakeDefault).toHaveBeenCalledWith('mastercard'); expect(onRemove).toHaveBeenCalledWith('mastercard'); + expect(onViewInvoice).toHaveBeenCalledWith('stmt_202605_0644'); }); it('keeps an empty payment method list actionable', () => { @@ -85,4 +102,24 @@ describe('UserProfileBillingPanelView', () => { expect(screen.getByText('No payment methods added')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add payment method' })).toBeInTheDocument(); }); + + it('forwards billing history pagination', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + const user = userEvent.setup(); + + renderView({ + historyPagination: { page: 2, pageCount: 3, pageSize: 10, pageSizeOptions: [10, 25] }, + onBillingHistoryPageChange: onPageChange, + onBillingHistoryPageSizeChange: onPageSizeChange, + }); + + await user.click(screen.getByRole('button', { name: 'Previous invoice page' })); + await user.click(screen.getByRole('button', { name: 'Next invoice page' })); + await user.selectOptions(screen.getByRole('combobox', { name: 'Results per page' }), '25'); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1); + expect(onPageChange).toHaveBeenNthCalledWith(2, 3); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + }); }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.styles.ts new file mode 100644 index 00000000000..ed30ce9b878 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.styles.ts @@ -0,0 +1,121 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + actionCell: { + textAlign: 'end', + }, + amountCell: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + cell: { + paddingBlock: space['3'], + paddingInline: space['4'], + verticalAlign: 'middle', + }, + emptyCell: { + paddingBlock: space['6'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textAlign: 'center', + }, + header: { + backgroundColor: colorVars['--cl-color-border-faded'], + }, + headerCell: { + paddingBlock: space['2.5'], + paddingInline: space['4'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + textAlign: 'start', + }, + invoiceColumn: { + width: '34%', + }, + invoiceId: { + overflow: 'hidden', + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + marginBlockStart: space['0.5'], + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + invoiceLabel: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + pageSizeLabel: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-neutral-faded'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pageSizeSelect: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['1'], + paddingInline: space['2'], + backgroundColor: colorVars['--cl-color-card'], + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pagination: { + gap: space['2'], + paddingBlock: space['2'], + paddingInline: space['3'], + alignItems: 'center', + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + display: 'flex', + justifyContent: 'space-between', + }, + paginationControls: { + gap: space['1'], + alignItems: 'center', + display: 'flex', + }, + row: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + shell: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + width: '100%', + }, + statusColumn: { + width: '20%', + }, + table: { + borderCollapse: 'collapse', + tableLayout: 'fixed', + width: '100%', + }, + tableScroller: { + overflowX: 'auto', + width: '100%', + }, + viewColumn: { + width: '14%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx new file mode 100644 index 00000000000..e922c6a03aa --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx @@ -0,0 +1,178 @@ +import * as stylex from '@stylexjs/stylex'; + +import type { BadgeProps } from '../components/badge'; +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { styles } from './user-profile-billing-history-section.styles'; + +export interface UserProfileBillingHistoryItem { + id: string; + dateLabel: string; + invoiceLabel: string; + amountLabel: string; + statusLabel: string; + statusColor?: BadgeProps['color']; +} + +export interface UserProfileBillingHistoryPagination { + page: number; + pageCount: number; + pageSize: number; + pageSizeOptions?: readonly number[]; +} + +export interface UserProfileBillingHistorySectionViewProps { + items: UserProfileBillingHistoryItem[]; + pagination?: UserProfileBillingHistoryPagination; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onView?: (id: string) => void; +} + +export function UserProfileBillingHistorySectionView({ + items, + pagination, + onPageChange, + onPageSizeChange, + onView, +}: UserProfileBillingHistorySectionViewProps) { + return ( + <Section.Root aria-label='Billing history'> + <Section.Title>History</Section.Title> + <div {...stylex.props(styles.shell)}> + <div {...stylex.props(styles.tableScroller)}> + <table {...stylex.props(styles.table)}> + <thead {...stylex.props(styles.header)}> + <tr> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.invoiceColumn)} + > + Invoice + </th> + <th + scope='col' + {...stylex.props(styles.headerCell)} + > + Amount + </th> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.statusColumn)} + > + Status + </th> + <th + aria-label='Actions' + scope='col' + {...stylex.props(styles.headerCell, styles.viewColumn)} + /> + </tr> + </thead> + <tbody> + {items.length > 0 ? ( + items.map(item => ( + <tr + key={item.id} + {...stylex.props(styles.row)} + > + <td {...stylex.props(styles.cell)}> + <div {...stylex.props(styles.invoiceLabel)}>{item.dateLabel}</div> + <div {...stylex.props(styles.invoiceId)}>{item.invoiceLabel}</div> + </td> + <td {...stylex.props(styles.cell, styles.amountCell)}>{item.amountLabel}</td> + <td {...stylex.props(styles.cell)}> + <Badge color={item.statusColor ?? 'positive'}>{item.statusLabel}</Badge> + </td> + <td {...stylex.props(styles.cell, styles.actionCell)}> + {onView ? ( + <Button + color='neutral' + size='sm' + variant='link' + onClick={() => onView(item.id)} + > + View + </Button> + ) : null} + </td> + </tr> + )) + ) : ( + <tr {...stylex.props(styles.row)}> + <td + colSpan={4} + {...stylex.props(styles.emptyCell)} + > + No invoices yet + </td> + </tr> + )} + </tbody> + </table> + </div> + {pagination ? ( + <div {...stylex.props(styles.pagination)}> + <div {...stylex.props(styles.paginationControls)}> + <Button + aria-label='Previous invoice page' + color='neutral' + disabled={pagination.page <= 1} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page - 1)} + > + <Icon name='chevron-left' /> + </Button> + <Button + aria-current='page' + aria-label={`Invoice page ${pagination.page}`} + color='neutral' + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + > + {pagination.page} + </Button> + <Button + aria-label='Next invoice page' + color='neutral' + disabled={pagination.page >= pagination.pageCount} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page + 1)} + > + <Icon name='chevron-right' /> + </Button> + </div> + <label {...stylex.props(styles.pageSizeLabel)}> + <span>Results per page</span> + <select + aria-label='Results per page' + value={pagination.pageSize} + {...stylex.props(styles.pageSizeSelect)} + onChange={event => onPageSizeChange?.(Number(event.currentTarget.value))} + > + {(pagination.pageSizeOptions ?? [10, 25, 50]).map(pageSize => ( + <option + key={pageSize} + value={pageSize} + > + {pageSize} + </option> + ))} + </select> + </label> + </div> + ) : null} + </div> + </Section.Root> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx index 8be1c2bd520..766e88d1bdc 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx @@ -3,30 +3,50 @@ import type { ReactElement } from 'react'; import { Heading } from '../components/heading'; import { mergeStyleProps, themeProps } from '../props'; +import type { + UserProfileBillingHistoryItem, + UserProfileBillingHistoryPagination, +} from './user-profile-billing-history-section.view'; +import { UserProfileBillingHistorySectionView } from './user-profile-billing-history-section.view'; import { styles } from './user-profile-billing-panel.styles'; import type { UserProfilePaymentMethod } from './user-profile-payment-methods-section.view'; import { UserProfilePaymentMethodsSectionView } from './user-profile-payment-methods-section.view'; import type { UserProfileSubscription } from './user-profile-subscription-section.view'; import { UserProfileSubscriptionSectionView } from './user-profile-subscription-section.view'; -export type { UserProfilePaymentMethod, UserProfileSubscription }; +export type { + UserProfileBillingHistoryItem, + UserProfileBillingHistoryPagination, + UserProfilePaymentMethod, + UserProfileSubscription, +}; export interface UserProfileBillingPanelViewProps { subscription: UserProfileSubscription; paymentMethods: UserProfilePaymentMethod[]; + historyItems: UserProfileBillingHistoryItem[]; + historyPagination?: UserProfileBillingHistoryPagination; onChangePlan?: () => void; onAddPaymentMethod?: () => void; onMakeDefaultPaymentMethod?: (id: string) => void; onRemovePaymentMethod?: (id: string) => void; + onBillingHistoryPageChange?: (page: number) => void; + onBillingHistoryPageSizeChange?: (pageSize: number) => void; + onViewInvoice?: (id: string) => void; } export function UserProfileBillingPanelView({ subscription, paymentMethods, + historyItems, + historyPagination, onChangePlan, onAddPaymentMethod, onMakeDefaultPaymentMethod, onRemovePaymentMethod, + onBillingHistoryPageChange, + onBillingHistoryPageSizeChange, + onViewInvoice, }: UserProfileBillingPanelViewProps): ReactElement { return ( <div {...mergeStyleProps(themeProps('user-profile-billing-panel'), stylex.props(styles.root))}> @@ -47,6 +67,13 @@ export function UserProfileBillingPanelView({ onMakeDefault={onMakeDefaultPaymentMethod} onRemove={onRemovePaymentMethod} /> + <UserProfileBillingHistorySectionView + items={historyItems} + pagination={historyPagination} + onPageChange={onBillingHistoryPageChange} + onPageSizeChange={onBillingHistoryPageSizeChange} + onView={onViewInvoice} + /> </div> </div> ); From 485bad285f07b18206fc4a5e060f252f5ab326a0 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 15:01:55 -0600 Subject: [PATCH 13/18] feat(ui): add Mosaic API keys profile panel --- packages/ui/src/mosaic/icons/registry.tsx | 8 + .../user-profile-api-keys-panel.view.test.tsx | 104 +++++++ .../user-profile-api-keys-panel.styles.ts | 150 +++++++++++ .../user-profile-api-keys-panel.view.tsx | 253 ++++++++++++++++++ 4 files changed, 515 insertions(+) create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.styles.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 1d16a1ac3f6..b77f1c3e52d 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -94,6 +94,13 @@ const Plus = glyph( />, ); +const Search = glyph( + <path + d='M10 10.0104C10.7722 9.24089 11.25 8.17625 11.25 7C11.25 4.65279 9.34721 2.75 7 2.75C4.65279 2.75 2.75 4.65279 2.75 7C2.75 9.34721 4.65279 11.25 7 11.25C8.17096 11.25 9.23132 10.7764 10 10.0104ZM10 10.0104L13.25 13.25' + {...strokeProps} + />, +); + const CreditCard = glyph( <path d='M2.75 6.75V10.25C2.75 11.3546 3.64543 12.25 4.75 12.25H11.25C12.3546 12.25 13.25 11.3546 13.25 10.25V6.75M2.75 6.75V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H11.25C12.3546 3.75 13.25 4.64543 13.25 5.75V6.75M2.75 6.75H13.25M5.75 9.25H6.25' @@ -288,6 +295,7 @@ export const iconRegistry = { ellipsis: Ellipsis, pen: Pen, plus: Plus, + search: Search, 'log-out': LogOut, cog: Cog, 'device-laptop': DeviceLaptop, diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx new file mode 100644 index 00000000000..255eb8ea7fa --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileApiKeysPanelView } from '../user-profile-api-keys-panel.view'; + +const apiKeys = [ + { + id: 'primary', + name: 'Primary API Key', + expirationLabel: 'Expires Dec 31, 2027', + createdAtLabel: 'Jan 05, 2026', + lastUsedAtLabel: 'Dec 31, 2026', + }, + { + id: 'legacy', + name: 'Legacy API Key', + expirationLabel: 'Expired Jul 1, 2025', + createdAtLabel: 'Jul 1, 2024', + lastUsedAtLabel: 'Jul 1, 2025', + isExpired: true, + }, +]; + +function renderView(overrides: Partial<React.ComponentProps<typeof UserProfileApiKeysPanelView>> = {}) { + const props = { + apiKeys, + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + ...overrides, + }; + + return { + ...render( + <MosaicProvider> + <UserProfileApiKeysPanelView {...props} /> + </MosaicProvider>, + ), + props, + }; +} + +describe('UserProfileApiKeysPanelView', () => { + it('renders search, key metadata, and expired state', () => { + renderView(); + + expect(screen.getByRole('heading', { level: 3, name: 'API Keys' })).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search API keys' })).toBeInTheDocument(); + expect(screen.getByText('Primary API Key')).toBeInTheDocument(); + expect(screen.getByText('Expired')).toBeInTheDocument(); + }); + + it('forwards search, selection, creation, and revoke actions', async () => { + const onCreate = vi.fn(); + const onRevoke = vi.fn(); + const onSearchChange = vi.fn(); + const onSelectionChange = vi.fn(); + const user = userEvent.setup(); + + renderView({ onCreate, onRevoke, onSearchChange, onSelectionChange }); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search API keys' }), { target: { value: 'primary' } }); + await user.click(screen.getByRole('button', { name: 'Create API key' })); + await user.click(screen.getByRole('checkbox', { name: 'Select Primary API Key' })); + await user.click(screen.getByRole('checkbox', { name: 'Select all API keys' })); + await user.click(screen.getByRole('button', { name: 'Manage Primary API Key' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke' })); + + expect(onSearchChange).toHaveBeenCalledWith('primary'); + expect(onCreate).toHaveBeenCalledOnce(); + expect(onSelectionChange).toHaveBeenNthCalledWith(1, ['primary']); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, ['primary', 'legacy']); + expect(onRevoke).toHaveBeenCalledWith('primary'); + }); + + it('forwards page and results-per-page changes', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + const user = userEvent.setup(); + + renderView({ + pagination: { page: 2, pageCount: 3, pageSize: 10, pageSizeOptions: [10, 25] }, + onPageChange, + onPageSizeChange, + }); + + await user.click(screen.getByRole('button', { name: 'Previous API keys page' })); + await user.click(screen.getByRole('button', { name: 'Next API keys page' })); + await user.selectOptions(screen.getByRole('combobox', { name: 'Results per page' }), '25'); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1); + expect(onPageChange).toHaveBeenNthCalledWith(2, 3); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + }); + + it('renders an empty state', () => { + renderView({ apiKeys: [] }); + + expect(screen.getByText('No API keys found')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.styles.ts new file mode 100644 index 00000000000..1aa2b4aebd3 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.styles.ts @@ -0,0 +1,150 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + actionCell: { + textAlign: 'end', + width: space['12'], + }, + cell: { + paddingBlock: space['3'], + paddingInline: space['4'], + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + verticalAlign: 'middle', + }, + checkbox: { + accentColor: colorVars['--cl-color-primary'], + cursor: 'pointer', + height: space['4'], + width: space['4'], + }, + checkboxCell: { + paddingInlineEnd: space['1'], + paddingInlineStart: space['4'], + textAlign: 'center', + width: space['8'], + }, + emptyCell: { + paddingBlock: space['8'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textAlign: 'center', + }, + header: { + backgroundColor: colorVars['--cl-color-border-faded'], + }, + headerCell: { + paddingBlock: space['2.5'], + paddingInline: space['4'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + textAlign: 'start', + }, + keyDescription: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + marginBlockStart: space['0.5'], + }, + keyName: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + nameColumn: { + width: '38%', + }, + pageSizeLabel: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-neutral-faded'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pageSizeSelect: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['1'], + paddingInline: space['2'], + backgroundColor: colorVars['--cl-color-card'], + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pagination: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, + paginationControls: { + gap: space['1'], + alignItems: 'center', + display: 'flex', + }, + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + row: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + search: { + paddingInlineStart: space['8'], + }, + searchIcon: { + color: colorVars['--cl-color-neutral-faded'], + insetInlineStart: space['3'], + pointerEvents: 'none', + position: 'absolute', + transform: 'translateY(-50%)', + top: '50%', + }, + searchWrapper: { + position: 'relative', + width: '17rem', + }, + table: { + borderCollapse: 'collapse', + tableLayout: 'fixed', + width: '100%', + }, + tableScroller: { + overflowX: 'auto', + width: '100%', + }, + tableShell: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + width: '100%', + }, + toolbar: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx new file mode 100644 index 00000000000..2ee87aab132 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx @@ -0,0 +1,253 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Heading } from '../components/heading'; +import { Icon } from '../components/icon'; +import { Input } from '../components/input'; +import { Menu } from '../components/menu'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './user-profile-api-keys-panel.styles'; + +export interface UserProfileAPIKey { + id: string; + name: string; + expirationLabel: string; + createdAtLabel: string; + lastUsedAtLabel: string; + isExpired?: boolean; +} + +export interface UserProfileAPIKeysPagination { + page: number; + pageCount: number; + pageSize: number; + pageSizeOptions?: readonly number[]; +} + +export interface UserProfileApiKeysPanelViewProps { + apiKeys: UserProfileAPIKey[]; + pagination?: UserProfileAPIKeysPagination; + searchValue: string; + selectedIds: readonly string[]; + onCreate?: () => void; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onRevoke?: (id: string) => void; + onSearchChange: (value: string) => void; + onSelectionChange: (ids: string[]) => void; +} + +export function UserProfileApiKeysPanelView({ + apiKeys, + pagination, + searchValue, + selectedIds, + onCreate, + onPageChange, + onPageSizeChange, + onRevoke, + onSearchChange, + onSelectionChange, +}: UserProfileApiKeysPanelViewProps): ReactElement { + const allSelected = apiKeys.length > 0 && apiKeys.every(apiKey => selectedIds.includes(apiKey.id)); + + const toggleAll = () => { + onSelectionChange(allSelected ? [] : apiKeys.map(apiKey => apiKey.id)); + }; + + const toggleOne = (id: string) => { + onSelectionChange( + selectedIds.includes(id) ? selectedIds.filter(selectedId => selectedId !== id) : [...selectedIds, id], + ); + }; + + return ( + <div {...mergeStyleProps(themeProps('user-profile-api-keys-panel'), stylex.props(styles.root))}> + <Heading + render={props => <h3 {...props} />} + size='2xl' + > + API Keys + </Heading> + <div {...stylex.props(styles.toolbar)}> + <div {...stylex.props(styles.searchWrapper)}> + <Icon + aria-hidden + name='search' + size='sm' + {...stylex.props(styles.searchIcon)} + /> + <Input + aria-label='Search API keys' + autoComplete='off' + placeholder='Search' + size='sm' + type='search' + value={searchValue} + {...stylex.props(styles.search)} + onChange={event => onSearchChange(event.currentTarget.value)} + /> + </div> + {onCreate ? <Button onClick={onCreate}>Create API key</Button> : null} + </div> + <div {...stylex.props(styles.tableShell)}> + <div {...stylex.props(styles.tableScroller)}> + <table {...stylex.props(styles.table)}> + <thead {...stylex.props(styles.header)}> + <tr> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.checkboxCell)} + > + <input + aria-label='Select all API keys' + checked={allSelected} + type='checkbox' + {...stylex.props(styles.checkbox)} + onChange={toggleAll} + /> + </th> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.nameColumn)} + > + Name + </th> + <th + scope='col' + {...stylex.props(styles.headerCell)} + > + Created + </th> + <th + scope='col' + {...stylex.props(styles.headerCell)} + > + Last used + </th> + <th + aria-label='Actions' + scope='col' + {...stylex.props(styles.headerCell, styles.actionCell)} + /> + </tr> + </thead> + <tbody> + {apiKeys.length > 0 ? ( + apiKeys.map(apiKey => ( + <tr + key={apiKey.id} + {...stylex.props(styles.row)} + > + <td {...stylex.props(styles.cell, styles.checkboxCell)}> + <input + aria-label={`Select ${apiKey.name}`} + checked={selectedIds.includes(apiKey.id)} + type='checkbox' + {...stylex.props(styles.checkbox)} + onChange={() => toggleOne(apiKey.id)} + /> + </td> + <td {...stylex.props(styles.cell)}> + <div {...stylex.props(styles.keyName)}> + <span>{apiKey.name}</span> + {apiKey.isExpired ? <Badge color='warning'>Expired</Badge> : null} + </div> + <div {...stylex.props(styles.keyDescription)}>{apiKey.expirationLabel}</div> + </td> + <td {...stylex.props(styles.cell)}>{apiKey.createdAtLabel}</td> + <td {...stylex.props(styles.cell)}>{apiKey.lastUsedAtLabel}</td> + <td {...stylex.props(styles.cell, styles.actionCell)}> + {onRevoke ? ( + <Menu.Root placement='bottom-end'> + <Menu.Trigger aria-label={`Manage ${apiKey.name}`} /> + <Menu.Content> + <Menu.Item + color='negative' + label='Revoke' + onClick={() => onRevoke(apiKey.id)} + /> + </Menu.Content> + </Menu.Root> + ) : null} + </td> + </tr> + )) + ) : ( + <tr {...stylex.props(styles.row)}> + <td + colSpan={5} + {...stylex.props(styles.emptyCell)} + > + No API keys found + </td> + </tr> + )} + </tbody> + </table> + </div> + </div> + {pagination ? ( + <div {...stylex.props(styles.pagination)}> + <div {...stylex.props(styles.paginationControls)}> + <Button + aria-label='Previous API keys page' + color='neutral' + disabled={pagination.page <= 1} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page - 1)} + > + <Icon name='chevron-left' /> + </Button> + <Button + aria-current='page' + aria-label={`API keys page ${pagination.page}`} + color='neutral' + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + > + {pagination.page} + </Button> + <Button + aria-label='Next API keys page' + color='neutral' + disabled={pagination.page >= pagination.pageCount} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page + 1)} + > + <Icon name='chevron-right' /> + </Button> + </div> + <label {...stylex.props(styles.pageSizeLabel)}> + <span>Results per page</span> + <select + aria-label='Results per page' + value={pagination.pageSize} + {...stylex.props(styles.pageSizeSelect)} + onChange={event => onPageSizeChange?.(Number(event.currentTarget.value))} + > + {(pagination.pageSizeOptions ?? [10, 25, 50]).map(pageSize => ( + <option + key={pageSize} + value={pageSize} + > + {pageSize} + </option> + ))} + </select> + </label> + </div> + ) : null} + </div> + ); +} From 3ec6b3be92021adb526a8dc1e80f306a25a6e992 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 15:02:29 -0600 Subject: [PATCH 14/18] feat(ui): add Mosaic user page composition --- packages/ui/src/mosaic/icons/registry.tsx | 24 ++++ .../__tests__/user-page.view.test.tsx | 93 +++++++++++++ .../mosaic/user-profile/user-page.view.tsx | 92 +++++++++++++ .../user-profile/user-profile-sidebar.tsx | 77 +++++++++++ .../user-profile/user-profile.styles.ts | 129 ++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-page.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile.styles.ts diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index b77f1c3e52d..71f4e41bfb4 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -108,6 +108,27 @@ const CreditCard = glyph( />, ); +const UserCircle = glyph( + <path + d='M11.1786 12.1788C10.4001 11.3023 9.26453 10.75 8 10.75C6.73547 10.75 5.59993 11.3023 4.82141 12.1788M11.1786 12.1788C12.4375 11.2197 13.25 9.70474 13.25 8C13.25 5.10051 10.8995 2.75 8 2.75C5.10051 2.75 2.75 5.10051 2.75 8C2.75 9.70474 3.56251 11.2197 4.82141 12.1788M11.1786 12.1788C10.2963 12.8509 9.19476 13.25 8 13.25C6.80524 13.25 5.7037 12.8509 4.82141 12.1788M9.25 7C9.25 7.69036 8.69036 8.25 8 8.25C7.30964 8.25 6.75 7.69036 6.75 7C6.75 6.30964 7.30964 5.75 8 5.75C8.69036 5.75 9.25 6.30964 9.25 7Z' + {...strokeProps} + />, +); + +const ShieldCheck = glyph( + <path + d='M13.25 5.9L8 2.75L2.75 5.9C2.75 5.9 3 12 7.25 13.25M9.75 10.85L11.15 12.25L13.25 8.75' + {...strokeProps} + />, +); + +const Code = glyph( + <path + d='M5.25 5.75L2.75 8L5.25 10.25M10.75 5.75L13.25 8L10.75 10.25' + {...strokeProps} + />, +); + const SecurityPasskey = glyph( <> <path @@ -291,11 +312,13 @@ export const iconRegistry = { 'chevron-up-down': ChevronUpDown, check: Check, close: Close, + code: Code, 'credit-card': CreditCard, ellipsis: Ellipsis, pen: Pen, plus: Plus, search: Search, + 'shield-check': ShieldCheck, 'log-out': LogOut, cog: Cog, 'device-laptop': DeviceLaptop, @@ -305,6 +328,7 @@ export const iconRegistry = { 'security-passkey': SecurityPasskey, 'security-phone': SecurityPhone, users: Users, + 'user-circle': UserCircle, } satisfies Record<string, IconComponent>; export type IconName = keyof typeof iconRegistry; diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx new file mode 100644 index 00000000000..a7f73cfe61c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserPageViewProps } from '../user-page.view'; +import { UserPageView } from '../user-page.view'; + +const panels: UserPageViewProps['panels'] = { + account: { name: 'Preston Booth', username: 'prestonxyz' }, + security: { hasPassword: true }, + billing: { + subscription: { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }, + paymentMethods: [], + historyItems: [], + }, + apiKeys: { + apiKeys: [], + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + }, +}; + +function renderView(overrides: Partial<UserPageViewProps> = {}) { + const props: UserPageViewProps = { + activePanel: 'account', + panels, + onPanelChange: vi.fn(), + ...overrides, + }; + + return { + ...render( + <MosaicProvider> + <UserPageView {...props} /> + </MosaicProvider>, + ), + props, + }; +} + +describe('UserPageView', () => { + it('renders the active panel and all available destinations', () => { + renderView(); + + expect(screen.getByRole('navigation', { name: 'User profile' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Account' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Security' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Billing' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'API Keys' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); + expect(screen.getByText('Secured by')).toBeInTheDocument(); + }); + + it('forwards panel changes', async () => { + const onPanelChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPanelChange }); + + await user.click(screen.getByRole('button', { name: 'Security' })); + + expect(onPanelChange).toHaveBeenCalledWith('security'); + expect(screen.queryByRole('button', { name: 'Close user profile' })).not.toBeInTheDocument(); + }); + + it('only exposes supplied optional panels', () => { + renderView({ panels: { account: panels.account } }); + + expect(screen.queryByRole('button', { name: 'Security' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Billing' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'API Keys' })).not.toBeInTheDocument(); + }); + + it('falls back to Account when the requested panel is unavailable', () => { + renderView({ activePanel: 'billing', panels: { account: panels.account } }); + + expect(screen.getByRole('button', { name: 'Account' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); + }); + + it('can omit Clerk branding', () => { + renderView({ renderBranding: false }); + + expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-page.view.tsx b/packages/ui/src/mosaic/user-profile/user-page.view.tsx new file mode 100644 index 00000000000..907eaf111c4 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-page.view.tsx @@ -0,0 +1,92 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './user-profile.styles'; +import type { UserProfileApiKeysPanelViewProps } from './user-profile-api-keys-panel.view'; +import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view'; +import type { UserProfileBillingPanelViewProps } from './user-profile-billing-panel.view'; +import { UserProfileBillingPanelView } from './user-profile-billing-panel.view'; +import type { UserProfileProfilePanelViewProps } from './user-profile-profile-panel.view'; +import { UserProfileProfilePanelView } from './user-profile-profile-panel.view'; +import type { UserProfileSecurityPanelViewProps } from './user-profile-security-panel.view'; +import { UserProfileSecurityPanelView } from './user-profile-security-panel.view'; +import type { UserProfilePanelId } from './user-profile-sidebar'; +import { UserProfileSidebar } from './user-profile-sidebar'; + +export interface UserPagePanels { + account: UserProfileProfilePanelViewProps; + security?: UserProfileSecurityPanelViewProps; + billing?: UserProfileBillingPanelViewProps; + apiKeys?: UserProfileApiKeysPanelViewProps; +} + +export interface UserPageViewProps { + activePanel: UserProfilePanelId; + panels: UserPagePanels; + onPanelChange: (panel: UserProfilePanelId) => void; + renderBranding?: boolean; +} + +function getAvailablePanels(panels: UserPagePanels): UserProfilePanelId[] { + return [ + 'account', + ...(panels.security ? (['security'] as const) : []), + ...(panels.billing ? (['billing'] as const) : []), + ...(panels.apiKeys ? (['api-keys'] as const) : []), + ]; +} + +function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPagePanels }): ReactElement { + switch (panel) { + case 'security': + return panels.security ? ( + <UserProfileSecurityPanelView {...panels.security} /> + ) : ( + <UserProfileProfilePanelView {...panels.account} /> + ); + case 'billing': + return panels.billing ? ( + <UserProfileBillingPanelView {...panels.billing} /> + ) : ( + <UserProfileProfilePanelView {...panels.account} /> + ); + case 'api-keys': + return panels.apiKeys ? ( + <UserProfileApiKeysPanelView {...panels.apiKeys} /> + ) : ( + <UserProfileProfilePanelView {...panels.account} /> + ); + case 'account': + return <UserProfileProfilePanelView {...panels.account} />; + } +} + +export function UserPageView({ + activePanel, + panels, + onPanelChange, + renderBranding = true, +}: UserPageViewProps): ReactElement { + const availablePanels = getAvailablePanels(panels); + const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'account'; + + return ( + <div {...mergeStyleProps(themeProps('user-page'), stylex.props(styles.root))}> + <UserProfileSidebar + activePanel={resolvedPanel} + panels={availablePanels} + renderBranding={renderBranding} + onPanelChange={onPanelChange} + /> + <main {...stylex.props(styles.main)}> + <div {...stylex.props(styles.content)}> + <Panel + panel={resolvedPanel} + panels={panels} + /> + </div> + </main> + </div> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx new file mode 100644 index 00000000000..a04d06e9f3c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx @@ -0,0 +1,77 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { ClerkLogo } from '../components/clerk-logo'; +import { Icon } from '../components/icon'; +import { reset } from '../components/reset.styles'; +import type { IconName } from '../icons/registry'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './user-profile.styles'; + +export type UserProfilePanelId = 'account' | 'security' | 'billing' | 'api-keys'; + +const destinations: Record<UserProfilePanelId, { label: string; icon: IconName }> = { + account: { label: 'Account', icon: 'user-circle' }, + security: { label: 'Security', icon: 'shield-check' }, + billing: { label: 'Billing', icon: 'credit-card' }, + 'api-keys': { label: 'API Keys', icon: 'code' }, +}; + +export interface UserProfileSidebarProps { + activePanel: UserProfilePanelId; + panels: readonly UserProfilePanelId[]; + onPanelChange: (panel: UserProfilePanelId) => void; + renderBranding?: boolean; +} + +export function UserProfileSidebar({ + activePanel, + panels, + onPanelChange, + renderBranding = true, +}: UserProfileSidebarProps): ReactElement { + return ( + <aside {...mergeStyleProps(themeProps('user-profile-sidebar'), stylex.props(reset.base, styles.sidebar))}> + <nav + aria-label='User profile' + {...stylex.props(reset.base, styles.navigation)} + > + {panels.map(panel => { + const destination = destinations[panel]; + const active = panel === activePanel; + + return ( + <button + key={panel} + aria-current={active ? 'page' : undefined} + type='button' + {...stylex.props(reset.base, styles.navigationItem, active && styles.navigationItemActive)} + onClick={() => onPanelChange(panel)} + > + <Icon + aria-hidden + name={destination.icon} + size='sm' + /> + <span>{destination.label}</span> + </button> + ); + })} + </nav> + {renderBranding ? ( + <div {...stylex.props(reset.base, styles.branding)}> + <span>Secured by</span> + <a + aria-label='Clerk' + href='https://go.clerk.com/components' + rel='noopener noreferrer' + target='_blank' + {...stylex.props(reset.base, styles.brandingLink)} + > + <ClerkLogo height={12} /> + </a> + </div> + ) : null} + </aside> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile.styles.ts new file mode 100644 index 00000000000..58ea21c83f8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.styles.ts @@ -0,0 +1,129 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + root: { + borderRadius: radiusVars['--cl-radius-xl'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + color: colorVars['--cl-color-card-foreground'], + display: 'grid', + gridTemplateColumns: { + default: `calc(${space['40']} + ${space['15']}) minmax(0, 1fr)`, + '@media (max-width: 47.99rem)': 'minmax(0, 1fr)', + }, + gridTemplateRows: 'auto', + maxWidth: '66rem', + minHeight: 0, + width: '100%', + }, + sidebar: { + padding: space['4'], + borderBlockEndColor: { + default: 'transparent', + '@media (max-width: 47.99rem)': colorVars['--cl-color-border'], + }, + borderBlockEndStyle: 'solid', + borderBlockEndWidth: { + default: '0px', + '@media (max-width: 47.99rem)': '1px', + }, + borderInlineEndColor: colorVars['--cl-color-border'], + borderInlineEndStyle: 'solid', + borderInlineEndWidth: { + default: '1px', + '@media (max-width: 47.99rem)': '0px', + }, + display: 'flex', + flexDirection: { + default: 'column', + '@media (max-width: 47.99rem)': 'row', + }, + minHeight: 0, + minWidth: 0, + }, + navigation: { + gap: space['1'], + display: 'flex', + flexDirection: { + default: 'column', + '@media (max-width: 47.99rem)': 'row', + }, + minWidth: 0, + overflowX: { + default: 'visible', + '@media (max-width: 47.99rem)': 'auto', + }, + }, + navigationItem: { + borderColor: 'transparent', + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '0px', + gap: space['2'], + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, + }, + paddingBlock: space['2'], + paddingInline: space['2.5'], + alignItems: 'center', + backgroundColor: { + default: 'transparent', + ':hover': colorVars['--cl-color-border-faded'], + }, + color: colorVars['--cl-color-neutral-faded'], + cursor: 'pointer', + display: 'flex', + flexShrink: 0, + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + outlineOffset: '2px', + textAlign: 'start', + whiteSpace: 'nowrap', + width: { + default: '100%', + '@media (max-width: 47.99rem)': 'auto', + }, + }, + navigationItemActive: { + backgroundColor: colorVars['--cl-color-border-faded'], + color: colorVars['--cl-color-card-foreground'], + }, + branding: { + gap: space['1'], + alignItems: 'center', + color: colorVars['--cl-color-neutral-faded'], + display: { + default: 'flex', + '@media (max-width: 47.99rem)': 'none', + }, + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + marginBlockStart: 'auto', + }, + brandingLink: { + borderRadius: radiusVars['--cl-radius-sm'], + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, + }, + alignItems: 'center', + color: 'inherit', + display: 'inline-flex', + outlineOffset: '2px', + height: space['4'], + }, + main: { + minWidth: 0, + }, + content: { + paddingBlock: space['16'], + paddingInline: space['16'], + }, +}); From abe74f315a11ea13b2ea741e97dd069427c96f3a Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 15:02:45 -0600 Subject: [PATCH 15/18] docs(swingset): add user page compositions --- .../swingset/src/components/DocsViewer.tsx | 6 +- packages/swingset/src/lib/registry.ts | 18 ++ packages/swingset/src/lib/types.ts | 2 + packages/swingset/src/stories/user-page.mdx | 17 ++ .../src/stories/user-page.stories.tsx | 251 ++++++++++++++++++ .../stories/user-profile-api-keys-panel.mdx | 21 ++ .../user-profile-api-keys-panel.stories.tsx | 117 ++++++++ 7 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 packages/swingset/src/stories/user-page.mdx create mode 100644 packages/swingset/src/stories/user-page.stories.tsx create mode 100644 packages/swingset/src/stories/user-profile-api-keys-panel.mdx create mode 100644 packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 429af10c58a..56efb348dce 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -11,6 +11,8 @@ import { ViewSource } from './ViewSource'; // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record<string, Record<string, React.ComponentType>> = { user: { + 'user-page': dynamic(() => import('../stories/user-page.mdx')), + 'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')), 'user-button': dynamic(() => import('../stories/user-button.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), @@ -96,7 +98,9 @@ export function DocsViewer({ group, slug }: DocsViewerProps) { key={`${group}/${slug}`} meta={meta} > - <article className='prose relative mx-auto w-full min-w-0 max-w-3xl p-8'> + <article + className={`prose relative mx-auto w-full min-w-0 p-8 ${meta?.layout === 'wide' ? 'max-w-7xl' : 'max-w-3xl'}`} + > {meta?.source ? ( <div className='absolute right-8 top-8'> <ViewSource source={meta.source} /> diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 2060572b80d..9bb529c1a36 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -101,6 +101,7 @@ import { Organizations as UserButtonOrganizations, User as UserButtonUser, } from '../stories/user-button.stories'; +import { Default as UserPageDefault, meta as userPageMeta } from '../stories/user-page.stories'; import { Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, @@ -109,6 +110,11 @@ import { Default as UserProfileActiveDevicesSectionDefault, meta as userProfileActiveDevicesSectionMeta, } from '../stories/user-profile-active-devices-section.stories'; +import { + Default as UserProfileApiKeysPanelDefault, + Empty as UserProfileApiKeysPanelEmpty, + meta as userProfileApiKeysPanelMeta, +} from '../stories/user-profile-api-keys-panel.stories'; import { Default as UserProfileBillingHistorySectionDefault, Empty as UserProfileBillingHistorySectionEmpty, @@ -275,6 +281,16 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const userProfileApiKeysPanelModule: StoryModule = { + meta: userProfileApiKeysPanelMeta, + Default: UserProfileApiKeysPanelDefault, + Empty: UserProfileApiKeysPanelEmpty, +}; +const userPageModule: StoryModule = { + meta: userPageMeta, + Default: UserPageDefault, +}; + const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, @@ -339,9 +355,11 @@ const userProfileDeleteSectionModule: StoryModule = { export const registry: StoryModule[] = [ // User userButtonModule, + userPageModule, userProfileProfilePanelModule, userProfileSecurityPanelModule, userProfileBillingPanelModule, + userProfileApiKeysPanelModule, userProfileAccountSectionModule, userProfilePasswordSectionModule, userProfilePasskeysSectionModule, diff --git a/packages/swingset/src/lib/types.ts b/packages/swingset/src/lib/types.ts index 837177928fc..5ca91be71ab 100644 --- a/packages/swingset/src/lib/types.ts +++ b/packages/swingset/src/lib/types.ts @@ -37,6 +37,8 @@ export type KnobValues = Record<string, string | boolean | number>; export interface StoryMeta { group: string; title: string; + /** Controls the documentation canvas width. Wide compositions still keep prose at a readable measure. */ + layout?: 'default' | 'wide'; /** * Optional human-friendly label shown in the sidebar. Falls back to `title` when * omitted. Use this when the desired sidebar text differs from the component name diff --git a/packages/swingset/src/stories/user-page.mdx b/packages/swingset/src/stories/user-page.mdx new file mode 100644 index 00000000000..703e198507a --- /dev/null +++ b/packages/swingset/src/stories/user-page.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-page.stories'; + +# UserPage + +The complete User page. It owns the profile navigation and composes the Account, Security, Billing, +and API Keys panels without imposing a modal height or scroll container. + +<Story + name='Default' + storyModule={Stories} + composition={[ + { name: 'Profile panel', href: '/user/user-profile-profile-panel', layer: 'Compositions' }, + { name: 'Security panel', href: '/user/user-profile-security-panel', layer: 'Compositions' }, + { name: 'Billing panel', href: '/user/user-profile-billing-panel', layer: 'Compositions' }, + { name: 'API keys panel', href: '/user/user-profile-api-keys-panel', layer: 'Compositions' }, + ]} +/> diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx new file mode 100644 index 00000000000..73601476229 --- /dev/null +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -0,0 +1,251 @@ +import type { UserPageViewProps } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import type { UserProfileAPIKey } from '@clerk/ui/mosaic/user-profile/user-profile-api-keys-panel.view'; +import type { + UserProfilePaymentMethod, + UserProfileSubscription, +} from '@clerk/ui/mosaic/user-profile/user-profile-billing-panel.view'; +import type { UserProfileEmail, UserProfilePhone } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import type { + UserProfileDevice, + UserProfileMfaMethod, + UserProfilePasskey, +} from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import type { UserProfilePanelId } from '@clerk/ui/mosaic/user-profile/user-profile-sidebar'; +import { useMemo, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-page.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserPage', + label: 'User page', + layout: 'wide', + navigation: { family: 'User profile', category: 'Compositions', order: 0 }, + source: 'packages/ui/src/mosaic/user-profile/user-page.view.tsx', +}; + +const initialAPIKeys: UserProfileAPIKey[] = [ + { + id: 'primary', + name: 'Primary API Key', + expirationLabel: 'Expires Dec 31, 2027', + createdAtLabel: 'Jan 05, 2026', + lastUsedAtLabel: 'Dec 31, 2026', + }, + { + id: 'legacy', + name: 'Legacy API Key', + expirationLabel: 'Expired Jul 1, 2025', + createdAtLabel: 'Jul 1, 2024', + lastUsedAtLabel: 'Jul 1, 2025', + isExpired: true, + }, +]; + +export function Default() { + const [activePanel, setActivePanel] = useState<UserProfilePanelId>('account'); + const [emails, setEmails] = useState<UserProfileEmail[]>([ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + ]); + const [phones, setPhones] = useState<UserProfilePhone[]>([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([ + { + id: 'passkey', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ]); + const [mfaMethods, setMfaMethods] = useState<UserProfileMfaMethod[]>([ + { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup', type: 'backup-codes' }, + ]); + const [devices, setDevices] = useState<UserProfileDevice[]>([ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + ]); + const [subscription, setSubscription] = useState<UserProfileSubscription>({ + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }); + const [paymentMethods, setPaymentMethods] = useState<UserProfilePaymentMethod[]>([ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + ]); + const [historyPageSize, setHistoryPageSize] = useState(10); + const [apiKeys, setAPIKeys] = useState(initialAPIKeys); + const [apiKeysPageSize, setAPIKeysPageSize] = useState(10); + const [searchValue, setSearchValue] = useState(''); + const [selectedIds, setSelectedIds] = useState<string[]>([]); + const visibleAPIKeys = useMemo( + () => apiKeys.filter(apiKey => apiKey.name.toLowerCase().includes(searchValue.toLowerCase())), + [apiKeys, searchValue], + ); + + const panels: UserPageViewProps['panels'] = { + account: { + imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', + name: 'Preston Booth', + username: 'prestonxyz', + emails, + phones, + onAddEmail: () => + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]), + onAddPhone: () => + setPhones(current => [ + ...current, + { + id: `phone_${Date.now()}`, + value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, + isVerified: true, + }, + ]), + onDeleteAccount: () => undefined, + onEditProfilePicture: () => undefined, + onManageEmail: () => undefined, + onManagePhone: () => undefined, + onNameChange: () => undefined, + onRemoveEmail: id => setEmails(current => current.filter(email => email.id !== id)), + onRemovePhone: id => setPhones(current => current.filter(phone => phone.id !== id)), + onSetPrimaryEmail: id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))), + onSetPrimaryPhone: id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))), + onUsernameChange: () => undefined, + onVerifyEmail: id => + setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), + onVerifyPhone: id => + setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), + }, + security: { + hasPassword: true, + passkeys, + mfaMethods, + devices, + onAddMfaMethod: type => + setMfaMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }), + onAddPasskey: () => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]), + onChangePassword: () => undefined, + onDeleteAccount: () => undefined, + onManageDevice: () => undefined, + onManagePasskey: () => undefined, + onRegenerateBackupCodes: () => + setMfaMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ), + onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), + onRemovePasskey: id => setPasskeys(current => current.filter(passkey => passkey.id !== id)), + onSignOutAllOtherDevices: () => setDevices(current => current.filter(device => device.isCurrent)), + onSignOutDevice: id => setDevices(current => current.filter(device => device.id !== id)), + }, + billing: { + subscription, + paymentMethods, + historyItems: [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + ], + historyPagination: { page: 1, pageCount: 1, pageSize: historyPageSize }, + onAddPaymentMethod: () => + setPaymentMethods(current => [ + ...current, + { id: `card-${Date.now()}`, label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030' }, + ]), + onChangePlan: () => + setSubscription(current => + current.planName === 'Basic Plan' + ? { + planName: 'Pro Plan', + priceLabel: '$25 / Month', + totalDueLabel: '$25.00', + renewsAtLabel: 'Renews Aug 26', + } + : { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }, + ), + onMakeDefaultPaymentMethod: id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))), + onRemovePaymentMethod: id => + setPaymentMethods(current => current.filter(paymentMethod => paymentMethod.id !== id)), + onBillingHistoryPageSizeChange: setHistoryPageSize, + onViewInvoice: () => undefined, + }, + apiKeys: { + apiKeys: visibleAPIKeys, + pagination: { page: 1, pageCount: 1, pageSize: apiKeysPageSize }, + searchValue, + selectedIds, + onCreate: () => + setAPIKeys(current => [ + ...current, + { + id: `key-${Date.now()}`, + name: `API Key ${current.length + 1}`, + expirationLabel: 'Expires Never', + createdAtLabel: 'Just now', + lastUsedAtLabel: 'Never', + }, + ]), + onPageSizeChange: setAPIKeysPageSize, + onRevoke: id => { + setAPIKeys(current => current.filter(apiKey => apiKey.id !== id)); + setSelectedIds(current => current.filter(selectedId => selectedId !== id)); + }, + onSearchChange: setSearchValue, + onSelectionChange: setSelectedIds, + }, + }; + + return ( + <UserPageView + activePanel={activePanel} + panels={panels} + onPanelChange={setActivePanel} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx new file mode 100644 index 00000000000..d9c1fa6494c --- /dev/null +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx @@ -0,0 +1,21 @@ +import * as Stories from './user-profile-api-keys-panel.stories'; + +# UserProfileApiKeysPanel + +Search, selection, key metadata, row actions, and pagination composed without the surrounding navigation shell. + +<Story + name='Default' + storyModule={Stories} + composition={[ + { name: 'Input', href: '/components/input', layer: 'Components' }, + { name: 'Button', href: '/components/button', layer: 'Components' }, + { name: 'Badge', href: '/components/badge', layer: 'Components' }, + { name: 'Menu', href: '/components/menu', layer: 'Components' }, + ]} +/> + +<Story + name='Empty' + storyModule={Stories} +/> diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx new file mode 100644 index 00000000000..2a3eebba9a3 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx @@ -0,0 +1,117 @@ +import type { UserProfileAPIKey } from '@clerk/ui/mosaic/user-profile/user-profile-api-keys-panel.view'; +import { UserProfileApiKeysPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-api-keys-panel.view'; +import { useMemo, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-api-keys-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileApiKeysPanel', + label: 'API keys panel', + navigation: { family: 'User profile', category: 'Compositions', order: 40 }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx', +}; + +const initialAPIKeys: UserProfileAPIKey[] = [ + { + id: 'primary', + name: 'Primary API Key', + expirationLabel: 'Expires Dec 31, 2027', + createdAtLabel: 'Jan 05, 2026', + lastUsedAtLabel: 'Dec 31, 2026', + }, + { + id: 'backup', + name: 'Backup API Key', + expirationLabel: 'Expires Never', + createdAtLabel: 'Mar 22, 2022', + lastUsedAtLabel: 'Mar 22, 2022', + }, + { + id: 'analytics', + name: 'Analytics Key', + expirationLabel: 'Expires Never', + createdAtLabel: 'Feb 10, 2021', + lastUsedAtLabel: 'Feb 10, 2021', + }, + { + id: 'integration', + name: 'Integration Key', + expirationLabel: 'Expires Nov 5, 2026', + createdAtLabel: 'Nov 5, 2025', + lastUsedAtLabel: 'Nov 5, 2026', + isExpired: true, + }, + { + id: 'legacy', + name: 'Legacy API Key', + expirationLabel: 'Expired Jul 1, 2025', + createdAtLabel: 'Jul 1, 2024', + lastUsedAtLabel: 'Jul 1, 2025', + isExpired: true, + }, + { + id: 'development', + name: 'Dev Environment Key', + expirationLabel: 'Expired Sep 30, 2024', + createdAtLabel: 'Sep 30, 2022', + lastUsedAtLabel: 'Sep 30, 2024', + isExpired: true, + }, +]; + +export function Default() { + const [apiKeys, setAPIKeys] = useState(initialAPIKeys); + const [pageSize, setPageSize] = useState(10); + const [searchValue, setSearchValue] = useState(''); + const [selectedIds, setSelectedIds] = useState<string[]>([]); + const visibleAPIKeys = useMemo( + () => apiKeys.filter(apiKey => apiKey.name.toLowerCase().includes(searchValue.toLowerCase())), + [apiKeys, searchValue], + ); + + return ( + <UserProfileApiKeysPanelView + apiKeys={visibleAPIKeys} + pagination={{ page: 1, pageCount: 1, pageSize }} + searchValue={searchValue} + selectedIds={selectedIds} + onCreate={() => + setAPIKeys(current => [ + ...current, + { + id: `key-${Date.now()}`, + name: `API Key ${current.length + 1}`, + expirationLabel: 'Expires Never', + createdAtLabel: 'Just now', + lastUsedAtLabel: 'Never', + }, + ]) + } + onPageSizeChange={setPageSize} + onRevoke={id => { + setAPIKeys(current => current.filter(apiKey => apiKey.id !== id)); + setSelectedIds(current => current.filter(selectedId => selectedId !== id)); + }} + onSearchChange={setSearchValue} + onSelectionChange={setSelectedIds} + /> + ); +} + +export function Empty() { + const [searchValue, setSearchValue] = useState(''); + + return ( + <UserProfileApiKeysPanelView + apiKeys={[]} + searchValue={searchValue} + selectedIds={[]} + onCreate={() => {}} + onSearchChange={setSearchValue} + onSelectionChange={() => {}} + /> + ); +} From 920e3e85d286a8b2da1fc5094183d5971dc3d51e Mon Sep 17 00:00:00 2001 From: Kyle MacDonald <kylemac@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:30:46 -0400 Subject: [PATCH 16/18] feat(swingset): collapsible sidebar with User Button / User Profile groups (#9499) --- .changeset/swingset-sidebar-organization.md | 2 + packages/swingset/CLAUDE.md | 9 +- .../swingset/src/components/Composition.tsx | 4 +- .../swingset/src/components/DocsViewer.tsx | 14 +- .../swingset/src/components/app-sidebar.tsx | 241 +++++++++++------- packages/swingset/src/lib/registry.ts | 5 +- .../src/stories/user-button.stories.tsx | 3 +- packages/swingset/src/stories/user-page.mdx | 8 +- .../src/stories/user-page.stories.tsx | 3 +- .../user-profile-account-section.stories.tsx | 4 +- ...profile-active-devices-section.stories.tsx | 4 +- .../user-profile-api-keys-panel.stories.tsx | 4 +- ...rofile-billing-history-section.stories.tsx | 4 +- .../user-profile-billing-panel.stories.tsx | 4 +- ...ile-connected-accounts-section.stories.tsx | 4 +- .../user-profile-delete-section.stories.tsx | 4 +- .../user-profile-mfa-section.stories.tsx | 4 +- .../user-profile-passkeys-section.stories.tsx | 4 +- .../user-profile-password-section.stories.tsx | 4 +- ...rofile-payment-methods-section.stories.tsx | 4 +- .../user-profile-profile-panel.stories.tsx | 4 +- .../user-profile-security-panel.stories.tsx | 4 +- ...r-profile-subscription-section.stories.tsx | 4 +- ...r-profile-web3-wallets-section.stories.tsx | 4 +- 24 files changed, 209 insertions(+), 140 deletions(-) create mode 100644 .changeset/swingset-sidebar-organization.md diff --git a/.changeset/swingset-sidebar-organization.md b/.changeset/swingset-sidebar-organization.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/swingset-sidebar-organization.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index ddd0765758f..32550e15031 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f ### Layers -`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: +`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings: | Group | What lives here | Archetype | | ------------ | -------------------------------------------------------------- | --------- | -| `User` | Composed flow UI (e.g. `UserButton`) | C | +| `User Button` | Composed flow UI (e.g. `UserButton`) | C | +| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C | | `Components` | Styled Mosaic components — simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A | | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | | `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | | `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | -`User` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). +`User Button` / `User Profile` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). `Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export @@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th **Document the default value for every prop in a dedicated Default column.** Every props table — auto and hand-written — has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `<PropTable>` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `—` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`). -### Archetype C — composed layer (`User`) +### Archetype C — composed layer (`User Button`, `User Profile`) These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX: diff --git a/packages/swingset/src/components/Composition.tsx b/packages/swingset/src/components/Composition.tsx index 60ee46ad440..27dc0fd44bb 100644 --- a/packages/swingset/src/components/Composition.tsx +++ b/packages/swingset/src/components/Composition.tsx @@ -7,13 +7,13 @@ export interface CompositionPiece { name: string; /** Route to the piece's page in swingset (e.g. `/components/button`). */ href: string; - /** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */ + /** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */ layer: string; } // Mosaic layers, high → low. Drives the order the composition groups render in. // Matches the sidebar group names. -const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives']; +const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives']; function layerRank(layer: string): number { const i = LAYER_ORDER.indexOf(layer); diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 56efb348dce..3a52fbae376 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,25 +10,27 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record<string, Record<string, React.ComponentType>> = { - user: { - 'user-page': dynamic(() => import('../stories/user-page.mdx')), - 'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')), + 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), + }, + 'user-profile': { + 'user-page': dynamic(() => import('../stories/user-page.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), + 'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), 'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')), 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), 'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')), 'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')), - 'user-profile-billing-history-section': dynamic( - () => import('../stories/user-profile-billing-history-section.mdx'), - ), 'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')), 'user-profile-payment-methods-section': dynamic( () => import('../stories/user-profile-payment-methods-section.mdx'), ), + 'user-profile-billing-history-section': dynamic( + () => import('../stories/user-profile-billing-history-section.mdx'), + ), 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), ), diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 4a68f409fec..00cc791f6c2 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -1,9 +1,11 @@ 'use client'; +import { ChevronRightIcon } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import * as React from 'react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Sidebar, SidebarContent, @@ -15,78 +17,113 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarRail, + SidebarSeparator, } from '@/components/ui/sidebar'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { getSidebarGroups } from '@/lib/registry'; -import type { StoryModule } from '@/lib/types'; const groups = getSidebarGroups(); -type SidebarEntry = { mod: StoryModule; componentSlug: string }; +const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']); -function getNavigationFamilies(components: SidebarEntry[]) { - const families = new Map<string, Map<string, SidebarEntry[]>>(); +type SidebarEntry = ReturnType<typeof getSidebarGroups>[number]['components'][number]; +// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and +// entry order both follow first appearance in the registry; uncategorized entries get no subheading. +function byCategory(components: SidebarEntry[]) { + const categories: { category: string; components: SidebarEntry[] }[] = []; for (const component of components) { - const family = component.mod.meta.navigation?.family ?? ''; const category = component.mod.meta.navigation?.category ?? ''; - const categories = families.get(family) ?? new Map<string, SidebarEntry[]>(); - const entries = categories.get(category) ?? []; - - entries.push(component); - categories.set(category, entries); - families.set(family, categories); + const bucket = categories.find(c => c.category === category); + if (bucket) { + bucket.components.push(component); + } else { + categories.push({ category, components: [component] }); + } } + return categories; +} + +function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) { + const labelRef = React.useRef<HTMLSpanElement>(null); + const [isTruncated, setIsTruncated] = React.useState(false); - return Array.from(families, ([family, categories]) => ({ - family, - categories: Array.from(categories, ([category, components]) => ({ - category, - components: components.sort( - (a, b) => - (a.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER) - - (b.mod.meta.navigation?.order ?? Number.MAX_SAFE_INTEGER), - ), - })), - })); + React.useEffect(() => { + const label = labelRef.current; + if (!label) { + return; + } + const check = () => setIsTruncated(label.scrollWidth > label.clientWidth); + check(); + const observer = new ResizeObserver(check); + observer.observe(label); + return () => observer.disconnect(); + }, []); + + return ( + <SidebarMenuItem> + <Tooltip disabled={!isTruncated}> + <TooltipTrigger + delay={300} + render={ + <SidebarMenuButton + className='h-auto py-1 text-xs' + isActive={isActive} + render={<Link href={href} />} + > + <span + ref={labelRef} + className='truncate font-mono text-[10px] leading-relaxed' + > + {usage} + </span> + </SidebarMenuButton> + } + /> + <TooltipContent + side='right' + className='font-mono text-[10px]' + > + {usage} + </TooltipContent> + </Tooltip> + </SidebarMenuItem> + ); } -function SidebarEntryLink({ - entry, +function SidebarEntryMenu({ + components, groupSlug, pathname, }: { - entry: SidebarEntry; + components: SidebarEntry[]; groupSlug: string; pathname: string; }) { - const { mod, componentSlug } = entry; - const href = `/${groupSlug}/${componentSlug}`; - const usage = mod.meta.label - ? mod.meta.label - : mod.meta.group === 'Hooks' - ? `${mod.meta.title}()` - : mod.meta.group === 'Styles' - ? mod.meta.title - : `<${mod.meta.title} />`; - return ( - <SidebarMenuItem> - <SidebarMenuButton - className='h-auto items-start py-1 text-xs leading-relaxed' - isActive={pathname === href} - render={<Link href={href} />} - > - <span - className={ - mod.meta.label - ? 'whitespace-normal text-[11px] leading-relaxed' - : 'whitespace-normal! break-all font-mono text-[10px] leading-relaxed' - } - > - {usage} - </span> - </SidebarMenuButton> - </SidebarMenuItem> + <SidebarMenu> + {components.map(({ mod, componentSlug }) => { + const href = `/${groupSlug}/${componentSlug}`; + // How an entry is USED differs by layer, so the label follows the layer rather + // than a guess at the title: hooks are called, atomic styles are a set of + // exports with no single call form worth privileging, and everything else is a + // component rendered as JSX. + const usage = + mod.meta.group === 'Hooks' + ? `${mod.meta.title}()` + : mod.meta.group === 'Styles' + ? mod.meta.title + : `<${mod.meta.title} />`; + return ( + <SidebarUsageItem + key={mod.meta.title} + usage={usage} + href={href} + isActive={pathname === href} + /> + ); + })} + </SidebarMenu> ); } @@ -129,43 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { </SidebarHeader> <SidebarContent className='gap-0'> {groups.map(({ group, groupSlug, components }) => ( - <SidebarGroup - key={group} - className='py-1' - data-section={group} - > - <SidebarGroupLabel className='text-sidebar-foreground/50 h-auto px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider'> - {group} - </SidebarGroupLabel> - <SidebarGroupContent> - {getNavigationFamilies(components).map(({ family, categories }) => ( - <div key={family || group}> - {family ? ( - <div className='text-sidebar-foreground/80 px-2 pb-1 pt-3 text-[11px] font-semibold'>{family}</div> - ) : null} - {categories.map(({ category, components }) => ( - <div key={category || group}> - {category ? ( - <div className='text-sidebar-foreground/45 px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider'> - {category} - </div> - ) : null} - <SidebarMenu className={category ? 'px-1' : undefined}> - {components.map(entry => ( - <SidebarEntryLink - key={entry.mod.meta.title} - entry={entry} - groupSlug={groupSlug} - pathname={pathname} - /> - ))} - </SidebarMenu> - </div> - ))} - </div> - ))} - </SidebarGroupContent> - </SidebarGroup> + <React.Fragment key={group}> + {group === 'Components' && <SidebarSeparator className='data-horizontal:w-auto my-1' />} + <Collapsible + defaultOpen={!COLLAPSED_BY_DEFAULT.has(group)} + className='group/collapsible' + > + <SidebarGroup + className='py-1' + data-section={group} + > + <SidebarGroupLabel + className='text-sidebar-foreground/50 hover:text-sidebar-foreground/80 h-auto w-full px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider' + render={<CollapsibleTrigger />} + > + {group} + <ChevronRightIcon className='size-3! ml-auto transition-transform group-data-[open]/collapsible:rotate-90' /> + </SidebarGroupLabel> + <CollapsibleContent> + <SidebarGroupContent> + {byCategory(components).map(({ category, components }) => + category ? ( + <Collapsible + key={category} + // Collapsed by default, unless it holds the page being viewed. + defaultOpen={components.some( + ({ componentSlug }) => pathname === `/${groupSlug}/${componentSlug}`, + )} + className='group/category' + > + <CollapsibleTrigger className='text-sidebar-foreground/40 hover:text-sidebar-foreground/70 flex w-full items-center gap-1 px-2 pb-0.5 pt-2 text-[9px] font-semibold uppercase tracking-wider'> + <span + aria-hidden='true' + className='font-mono text-[10px] leading-none' + > + └ + </span> + {category} + <ChevronRightIcon className='size-2.5! ml-auto transition-transform group-data-[open]/category:rotate-90' /> + </CollapsibleTrigger> + <CollapsibleContent> + <div className='border-sidebar-border ml-3 border-l pl-1'> + <SidebarEntryMenu + components={components} + groupSlug={groupSlug} + pathname={pathname} + /> + </div> + </CollapsibleContent> + </Collapsible> + ) : ( + <SidebarEntryMenu + key={group} + components={components} + groupSlug={groupSlug} + pathname={pathname} + /> + ), + )} + </SidebarGroupContent> + </CollapsibleContent> + </SidebarGroup> + </Collapsible> + </React.Fragment> ))} </SidebarContent> <SidebarRail /> diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 9bb529c1a36..05d34dccb2d 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -353,13 +353,16 @@ const userProfileDeleteSectionModule: StoryModule = { }; export const registry: StoryModule[] = [ - // User + // User Button userButtonModule, + // User Profile userPageModule, + // User Profile · Panels userProfileProfilePanelModule, userProfileSecurityPanelModule, userProfileBillingPanelModule, userProfileApiKeysPanelModule, + // User Profile · Sections userProfileAccountSectionModule, userProfilePasswordSectionModule, userProfilePasskeysSectionModule, diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index 784be217083..60f6fb2f9ed 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -17,10 +17,9 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-button.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Button', title: 'UserButton', label: 'User button', - navigation: { family: 'User button', category: 'Compositions', order: 10 }, source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx', }; diff --git a/packages/swingset/src/stories/user-page.mdx b/packages/swingset/src/stories/user-page.mdx index 703e198507a..8592ea9f463 100644 --- a/packages/swingset/src/stories/user-page.mdx +++ b/packages/swingset/src/stories/user-page.mdx @@ -9,9 +9,9 @@ and API Keys panels without imposing a modal height or scroll container. name='Default' storyModule={Stories} composition={[ - { name: 'Profile panel', href: '/user/user-profile-profile-panel', layer: 'Compositions' }, - { name: 'Security panel', href: '/user/user-profile-security-panel', layer: 'Compositions' }, - { name: 'Billing panel', href: '/user/user-profile-billing-panel', layer: 'Compositions' }, - { name: 'API keys panel', href: '/user/user-profile-api-keys-panel', layer: 'Compositions' }, + { name: 'Profile panel', href: '/user-profile/user-profile-profile-panel', layer: 'User Profile' }, + { name: 'Security panel', href: '/user-profile/user-profile-security-panel', layer: 'User Profile' }, + { name: 'Billing panel', href: '/user-profile/user-profile-billing-panel', layer: 'User Profile' }, + { name: 'API keys panel', href: '/user-profile/user-profile-api-keys-panel', layer: 'User Profile' }, ]} /> diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx index 73601476229..d07565d467a 100644 --- a/packages/swingset/src/stories/user-page.stories.tsx +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -19,11 +19,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-page.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserPage', label: 'User page', layout: 'wide', - navigation: { family: 'User profile', category: 'Compositions', order: 0 }, source: 'packages/ui/src/mosaic/user-profile/user-page.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 653eb5b9ab4..bba49d88af4 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -10,10 +10,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-account-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileAccountSection', label: 'Account', - navigation: { family: 'User profile', category: 'Sections', order: 10 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx index c39c3ef0f8d..1c231a6e034 100644 --- a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx @@ -7,10 +7,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-active-devices-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileActiveDevicesSection', label: 'Active devices', - navigation: { family: 'User profile', category: 'Sections', order: 50 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx index 2a3eebba9a3..b8421bbe5fd 100644 --- a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx @@ -7,10 +7,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-api-keys-panel.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileApiKeysPanel', label: 'API keys panel', - navigation: { family: 'User profile', category: 'Compositions', order: 40 }, + navigation: { category: 'Panels' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx b/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx index af3cc9c4a75..aabb3f04b83 100644 --- a/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx @@ -7,10 +7,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-billing-history-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileBillingHistorySection', label: 'Billing history', - navigation: { family: 'User profile', category: 'Billing sections', order: 30 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx index 2f645f5dea8..b048aa576f6 100644 --- a/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx @@ -11,10 +11,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-billing-panel.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileBillingPanel', label: 'Billing panel', - navigation: { family: 'User profile', category: 'Compositions', order: 30 }, + navigation: { category: 'Panels' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx index 12dbba0267d..61a8f4d63af 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx @@ -5,10 +5,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-connected-accounts-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileConnectedAccountsSection', label: 'Connected accounts', - navigation: { family: 'User profile', category: 'Sections', order: 60 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index e9f3f65d4b9..cc18ac403eb 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -5,10 +5,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-delete-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileDeleteSection', label: 'Danger zone', - navigation: { family: 'User profile', category: 'Sections', order: 80 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx index 088aafcb23c..0fd8382332d 100644 --- a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -7,10 +7,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-mfa-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileMfaSection', label: '2-step verification', - navigation: { family: 'User profile', category: 'Sections', order: 40 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx index 36b8db7ea07..fe476e55d54 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -7,10 +7,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-passkeys-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfilePasskeysSection', label: 'Passkeys', - navigation: { family: 'User profile', category: 'Sections', order: 30 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index ea87582a5ac..462112b0db4 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -5,10 +5,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-password-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfilePasswordSection', label: 'Password', - navigation: { family: 'User profile', category: 'Sections', order: 20 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx b/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx index bd5fa8581e3..4a4148f1ac3 100644 --- a/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx @@ -7,10 +7,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-payment-methods-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfilePaymentMethodsSection', label: 'Payment methods', - navigation: { family: 'User profile', category: 'Billing sections', order: 20 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 0cf5d47d924..7662754284e 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -10,10 +10,10 @@ const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; export { default as __source } from './user-profile-profile-panel.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileProfilePanel', label: 'Profile panel', - navigation: { family: 'User profile', category: 'Compositions', order: 10 }, + navigation: { category: 'Panels' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx index 10ed084ee17..e02a43d8489 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -11,10 +11,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-security-panel.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileSecurityPanel', label: 'Security panel', - navigation: { family: 'User profile', category: 'Compositions', order: 20 }, + navigation: { category: 'Panels' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx b/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx index b865ff7b82f..5ef950b817e 100644 --- a/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx @@ -6,10 +6,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-subscription-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileSubscriptionSection', label: 'Subscription', - navigation: { family: 'User profile', category: 'Billing sections', order: 10 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index ba03cc6280c..9b0bec9b6ab 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -5,10 +5,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-web3-wallets-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', - navigation: { family: 'User profile', category: 'Sections', order: 70 }, + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx', }; From 4e0835fe488572e95db773f4fa1c5b1a7c94f838 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 18:46:12 -0600 Subject: [PATCH 17/18] refactor(ui): derive section styles from structure --- .../ui/src/mosaic/components/section/index.ts | 1 - .../components/section/section.styles.ts | 55 ++++++++--------- .../components/section/section.test.tsx | 22 ++++--- .../src/mosaic/components/section/section.tsx | 59 ++++--------------- .../user-profile-account-section.view.tsx | 2 +- ...er-profile-active-devices-section.view.tsx | 2 +- ...r-profile-payment-methods-section.view.tsx | 2 +- .../user-profile-security-list.tsx | 2 +- 8 files changed, 50 insertions(+), 95 deletions(-) diff --git a/packages/ui/src/mosaic/components/section/index.ts b/packages/ui/src/mosaic/components/section/index.ts index d220b70a895..8b920fdc6fe 100644 --- a/packages/ui/src/mosaic/components/section/index.ts +++ b/packages/ui/src/mosaic/components/section/index.ts @@ -11,6 +11,5 @@ export type { SectionMediaSize, SectionRootProps, SectionRowProps, - SectionRowVariant, SectionTitleProps, } from './section'; diff --git a/packages/ui/src/mosaic/components/section/section.styles.ts b/packages/ui/src/mosaic/components/section/section.styles.ts index 3466f44fe69..f324b95efb2 100644 --- a/packages/ui/src/mosaic/components/section/section.styles.ts +++ b/packages/ui/src/mosaic/components/section/section.styles.ts @@ -35,56 +35,51 @@ export const styles = stylex.create({ }, display: 'flex', flexDirection: 'column', - width: 'auto', - }, - rowDefault: { paddingBlockEnd: { default: space['4'], - [stylex.when.descendant('[data-nested]', sectionItemsMarker)]: space['1'], + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, + }, + paddingBlockStart: { + default: space['4'], + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: space['3'], }, - paddingBlockStart: space['4'], rowGap: { default: space['2'], - [stylex.when.descendant('[data-nested]', sectionItemsMarker)]: space['3'], + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, }, - minHeight: `calc(${space['18.5']} + 1px)`, - }, - rowList: { - paddingBlock: 0, - rowGap: 0, - minHeight: 0, + minHeight: { + default: `calc(${space['18.5']} + 1px)`, + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, + }, + width: 'auto', }, items: { + backgroundColor: colorVars['--cl-color-border'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', display: 'flex', flexDirection: 'column', + marginBlockStart: space['3'], + rowGap: '1px', width: '100%', }, item: { + paddingBlock: { + default: null, + [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: space['4'], + }, alignItems: 'center', + backgroundColor: { + default: null, + [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: colorVars['--cl-color-card'], + }, columnGap: space['3'], display: 'flex', flexWrap: 'nowrap', justifyContent: 'space-between', width: '100%', }, - nestedItem: { - paddingBlock: space['1'], - }, - listHeader: { - paddingBlock: space['3'], - borderBlockEndColor: colorVars['--cl-color-border'], - borderBlockEndStyle: 'solid', - borderBlockEndWidth: '1px', - }, - listItem: { - paddingBlock: space['4'], - borderBlockStartColor: colorVars['--cl-color-border'], - borderBlockStartStyle: 'solid', - borderBlockStartWidth: { - default: '1px', - ':first-child': '0px', - }, - }, mediaBase: { alignItems: 'center', alignSelf: 'center', diff --git a/packages/ui/src/mosaic/components/section/section.test.tsx b/packages/ui/src/mosaic/components/section/section.test.tsx index ba33c07c823..f474cf35f8e 100644 --- a/packages/ui/src/mosaic/components/section/section.test.tsx +++ b/packages/ui/src/mosaic/components/section/section.test.tsx @@ -59,7 +59,7 @@ describe('Section', () => { <Section.Root> <Section.Title>Profile</Section.Title> <Section.Group> - <Section.Row> + <Section.Row data-testid='row'> <Section.Item> <Section.Content> <Section.Label>Email</Section.Label> @@ -83,19 +83,17 @@ describe('Section', () => { expect(screen.getByText('ada@example.com')).toBeInTheDocument(); expect(screen.getAllByText(/Edit|More/)).toHaveLength(2); expect(screen.getByTestId('items')).toHaveClass('cl-section-items'); - expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); - expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); - expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant'); + expect(screen.getByTestId('items')).not.toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-item')).not.toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-content')).not.toHaveAttribute('data-nested'); }); - it('supports a divided list row', () => { + it('uses the item collection structure without public styling variants', () => { render( <Section.Root> <Section.Group> - <Section.Row - data-testid='row' - variant='list' - > + <Section.Row data-testid='row'> <Section.Item>Email</Section.Item> <Section.Items> <Section.Item>one@example.com</Section.Item> @@ -106,9 +104,9 @@ describe('Section', () => { </Section.Root>, ); - expect(screen.getByTestId('row')).toHaveAttribute('data-variant', 'list'); - expect(screen.getByText('one@example.com')).toHaveAttribute('data-nested'); - expect(screen.getByText('two@example.com')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant'); + expect(screen.getByText('one@example.com')).not.toHaveAttribute('data-nested'); + expect(screen.getByText('two@example.com')).not.toHaveAttribute('data-nested'); }); it('lets consumer props win and forwards refs and custom elements', () => { diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 9da6fa7f55e..1e934c48840 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -14,8 +14,7 @@ import { styles } from './section.styles'; export type SectionRootProps = Omit<MosaicComponentProps<'section'>, 'title'>; export type SectionTitleProps = Omit<HeadingProps, 'size'>; export type SectionGroupProps = MosaicComponentProps<'div'>; -export type SectionRowVariant = 'default' | 'list'; -export type SectionRowProps = MosaicComponentProps<'div'> & { variant?: SectionRowVariant }; +export type SectionRowProps = MosaicComponentProps<'div'>; export type SectionItemsProps = MosaicComponentProps<'div'>; export type SectionItemProps = MosaicComponentProps<'div'>; export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; @@ -32,14 +31,7 @@ const mediaSizes = { xl: styles.mediaXl, }; -const rowVariants = { - default: styles.rowDefault, - list: styles.rowList, -}; - const SectionTitleContext = React.createContext<React.Dispatch<React.SetStateAction<string[]>> | null>(null); -const SectionItemsContext = React.createContext(false); -const SectionRowVariantContext = React.createContext<SectionRowVariant>('default'); const Root = React.forwardRef<HTMLElement, SectionRootProps>(function SectionRoot( { render, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, @@ -106,73 +98,51 @@ const Group = React.forwardRef<HTMLDivElement, SectionGroupProps>(function Secti }); }); -const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(function SectionRow( - { variant = 'default', render, className, style, ...rest }, +const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function SectionItems( + { render, className, style, ...rest }, ref, ) { - const element = useRender({ + return useRender({ defaultTagName: 'div', render, ref, props: { ...mergeStyleProps( - themeProps('section-row', { variant }), - stylex.props(reset.base, styles.row, rowVariants[variant]), + themeProps('section-items'), + stylex.props(reset.base, styles.items, sectionItemsMarker), className, style, ), ...rest, }, }); - - return <SectionRowVariantContext.Provider value={variant}>{element}</SectionRowVariantContext.Provider>; }); -const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function SectionItems( +const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(function SectionRow( { render, className, style, ...rest }, ref, ) { - const element = useRender({ + return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps( - themeProps('section-items', { nested: true }), - stylex.props(reset.base, styles.items, sectionItemsMarker), - className, - style, - ), + ...mergeStyleProps(themeProps('section-row'), stylex.props(reset.base, styles.row), className, style), ...rest, }, }); - - return <SectionItemsContext.Provider value>{element}</SectionItemsContext.Provider>; }); const Item = React.forwardRef<HTMLDivElement, SectionItemProps>(function SectionItem( { render, className, style, ...rest }, ref, ) { - const nested = React.useContext(SectionItemsContext); - const rowVariant = React.useContext(SectionRowVariantContext); - return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps( - themeProps('section-item', { nested }), - stylex.props( - reset.base, - styles.item, - nested && styles.nestedItem, - rowVariant === 'list' && (nested ? styles.listItem : styles.listHeader), - ), - className, - style, - ), + ...mergeStyleProps(themeProps('section-item'), stylex.props(reset.base, styles.item), className, style), ...rest, }, }); @@ -202,19 +172,12 @@ const Content = React.forwardRef<HTMLDivElement, SectionContentProps>(function S { render, className, style, ...rest }, ref, ) { - const nested = React.useContext(SectionItemsContext); - return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps( - themeProps('section-content', { nested }), - stylex.props(reset.base, styles.content), - className, - style, - ), + ...mergeStyleProps(themeProps('section-content'), stylex.props(reset.base, styles.content), className, style), ...rest, }, }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index c60ff82e15e..25202fce18d 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -266,7 +266,7 @@ function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimar const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; return ( - <Section.Row variant='list'> + <Section.Row> <Section.Item> <Section.Content> <Section.Label>{label}</Section.Label> diff --git a/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx index e1084ded966..176bcaa3ca8 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx @@ -56,7 +56,7 @@ export function UserProfileActiveDevicesSectionView({ {otherDevices.length > 0 ? ( <Section.Root aria-label='Other devices'> <Section.Group> - <Section.Row variant='list'> + <Section.Row> <Section.Item> <Section.Content> <Section.Label> diff --git a/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx index 8354a4428ea..64eadcc3558 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx @@ -30,7 +30,7 @@ export function UserProfilePaymentMethodsSectionView({ return ( <Section.Root aria-label='Payment methods'> <Section.Group> - <Section.Row variant='list'> + <Section.Row> <Section.Item> <Section.Content> <Section.Label>Payment methods</Section.Label> diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx index 417b85f0e3d..aefc2b7b70f 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx @@ -27,7 +27,7 @@ export function UserProfileSecurityList({ <Section.Root aria-label={sectionTitle ? undefined : label}> {sectionTitle ? <Section.Title>{sectionTitle}</Section.Title> : null} <Section.Group> - <Section.Row variant='list'> + <Section.Row> <Section.Item> <Section.Content> <Section.Label>{label}</Section.Label> From 87509549f06d3fea31a0d3ef72ce6609f944594a Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Wed, 19 Aug 2026 09:22:56 -0600 Subject: [PATCH 18/18] chore(ui): note pending mosaic component replacements --- .../mosaic/user-profile/user-profile-api-keys-panel.view.tsx | 4 ++++ .../user-profile-billing-history-section.view.tsx | 3 +++ .../ui/src/mosaic/user-profile/user-profile-provider-icon.tsx | 1 + 3 files changed, 8 insertions(+) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx index 2ee87aab132..57c45c2c9e2 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx @@ -92,6 +92,7 @@ export function UserProfileApiKeysPanelView({ </div> {onCreate ? <Button onClick={onCreate}>Create API key</Button> : null} </div> + {/* TODO: Replace this inline implementation with the Mosaic Table component. */} <div {...stylex.props(styles.tableShell)}> <div {...stylex.props(styles.tableScroller)}> <table {...stylex.props(styles.table)}> @@ -101,6 +102,7 @@ export function UserProfileApiKeysPanelView({ scope='col' {...stylex.props(styles.headerCell, styles.checkboxCell)} > + {/* TODO: Replace these inline selection controls with the Mosaic Checkbox component. */} <input aria-label='Select all API keys' checked={allSelected} @@ -190,6 +192,7 @@ export function UserProfileApiKeysPanelView({ </div> </div> {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. <div {...stylex.props(styles.pagination)}> <div {...stylex.props(styles.paginationControls)}> <Button @@ -230,6 +233,7 @@ export function UserProfileApiKeysPanelView({ </div> <label {...stylex.props(styles.pageSizeLabel)}> <span>Results per page</span> + {/* TODO: Replace this inline implementation with the Mosaic Select component. */} <select aria-label='Results per page' value={pagination.pageSize} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx index e922c6a03aa..ad6a0d28cd7 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx @@ -42,6 +42,7 @@ export function UserProfileBillingHistorySectionView({ <Section.Root aria-label='Billing history'> <Section.Title>History</Section.Title> <div {...stylex.props(styles.shell)}> + {/* TODO: Replace this inline implementation with the Mosaic Table component. */} <div {...stylex.props(styles.tableScroller)}> <table {...stylex.props(styles.table)}> <thead {...stylex.props(styles.header)}> @@ -114,6 +115,7 @@ export function UserProfileBillingHistorySectionView({ </table> </div> {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. <div {...stylex.props(styles.pagination)}> <div {...stylex.props(styles.paginationControls)}> <Button @@ -154,6 +156,7 @@ export function UserProfileBillingHistorySectionView({ </div> <label {...stylex.props(styles.pageSizeLabel)}> <span>Results per page</span> + {/* TODO: Replace this inline implementation with the Mosaic Select component. */} <select aria-label='Results per page' value={pagination.pageSize} 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 768ddfcb3fe..a200fe914b9 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 @@ -7,6 +7,7 @@ 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 ( <Section.Media