From e39d4528bf1cf045466240b4432a135b5276b0ff Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 25 Sep 2026 15:12:30 -0600 Subject: [PATCH] feat(mosaic): contained section groups and Section.Surface Section.Group pairs an optional Section.Title with a new Section.Surface that draws the card. Groups take variant='contained' for nested lists, rendered as Section.Header + Section.Items inside the surface, and --cl-section-row-min-height exposes the row height. The account, active devices, and authentication sections render one
with contained groups instead of sibling sections. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/section-contained-groups.md | 2 + .../mosaic/src/components/section/index.ts | 3 + .../section/section.markers.stylex.ts | 2 +- .../src/components/section/section.styles.ts | 62 +- .../src/components/section/section.test.tsx | 137 ++-- .../mosaic/src/components/section/section.tsx | 56 +- ...ganization-profile-danger-section.view.tsx | 48 +- ...ization-profile-workspace-section.view.tsx | 36 +- .../user-profile-profile-panel.view.test.tsx | 18 +- .../user-profile-security-panel.view.test.tsx | 8 +- .../user-profile-account-section.styles.ts | 12 - .../user-profile-account-section.view.tsx | 27 +- .../user-profile-contact-list-row.view.tsx | 153 +++-- .../user-profile-email-row.view.tsx | 19 +- .../user-profile-phone-row.view.tsx | 19 +- ...er-profile-active-devices-section.view.tsx | 61 +- ...rofile-connected-accounts-section.view.tsx | 40 +- .../user-profile-delete-section.view.tsx | 68 +- ...ofile-enterprise-accounts-section.view.tsx | 38 +- .../user-profile-mfa-section.view.tsx | 6 +- .../user-profile-passkeys-section.styles.ts | 4 + .../user-profile-passkeys-section.view.tsx | 138 ++-- .../user-profile-password-section.types.ts | 2 + .../user-profile-password-section.view.tsx | 13 +- ...r-profile-payment-methods-section.view.tsx | 8 +- .../user-profile-security-list.tsx | 99 +-- .../user-profile-security-panel.styles.ts | 3 - .../user-profile-security-panel.view.tsx | 72 +- ...user-profile-subscription-section.view.tsx | 62 +- ...user-profile-web3-wallets-section.view.tsx | 40 +- packages/mosaic/src/tokens.stylex.ts | 4 + packages/swingset/src/lib/exampleSnippet.ts | 17 +- packages/swingset/src/lib/registry.ts | 2 - .../src/stories/panel.component.stories.tsx | 38 +- .../src/stories/profile.component.stories.tsx | 16 +- packages/swingset/src/stories/section.mdx | 48 +- .../swingset/src/stories/section.stories.tsx | 648 ++++++++++-------- 37 files changed, 1132 insertions(+), 897 deletions(-) create mode 100644 .changeset/section-contained-groups.md delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.styles.ts diff --git a/.changeset/section-contained-groups.md b/.changeset/section-contained-groups.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/section-contained-groups.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/section/index.ts b/packages/mosaic/src/components/section/index.ts index 8b920fdc6fe..95b9909c07e 100644 --- a/packages/mosaic/src/components/section/index.ts +++ b/packages/mosaic/src/components/section/index.ts @@ -4,6 +4,8 @@ export type { SectionContentProps, SectionDescriptionProps, SectionGroupProps, + SectionGroupVariant, + SectionHeaderProps, SectionItemProps, SectionItemsProps, SectionLabelProps, @@ -11,5 +13,6 @@ export type { SectionMediaSize, SectionRootProps, SectionRowProps, + SectionSurfaceProps, SectionTitleProps, } from './section'; diff --git a/packages/mosaic/src/components/section/section.markers.stylex.ts b/packages/mosaic/src/components/section/section.markers.stylex.ts index b0755da6f9c..c2598c508aa 100644 --- a/packages/mosaic/src/components/section/section.markers.stylex.ts +++ b/packages/mosaic/src/components/section/section.markers.stylex.ts @@ -1,3 +1,3 @@ import * as stylex from '@stylexjs/stylex'; -export const sectionItemsMarker = stylex.defineMarker(); +export const sectionNestedItemMarker = stylex.defineMarker(); diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index a8978b6bf7f..9cc55d4fa0b 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -1,10 +1,8 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; -import { sectionItemsMarker } from './section.markers.stylex'; +import { colorVars, fontWeightVars, radiusVars, sectionVars, space, typeScaleVars } from '../../tokens.stylex'; +import { sectionNestedItemMarker } from './section.markers.stylex'; -/* eslint-disable @stylexjs/no-lookahead-selectors -- Mosaic's supported browsers include :has(); - the marker keeps this selector scoped to Section.Items. */ export const styles = stylex.create({ root: { display: 'flex', @@ -17,16 +15,22 @@ export const styles = stylex.create({ fontWeight: fontWeightVars['--cl-font-medium'], }, group: { + display: 'flex', + flexDirection: 'column', + rowGap: space['3'], + width: '100%', + }, + surface: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-xl'], borderStyle: 'solid', borderWidth: '1px', - overflow: 'hidden', backgroundColor: colorVars['--cl-color-background'], width: '100%', }, row: { marginInline: space['4'], + paddingBlock: space['4'], borderBlockStartColor: colorVars['--cl-color-border'], borderBlockStartStyle: 'solid', borderBlockStartWidth: { @@ -35,48 +39,41 @@ export const styles = stylex.create({ }, display: 'flex', flexDirection: 'column', - paddingBlockEnd: { - default: space['4'], - [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, - }, - paddingBlockStart: { - default: space['4'], - [stylex.when.descendant(':where(*)', sectionItemsMarker)]: space['3'], - }, - rowGap: { - default: space['2'], - [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, - }, - minHeight: { - default: `calc(${space['18.5']} + 1px)`, - [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, - }, + rowGap: space['2'], + minHeight: sectionVars['--cl-section-row-min-height'], + width: 'auto', + }, + header: { + marginInline: space['4'], + paddingBlock: space['3'], + borderBlockEndColor: colorVars['--cl-color-border'], + borderBlockEndStyle: 'solid', + borderBlockEndWidth: '1px', width: 'auto', }, items: { + paddingInline: space['4'], display: 'flex', flexDirection: 'column', - marginBlockStart: space['3'], width: '100%', }, item: { - paddingBlock: { - default: null, - [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: space['4'], - }, alignItems: 'center', - borderBlockStartColor: colorVars['--cl-color-border'], - borderBlockStartStyle: 'solid', - borderBlockStartWidth: { - default: '0px', - [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: '1px', - }, columnGap: space['3'], display: 'flex', flexWrap: 'nowrap', justifyContent: 'space-between', width: '100%', }, + nestedItem: { + paddingBlock: space['4'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { + default: '0px', + [stylex.when.siblingBefore(':where(*)', sectionNestedItemMarker)]: '1px', + }, + }, mediaBase: { alignItems: 'center', alignSelf: 'center', @@ -134,4 +131,3 @@ export const styles = stylex.create({ width: '100%', }, }); -/* eslint-enable @stylexjs/no-lookahead-selectors */ diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index 06d629f4ae9..10a38e3a6f5 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -20,23 +20,25 @@ describe('Section', () => { it('renders an accessible section and every compound part', () => { render( - Account - - - - Icon - - - Name - Shown throughout the application. - - Control - - + Account + + + + + Icon + + + Name + Shown throughout the application. + + Control + + + , ); @@ -44,6 +46,8 @@ describe('Section', () => { expect(screen.getByRole('region', { name: 'Account' })).toHaveClass('cl-section'); expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toHaveClass('cl-section-title'); expect(screen.getByTestId('group')).toHaveClass('cl-section-group'); + expect(screen.getByTestId('group')).toContainElement(screen.getByRole('heading', { name: 'Account' })); + expect(screen.getByTestId('surface')).toHaveClass('cl-section-surface'); expect(screen.getByTestId('row')).toHaveClass('cl-section-row'); expect(screen.getByTestId('item')).toHaveClass('cl-section-item'); expect(screen.getByTestId('media')).toHaveClass('cl-section-media'); @@ -79,8 +83,10 @@ describe('Section', () => { it('supports an explicit accessible name', () => { render( - Account - + + Account + + , ); @@ -88,18 +94,18 @@ describe('Section', () => { expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); }); - it('composes multiple items in one row', () => { + it('composes a header over a nested list', () => { render( - Profile - - + Profile + + Email Edit - + @@ -108,7 +114,7 @@ describe('Section', () => { More - + , ); @@ -116,29 +122,56 @@ describe('Section', () => { expect(screen.getByText('Email')).toBeInTheDocument(); expect(screen.getByText('ada@example.com')).toBeInTheDocument(); expect(screen.getAllByText(/Edit|More/)).toHaveLength(2); + expect(screen.getByTestId('header')).toHaveClass('cl-section-header'); expect(screen.getByTestId('items')).toHaveClass('cl-section-items'); - expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant'); + expect(screen.getByTestId('header')).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('retains public nesting hooks while deriving layout from the item collection structure', () => { + it('renders a contained group as a named group inside the one section', () => { + render( + + + Account + + + + + + , + ); + + expect(screen.getAllByRole('region')).toHaveLength(1); + expect(screen.getByTestId('group')).toHaveAttribute('data-variant', 'default'); + expect(screen.getByTestId('group')).not.toHaveAttribute('role'); + expect(screen.getByRole('group', { name: 'Email' })).toBe(screen.getByTestId('contained')); + expect(screen.getByTestId('contained')).toHaveAttribute('data-variant', 'contained'); + }); + + it('marks only items inside Section.Items as nested', () => { render( - - Email + + + Name + one@example.com two@example.com - + , ); - expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant'); + expect(screen.getByText('Name')).not.toHaveAttribute('data-nested'); expect(screen.getByText('one@example.com')).toHaveAttribute('data-nested'); expect(screen.getByText('two@example.com')).toHaveAttribute('data-nested'); }); @@ -156,22 +189,24 @@ describe('Section', () => { render={props =>
} xstyle={overrides.root} > - Account - - - - Name - - - - + Account + + + + + Name + + + + + , ); @@ -201,14 +236,16 @@ describe('Section', () => { render( - - - - Profile picture - - - File type not supported. - + + + + + Profile picture + + + File type not supported. + + , ); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 201c410c958..383ea270131 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -12,13 +12,16 @@ import { reset } from '../../utils/reset.styles'; import { sizes as typographySizes, styles as typographyStyles } from '../../utils/typography.styles'; import type { HeadingProps } from '../heading'; import { Heading, useHeadingLevel } from '../heading'; -import { sectionItemsMarker } from './section.markers.stylex'; +import { sectionNestedItemMarker } from './section.markers.stylex'; import { styles } from './section.styles'; export type SectionRootProps = Omit, 'title'>; export type SectionTitleProps = Omit; -export type SectionGroupProps = MosaicComponentProps<'div'>; +export type SectionGroupVariant = 'default' | 'contained'; +export type SectionGroupProps = MosaicComponentProps<'div'> & { variant?: SectionGroupVariant }; export type SectionRowProps = MosaicComponentProps<'div'>; +export type SectionSurfaceProps = MosaicComponentProps<'div'>; +export type SectionHeaderProps = MosaicComponentProps<'div'>; export type SectionItemsProps = MosaicComponentProps<'div'>; export type SectionItemProps = MosaicComponentProps<'div'>; export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; @@ -90,6 +93,39 @@ const Title = React.forwardRef(function S }); const Group = React.forwardRef(function SectionGroup( + { variant = 'default', render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + role: variant === 'contained' ? 'group' : undefined, + ...mergeStyleProps( + themeProps('section-group', { variant }), + stylex.props(reset.base, styles.group, xstyle), + rest, + ), + }, + }); +}); + +const Surface = React.forwardRef(function SectionSurface( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('section-surface'), stylex.props(reset.base, styles.surface, xstyle), rest), + }, + }); +}); + +const Header = React.forwardRef(function SectionHeader( { render, xstyle, ...rest }, ref, ) { @@ -98,7 +134,11 @@ const Group = React.forwardRef(function Secti render, ref, props: { - ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group, xstyle), rest), + ...mergeStyleProps( + themeProps('section-header'), + stylex.props(reset.base, styles.item, styles.header, xstyle), + rest, + ), }, }); }); @@ -114,7 +154,7 @@ const Items = React.forwardRef(function Secti props: { ...mergeStyleProps( themeProps('section-items', { nested: true }), - stylex.props(reset.base, styles.items, sectionItemsMarker, xstyle), + stylex.props(reset.base, styles.items, xstyle), rest, ), }, @@ -142,7 +182,11 @@ const Item = React.forwardRef(function Section render, ref, props: { - ...mergeStyleProps(themeProps('section-item', { nested }), stylex.props(reset.base, styles.item, xstyle), rest), + ...mergeStyleProps( + themeProps('section-item', { nested }), + stylex.props(reset.base, styles.item, nested && styles.nestedItem, nested && sectionNestedItemMarker, xstyle), + rest, + ), }, }); }); @@ -291,7 +335,9 @@ export const Section = { Root, Title, Group, + Surface, Row, + Header, Items, Item, Media, diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx index 6d70e5a9bef..fbb0d49f964 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.view.tsx @@ -26,30 +26,32 @@ export function OrganizationProfileDangerSectionView({ return ( - {m.sectionTitle} - {onLeave ? ( - - ) : null} - {onDelete ? ( - - ) : null} + {m.sectionTitle} + + {onLeave ? ( + + ) : null} + {onDelete ? ( + + ) : null} + ); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.view.tsx index 11d06db54d8..b7cfe351c7b 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-workspace-section/organization-profile-workspace-section.view.tsx @@ -32,24 +32,26 @@ export function OrganizationProfileWorkspaceSectionView({ return ( - {m.sectionTitle} - - - + {m.sectionTitle} + + + + + ); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index a12b39214b2..7a1a0190f6e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -213,7 +213,7 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('alert')).toBeNull(); }); - it('breaks out both contact types when multiple accounts are allowed', () => { + it('nests both contact types as groups inside Account when multiple accounts are allowed', () => { renderView({ emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], onAddEmail: vi.fn(), @@ -222,11 +222,13 @@ describe('UserProfileProfilePanelView', () => { }); const accountSection = screen.getByRole('region', { name: 'Account' }); - const emailSection = screen.getByRole('region', { name: 'Email' }); - const phoneSection = screen.getByRole('region', { name: 'Phone' }); + const emailSection = within(accountSection).getByRole('group', { name: 'Email' }); + const phoneSection = within(accountSection).getByRole('group', { name: 'Phone' }); - expect(accountSection).not.toContainElement(emailSection); - expect(accountSection).not.toContainElement(phoneSection); + expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Phone' })).not.toBeInTheDocument(); + expect(emailSection).toHaveAttribute('data-variant', 'contained'); + expect(emailSection.querySelector('.cl-section-header')).toHaveTextContent('Email'); expect(emailSection).toHaveTextContent('item1@clerk.dev'); expect(phoneSection).toHaveTextContent('+1 (801) 888-8181'); expect(within(emailSection).getByRole('button', { name: 'Add email' })).toHaveTextContent('Add'); @@ -247,8 +249,8 @@ describe('UserProfileProfilePanelView', () => { 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(); + expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Phone' })).not.toBeInTheDocument(); }); it('forwards inline contact update and add actions', async () => { @@ -273,7 +275,7 @@ describe('UserProfileProfilePanelView', () => { it('renders an actionable empty state when no phone number exists', () => { renderView({ phones: [], onSendPhoneCode: () => Promise.resolve(), onVerifyPhoneCode: () => Promise.resolve() }); - const phoneSection = screen.getByRole('region', { name: 'Phone' }); + const phoneSection = screen.getByRole('group', { name: 'Phone' }); const emptyState = within(phoneSection).getByText('No phone numbers added'); expect(emptyState.closest('.cl-section-items')).not.toBeNull(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index f1bab06b2aa..eca770a25c6 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -67,8 +67,8 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.getByText('Password')).toBeVisible(); expect(screen.getByText('Passkeys')).toBeVisible(); expect(screen.getByText('2-step verification')).toBeVisible(); - expect(screen.getByRole('region', { name: 'Passkeys' })).toBeInTheDocument(); - expect(screen.getByRole('region', { name: '2-step verification' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: '2-step verification' })).toBeInTheDocument(); expect(screen.getByText('This device')).toBeInTheDocument(); expect(screen.getByText('2 other devices')).toBeInTheDocument(); expect( @@ -131,7 +131,7 @@ describe('UserProfileSecurityPanelView', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove', exact: true })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - const otherDevices = screen.getByRole('region', { name: 'Other devices' }); + const otherDevices = screen.getByRole('group', { name: 'Other devices' }); await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); @@ -203,7 +203,7 @@ describe('UserProfileSecurityPanelView', () => { expect(within(section).getByText('No passkeys added')).toBeVisible(); expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); - expect(screen.getByRole('region', { name: '2-step verification' })).toBeVisible(); + expect(screen.getByRole('group', { name: '2-step verification' })).toBeVisible(); }); it('keeps the empty section and final passkey confirmation mounted without Add', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.styles.ts deleted file mode 100644 index 73d555e346a..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.styles.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { space } from '../../../tokens.stylex'; - -export const styles = stylex.create({ - sections: { - gap: space['8'], - display: 'flex', - flexDirection: 'column', - width: '100%', - }, -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 890216a2425..0ec26bf8a0f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,9 +1,6 @@ -import * as stylex from '@stylexjs/stylex'; - import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; -import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -115,10 +112,10 @@ export function UserProfileAccountSectionView({ ); return ( -
- + + {m.sectionTitle} - + {!allowMultipleAccounts ? emailRow : null} {!allowMultipleAccounts ? phoneRow : null} - - - {allowMultipleAccounts ? ( - - {emailRow} - - ) : null} - {allowMultipleAccounts ? ( - - {phoneRow} - - ) : null} -
+ + + {allowMultipleAccounts ? emailRow : null} + {allowMultipleAccounts ? phoneRow : null} + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx index d21c5abd97b..73a0eb05615 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -20,6 +20,7 @@ export interface UserProfileContactListRowViewProps { onVerify?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void; + children?: ReactNode; } export function UserProfileContactListRowView({ @@ -33,92 +34,96 @@ export function UserProfileContactListRowView({ addAction, rowRef, triggerRef, + children, }: UserProfileContactListRowViewProps) { const m = useMessages('userProfileAccountSection'); const emptyDescription = m[kind].empty; return ( - - - - {label} - - {addAction ? ( - {addAction} - ) : onAdd ? ( - - - - ) : null} - - - {items.length === 0 ? ( - - - {emptyDescription} - - - ) : ( - items.map(item => { - const actions: ActionMenuAction[] = []; + variant='outline' + onClick={onAdd} + > + + {m.add} + + + ) : null} + + + {items.length === 0 ? ( + + + {emptyDescription} + + + ) : ( + items.map(item => { + const actions: ActionMenuAction[] = []; - if (item.isVerified === false && onVerify) { - actions.push({ - label: item.isDefault ? m.completeVerification : m[kind].verify, - onClick: () => onVerify(item.id), - }); - } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { - actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); - } + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? m.completeVerification : m[kind].verify, + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); + } - if (onRemove && item.canRemove !== false) { - actions.push({ - label: m[kind].remove, - color: 'negative', - onClick: () => onRemove(item.id), - }); - } + if (onRemove && item.canRemove !== false) { + actions.push({ + label: m[kind].remove, + color: 'negative', + onClick: () => onRemove(item.id), + }); + } - return ( - - - - {item.value} - {item.isDefault ? {m.primary} : null} - - - {actions.length > 0 ? ( - - - - ) : null} - - ); - }) - )} - - + return ( + + + + {item.value} + {item.isDefault ? {m.primary} : null} + + + {actions.length > 0 ? ( + + + + ) : null} + + ); + }) + )} + + {children} + + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx index 471492e5a03..11a4674b6b8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -121,15 +121,16 @@ export function UserProfileEmailRowView({ onRemove={onRemoveEmail ? removeEmail : undefined} onSetPrimary={onSetPrimaryEmail && !isSettingPrimary ? id => void setPrimaryEmail(id) : undefined} onVerify={onVerifyEmail} - /> - {primaryError ? ( - - {primaryError} - - ) : null} + > + {primaryError ? ( + + {primaryError} + + ) : null} + {onRemoveEmail ? ( void setPrimaryPhone(id) : undefined} onVerify={onVerifyPhone} - /> - {primaryError ? ( - - {primaryError} - - ) : null} + > + {primaryError ? ( + + {primaryError} + + ) : null} + {onRemovePhone ? ( - {m.title} - {currentDevices.length > 0 ? ( - currentDevices.map(device => ( - - deviceDetails.open(device)} - /> + {m.title} + + {currentDevices.length > 0 ? ( + currentDevices.map(device => ( + + deviceDetails.open(device)} + /> + + )) + ) : ( + + + + {m.emptyCurrent} + + - )) - ) : ( - - - - {m.emptyCurrent} - - - - )} + )} + - - {otherDevices.length > 0 ? ( - - - - + {otherDevices.length > 0 ? ( + + + {fill(otherDevices.length === 1 ? m.otherDevice : m.otherDevices, { @@ -133,7 +136,7 @@ export function UserProfileActiveDevicesSectionView({ ) : null} - + {otherDevices.map(device => ( ))} - + - - ) : null} + ) : null} + {onSignOutAllOtherDevices ? ( - {m.title} - {accounts.map(account => ( - removeAccount.open(account) : undefined} - /> - ))} - {onConnect - ? availableProviders.map(provider => ( - - )) - : null} + {m.title} + + {accounts.map(account => ( + removeAccount.open(account) : undefined} + /> + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} + ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx index d9c00997b64..8587672cc68 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-delete-section/user-profile-delete-section.view.tsx @@ -20,40 +20,42 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect return ( - {m.sectionTitle} - - - - {m.sectionLabel} - {m.sectionDescription} - - - - {m.actionLabel} - - } - title={m.dialogTitle} - description={m.dialogDescription} - fieldLabel={fill(m.fieldLabel, { phrase: m.fieldPlaceholder })} - confirmationValue={m.fieldPlaceholder} - actionLabel={m.actionLabel} - cancelLabel={m.cancelLabel} - onDelete={onConfirm} - isDeleting={isDeleting} - errorMessage={errorMessage} - /> - - - + {m.sectionTitle} + + + + + {m.sectionLabel} + {m.sectionDescription} + + + + {m.actionLabel} + + } + title={m.dialogTitle} + description={m.dialogDescription} + fieldLabel={fill(m.fieldLabel, { phrase: m.fieldPlaceholder })} + confirmationValue={m.fieldPlaceholder} + actionLabel={m.actionLabel} + cancelLabel={m.cancelLabel} + onDelete={onConfirm} + isDeleting={isDeleting} + errorMessage={errorMessage} + /> + + + + ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx index c6321e37602..ce2cec3856d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx @@ -25,25 +25,27 @@ export function UserProfileEnterpriseAccountsSectionView({ } return ( - {m.title} - {accounts.map(account => ( - - ))} - {onConnect - ? connections.map(connection => ( - - )) - : null} + {m.title} + + {accounts.map(account => ( + + ))} + {onConnect + ? connections.map(connection => ( + + )) + : null} + ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx index 1a5270fe6ce..e09d512a622 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section.view.tsx @@ -27,6 +27,8 @@ export interface UserProfileMfaSectionViewProps { addButtonRef?: Ref; addControl?: ReactNode; sectionTitle?: string; + /** Renders only the group, for composing inside a parent `Section.Root`. */ + asGroup?: boolean; onAdd?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; onRemove?: (id: string) => void | Promise; @@ -39,13 +41,14 @@ export function UserProfileMfaSectionView({ addButtonRef, addControl, sectionTitle, + asGroup, onAdd, onRegenerateBackupCodes, onRemove, onSetDefault, }: UserProfileMfaSectionViewProps) { const m = useMessages('userProfileMfa'); - const section = useRef(null); + const section = useRef(null); const removalFocus = useListRemovalFocus({ ids: methods.map(method => method.id), onRemove, @@ -108,6 +111,7 @@ export function UserProfileMfaSectionView({ hasItems={methods.length > 0} label={m.label} sectionTitle={sectionTitle} + asGroup={asGroup} > {methods.map(method => ( void; addError?: string; onRename?: (id: string, name: string) => void | Promise; @@ -30,13 +32,14 @@ export interface UserProfilePasskeysSectionViewProps { export function UserProfilePasskeysSectionView({ passkeys, sectionTitle, + asGroup = false, onAdd, addError, onRename, onRemove, }: UserProfilePasskeysSectionViewProps) { const addButton = useRef(null); - const section = useRef(null); + const section = useRef(null); const removalFocus = useListRemovalFocus({ ids: passkeys.map(passkey => passkey.id), onRemove, @@ -44,70 +47,83 @@ export function UserProfilePasskeysSectionView({ }); const removePasskey = useMemo(() => Confirmation.createHandle(), []); - return ( - <> - - {sectionTitle ? {sectionTitle} : null} - - + const group = ( + + {sectionTitle ? {sectionTitle} : null} + + + + {m.label} + + {onAdd ? ( + + + + ) : null} + + {addError} + + + {passkeys.length > 0 ? ( + + {passkeys.map(passkey => ( + removePasskey.open(passkey) : undefined} + /> + ))} + + ) : ( + - {m.label} + {m.empty} - {onAdd ? ( - - - - ) : null} - - {addError} - - {passkeys.length > 0 ? ( - - {passkeys.map(passkey => ( - removePasskey.open(passkey) : undefined} - /> - ))} - - ) : ( - - - - {m.empty} - - - - )} - - - + + )} + + + ); + + return ( + <> + {asGroup ? ( + group + ) : ( + + {group} + + )} {onRemove ? ( + const group = ( + {title ? {title} : null} - + - - + + ); + + return asGroup ? group : {group}; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section.view.tsx index 1a58aa7fd1f..ae10af701ec 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-payment-methods-section.view.tsx @@ -30,8 +30,8 @@ export function UserProfilePaymentMethodsSectionView({ return ( - - + + Payment methods @@ -53,7 +53,7 @@ export function UserProfilePaymentMethodsSectionView({ ) : null} - + {paymentMethods.length > 0 ? ( paymentMethods.map(paymentMethod => ( @@ -72,7 +72,7 @@ export function UserProfilePaymentMethodsSectionView({ )} - + ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx index e8a2ee18d70..ab576a72b63 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx @@ -7,6 +7,7 @@ import { Section } from '../../components/section'; export function UserProfileSecurityList({ sectionTitle, sectionRef, + asGroup = false, label, addLabel, emptyLabel, @@ -16,7 +17,8 @@ export function UserProfileSecurityList({ children, }: { sectionTitle?: string; - sectionRef?: Ref; + sectionRef?: Ref; + asGroup?: boolean; label: string; addLabel: string; emptyLabel: string; @@ -25,53 +27,64 @@ export function UserProfileSecurityList({ addControl?: ReactNode; children: ReactNode; }) { - return ( + const group = ( + + {sectionTitle ? {sectionTitle} : null} + + + + {label} + + {addControl ? ( + {addControl} + ) : onAdd ? ( + + + + ) : null} + + + {hasItems ? ( + children + ) : ( + + + {emptyLabel} + + + )} + + + + ); + + return asGroup ? ( + group + ) : ( - {sectionTitle ? {sectionTitle} : null} - - - - - {label} - - {addControl ? ( - {addControl} - ) : onAdd ? ( - - - - ) : null} - - - {hasItems ? ( - children - ) : ( - - - {emptyLabel} - - - )} - - - + {group} ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts index 67454eca28a..ab7f24daae3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.styles.ts @@ -27,7 +27,4 @@ export const styles = stylex.create({ display: 'flex', flexDirection: 'column', }, - emptySectionCards: { - display: 'none', - }, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 0062d803e29..d1445ccf154 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -1,7 +1,7 @@ -import * as stylex from '@stylexjs/stylex'; import type { ReactElement, ReactNode } from 'react'; import { Panel } from '../../components/panel'; +import { Section } from '../../components/section'; import { themeProps } from '../../props'; import type { UserProfileActiveDevicesSectionViewProps, @@ -19,7 +19,6 @@ import type { UserProfilePasswordSectionViewProps, } from './user-profile-password-section/user-profile-password-section.view'; import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; -import { styles } from './user-profile-security-panel.styles'; export type { UserProfileDevice, @@ -86,38 +85,43 @@ export function UserProfileSecurityPanelView({ }> Security -
- {showPassword ? ( - - ) : null} - {showPasskeys ? ( - - ) : null} - {mfaMethods !== undefined ? ( - - ) : null} -
+ {hasAuthentication ? ( + + {showPassword ? ( + + ) : null} + {showPasskeys ? ( + + ) : null} + {mfaMethods !== undefined ? ( + + ) : null} + + ) : null} {devices ? ( - Subscription - - - - {subscription.planName} - {subscription.priceLabel} - - {onChangePlan ? ( + Subscription + + + + + {subscription.planName} + {subscription.priceLabel} + + {onChangePlan ? ( + + + + ) : null} + + + + + + Total due + {subscription.renewsAtLabel} + - + {subscription.totalDueLabel} - ) : null} - - - - - - Total due - {subscription.renewsAtLabel} - - - {subscription.totalDueLabel} - - - + + + ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx index f4cd5ad851b..9259b1b0397 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -63,26 +63,28 @@ export function UserProfileWeb3WalletsSectionView({ ref={section} tabIndex={-1} > - {m.title} - {wallets.map(wallet => ( - removeWallet.open(wallet) : undefined} - /> - ))} - {onConnect - ? availableProviders.map(provider => ( - - )) - : null} + {m.title} + + {wallets.map(wallet => ( + removeWallet.open(wallet) : undefined} + /> + ))} + {onConnect + ? availableProviders.map(provider => ( + + )) + : null} + ) : null} diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 256a7957d35..8011f3c821f 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -296,6 +296,10 @@ export const space = stylex.defineVars({ '40': step(40), }); +export const sectionVars = stylex.defineVars({ + '--cl-section-row-min-height': `calc(${space['18.5']} + 1px)`, +}); + // ============================================================================= // Typography Tokens — type scale // ============================================================================= diff --git a/packages/swingset/src/lib/exampleSnippet.ts b/packages/swingset/src/lib/exampleSnippet.ts index 446e57c27af..8a9fd7f8440 100644 --- a/packages/swingset/src/lib/exampleSnippet.ts +++ b/packages/swingset/src/lib/exampleSnippet.ts @@ -41,9 +41,20 @@ function unwrapReturn(fnSource: string): string { return expr; } - // `return ;` — a single expression statement; take up to its terminating semicolon. - const semicolon = expr.indexOf(';'); - return semicolon === -1 ? expr : expr.slice(0, semicolon); + // `return ;` — the return is the function's last statement, so its terminating semicolon + // is the last one; an earlier one can sit inside the JSX (an inline ` + + + Account + + + + + Name + Preston Booth + + + + + + + + + + Username + Prestonb.xyz + + + + + + + + + {[ + { label: 'Email', values: ['item1@clerk.dev', 'item2@clerk.dev'] }, + { label: 'Phone', values: ['+1 801-888-8181'] }, + ].map(({ label, values }) => ( + + + + + {label} + + + + + + + {values.map((value, index) => ( + + + + {value} + {index === 0 ? Primary : null} + + + + + + + ))} + + + + ))} + + + ); +} + export function ConnectedAccounts() { return ( - Connected accounts - - - - - Google - test@google.com - - - - - - - - - - - Apple - - - - - - + variant='ghost' + > + + + + + + + + + + Apple + + + + + + + ); @@ -353,24 +431,26 @@ export function ConnectedAccounts() { export function IconFrameMedia() { return ( - Team - - - - - - - - - Engineering - 12 members - - - + Team + + + + + + + + + + Engineering + 12 members + + + + ); @@ -379,27 +459,29 @@ export function IconFrameMedia() { export function Destructive() { return ( - Danger zone - - - - Delete account - - Permanently delete this profile and all its data. This cannot be undone. - - - - - - - + Danger zone + + + + + Delete account + + Permanently delete this profile and all its data. This cannot be undone. + + + + + + + + );