From 6d675c9b26a84e204d2f9a1347adfa24adac25bf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 18 Aug 2026 11:25:38 -0600 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 From c781a71dc3c2597cbdf8131519707d0690aac1c5 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Wed, 19 Aug 2026 14:29:58 -0600 Subject: [PATCH 19/43] fix(ui): preserve section nesting style hooks --- .../components/section/section.test.tsx | 12 ++++----- .../src/mosaic/components/section/section.tsx | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/mosaic/components/section/section.test.tsx b/packages/ui/src/mosaic/components/section/section.test.tsx index f474cf35f8e..e4d2b349761 100644 --- a/packages/ui/src/mosaic/components/section/section.test.tsx +++ b/packages/ui/src/mosaic/components/section/section.test.tsx @@ -84,12 +84,12 @@ describe('Section', () => { expect(screen.getAllByText(/Edit|More/)).toHaveLength(2); expect(screen.getByTestId('items')).toHaveClass('cl-section-items'); 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'); + expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); }); - it('uses the item collection structure without public styling variants', () => { + it('retains public nesting hooks while deriving layout from the item collection structure', () => { render( <Section.Root> <Section.Group> @@ -105,8 +105,8 @@ describe('Section', () => { ); 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'); + 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', () => { diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 1e934c48840..84158cc0612 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -32,6 +32,7 @@ const mediaSizes = { }; const SectionTitleContext = React.createContext<React.Dispatch<React.SetStateAction<string[]>> | null>(null); +const SectionItemsContext = React.createContext(false); const Root = React.forwardRef<HTMLElement, SectionRootProps>(function SectionRoot( { render, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, @@ -102,13 +103,13 @@ const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function Secti { render, className, style, ...rest }, ref, ) { - return useRender({ + const element = useRender({ defaultTagName: 'div', render, ref, props: { ...mergeStyleProps( - themeProps('section-items'), + themeProps('section-items', { nested: true }), stylex.props(reset.base, styles.items, sectionItemsMarker), className, style, @@ -116,6 +117,8 @@ const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function Secti ...rest, }, }); + + return <SectionItemsContext.Provider value>{element}</SectionItemsContext.Provider>; }); const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(function SectionRow( @@ -137,12 +140,19 @@ const Item = React.forwardRef<HTMLDivElement, SectionItemProps>(function Section { render, className, style, ...rest }, ref, ) { + const nested = React.useContext(SectionItemsContext); + return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('section-item'), stylex.props(reset.base, styles.item), className, style), + ...mergeStyleProps( + themeProps('section-item', { nested }), + stylex.props(reset.base, styles.item), + className, + style, + ), ...rest, }, }); @@ -172,12 +182,19 @@ 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'), stylex.props(reset.base, styles.content), className, style), + ...mergeStyleProps( + themeProps('section-content', { nested }), + stylex.props(reset.base, styles.content), + className, + style, + ), ...rest, }, }); From bd063a20649525c2dc07d4d8af4a298d72b8bb4f Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Wed, 19 Aug 2026 16:45:57 -0600 Subject: [PATCH 20/43] refactor(ui): share profile page tabs layout --- ...ofile.styles.ts => profile-page.styles.ts} | 54 +++--- packages/ui/src/mosaic/profile-page.tsx | 173 ++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 8 + .../__tests__/user-page.view.test.tsx | 51 +++++- .../mosaic/user-profile/user-page.view.tsx | 64 ++++--- .../user-profile-profile-panel.styles.ts | 2 +- .../user-profile/user-profile-sidebar.tsx | 74 ++------ 7 files changed, 309 insertions(+), 117 deletions(-) rename packages/ui/src/mosaic/{user-profile/user-profile.styles.ts => profile-page.styles.ts} (67%) create mode 100644 packages/ui/src/mosaic/profile-page.tsx diff --git a/packages/ui/src/mosaic/user-profile/user-profile.styles.ts b/packages/ui/src/mosaic/profile-page.styles.ts similarity index 67% rename from packages/ui/src/mosaic/user-profile/user-profile.styles.ts rename to packages/ui/src/mosaic/profile-page.styles.ts index 58ea21c83f8..aa4574ec8c8 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile.styles.ts +++ b/packages/ui/src/mosaic/profile-page.styles.ts @@ -1,47 +1,49 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; +import { colorVars, fontWeightVars, radiusVars, space, targetVars, typeScaleVars } from './tokens.stylex'; + +const profilePageCompact = '@media (max-width: 48rem)' as const; export const styles = stylex.create({ root: { + borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', 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)', + [profilePageCompact]: 'minmax(0, 1fr)', }, gridTemplateRows: 'auto', maxWidth: '66rem', - minHeight: 0, + minHeight: '37.5rem', width: '100%', }, sidebar: { padding: space['4'], borderBlockEndColor: { default: 'transparent', - '@media (max-width: 47.99rem)': colorVars['--cl-color-border'], + [profilePageCompact]: colorVars['--cl-color-border'], }, borderBlockEndStyle: 'solid', borderBlockEndWidth: { default: '0px', - '@media (max-width: 47.99rem)': '1px', + [profilePageCompact]: '1px', }, borderInlineEndColor: colorVars['--cl-color-border'], borderInlineEndStyle: 'solid', borderInlineEndWidth: { default: '1px', - '@media (max-width: 47.99rem)': '0px', + [profilePageCompact]: '0px', }, display: 'flex', flexDirection: { default: 'column', - '@media (max-width: 47.99rem)': 'row', + [profilePageCompact]: 'row', }, minHeight: 0, minWidth: 0, @@ -51,12 +53,12 @@ export const styles = stylex.create({ display: 'flex', flexDirection: { default: 'column', - '@media (max-width: 47.99rem)': 'row', + [profilePageCompact]: 'row', }, minWidth: 0, overflowX: { default: 'visible', - '@media (max-width: 47.99rem)': 'auto', + [profilePageCompact]: 'auto', }, }, navigationItem: { @@ -74,9 +76,17 @@ export const styles = stylex.create({ alignItems: 'center', backgroundColor: { default: 'transparent', - ':hover': colorVars['--cl-color-border-faded'], + ':where([data-selected])': colorVars['--cl-color-border-faded'], + ':active': colorVars['--cl-color-border-faded'], + '@media (hover: hover)': { + default: null, + ':hover:not(:active):not([data-selected])': colorVars['--cl-color-border-faded'], + }, + }, + color: { + default: colorVars['--cl-color-neutral-faded'], + ':where([data-selected])': colorVars['--cl-color-card-foreground'], }, - color: colorVars['--cl-color-neutral-faded'], cursor: 'pointer', display: 'flex', flexShrink: 0, @@ -86,22 +96,22 @@ export const styles = stylex.create({ outlineOffset: '2px', textAlign: 'start', whiteSpace: 'nowrap', + minHeight: { + default: null, + '@media (pointer: coarse)': targetVars['--cl-target-coarse'], + }, width: { default: '100%', - '@media (max-width: 47.99rem)': 'auto', + [profilePageCompact]: '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', + [profilePageCompact]: 'none', }, fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], @@ -119,9 +129,7 @@ export const styles = stylex.create({ outlineOffset: '2px', height: space['4'], }, - main: { - minWidth: 0, - }, + main: { minWidth: 0 }, content: { paddingBlock: space['16'], paddingInline: space['16'], diff --git a/packages/ui/src/mosaic/profile-page.tsx b/packages/ui/src/mosaic/profile-page.tsx new file mode 100644 index 00000000000..47477d63482 --- /dev/null +++ b/packages/ui/src/mosaic/profile-page.tsx @@ -0,0 +1,173 @@ +import type { TabsProps } from '@clerk/headless/tabs'; +import { Tabs } from '@clerk/headless/tabs'; +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React 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 { styles } from './profile-page.styles'; +import type { MosaicComponentProps } from './props'; +import { mergeStyleProps, themeProps } from './props'; + +export interface ProfilePageItem { + value: string; + label: string; + icon: IconName; +} + +export interface ProfilePageRootProps extends Omit<MosaicComponentProps<'div'>, 'children'> { + value: string; + onValueChange?: (value: string) => void; + orientation?: TabsProps['orientation']; + activationMode?: TabsProps['activationMode']; + children: React.ReactNode; +} + +const ProfilePageRoot = React.forwardRef<HTMLDivElement, ProfilePageRootProps>(function ProfilePageRoot( + { value, onValueChange, orientation = 'vertical', activationMode, children, render, className, style, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('profile-page'), stylex.props(styles.root), className, style), + ...rest, + children, + }, + }); + + return ( + <Tabs.Root + value={value} + onValueChange={onValueChange} + orientation={orientation} + activationMode={activationMode} + > + {element} + </Tabs.Root> + ); +}); + +export interface ProfilePageSidebarProps extends Omit<MosaicComponentProps<'aside'>, 'children'> { + items: readonly ProfilePageItem[]; + navigationLabel: string; + renderBranding?: boolean; +} + +const ProfilePageSidebar = React.forwardRef<HTMLElement, ProfilePageSidebarProps>(function ProfilePageSidebar( + { items, navigationLabel, renderBranding = true, render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'aside', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('profile-page-sidebar'), + stylex.props(reset.base, styles.sidebar), + className, + style, + ), + ...rest, + children: ( + <> + <nav aria-label={navigationLabel}> + <Tabs.List + {...mergeStyleProps(themeProps('profile-page-navigation'), stylex.props(reset.base, styles.navigation))} + > + {items.map(item => ( + <Tabs.Tab + key={item.value} + value={item.value} + {...mergeStyleProps( + themeProps('profile-page-navigation-item'), + stylex.props(reset.base, styles.navigationItem), + )} + > + <Icon + aria-hidden + name={item.icon} + size='sm' + /> + <span {...themeProps('profile-page-navigation-label')}>{item.label}</span> + </Tabs.Tab> + ))} + </Tabs.List> + </nav> + {renderBranding ? ( + <div {...mergeStyleProps(themeProps('profile-page-branding'), 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' + {...mergeStyleProps( + themeProps('profile-page-branding-link'), + stylex.props(reset.base, styles.brandingLink), + )} + > + <ClerkLogo height={12} /> + </a> + </div> + ) : null} + </> + ), + }, + }); +}); + +export interface ProfilePageContentProps extends Omit<MosaicComponentProps<'main'>, 'children'> { + children: React.ReactNode; +} + +const ProfilePageContent = React.forwardRef<HTMLElement, ProfilePageContentProps>(function ProfilePageContent( + { children, render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'main', + render, + ref, + props: { + ...mergeStyleProps(themeProps('profile-page-main'), stylex.props(reset.base, styles.main), className, style), + ...rest, + children: ( + <div {...mergeStyleProps(themeProps('profile-page-content'), stylex.props(reset.base, styles.content))}> + {children} + </div> + ), + }, + }); +}); + +export interface ProfilePagePanelProps extends MosaicComponentProps<'div'> { + value: string; +} + +const ProfilePagePanel = React.forwardRef<HTMLDivElement, ProfilePagePanelProps>(function ProfilePagePanel( + { value, className, style, ...rest }, + ref, +) { + return ( + <Tabs.Panel + ref={ref} + value={value} + {...mergeStyleProps(themeProps('profile-page-panel', { value }), className, style)} + {...rest} + /> + ); +}); + +export const ProfilePage = { + Root: ProfilePageRoot, + Sidebar: ProfilePageSidebar, + Content: ProfilePageContent, + Panel: ProfilePagePanel, +}; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index f4f0ba9c26a..e1899b7ca86 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -5,6 +5,14 @@ // as components migrate. export type { MosaicComponentProps, MosaicElementProps } from '../props'; +export { ProfilePage } from '../profile-page'; +export type { + ProfilePageContentProps, + ProfilePageItem, + ProfilePagePanelProps, + ProfilePageRootProps, + ProfilePageSidebarProps, +} from '../profile-page'; export { AlertDialog, createConfirmHandle, useConfirmedClose } from '../components/alert-dialog'; export type { 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 index a7f73cfe61c..ae75a6a006d 100644 --- 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 @@ -51,10 +51,16 @@ describe('UserPageView', () => { 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('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + const accountTab = screen.getByRole('tab', { name: 'Account' }); + const accountPanel = screen.getByRole('tabpanel'); + + expect(accountTab).toHaveAttribute('aria-selected', 'true'); + expect(accountTab).toHaveAttribute('aria-controls', accountPanel.id); + expect(screen.getByRole('tab', { name: 'Security' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Billing' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'API Keys' })).toBeInTheDocument(); + expect(accountPanel).toHaveAccessibleName('Account'); expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); expect(screen.getByText('Secured by')).toBeInTheDocument(); }); @@ -64,24 +70,51 @@ describe('UserPageView', () => { const user = userEvent.setup(); renderView({ onPanelChange }); - await user.click(screen.getByRole('button', { name: 'Security' })); + await user.click(screen.getByRole('tab', { name: 'Security' })); expect(onPanelChange).toHaveBeenCalledWith('security'); expect(screen.queryByRole('button', { name: 'Close user profile' })).not.toBeInTheDocument(); }); + it('supports sidebar keyboard navigation through the tabs primitive', async () => { + const onPanelChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPanelChange }); + + screen.getByRole('tab', { name: 'Account' }).focus(); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('tab', { name: 'Security' })).toHaveFocus(); + expect(onPanelChange).toHaveBeenCalledWith('security'); + }); + + it('reflects navigation state through stable Mosaic styling hooks', () => { + renderView({ activePanel: 'security' }); + + expect(screen.getByRole('tab', { name: 'Security' })).toHaveClass('cl-profile-page-navigation-item'); + expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute('data-selected'); + expect(screen.getByRole('tab', { name: 'Account' })).not.toHaveAttribute('data-selected'); + }); + + it('merges consumer styling props onto the page root', () => { + const { container } = renderView({ className: 'custom-page', style: { maxWidth: 900 } }); + + expect(container.firstChild).toHaveClass('cl-profile-page', 'custom-page'); + expect(container.firstChild).toHaveStyle({ maxWidth: '900px' }); + }); + 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(); + expect(screen.queryByRole('tab', { name: 'Security' })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Billing' })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { 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('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('heading', { level: 3, name: 'Account' })).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 index 907eaf111c4..9d302d466db 100644 --- a/packages/ui/src/mosaic/user-profile/user-page.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-page.view.tsx @@ -1,8 +1,7 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactElement } from 'react'; +import React from 'react'; -import { mergeStyleProps, themeProps } from '../props'; -import { styles } from './user-profile.styles'; +import type { ProfilePageRootProps } from '../profile-page'; +import { ProfilePage } from '../profile-page'; 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'; @@ -21,7 +20,7 @@ export interface UserPagePanels { apiKeys?: UserProfileApiKeysPanelViewProps; } -export interface UserPageViewProps { +export interface UserPageViewProps extends Omit<ProfilePageRootProps, 'children' | 'value' | 'onValueChange'> { activePanel: UserProfilePanelId; panels: UserPagePanels; onPanelChange: (panel: UserProfilePanelId) => void; @@ -37,7 +36,7 @@ function getAvailablePanels(panels: UserPagePanels): UserProfilePanelId[] { ]; } -function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPagePanels }): ReactElement { +function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPagePanels }): React.ReactElement { switch (panel) { case 'security': return panels.security ? ( @@ -62,31 +61,46 @@ function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPageP } } -export function UserPageView({ - activePanel, - panels, - onPanelChange, - renderBranding = true, -}: UserPageViewProps): ReactElement { +export const UserPageView = React.forwardRef<HTMLDivElement, UserPageViewProps>(function UserPageView( + { activePanel, panels, onPanelChange, renderBranding = true, render, className, style, ...rest }, + ref, +) { const availablePanels = getAvailablePanels(panels); const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'account'; + const handlePanelChange = (value: string) => { + const panel = availablePanels.find(candidate => candidate === value); + if (panel) { + onPanelChange(panel); + } + }; return ( - <div {...mergeStyleProps(themeProps('user-page'), stylex.props(styles.root))}> + <ProfilePage.Root + ref={ref} + value={resolvedPanel} + onValueChange={handlePanelChange} + render={render} + className={className} + style={style} + {...rest} + > <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> + <ProfilePage.Content> + {availablePanels.map(panel => ( + <ProfilePage.Panel + key={panel} + value={panel} + > + <Panel + panel={panel} + panels={panels} + /> + </ProfilePage.Panel> + ))} + </ProfilePage.Content> + </ProfilePage.Root> ); -} +}); 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 47fb979d2fc..539b9ee02a8 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 @@ -15,7 +15,7 @@ export const styles = stylex.create({ width: space['5'], }, providerMedia: { - borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', + borderColor: `light-dark(${colorVars['--cl-color-border-faded']}, ${colorVars['--cl-color-background']})`, borderRadius: radiusVars['--cl-radius-lg'], borderStyle: 'solid', borderWidth: '1px', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx index a04d06e9f3c..1d1b6f9cafd 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx @@ -1,12 +1,8 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactElement } from 'react'; +import React 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'; +import type { ProfilePageSidebarProps } from '../profile-page'; +import { ProfilePage } from '../profile-page'; export type UserProfilePanelId = 'account' | 'security' | 'billing' | 'api-keys'; @@ -17,61 +13,21 @@ const destinations: Record<UserProfilePanelId, { label: string; icon: IconName } 'api-keys': { label: 'API Keys', icon: 'code' }, }; -export interface UserProfileSidebarProps { - activePanel: UserProfilePanelId; +export interface UserProfileSidebarProps extends Omit<ProfilePageSidebarProps, 'items' | 'navigationLabel'> { panels: readonly UserProfilePanelId[]; - onPanelChange: (panel: UserProfilePanelId) => void; renderBranding?: boolean; } -export function UserProfileSidebar({ - activePanel, - panels, - onPanelChange, - renderBranding = true, -}: UserProfileSidebarProps): ReactElement { +export const UserProfileSidebar = React.forwardRef<HTMLElement, UserProfileSidebarProps>(function UserProfileSidebar( + { panels, ...rest }, + ref, +) { 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> + <ProfilePage.Sidebar + ref={ref} + items={panels.map(value => ({ value, ...destinations[value] }))} + navigationLabel='User profile' + {...rest} + /> ); -} +}); From 5caec639a9c038fbd04ab6a493664f6a49282e34 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 11:25:38 -0600 Subject: [PATCH 21/43] 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( + <Section.Root> + <Section.Group> + <Section.Row + data-testid='row' + variant='list' + > + <Section.Item>Email</Section.Item> + <Section.Items> + <Section.Item>one@example.com</Section.Item> + <Section.Item>two@example.com</Section.Item> + </Section.Items> + </Section.Row> + </Section.Group> + </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'); + }); + it('lets consumer props win and forwards refs and custom elements', () => { const rootRef = React.createRef<HTMLElement>(); const groupRef = React.createRef<HTMLDivElement>(); 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<MosaicComponentProps<'section'>, 'title'>; export type SectionTitleProps = Omit<HeadingProps, 'size'>; 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<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 }, @@ -77,7 +84,7 @@ const Title = React.forwardRef<HTMLHeadingElement, SectionTitleProps>(function S ref={ref} id={id} render={render ?? (props => <h4 {...props} />)} - size='sm' + size='base' {...mergeStyleProps(themeProps('section-title'), stylex.props(styles.title), className, style)} {...rest} /> @@ -100,18 +107,25 @@ const Group = React.forwardRef<HTMLDivElement, SectionGroupProps>(function Secti }); const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(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 <SectionRowVariantContext.Provider value={variant}>{element}</SectionRowVariantContext.Provider>; }); const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function SectionItems( @@ -141,6 +155,7 @@ const Item = React.forwardRef<HTMLDivElement, SectionItemProps>(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<HTMLDivElement, SectionItemProps>(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 e9a1c6d160bd2587a88e8ebf5c05916acb53f79d Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 11:25:53 -0600 Subject: [PATCH 22/43] 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 ( - <Section.Root aria-label='Account'> - <Section.Group> - <Section.Row> - <Section.Item> - <Section.Media size='lg'> - <Avatar.Root - size='fit' - render={ - onEditProfilePicture ? ( - <button - type='button' - aria-label='Edit profile picture' - onClick={onEditProfilePicture} - /> - ) : undefined - } - > - <Avatar.Image - alt={name} - src={imageUrl} - /> - <Avatar.Fallback>{initials}</Avatar.Fallback> - {onEditProfilePicture ? ( - <Avatar.Icon> - <Icon name='pen' /> - </Avatar.Icon> - ) : null} - </Avatar.Root> - </Section.Media> - <Section.Content> - <Section.Label>Profile picture</Section.Label> - <Section.Description>Recommend size 1:1, up to 10MB.</Section.Description> - </Section.Content> - </Section.Item> - </Section.Row> - <Section.Row> - <Section.Item> - <Section.Content> - <Section.Label>Name</Section.Label> - <Section.Description>{name}</Section.Description> - </Section.Content> - {updateName ? ( - <Section.Actions> - <Button - color='neutral' - size='sm' - variant='outline' - onClick={updateName} - > - Edit name - </Button> - </Section.Actions> - ) : null} - </Section.Item> - </Section.Row> - <Section.Row> - <Section.Item> - <Section.Content> - <Section.Label>Username</Section.Label> - <Section.Description>{username}</Section.Description> - </Section.Content> - {updateUsername ? ( - <Section.Actions> - <Button - color='neutral' - size='sm' - variant='outline' - onClick={updateUsername} - > - Edit username - </Button> - </Section.Actions> - ) : null} - </Section.Item> - </Section.Row> + <div {...stylex.props(styles.sections)}> + <Section.Root aria-label='Account'> + <Section.Title>Profile</Section.Title> + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Media size='lg'> + <Avatar.Root size='fit'> + <Avatar.Image + alt={name} + src={imageUrl} + /> + <Avatar.Fallback>{initials}</Avatar.Fallback> + </Avatar.Root> + </Section.Media> + <Section.Content> + <Section.Label>Profile picture</Section.Label> + <Section.Description>Recommend size 1:1, up to 10MB.</Section.Description> + </Section.Content> + {onEditProfilePicture ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onEditProfilePicture} + > + Upload + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Name</Section.Label> + <Section.Description>{name}</Section.Description> + </Section.Content> + {updateName ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={updateName} + > + Edit name + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Username</Section.Label> + <Section.Description>{username}</Section.Description> + </Section.Content> + {updateUsername ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={updateUsername} + > + Edit username + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + {!shouldBreakOutContacts ? ( + <SingleContactRow + items={emails} + kind='email' + label='Email' + onAdd={onAddEmail} + onManage={onManageEmail} + /> + ) : null} + {!shouldBreakOutContacts ? ( + <SingleContactRow + items={phones} + kind='phone' + label='Phone' + onAdd={onAddPhone} + onManage={onManagePhone} + /> + ) : null} + </Section.Group> + </Section.Root> + {shouldBreakOutContacts ? ( <ContactSection items={emails} kind='email' @@ -161,6 +181,8 @@ export function UserProfileAccountSectionView({ onSetPrimary={onSetPrimaryEmail} onVerify={onVerifyEmail} /> + ) : null} + {shouldBreakOutContacts ? ( <ContactSection items={phones} kind='phone' @@ -171,21 +193,12 @@ export function UserProfileAccountSectionView({ onSetPrimary={onSetPrimaryPhone} onVerify={onVerifyPhone} /> - </Section.Group> - </Section.Root> + ) : null} + </div> ); } -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 ( - <Section.Row - render={props => ( - <section - {...props} - aria-labelledby={labelId} - /> - )} - > + <Section.Root aria-label={props.label}> + <Section.Group> + <ContactRow {...props} /> + </Section.Group> + </Section.Root> + ); +} + +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 ( + <Section.Row> <Section.Item> <Section.Content> - <Section.Label id={labelId}>{label}</Section.Label> + <Section.Label>{label}</Section.Label> + {item ? ( + <Section.Description {...stylex.props(styles.contactValue)}> + <span>{item.value}</span> + {item.isDefault ? <Badge color='neutral'>Primary</Badge> : null} + </Section.Description> + ) : ( + <Section.Description>{emptyDescription}</Section.Description> + )} + </Section.Content> + {onClick ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onClick} + > + {actionLabel} + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + ); +} + +function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimary, onRemove }: ContactSectionProps) { + const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; + + return ( + <Section.Row variant='list'> + <Section.Item> + <Section.Content> + <Section.Label>{label}</Section.Label> </Section.Content> {onAdd ? ( <Section.Actions> <Button + aria-label={kind === 'email' ? 'Add email' : 'Add phone number'} color='neutral' size='sm' variant='outline' onClick={onAdd} > - {kind === 'email' ? 'Add email' : 'Add phone number'} + <Icon + name='plus' + placement='inline-start' + size='sm' + /> + Add </Button> </Section.Actions> ) : null} </Section.Item> <Section.Items> - {items.map(item => { - const actions: UserProfileMenuAction[] = []; - const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + {items.length === 0 ? ( + <Section.Item> + <Section.Content> + <Section.Description>{emptyDescription}</Section.Description> + </Section.Content> + </Section.Item> + ) : ( + 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 ( - <Section.Item key={item.id}> - <Section.Content> - <Section.Description {...stylex.props(styles.contactValue)}> - <span>{item.value}</span> - {item.isDefault ? <Badge color='neutral'>Primary</Badge> : null} - </Section.Description> - </Section.Content> - {actions.length > 0 ? ( - <Section.Actions> - <UserProfileActionMenu - actions={actions} - label={`Manage ${item.value}`} - /> - </Section.Actions> - ) : null} - </Section.Item> - ); - })} + return ( + <Section.Item key={item.id}> + <Section.Content> + <Section.Description {...stylex.props(styles.contactValue)}> + <span>{item.value}</span> + {item.isDefault ? <Badge color='neutral'>Primary</Badge> : null} + </Section.Description> + </Section.Content> + {actions.length > 0 ? ( + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${item.value}`} + /> + </Section.Actions> + ) : null} + </Section.Item> + ); + }) + )} </Section.Items> </Section.Row> ); 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 ( <Section.Row key={account.id}> <Section.Item> - {account.iconUrl ? ( - <Section.Media - size='xl' - {...stylex.props(styles.providerMedia)} - > - <img - alt='' - src={account.iconUrl} - {...stylex.props(styles.providerIcon)} - /> - </Section.Media> - ) : null} + {account.iconUrl ? <UserProfileProviderIcon iconUrl={account.iconUrl} /> : null} <Section.Content> <Section.Label>{account.provider}</Section.Label> {account.identifier ? <Section.Description>{account.identifier}</Section.Description> : 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 => <h3 {...props} />} size='2xl' > - Profile + Account </Heading> <div {...stylex.props(styles.sections)}> <UserProfileAccountSectionView 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 new file mode 100644 index 00000000000..962449e80b6 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx @@ -0,0 +1,19 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Section } from '../components/section'; +import { styles } from './user-profile-profile-panel.styles'; + +export function UserProfileProviderIcon({ iconUrl }: { iconUrl: string }) { + return ( + <Section.Media + size='lg' + {...stylex.props(styles.providerMedia)} + > + <img + alt='' + src={iconUrl} + {...stylex.props(styles.providerIcon)} + /> + </Section.Media> + ); +} 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 ( <Section.Row key={wallet.id}> <Section.Item> - {wallet.iconUrl ? ( - <Section.Media - size='xl' - {...stylex.props(styles.providerMedia)} - > - <img - alt='' - src={wallet.iconUrl} - {...stylex.props(styles.providerIcon)} - /> - </Section.Media> - ) : null} + {wallet.iconUrl ? <UserProfileProviderIcon iconUrl={wallet.iconUrl} /> : null} <Section.Content> <Section.Label> <span {...stylex.props(styles.contactValue)}> From af9ac325732b3125c189dbbe86ddb07a8e60dd86 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 11:26:08 -0600 Subject: [PATCH 23/43] 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<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 }, + ]); + return ( <UserProfileAccountSectionView - emails={[ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, - ]} + emails={emails} imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4' 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, + }, + ]) + } 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<string, unknown>) { + 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 }, + ]); + return ( <UserProfileProfilePanelView - emails={[ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, - ]} + emails={emails} connectedAccounts={[ { id: 'google', @@ -48,16 +55,32 @@ export function Default(_args: Record<string, unknown>) { ]} 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 e3d10fa43ff71f04dbc8c8a78142fcef61ba586b Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:08:40 -0600 Subject: [PATCH 24/43] 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 3fd96c2dc728298e0503e19177e59ce33788deaa Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:08:59 -0600 Subject: [PATCH 25/43] 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(<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']], + ['device-laptop', ['black', '#575757', 'black', '#444444', '#171717']], + ] as const)('preserves the supplied %s palette', (name, palette) => { + const { container } = wrap(<Icon name={name} />); + 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(<Icon name='chevron-right' />); 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( + <> + <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' + 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' + fill='currentColor' + /> + </>, + '0 0 14.604 13.511', +); + +const SecurityPhone = glyph( + <> + <path + d='M6.04014 2.8125C5.54942 2.8125 5.0625 3.25236 5.0625 3.90841V14.0916C5.0625 14.7477 5.54941 15.1875 6.04014 15.1875H11.9599C12.4506 15.1875 12.9375 14.7477 12.9375 14.0916V3.90841C12.9375 3.25236 12.4506 2.8125 11.9599 2.8125H6.04014ZM3.375 3.90841C3.375 2.42196 4.51901 1.125 6.04014 1.125H11.9599C13.481 1.125 14.625 2.42197 14.625 3.90841V14.0916C14.625 15.5781 13.481 16.875 11.9599 16.875H6.04014C4.51902 16.875 3.375 15.5781 3.375 14.0916V3.90841Z' + fill='currentColor' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M7.875 12.6562C7.875 12.1903 8.25276 11.8125 8.71875 11.8125H9.28125C9.74724 11.8125 10.125 12.1903 10.125 12.6562C10.125 13.1222 9.74724 13.5 9.28125 13.5H8.71875C8.25276 13.5 7.875 13.1222 7.875 12.6562Z' + fill='currentColor' + fillRule='evenodd' + clipRule='evenodd' + /> + </>, + '0 0 18 18', +); + +const SecurityLockSquare = glyph( + <path + d='M2.25 5.34375C2.25 3.63512 3.63512 2.25 5.34375 2.25H12.6562C14.3649 2.25 15.75 3.63512 15.75 5.34375V5.90625C15.75 6.37224 15.3722 6.75 14.9062 6.75C14.4403 6.75 14.0625 6.37224 14.0625 5.90625V5.34375C14.0625 4.5671 13.4329 3.9375 12.6562 3.9375H5.34375C4.5671 3.9375 3.9375 4.5671 3.9375 5.34375V12.6562C3.9375 13.4329 4.5671 14.0625 5.34375 14.0625H5.90625C6.37224 14.0625 6.75 14.4403 6.75 14.9062C6.75 15.3722 6.37224 15.75 5.90625 15.75H5.34375C3.63512 15.75 2.25 14.3649 2.25 12.6562V5.34375ZM11.8125 8.4375C11.1912 8.4375 10.6875 8.94118 10.6875 9.5625V10.125H12.9375V9.5625C12.9375 8.94118 12.4338 8.4375 11.8125 8.4375ZM14.625 10.125V9.5625C14.625 8.0092 13.3658 6.75 11.8125 6.75C10.2592 6.75 9 8.0092 9 9.5625V10.125H8.71875C8.25276 10.125 7.875 10.5028 7.875 10.9688V13.7812C7.875 14.8686 8.75644 15.75 9.84375 15.75H13.7812C14.8686 15.75 15.75 14.8686 15.75 13.7812V10.9688C15.75 10.5028 15.3722 10.125 14.9062 10.125H14.625ZM14.0625 11.8125H9.5625V13.7812C9.5625 13.9366 9.68842 14.0625 9.84375 14.0625H13.7812C13.9366 14.0625 14.0625 13.9366 14.0625 13.7812V11.8125Z' + fill='currentColor' + fillRule='evenodd' + clipRule='evenodd' + />, + '0 0 18 18', +); + +const DevicePhone = glyph( + <> + <path + d='M4.6045 6.07587V5.87713H4.54673C4.53907 5.87337 4.5302 5.87275 4.52207 5.87541C4.51395 5.87807 4.50723 5.88379 4.5034 5.89132C4.49957 5.89885 4.49895 5.90757 4.50166 5.91556C4.50437 5.92355 4.51018 5.93015 4.51784 5.93391V7.18312C4.51784 7.18312 4.51785 7.23991 4.57561 7.23991V6.07587H4.6045Z' + fill='#646464' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M4.6045 4.3725V4.17376H4.54673C4.53907 4.17 4.5302 4.16938 4.52207 4.17204C4.51395 4.1747 4.50723 4.18043 4.5034 4.18796C4.49957 4.19549 4.49895 4.2042 4.50166 4.21219C4.50437 4.22018 4.51018 4.22678 4.51784 4.23054V5.47975C4.51784 5.47975 4.51785 5.53654 4.57561 5.53654V4.3725H4.6045Z' + fill='#646464' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M11.9117 0C12.9515 0 13.5002 0.596216 13.5002 1.6183V16.4953C13.5002 17.4606 12.836 18 11.8251 18H6.22201C5.1245 18 4.54686 17.3186 4.57574 16.4669V1.6183C4.57574 0.596216 5.15339 0 6.19314 0H11.9117Z' + fill='#343434' + /> + <path + d='M11.7958 0.168701C12.8644 0.168701 13.3554 0.679743 13.3554 1.70182V16.4652C13.3554 17.3738 12.72 17.8848 11.7669 17.8848H6.27935C5.35513 17.8848 4.71973 17.317 4.71973 16.4652V1.70182C4.71973 0.679743 5.2396 0.168701 6.30823 0.168701H11.7958Z' + fill='#575757' + stroke='#444444' + strokeWidth={0.140625} + /> + <path + d='M6.77197 0.481689C6.82974 0.481689 6.85862 0.510079 6.85862 0.595252V0.652036C6.85862 0.907557 7.08966 1.13469 7.32072 1.13469H10.7288C11.0176 1.13469 11.2198 0.907557 11.2198 0.652036V0.595252C11.2198 0.510079 11.2487 0.481689 11.3064 0.481689H12.1729C12.635 0.481689 13.0393 0.964338 13.0393 1.4186V16.5795C13.0393 17.0621 12.6061 17.5164 12.0574 17.5164H5.99216C5.38564 17.5164 5.03906 17.1473 5.03906 16.6079V1.4186C5.03906 0.964338 5.41452 0.481689 5.87663 0.481689H6.74308H6.77197Z' + fill='#171717' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M9.77424 0.7378C9.77873 0.726659 9.78079 0.714714 9.78027 0.702742C9.77974 0.690769 9.77664 0.679039 9.77119 0.668321C9.76574 0.657602 9.75805 0.648135 9.74862 0.640539C9.7392 0.632942 9.72823 0.627388 9.71647 0.624237C9.70509 0.620508 9.69309 0.619019 9.68113 0.619855C9.66916 0.620691 9.65748 0.623833 9.64675 0.629106C9.63602 0.634379 9.62646 0.641678 9.6186 0.650585C9.61074 0.659492 9.60475 0.669833 9.60095 0.681018C9.59716 0.692203 9.59564 0.704012 9.59649 0.715773C9.59734 0.727533 9.60054 0.739016 9.60591 0.749561C9.61127 0.760107 9.61869 0.76951 9.62775 0.777235C9.63681 0.78496 9.64734 0.790855 9.65872 0.794583C9.67005 0.799 9.6822 0.801015 9.69438 0.800499C9.70656 0.799982 9.71849 0.796945 9.72939 0.791585C9.7403 0.786226 9.74992 0.778666 9.75765 0.769398C9.76538 0.76013 9.77103 0.749362 9.77424 0.7378ZM8.30127 0.681018C8.30127 0.7378 8.30127 0.766193 8.35904 0.766193H9.1966C9.21432 0.760068 9.22953 0.748448 9.23995 0.733086C9.25037 0.717724 9.25543 0.699451 9.25437 0.681018C9.25023 0.667614 9.2428 0.655421 9.23272 0.645516C9.22264 0.635612 9.21024 0.628303 9.1966 0.624237H8.35904C8.3454 0.628303 8.33299 0.635612 8.32292 0.645516C8.31284 0.655421 8.30541 0.667614 8.30127 0.681018Z' + fill='black' + fillRule='evenodd' + clipRule='evenodd' + /> + </>, + '0 0 18 18', +); + +const DeviceLaptop = glyph( + <> + <path + d='M1.63696 4.19191C1.63696 3.86515 1.68002 3.7251 1.76614 3.58506C1.8738 3.46836 2.02453 3.375 2.36903 3.375H15.6537C15.9551 3.375 16.0843 3.44502 16.192 3.56172C16.2996 3.67842 16.3427 3.8418 16.3427 4.19191V13.7614C16.3427 14.0882 16.2996 14.2282 16.235 14.3216C16.1805 14.402 16.1091 14.4672 16.0267 14.5118C15.9444 14.5565 15.8534 14.5792 15.7614 14.5783H2.19677C2.02452 14.5783 1.85227 14.5083 1.76614 14.3449C1.68002 14.2516 1.63696 14.1115 1.63696 13.7614V4.19191Z' + fill='black' + /> + <path + d='M1.93797 14.2505H16.0408C16.1054 14.2505 16.17 14.2038 16.2131 14.1571C16.2561 14.1104 16.2561 14.0404 16.2561 13.8304V4.19083C16.2561 3.91075 16.2346 3.72403 16.127 3.63067C16.0193 3.51397 15.9116 3.46729 15.6533 3.46729H2.36859C2.08868 3.46729 1.93797 3.53731 1.83031 3.65401C1.74419 3.74737 1.72266 3.88741 1.72266 4.19083V13.8304C1.72266 14.0404 1.72265 14.1104 1.76571 14.1571C1.80877 14.2038 1.87337 14.2505 1.93797 14.2505Z' + fill='#575757' + /> + <path + d='M8.99922 3.88789C9.00895 3.89489 9.02025 3.8989 9.03192 3.89949C9.04358 3.90008 9.05518 3.89723 9.06547 3.89124C9.07576 3.88525 9.08436 3.87635 9.09036 3.86549C9.09635 3.85463 9.09952 3.8422 9.09952 3.82954C9.09952 3.81688 9.09635 3.80446 9.09036 3.79359C9.08436 3.78273 9.07576 3.77383 9.06547 3.76785C9.05518 3.76186 9.04358 3.75901 9.03192 3.7596C9.02025 3.76019 9.00895 3.76419 8.99922 3.77119C8.98949 3.76419 8.97819 3.76019 8.96653 3.7596C8.95486 3.75901 8.94327 3.76186 8.93298 3.76785C8.92269 3.77383 8.91408 3.78273 8.90809 3.79359C8.90209 3.80446 8.89893 3.81688 8.89893 3.82954C8.89893 3.8422 8.90209 3.85463 8.90809 3.86549C8.91408 3.87635 8.92269 3.88525 8.93298 3.89124C8.94327 3.89723 8.95486 3.90008 8.96653 3.89949C8.97819 3.8989 8.98949 3.89489 8.99922 3.88789Z' + fill='black' + stroke='black' + strokeWidth={0.16875} + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M0 14.344V14.2273H18V14.344C18 14.344 17.5909 14.484 17.1388 14.5307C16.8373 14.5541 16.3421 14.6241 15.2225 14.6241H2.86363C1.89473 14.6241 1.07655 14.5541 0.710524 14.5074C0.344495 14.4607 0 14.344 0 14.344Z' + fill='#444444' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M2.21704 4.12207H15.7816V13.3181H2.21704V4.12207Z' + fill='#171717' + fillRule='evenodd' + clipRule='evenodd' + /> + </>, + '0 0 18 18', +); + const ArrowRightTop = glyph( <path d='M6.35014 5.40727L10.8285 5.17157M10.8285 5.17157L10.5928 9.64991M10.8285 5.17157L5.17163 10.8284' @@ -162,6 +278,12 @@ export const iconRegistry = { plus: Plus, 'log-out': LogOut, cog: Cog, + 'device-laptop': DeviceLaptop, + 'device-phone': DevicePhone, + 'security-authenticator': SecurityLockSquare, + 'security-lock-square': SecurityLockSquare, + 'security-passkey': SecurityPasskey, + 'security-phone': SecurityPhone, users: Users, } satisfies Record<string, IconComponent>; 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<UserProfileSecurityPanelViewProps> = {}) { + return render( + <MosaicProvider> + <UserProfileSecurityPanelView + {...props} + {...overrides} + /> + </MosaicProvider>, + ); +} + +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 ( + <div {...stylex.props(styles.sectionCards)}> + <Section.Root> + <Section.Title>Active devices</Section.Title> + <Section.Group> + {currentDevices.length > 0 ? ( + currentDevices.map(device => ( + <Section.Row key={device.id}> + <DeviceItem + device={device} + onManage={onManageDevice} + /> + </Section.Row> + )) + ) : ( + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Description>No current device available</Section.Description> + </Section.Content> + </Section.Item> + </Section.Row> + )} + </Section.Group> + </Section.Root> + {otherDevices.length > 0 ? ( + <Section.Root aria-label='Other devices'> + <Section.Group> + <Section.Row variant='list'> + <Section.Item> + <Section.Content> + <Section.Label> + {otherDevices.length} other {otherDevices.length === 1 ? 'device' : 'devices'} + </Section.Label> + </Section.Content> + {onSignOutAllOtherDevices ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onSignOutAllOtherDevices} + > + Sign out of all devices + </Button> + </Section.Actions> + ) : null} + </Section.Item> + <Section.Items> + {otherDevices.map(device => ( + <DeviceItem + key={device.id} + device={device} + onManage={onManageDevice} + onSignOut={onSignOutDevice} + /> + ))} + </Section.Items> + </Section.Row> + </Section.Group> + </Section.Root> + ) : null} + </div> + ); +} + +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 ( + <Section.Item> + <UserProfileSecurityIcon name={device.type} /> + <Section.Content> + <Section.Label>{device.name}</Section.Label> + {device.isCurrent || device.description ? ( + <Section.Description {...stylex.props(styles.descriptionLine)}> + {device.isCurrent ? <span {...stylex.props(styles.currentDevice)}>This device</span> : null} + {device.isCurrent && device.description ? <span>·</span> : null} + {device.description ? <span>{device.description}</span> : null} + </Section.Description> + ) : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${device.name}`} + /> + </Section.Actions> + </Section.Item> + ); +} 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 <Section.Content> <Section.Label>Delete account</Section.Label> <Section.Description> - Permanently delete this profile and all its data. This cannot be undone. + Permanently delete this account and all its data. This cannot be undone. </Section.Description> </Section.Content> <Section.Actions> 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<UserProfileMfaMethod['type'], 'sms' | 'authenticator'>; + +export interface UserProfileMfaSectionViewProps { + methods: UserProfileMfaMethod[]; + sectionTitle?: string; + onAdd?: (type: UserProfileMfaAddableMethod) => void; + onManage?: (id: string) => void; + onRegenerateBackupCodes?: () => void; + onRemove?: (id: string) => void; +} + +const labels: Record<UserProfileMfaMethod['type'], string> = { + 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 ( + <UserProfileSecurityList + addControl={ + onAdd && availableMethods.length > 0 ? ( + <Menu.Root placement='bottom-end'> + <Menu.Trigger + aria-label='Add verification method' + render={props => ( + <Button + color='neutral' + size='sm' + variant='outline' + {...props} + /> + )} + > + <Icon + name='plus' + placement='inline-start' + size='sm' + /> + Add + </Menu.Trigger> + <Menu.Content> + {availableMethods.map(type => ( + <Menu.Item + key={type} + label={labels[type]} + onClick={() => onAdd(type)} + /> + ))} + </Menu.Content> + </Menu.Root> + ) : null + } + addLabel='Add verification method' + emptyLabel='No verification methods added' + hasItems={visibleMethods.length > 0} + label='2-step verification' + sectionTitle={sectionTitle} + > + {visibleMethods.map(method => { + const label = method.label ?? labels[method.type]; + const actions: UserProfileMenuAction[] = []; + + if (method.type === 'backup-codes') { + if (onRegenerateBackupCodes) { + actions.push({ + label: 'Regenerate', + 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) }); + } + } + + return ( + <Section.Item key={method.id}> + <UserProfileSecurityIcon name={method.type} /> + <Section.Content> + <Section.Label>{label}</Section.Label> + {method.description ? <Section.Description>{method.description}</Section.Description> : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${label}`} + /> + </Section.Actions> + </Section.Item> + ); + })} + </UserProfileSecurityList> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx new file mode 100644 index 00000000000..dfbb07689bc --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx @@ -0,0 +1,70 @@ +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 UserProfilePasskey { + id: string; + name: string; + createdAtLabel?: string; + lastUsedAtLabel?: string; +} + +export interface UserProfilePasskeysSectionViewProps { + passkeys: UserProfilePasskey[]; + sectionTitle?: string; + onAdd?: () => void; + onManage?: (id: string) => void; + onRemove?: (id: string) => void; +} + +export function UserProfilePasskeysSectionView({ + passkeys, + sectionTitle, + onAdd, + onManage, + onRemove, +}: UserProfilePasskeysSectionViewProps) { + return ( + <UserProfileSecurityList + addLabel='Add passkey' + emptyLabel='No passkeys added' + hasItems={passkeys.length > 0} + label='Passkeys' + sectionTitle={sectionTitle} + onAdd={onAdd} + > + {passkeys.map(passkey => { + const actions: UserProfileMenuAction[] = []; + + if (onManage) { + actions.push({ label: 'Rename', onClick: () => onManage(passkey.id) }); + } + if (onRemove) { + actions.push({ label: 'Remove passkey', color: 'negative', onClick: () => onRemove(passkey.id) }); + } + + return ( + <Section.Item key={passkey.id}> + <UserProfileSecurityIcon name='passkey' /> + <Section.Content> + <Section.Label>{passkey.name}</Section.Label> + {passkey.createdAtLabel || passkey.lastUsedAtLabel ? ( + <Section.Description> + {[passkey.createdAtLabel, passkey.lastUsedAtLabel].filter(Boolean).join(' · ')} + </Section.Description> + ) : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${passkey.name}`} + /> + </Section.Actions> + </Section.Item> + ); + })} + </UserProfileSecurityList> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx new file mode 100644 index 00000000000..9a48fb65176 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx @@ -0,0 +1,40 @@ +import { Button } from '../components/button'; +import { Section } from '../components/section'; + +export interface UserProfilePasswordSectionViewProps { + sectionTitle?: string; + onChangePassword?: () => void; +} + +export function UserProfilePasswordSectionView({ + sectionTitle = 'Authentication', + onChangePassword, +}: UserProfilePasswordSectionViewProps) { + return ( + <Section.Root aria-label={sectionTitle ? undefined : 'Password'}> + {sectionTitle ? <Section.Title>{sectionTitle}</Section.Title> : null} + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Password</Section.Label> + <Section.Description>••••••••••••••••••</Section.Description> + </Section.Content> + {onChangePassword ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onChangePassword} + > + Change password + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} 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 ( + <Section.Media + size='lg' + {...mergeStyleProps(stylex.props(styles.media), { style: { height: space['9'], width: space['9'] } })} + > + <Icon + aria-hidden + name={icons[name]} + size='lg' + {...stylex.props(styles.icon)} + /> + </Section.Media> + ); +} 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 ( + <Section.Root aria-label={sectionTitle ? undefined : label}> + {sectionTitle ? <Section.Title>{sectionTitle}</Section.Title> : null} + <Section.Group> + <Section.Row variant='list'> + <Section.Item> + <Section.Content> + <Section.Label>{label}</Section.Label> + </Section.Content> + {addControl ? ( + <Section.Actions>{addControl}</Section.Actions> + ) : onAdd ? ( + <Section.Actions> + <Button + aria-label={addLabel} + 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> + {hasItems ? ( + children + ) : ( + <Section.Item> + <Section.Content> + <Section.Description>{emptyLabel}</Section.Description> + </Section.Content> + </Section.Item> + )} + </Section.Items> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} 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<UserProfileActiveDevicesSectionViewProps, 'devices'> { + 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 ( + <div {...mergeStyleProps(themeProps('user-profile-security-panel'), stylex.props(styles.root))}> + <Heading + render={props => <h3 {...props} />} + size='2xl' + > + Security + </Heading> + <div {...stylex.props(styles.sections)}> + {hasAuthentication ? ( + <div {...stylex.props(styles.sectionCards)}> + {hasPassword ? <UserProfilePasswordSectionView onChangePassword={onChangePassword} /> : null} + {passkeys !== undefined ? ( + <UserProfilePasskeysSectionView + passkeys={passkeys} + sectionTitle={hasPassword ? undefined : 'Authentication'} + onAdd={onAddPasskey} + onManage={onManagePasskey} + onRemove={onRemovePasskey} + /> + ) : null} + {mfaMethods !== undefined ? ( + <UserProfileMfaSectionView + methods={mfaMethods} + sectionTitle={!hasPassword && passkeys === undefined ? 'Authentication' : undefined} + onAdd={onAddMfaMethod} + onManage={onManageMfaMethod} + onRegenerateBackupCodes={onRegenerateBackupCodes} + onRemove={onRemoveMfaMethod} + /> + ) : null} + </div> + ) : null} + {devices ? ( + <UserProfileActiveDevicesSectionView + devices={devices} + onManageDevice={onManageDevice} + onSignOutAllOtherDevices={onSignOutAllOtherDevices} + onSignOutDevice={onSignOutDevice} + /> + ) : null} + {onDeleteAccount ? <UserProfileDeleteSectionView onDelete={onDeleteAccount} /> : null} + </div> + </div> + ); +} From ea35017817b330917b6deebb17fae39ae7fc64b5 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:09:16 -0600 Subject: [PATCH 26/43] 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<string, Map<string, 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); + } + + 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 ( + <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> + ); +} + export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { const pathname = usePathname(); @@ -68,34 +138,32 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { {group} </SidebarGroupLabel> <SidebarGroupContent> - <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 ( - <SidebarMenuItem key={mod.meta.title}> - <SidebarMenuButton - className='h-auto items-start py-1 text-xs leading-relaxed' - isActive={pathname === href} - render={<Link href={href} />} - > - <span className='whitespace-normal! break-all font-mono text-[10px] leading-relaxed'> - {usage} - </span> - </SidebarMenuButton> - </SidebarMenuItem> - ); - })} - </SidebarMenu> + {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> ))} 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 `<Title />` 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 c42a87ae6a719d48e0d6650435ea5dac07681bc2 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:09:36 -0600 Subject: [PATCH 27/43] 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 20dc863c423bf9a10ddb5ca7c1d7c4f728bfbacf Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:13:15 -0600 Subject: [PATCH 28/43] 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 eb7a34d33d25dd8d45693f4edc38e4fc851a2a05 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:14:13 -0600 Subject: [PATCH 29/43] 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 9df9ba35a94e08f1ef12fd1db2ffb67ea2566daf Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:16:31 -0600 Subject: [PATCH 30/43] 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 819681b898eac971d50aab73cd99a9ab34763e82 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 13:51:41 -0600 Subject: [PATCH 31/43] 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 83ef857b84b3ed416feb9bbb996256a54e065e4c Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 14:08:07 -0600 Subject: [PATCH 32/43] 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 28f2e2183b55b1998ebff865a0020c044a0187f5 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 15:01:55 -0600 Subject: [PATCH 33/43] 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 03867ff158e9c397d9104f16925dda2264cd6f50 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 15:02:29 -0600 Subject: [PATCH 34/43] 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 16845f5ad31f46fb40456cc7a1792a39257f6ee1 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 15:02:45 -0600 Subject: [PATCH 35/43] 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 35cf642d4ca7fe26e4a348d88ae2b7d16dd353fe 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 36/43] 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 a13a33413a9ed366f2d5ce5edec6e6c1ff92dd65 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Tue, 18 Aug 2026 18:46:12 -0600 Subject: [PATCH 37/43] 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 a505f3c4aa470208ffccfe61e4bcbd9e87791b03 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Wed, 19 Aug 2026 09:22:56 -0600 Subject: [PATCH 38/43] 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 From 874df6ce0ee74c017b16491a56a5def2421ba1c2 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Wed, 19 Aug 2026 14:29:58 -0600 Subject: [PATCH 39/43] fix(ui): preserve section nesting style hooks --- .../components/section/section.test.tsx | 12 ++++----- .../src/mosaic/components/section/section.tsx | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/mosaic/components/section/section.test.tsx b/packages/ui/src/mosaic/components/section/section.test.tsx index f474cf35f8e..e4d2b349761 100644 --- a/packages/ui/src/mosaic/components/section/section.test.tsx +++ b/packages/ui/src/mosaic/components/section/section.test.tsx @@ -84,12 +84,12 @@ describe('Section', () => { expect(screen.getAllByText(/Edit|More/)).toHaveLength(2); expect(screen.getByTestId('items')).toHaveClass('cl-section-items'); 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'); + expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); }); - it('uses the item collection structure without public styling variants', () => { + it('retains public nesting hooks while deriving layout from the item collection structure', () => { render( <Section.Root> <Section.Group> @@ -105,8 +105,8 @@ describe('Section', () => { ); 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'); + 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', () => { diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 1e934c48840..84158cc0612 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -32,6 +32,7 @@ const mediaSizes = { }; const SectionTitleContext = React.createContext<React.Dispatch<React.SetStateAction<string[]>> | null>(null); +const SectionItemsContext = React.createContext(false); const Root = React.forwardRef<HTMLElement, SectionRootProps>(function SectionRoot( { render, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, @@ -102,13 +103,13 @@ const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function Secti { render, className, style, ...rest }, ref, ) { - return useRender({ + const element = useRender({ defaultTagName: 'div', render, ref, props: { ...mergeStyleProps( - themeProps('section-items'), + themeProps('section-items', { nested: true }), stylex.props(reset.base, styles.items, sectionItemsMarker), className, style, @@ -116,6 +117,8 @@ const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function Secti ...rest, }, }); + + return <SectionItemsContext.Provider value>{element}</SectionItemsContext.Provider>; }); const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(function SectionRow( @@ -137,12 +140,19 @@ const Item = React.forwardRef<HTMLDivElement, SectionItemProps>(function Section { render, className, style, ...rest }, ref, ) { + const nested = React.useContext(SectionItemsContext); + return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('section-item'), stylex.props(reset.base, styles.item), className, style), + ...mergeStyleProps( + themeProps('section-item', { nested }), + stylex.props(reset.base, styles.item), + className, + style, + ), ...rest, }, }); @@ -172,12 +182,19 @@ 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'), stylex.props(reset.base, styles.content), className, style), + ...mergeStyleProps( + themeProps('section-content', { nested }), + stylex.props(reset.base, styles.content), + className, + style, + ), ...rest, }, }); From 8adb2231c5e72289861a387ee5b5279c6630c631 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Wed, 19 Aug 2026 16:45:57 -0600 Subject: [PATCH 40/43] refactor(ui): share profile page tabs layout --- ...ofile.styles.ts => profile-page.styles.ts} | 54 +++--- packages/ui/src/mosaic/profile-page.tsx | 173 ++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 8 + .../__tests__/user-page.view.test.tsx | 51 +++++- .../mosaic/user-profile/user-page.view.tsx | 64 ++++--- .../user-profile-profile-panel.styles.ts | 2 +- .../user-profile/user-profile-sidebar.tsx | 74 ++------ 7 files changed, 309 insertions(+), 117 deletions(-) rename packages/ui/src/mosaic/{user-profile/user-profile.styles.ts => profile-page.styles.ts} (67%) create mode 100644 packages/ui/src/mosaic/profile-page.tsx diff --git a/packages/ui/src/mosaic/user-profile/user-profile.styles.ts b/packages/ui/src/mosaic/profile-page.styles.ts similarity index 67% rename from packages/ui/src/mosaic/user-profile/user-profile.styles.ts rename to packages/ui/src/mosaic/profile-page.styles.ts index 58ea21c83f8..aa4574ec8c8 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile.styles.ts +++ b/packages/ui/src/mosaic/profile-page.styles.ts @@ -1,47 +1,49 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; +import { colorVars, fontWeightVars, radiusVars, space, targetVars, typeScaleVars } from './tokens.stylex'; + +const profilePageCompact = '@media (max-width: 48rem)' as const; export const styles = stylex.create({ root: { + borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', 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)', + [profilePageCompact]: 'minmax(0, 1fr)', }, gridTemplateRows: 'auto', maxWidth: '66rem', - minHeight: 0, + minHeight: '37.5rem', width: '100%', }, sidebar: { padding: space['4'], borderBlockEndColor: { default: 'transparent', - '@media (max-width: 47.99rem)': colorVars['--cl-color-border'], + [profilePageCompact]: colorVars['--cl-color-border'], }, borderBlockEndStyle: 'solid', borderBlockEndWidth: { default: '0px', - '@media (max-width: 47.99rem)': '1px', + [profilePageCompact]: '1px', }, borderInlineEndColor: colorVars['--cl-color-border'], borderInlineEndStyle: 'solid', borderInlineEndWidth: { default: '1px', - '@media (max-width: 47.99rem)': '0px', + [profilePageCompact]: '0px', }, display: 'flex', flexDirection: { default: 'column', - '@media (max-width: 47.99rem)': 'row', + [profilePageCompact]: 'row', }, minHeight: 0, minWidth: 0, @@ -51,12 +53,12 @@ export const styles = stylex.create({ display: 'flex', flexDirection: { default: 'column', - '@media (max-width: 47.99rem)': 'row', + [profilePageCompact]: 'row', }, minWidth: 0, overflowX: { default: 'visible', - '@media (max-width: 47.99rem)': 'auto', + [profilePageCompact]: 'auto', }, }, navigationItem: { @@ -74,9 +76,17 @@ export const styles = stylex.create({ alignItems: 'center', backgroundColor: { default: 'transparent', - ':hover': colorVars['--cl-color-border-faded'], + ':where([data-selected])': colorVars['--cl-color-border-faded'], + ':active': colorVars['--cl-color-border-faded'], + '@media (hover: hover)': { + default: null, + ':hover:not(:active):not([data-selected])': colorVars['--cl-color-border-faded'], + }, + }, + color: { + default: colorVars['--cl-color-neutral-faded'], + ':where([data-selected])': colorVars['--cl-color-card-foreground'], }, - color: colorVars['--cl-color-neutral-faded'], cursor: 'pointer', display: 'flex', flexShrink: 0, @@ -86,22 +96,22 @@ export const styles = stylex.create({ outlineOffset: '2px', textAlign: 'start', whiteSpace: 'nowrap', + minHeight: { + default: null, + '@media (pointer: coarse)': targetVars['--cl-target-coarse'], + }, width: { default: '100%', - '@media (max-width: 47.99rem)': 'auto', + [profilePageCompact]: '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', + [profilePageCompact]: 'none', }, fontSize: typeScaleVars['--cl-text-xs-size'], lineHeight: typeScaleVars['--cl-text-xs-leading'], @@ -119,9 +129,7 @@ export const styles = stylex.create({ outlineOffset: '2px', height: space['4'], }, - main: { - minWidth: 0, - }, + main: { minWidth: 0 }, content: { paddingBlock: space['16'], paddingInline: space['16'], diff --git a/packages/ui/src/mosaic/profile-page.tsx b/packages/ui/src/mosaic/profile-page.tsx new file mode 100644 index 00000000000..47477d63482 --- /dev/null +++ b/packages/ui/src/mosaic/profile-page.tsx @@ -0,0 +1,173 @@ +import type { TabsProps } from '@clerk/headless/tabs'; +import { Tabs } from '@clerk/headless/tabs'; +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React 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 { styles } from './profile-page.styles'; +import type { MosaicComponentProps } from './props'; +import { mergeStyleProps, themeProps } from './props'; + +export interface ProfilePageItem { + value: string; + label: string; + icon: IconName; +} + +export interface ProfilePageRootProps extends Omit<MosaicComponentProps<'div'>, 'children'> { + value: string; + onValueChange?: (value: string) => void; + orientation?: TabsProps['orientation']; + activationMode?: TabsProps['activationMode']; + children: React.ReactNode; +} + +const ProfilePageRoot = React.forwardRef<HTMLDivElement, ProfilePageRootProps>(function ProfilePageRoot( + { value, onValueChange, orientation = 'vertical', activationMode, children, render, className, style, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('profile-page'), stylex.props(styles.root), className, style), + ...rest, + children, + }, + }); + + return ( + <Tabs.Root + value={value} + onValueChange={onValueChange} + orientation={orientation} + activationMode={activationMode} + > + {element} + </Tabs.Root> + ); +}); + +export interface ProfilePageSidebarProps extends Omit<MosaicComponentProps<'aside'>, 'children'> { + items: readonly ProfilePageItem[]; + navigationLabel: string; + renderBranding?: boolean; +} + +const ProfilePageSidebar = React.forwardRef<HTMLElement, ProfilePageSidebarProps>(function ProfilePageSidebar( + { items, navigationLabel, renderBranding = true, render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'aside', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('profile-page-sidebar'), + stylex.props(reset.base, styles.sidebar), + className, + style, + ), + ...rest, + children: ( + <> + <nav aria-label={navigationLabel}> + <Tabs.List + {...mergeStyleProps(themeProps('profile-page-navigation'), stylex.props(reset.base, styles.navigation))} + > + {items.map(item => ( + <Tabs.Tab + key={item.value} + value={item.value} + {...mergeStyleProps( + themeProps('profile-page-navigation-item'), + stylex.props(reset.base, styles.navigationItem), + )} + > + <Icon + aria-hidden + name={item.icon} + size='sm' + /> + <span {...themeProps('profile-page-navigation-label')}>{item.label}</span> + </Tabs.Tab> + ))} + </Tabs.List> + </nav> + {renderBranding ? ( + <div {...mergeStyleProps(themeProps('profile-page-branding'), 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' + {...mergeStyleProps( + themeProps('profile-page-branding-link'), + stylex.props(reset.base, styles.brandingLink), + )} + > + <ClerkLogo height={12} /> + </a> + </div> + ) : null} + </> + ), + }, + }); +}); + +export interface ProfilePageContentProps extends Omit<MosaicComponentProps<'main'>, 'children'> { + children: React.ReactNode; +} + +const ProfilePageContent = React.forwardRef<HTMLElement, ProfilePageContentProps>(function ProfilePageContent( + { children, render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'main', + render, + ref, + props: { + ...mergeStyleProps(themeProps('profile-page-main'), stylex.props(reset.base, styles.main), className, style), + ...rest, + children: ( + <div {...mergeStyleProps(themeProps('profile-page-content'), stylex.props(reset.base, styles.content))}> + {children} + </div> + ), + }, + }); +}); + +export interface ProfilePagePanelProps extends MosaicComponentProps<'div'> { + value: string; +} + +const ProfilePagePanel = React.forwardRef<HTMLDivElement, ProfilePagePanelProps>(function ProfilePagePanel( + { value, className, style, ...rest }, + ref, +) { + return ( + <Tabs.Panel + ref={ref} + value={value} + {...mergeStyleProps(themeProps('profile-page-panel', { value }), className, style)} + {...rest} + /> + ); +}); + +export const ProfilePage = { + Root: ProfilePageRoot, + Sidebar: ProfilePageSidebar, + Content: ProfilePageContent, + Panel: ProfilePagePanel, +}; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index f4f0ba9c26a..e1899b7ca86 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -5,6 +5,14 @@ // as components migrate. export type { MosaicComponentProps, MosaicElementProps } from '../props'; +export { ProfilePage } from '../profile-page'; +export type { + ProfilePageContentProps, + ProfilePageItem, + ProfilePagePanelProps, + ProfilePageRootProps, + ProfilePageSidebarProps, +} from '../profile-page'; export { AlertDialog, createConfirmHandle, useConfirmedClose } from '../components/alert-dialog'; export type { 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 index a7f73cfe61c..ae75a6a006d 100644 --- 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 @@ -51,10 +51,16 @@ describe('UserPageView', () => { 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('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + const accountTab = screen.getByRole('tab', { name: 'Account' }); + const accountPanel = screen.getByRole('tabpanel'); + + expect(accountTab).toHaveAttribute('aria-selected', 'true'); + expect(accountTab).toHaveAttribute('aria-controls', accountPanel.id); + expect(screen.getByRole('tab', { name: 'Security' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Billing' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'API Keys' })).toBeInTheDocument(); + expect(accountPanel).toHaveAccessibleName('Account'); expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); expect(screen.getByText('Secured by')).toBeInTheDocument(); }); @@ -64,24 +70,51 @@ describe('UserPageView', () => { const user = userEvent.setup(); renderView({ onPanelChange }); - await user.click(screen.getByRole('button', { name: 'Security' })); + await user.click(screen.getByRole('tab', { name: 'Security' })); expect(onPanelChange).toHaveBeenCalledWith('security'); expect(screen.queryByRole('button', { name: 'Close user profile' })).not.toBeInTheDocument(); }); + it('supports sidebar keyboard navigation through the tabs primitive', async () => { + const onPanelChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPanelChange }); + + screen.getByRole('tab', { name: 'Account' }).focus(); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('tab', { name: 'Security' })).toHaveFocus(); + expect(onPanelChange).toHaveBeenCalledWith('security'); + }); + + it('reflects navigation state through stable Mosaic styling hooks', () => { + renderView({ activePanel: 'security' }); + + expect(screen.getByRole('tab', { name: 'Security' })).toHaveClass('cl-profile-page-navigation-item'); + expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute('data-selected'); + expect(screen.getByRole('tab', { name: 'Account' })).not.toHaveAttribute('data-selected'); + }); + + it('merges consumer styling props onto the page root', () => { + const { container } = renderView({ className: 'custom-page', style: { maxWidth: 900 } }); + + expect(container.firstChild).toHaveClass('cl-profile-page', 'custom-page'); + expect(container.firstChild).toHaveStyle({ maxWidth: '900px' }); + }); + 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(); + expect(screen.queryByRole('tab', { name: 'Security' })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Billing' })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { 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('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('heading', { level: 3, name: 'Account' })).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 index 907eaf111c4..9d302d466db 100644 --- a/packages/ui/src/mosaic/user-profile/user-page.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-page.view.tsx @@ -1,8 +1,7 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactElement } from 'react'; +import React from 'react'; -import { mergeStyleProps, themeProps } from '../props'; -import { styles } from './user-profile.styles'; +import type { ProfilePageRootProps } from '../profile-page'; +import { ProfilePage } from '../profile-page'; 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'; @@ -21,7 +20,7 @@ export interface UserPagePanels { apiKeys?: UserProfileApiKeysPanelViewProps; } -export interface UserPageViewProps { +export interface UserPageViewProps extends Omit<ProfilePageRootProps, 'children' | 'value' | 'onValueChange'> { activePanel: UserProfilePanelId; panels: UserPagePanels; onPanelChange: (panel: UserProfilePanelId) => void; @@ -37,7 +36,7 @@ function getAvailablePanels(panels: UserPagePanels): UserProfilePanelId[] { ]; } -function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPagePanels }): ReactElement { +function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPagePanels }): React.ReactElement { switch (panel) { case 'security': return panels.security ? ( @@ -62,31 +61,46 @@ function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPageP } } -export function UserPageView({ - activePanel, - panels, - onPanelChange, - renderBranding = true, -}: UserPageViewProps): ReactElement { +export const UserPageView = React.forwardRef<HTMLDivElement, UserPageViewProps>(function UserPageView( + { activePanel, panels, onPanelChange, renderBranding = true, render, className, style, ...rest }, + ref, +) { const availablePanels = getAvailablePanels(panels); const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'account'; + const handlePanelChange = (value: string) => { + const panel = availablePanels.find(candidate => candidate === value); + if (panel) { + onPanelChange(panel); + } + }; return ( - <div {...mergeStyleProps(themeProps('user-page'), stylex.props(styles.root))}> + <ProfilePage.Root + ref={ref} + value={resolvedPanel} + onValueChange={handlePanelChange} + render={render} + className={className} + style={style} + {...rest} + > <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> + <ProfilePage.Content> + {availablePanels.map(panel => ( + <ProfilePage.Panel + key={panel} + value={panel} + > + <Panel + panel={panel} + panels={panels} + /> + </ProfilePage.Panel> + ))} + </ProfilePage.Content> + </ProfilePage.Root> ); -} +}); 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 47fb979d2fc..539b9ee02a8 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 @@ -15,7 +15,7 @@ export const styles = stylex.create({ width: space['5'], }, providerMedia: { - borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', + borderColor: `light-dark(${colorVars['--cl-color-border-faded']}, ${colorVars['--cl-color-background']})`, borderRadius: radiusVars['--cl-radius-lg'], borderStyle: 'solid', borderWidth: '1px', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx index a04d06e9f3c..1d1b6f9cafd 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx @@ -1,12 +1,8 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactElement } from 'react'; +import React 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'; +import type { ProfilePageSidebarProps } from '../profile-page'; +import { ProfilePage } from '../profile-page'; export type UserProfilePanelId = 'account' | 'security' | 'billing' | 'api-keys'; @@ -17,61 +13,21 @@ const destinations: Record<UserProfilePanelId, { label: string; icon: IconName } 'api-keys': { label: 'API Keys', icon: 'code' }, }; -export interface UserProfileSidebarProps { - activePanel: UserProfilePanelId; +export interface UserProfileSidebarProps extends Omit<ProfilePageSidebarProps, 'items' | 'navigationLabel'> { panels: readonly UserProfilePanelId[]; - onPanelChange: (panel: UserProfilePanelId) => void; renderBranding?: boolean; } -export function UserProfileSidebar({ - activePanel, - panels, - onPanelChange, - renderBranding = true, -}: UserProfileSidebarProps): ReactElement { +export const UserProfileSidebar = React.forwardRef<HTMLElement, UserProfileSidebarProps>(function UserProfileSidebar( + { panels, ...rest }, + ref, +) { 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> + <ProfilePage.Sidebar + ref={ref} + items={panels.map(value => ({ value, ...destinations[value] }))} + navigationLabel='User profile' + {...rest} + /> ); -} +}); From 665aca3e56a394431fa3fe07f3b1536a8979a457 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Thu, 20 Aug 2026 09:21:42 -0600 Subject: [PATCH 41/43] refactor(ui): control account layout with allowMultipleAccounts --- packages/swingset/src/lib/registry.ts | 2 ++ .../src/stories/user-page.stories.tsx | 1 + .../stories/user-profile-account-section.mdx | 16 ++++++++++++- .../user-profile-account-section.stories.tsx | 23 +++++++++++++++---- .../stories/user-profile-profile-panel.mdx | 1 + .../user-profile-profile-panel.stories.tsx | 1 + .../user-profile-profile-panel.view.test.tsx | 14 +++++++---- .../user-profile-account-section.view.tsx | 11 +++++---- .../user-profile-profile-panel.view.tsx | 2 ++ 9 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 05d34dccb2d..dbf534ce294 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -105,6 +105,7 @@ import { Default as UserPageDefault, meta as userPageMeta } from '../stories/use import { Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, + MultipleAccounts as UserProfileAccountSectionMultipleAccounts, } from '../stories/user-profile-account-section.stories'; import { Default as UserProfileActiveDevicesSectionDefault, @@ -294,6 +295,7 @@ const userPageModule: StoryModule = { const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, + MultipleAccounts: UserProfileAccountSectionMultipleAccounts, }; const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx index d07565d467a..bcb7f35e382 100644 --- a/packages/swingset/src/stories/user-page.stories.tsx +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -101,6 +101,7 @@ export function Default() { const panels: UserPageViewProps['panels'] = { account: { + allowMultipleAccounts: true, imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', name: 'Preston Booth', username: 'prestonxyz', diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index 1b065919bd8..24133538ce5 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -2,7 +2,10 @@ import * as Stories from './user-profile-account-section.stories'; # UserProfileAccountSection -Account details, profile image, email addresses, and phone numbers composed with `Section`. +Account details, profile image, email addresses, and phone numbers composed with `Section`. The +`allowMultipleAccounts` flag controls whether contact methods appear inline or in dedicated sections. + +## Single account <Story name='Default' @@ -12,3 +15,14 @@ Account details, profile image, email addresses, and phone numbers composed with { name: 'Avatar', href: '/components/avatar', layer: 'Components' }, ]} /> + +## Multiple accounts + +<Story + name='MultipleAccounts' + storyModule={Stories} + composition={[ + { name: 'Section', href: '/components/section', layer: 'Components' }, + { name: 'Avatar', href: '/components/avatar', layer: 'Components' }, + ]} +/> 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 bba49d88af4..bf123b62230 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -17,17 +17,22 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx', }; -export function Default() { - 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 }, - ]); +function AccountSection({ allowMultipleAccounts }: { allowMultipleAccounts: boolean }) { + const [emails, setEmails] = useState<UserProfileEmail[]>( + allowMultipleAccounts + ? [ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + ] + : [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], + ); const [phones, setPhones] = useState<UserProfilePhone[]>([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); return ( <UserProfileAccountSectionView + allowMultipleAccounts={allowMultipleAccounts} emails={emails} imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4' name='Preston Booth' @@ -59,3 +64,11 @@ export function Default() { /> ); } + +export function Default() { + return <AccountSection allowMultipleAccounts={false} />; +} + +export function MultipleAccounts() { + return <AccountSection allowMultipleAccounts />; +} diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 62fefa81847..c9c59d8ae16 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -23,6 +23,7 @@ while preserving the existing resource props and callback seams. import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; <UserProfileProfilePanelView + allowMultipleAccounts={allowMultipleAccounts} imageUrl={user.imageUrl} name={user.fullName ?? ''} username={user.username ?? ''} 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 7662754284e..3a1b0164cc3 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -28,6 +28,7 @@ export function Default(_args: Record<string, unknown>) { return ( <UserProfileProfilePanelView + allowMultipleAccounts emails={emails} connectedAccounts={[ { 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 33a98752cd1..5d3e26e4c52 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 @@ -7,6 +7,7 @@ import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-p import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; const props: UserProfileProfilePanelViewProps = { + allowMultipleAccounts: true, name: 'Preston Booth', username: 'prestonxyz', emails: [ @@ -68,8 +69,12 @@ describe('UserProfileProfilePanelView', () => { expect(onEditProfilePicture).toHaveBeenCalledOnce(); }); - it('breaks out both contact types when either has multiple entries', () => { - renderView({ onAddEmail: vi.fn(), onAddPhone: vi.fn() }); + it('breaks out both contact types when multiple accounts are allowed', () => { + renderView({ + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], + onAddEmail: vi.fn(), + onAddPhone: vi.fn(), + }); const accountSection = screen.getByRole('region', { name: 'Account' }); const emailSection = screen.getByRole('region', { name: 'Email' }); @@ -78,14 +83,14 @@ describe('UserProfileProfilePanelView', () => { 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', () => { + it('keeps both contact types inside Account when multiple accounts are not allowed', () => { renderView({ + allowMultipleAccounts: false, emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], onManageEmail: vi.fn(), onManagePhone: vi.fn(), @@ -106,6 +111,7 @@ describe('UserProfileProfilePanelView', () => { const onManagePhone = vi.fn(); const user = userEvent.setup(); renderView({ + allowMultipleAccounts: false, emails: [], onAddEmail, onManagePhone, 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 25202fce18d..1e27884859d 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 @@ -26,6 +26,7 @@ export interface UserProfilePhone { } export interface UserProfileAccountSectionViewProps { + allowMultipleAccounts?: boolean; imageUrl?: string; name: string; username: string; @@ -47,6 +48,7 @@ export interface UserProfileAccountSectionViewProps { } export function UserProfileAccountSectionView({ + allowMultipleAccounts = false, imageUrl, name, username, @@ -74,7 +76,6 @@ 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 ( <div {...stylex.props(styles.sections)}> @@ -150,7 +151,7 @@ export function UserProfileAccountSectionView({ ) : null} </Section.Item> </Section.Row> - {!shouldBreakOutContacts ? ( + {!allowMultipleAccounts ? ( <SingleContactRow items={emails} kind='email' @@ -159,7 +160,7 @@ export function UserProfileAccountSectionView({ onManage={onManageEmail} /> ) : null} - {!shouldBreakOutContacts ? ( + {!allowMultipleAccounts ? ( <SingleContactRow items={phones} kind='phone' @@ -170,7 +171,7 @@ export function UserProfileAccountSectionView({ ) : null} </Section.Group> </Section.Root> - {shouldBreakOutContacts ? ( + {allowMultipleAccounts ? ( <ContactSection items={emails} kind='email' @@ -182,7 +183,7 @@ export function UserProfileAccountSectionView({ onVerify={onVerifyEmail} /> ) : null} - {shouldBreakOutContacts ? ( + {allowMultipleAccounts ? ( <ContactSection items={phones} kind='phone' 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 42947b075ea..5e00b16a71b 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 @@ -32,6 +32,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect } export function UserProfileProfilePanelView({ + allowMultipleAccounts, imageUrl, name = '', username = '', @@ -71,6 +72,7 @@ export function UserProfileProfilePanelView({ </Heading> <div {...stylex.props(styles.sections)}> <UserProfileAccountSectionView + allowMultipleAccounts={allowMultipleAccounts} emails={emails} imageUrl={imageUrl} name={name} From e9e1fc4be376a33eb8f2e39d87a45a9e04a1cb61 Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Thu, 20 Aug 2026 09:44:41 -0600 Subject: [PATCH 42/43] refactor(ui): simplify section item dividers --- .../src/mosaic/components/section/section.styles.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/mosaic/components/section/section.styles.ts b/packages/ui/src/mosaic/components/section/section.styles.ts index f324b95efb2..57991bbc1c5 100644 --- a/packages/ui/src/mosaic/components/section/section.styles.ts +++ b/packages/ui/src/mosaic/components/section/section.styles.ts @@ -54,14 +54,9 @@ export const styles = stylex.create({ 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: { @@ -70,9 +65,11 @@ export const styles = stylex.create({ [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: space['4'], }, alignItems: 'center', - backgroundColor: { - default: null, - [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: colorVars['--cl-color-card'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { + default: '0px', + [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: '1px', }, columnGap: space['3'], display: 'flex', From d21d04c3dd741f40667faccdebad7a974d9ecd9c Mon Sep 17 00:00:00 2001 From: austincalvelage <austin.calvelage@icloud.com> Date: Thu, 20 Aug 2026 10:31:36 -0600 Subject: [PATCH 43/43] docs(swingset): document account section dependencies --- packages/swingset/src/stories/user-profile-account-section.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index 24133538ce5..e7a319005b4 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -24,5 +24,8 @@ Account details, profile image, email addresses, and phone numbers composed with composition={[ { name: 'Section', href: '/components/section', layer: 'Components' }, { name: 'Avatar', href: '/components/avatar', layer: 'Components' }, + { name: 'Button', href: '/components/button', layer: 'Components' }, + { name: 'Badge', href: '/components/badge', layer: 'Components' }, + { name: 'Icon', href: '/components/icon', layer: 'Components' }, ]} />