From a09a7760cd37273e5f3e06f4f29b6734875a06b9 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 25 Sep 2026 15:12:30 -0600 Subject: [PATCH 1/7] 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 | 59 +- ...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, 1135 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 559650ed471..e428575bbc5 100644 --- a/packages/mosaic/src/components/section/index.ts +++ b/packages/mosaic/src/components/section/index.ts @@ -5,6 +5,8 @@ export type { SectionContentProps, SectionDescriptionProps, SectionGroupProps, + SectionGroupVariant, + SectionHeaderProps, SectionItemProps, SectionItemsProps, SectionLabelProps, @@ -12,5 +14,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 20f7e45dc94..659e74d9a98 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -1,12 +1,10 @@ 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'; const compact = '@container cl-section (width < 26rem)'; -/* 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', @@ -19,11 +17,16 @@ 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'], containerName: 'cl-section', containerType: 'inline-size', @@ -31,6 +34,7 @@ export const styles = stylex.create({ }, row: { marginInline: space['4'], + paddingBlock: space['4'], borderBlockStartColor: colorVars['--cl-color-border'], borderBlockStartStyle: 'solid', borderBlockStartWidth: { @@ -39,42 +43,26 @@ 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', @@ -85,6 +73,15 @@ export const styles = stylex.create({ flexWrap: { [compact]: 'wrap', default: 'nowrap' }, rowGap: { [compact]: space['3'], default: null }, }, + 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', @@ -146,7 +143,6 @@ export const styles = stylex.create({ width: '100%', }, }); -/* eslint-enable @stylexjs/no-lookahead-selectors */ export const sectionCompactStyles = stylex.create({ hidden: { diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index 16f84e8acb3..6282fad94b4 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 161b2d095d8..cb7f6603e05 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'> & { wrap?: boolean }; export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; @@ -91,6 +94,25 @@ 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, ) { @@ -99,7 +121,25 @@ const Group = React.forwardRef(function Secti render, ref, props: { - ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group, xstyle), rest), + ...mergeStyleProps(themeProps('section-surface'), stylex.props(reset.base, styles.surface, xstyle), rest), + }, + }); +}); + +const Header = React.forwardRef(function SectionHeader( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('section-header'), + stylex.props(reset.base, styles.item, styles.header, xstyle), + rest, + ), }, }); }); @@ -115,7 +155,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, ), }, @@ -148,7 +188,14 @@ const Item = React.forwardRef(function Section props: { ...mergeStyleProps( themeProps('section-item', { nested, wrap }), - stylex.props(reset.base, styles.item, wrap && styles.itemWrap, xstyle), + stylex.props( + reset.base, + styles.item, + nested && styles.nestedItem, + nested && sectionNestedItemMarker, + wrap && styles.itemWrap, + xstyle, + ), rest, ), }, @@ -307,7 +354,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 22d4fd7ec69..3979f40e634 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 e79c0ca791c..ed5ad9f78db 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 - - - + + + + + + + + Apple + + + - - - + variant='outline' + > + Connect + + + + + + ); @@ -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. + + + + + + + + ); From 3d66a108ad95e196f54247d7a4b4cb628aab4810 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 29 Sep 2026 14:31:20 -0600 Subject: [PATCH 2/7] feat(mosaic): rework Section into cards with titled headers Every Section.Group is now the card: border, background, and radius move onto it, Section.Surface and the contained variant go away, and the group is named by the Section.Title inside its Section.Header. A new Section.Body wraps the rows or list under the header and draws the divider between them. Section.Items renders a real list. Root loses its title and the gap between cards is a uniform space-8. The public --cl-section-row-min-height token is dropped in favor of a plain value. Every user profile and organization profile section composes the card model; the password, passkeys, and MFA views render a group and let the security panel own the shared root, so asGroup and sectionTitle go. Co-Authored-By: Claude Fable 5.1 --- .../src/components/panel/panel.styles.ts | 2 +- .../src/components/panel/panel.test.tsx | 6 +- .../src/components/profile/profile.test.tsx | 6 +- .../mosaic/src/components/section/index.ts | 3 +- .../section/section.markers.stylex.ts | 1 + .../src/components/section/section.styles.ts | 62 ++-- .../src/components/section/section.test.tsx | 244 ++++++++------- .../mosaic/src/components/section/section.tsx | 227 +++++++------- ...ganization-profile-danger-section.view.tsx | 8 +- ...ization-profile-workspace-section.view.tsx | 8 +- .../user-profile-billing-panel.view.test.tsx | 3 +- ...-enterprise-accounts-section.view.test.tsx | 3 +- .../user-profile-mfa-section.view.test.tsx | 3 +- ...ser-profile-passkeys-section.view.test.tsx | 14 +- ...ser-profile-password-section.view.test.tsx | 5 +- .../user-profile-profile-panel.view.test.tsx | 14 +- .../user-profile-security-panel.view.test.tsx | 32 +- .../user-profile-account-section.view.tsx | 8 +- .../user-profile-contact-list-row.view.tsx | 52 ++-- ...er-profile-active-devices-section.view.tsx | 59 ++-- .../user-profile-active-devices.messages.ts | 1 - ...-profile-billing-history-section.styles.ts | 12 +- ...r-profile-billing-history-section.view.tsx | 282 +++++++++--------- ...rofile-connected-accounts-section.view.tsx | 20 +- .../user-profile-delete-section.view.tsx | 8 +- ...ofile-enterprise-accounts-section.view.tsx | 8 +- .../user-profile-mfa-section.view.tsx | 7 - .../user-profile-passkeys-section.styles.ts | 3 - .../user-profile-passkeys-section.view.tsx | 82 ++--- .../user-profile-password-row.view.tsx | 2 +- .../user-profile-password-section.messages.ts | 1 - .../user-profile-password-section.types.ts | 3 - .../user-profile-password-section.view.tsx | 14 +- ...r-profile-payment-methods-section.view.tsx | 48 ++- .../user-profile-security-list.tsx | 75 ++--- .../user-profile-security-panel.view.tsx | 7 +- ...user-profile-subscription-section.view.tsx | 8 +- ...user-profile-web3-wallets-section.view.tsx | 20 +- packages/mosaic/src/tokens.stylex.ts | 4 - .../src/stories/fixtures/user-profile-mfa.ts | 1 - .../swingset/src/stories/panel.component.mdx | 7 +- .../src/stories/panel.component.stories.tsx | 8 +- .../src/stories/profile.component.stories.tsx | 8 +- packages/swingset/src/stories/section.mdx | 73 +++-- .../swingset/src/stories/section.stories.tsx | 241 +++++---------- .../stories/user-profile-passkeys-section.mdx | 15 +- .../user-profile-passkeys-section.stories.tsx | 8 +- 47 files changed, 812 insertions(+), 914 deletions(-) diff --git a/packages/mosaic/src/components/panel/panel.styles.ts b/packages/mosaic/src/components/panel/panel.styles.ts index 65befe926a3..14f4dc9aa95 100644 --- a/packages/mosaic/src/components/panel/panel.styles.ts +++ b/packages/mosaic/src/components/panel/panel.styles.ts @@ -40,7 +40,7 @@ export const styles = stylex.create({ verticalAlign: 'middle', }, sections: { - gap: space['10'], + gap: space['8'], display: 'flex', flexDirection: 'column', }, diff --git a/packages/mosaic/src/components/panel/panel.test.tsx b/packages/mosaic/src/components/panel/panel.test.tsx index d3d6f600fe2..b1cb0260563 100644 --- a/packages/mosaic/src/components/panel/panel.test.tsx +++ b/packages/mosaic/src/components/panel/panel.test.tsx @@ -13,7 +13,11 @@ function Account() { Account - Email addresses + + + Email addresses + +
diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index a2aa563fb59..93c559ca09c 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -37,7 +37,11 @@ function Surface(rootProps: Partial) { Account - Email addresses + + + Email addresses + + diff --git a/packages/mosaic/src/components/section/index.ts b/packages/mosaic/src/components/section/index.ts index e428575bbc5..f1c1cf21ee3 100644 --- a/packages/mosaic/src/components/section/index.ts +++ b/packages/mosaic/src/components/section/index.ts @@ -2,10 +2,10 @@ export { Section } from './section'; export { sectionCompactStyles } from './section.styles'; export type { SectionActionsProps, + SectionBodyProps, SectionContentProps, SectionDescriptionProps, SectionGroupProps, - SectionGroupVariant, SectionHeaderProps, SectionItemProps, SectionItemsProps, @@ -14,6 +14,5 @@ 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 c2598c508aa..98c5c36209b 100644 --- a/packages/mosaic/src/components/section/section.markers.stylex.ts +++ b/packages/mosaic/src/components/section/section.markers.stylex.ts @@ -1,3 +1,4 @@ import * as stylex from '@stylexjs/stylex'; +export const sectionHeaderMarker = 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 659e74d9a98..02c937335da 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, sectionVars, space, typeScaleVars } from '../../tokens.stylex'; -import { sectionNestedItemMarker } from './section.markers.stylex'; +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; const compact = '@container cl-section (width < 26rem)'; @@ -9,20 +9,10 @@ export const styles = stylex.create({ root: { display: 'flex', flexDirection: 'column', - rowGap: space['3'], + rowGap: space['8'], width: '100%', }, - title: { - color: colorVars['--cl-color-foreground'], - 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', @@ -30,10 +20,40 @@ export const styles = stylex.create({ backgroundColor: colorVars['--cl-color-background'], containerName: 'cl-section', containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', width: '100%', }, - row: { + header: { + marginInline: space['4'], + paddingBlock: space['3'], + alignContent: 'center', + flexWrap: 'wrap', + minHeight: space['13'], + width: 'auto', + }, + title: { + color: colorVars['--cl-color-foreground'], + flexGrow: 1, + fontWeight: fontWeightVars['--cl-font-medium'], + minWidth: 0, + }, + headerDescription: { + flexBasis: '100%', + }, + body: { marginInline: space['4'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: { + default: '0px', + [stylex.when.siblingBefore(':where(*)', sectionHeaderMarker)]: '1px', + }, + display: 'flex', + flexDirection: 'column', + width: 'auto', + }, + row: { paddingBlock: space['4'], borderBlockStartColor: colorVars['--cl-color-border'], borderBlockStartStyle: 'solid', @@ -44,19 +64,11 @@ export const styles = stylex.create({ display: 'flex', flexDirection: 'column', 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', + minHeight: `calc(${space['18.5']} + 1px)`, + width: '100%', }, items: { - paddingInline: space['4'], + listStyle: 'none', display: 'flex', flexDirection: 'column', width: '100%', diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index 6282fad94b4..3eea27e3a23 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; import { describe, expect, it } from 'vitest'; @@ -9,6 +9,7 @@ import { Section } from './section'; const overrides = stylex.create({ root: { containerType: 'inline-size' }, group: { borderWidth: 2 }, + body: { marginInline: 0 }, item: { minHeight: 80 }, label: { color: 'red' }, }); @@ -17,12 +18,16 @@ const atoms = (style: stylex.StyleXStyles) => (stylex.props(style).className ?? '').split(' ').filter(name => /^x[a-z0-9]+$/.test(name)); describe('Section', () => { - it('renders an accessible section and every compound part', () => { + it('renders a card named by its heading, with every compound part', () => { render( - + - Account - + + Account + Who you are to the application. + Add + + { Control - + , ); - expect(screen.getByRole('region', { name: 'Account' })).toHaveClass('cl-section'); + const group = screen.getByRole('group', { name: 'Account' }); + expect(group).toBe(screen.getByTestId('group')); + expect(group).toHaveClass('cl-section-group'); + expect(group.tagName).toBe('DIV'); + expect(screen.getByTestId('root')).toHaveClass('cl-section'); + expect(screen.getByTestId('root').tagName).toBe('SECTION'); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); 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('header')).toHaveClass('cl-section-header'); + expect(screen.getByTestId('header')).toContainElement(screen.getByRole('heading', { name: 'Account' })); + expect(screen.getByTestId('header-description')).toHaveClass('cl-section-description'); + expect(screen.getByTestId('header-actions')).toHaveClass('cl-section-actions'); + expect(screen.getByTestId('body')).toHaveClass('cl-section-body'); expect(screen.getByTestId('row')).toHaveClass('cl-section-row'); expect(screen.getByTestId('item')).toHaveClass('cl-section-item'); + expect(screen.getByTestId('item').tagName).toBe('DIV'); expect(screen.getByTestId('media')).toHaveClass('cl-section-media'); expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'lg'); expect(screen.getByTestId('content')).toHaveClass('cl-section-content'); @@ -58,11 +72,40 @@ describe('Section', () => { expect(screen.getByTestId('actions')).toHaveClass('cl-section-actions'); }); + it('names each card by its own title', () => { + render( + + + + Email + + + + + + Phone + + + + , + ); + + const email = screen.getByRole('group', { name: 'Email' }); + const phone = screen.getByRole('group', { name: 'Phone' }); + expect(email).not.toBe(phone); + expect(email).toHaveAttribute('aria-labelledby', screen.getByRole('heading', { name: 'Email' }).id); + expect(phone).toHaveAttribute('aria-labelledby', screen.getByRole('heading', { name: 'Phone' }).id); + }); + it('takes its title level from an enclosing HeadingLevelProvider', () => { render( - Account + + + Account + + , ); @@ -72,113 +115,100 @@ describe('Section', () => { it('lets the render prop override the heading level', () => { render( - - }>Account - , + + + }>Account + + , ); expect(screen.getByRole('heading', { level: 5, name: 'Account' })).toHaveClass('cl-section-title'); + expect(screen.getByRole('group', { name: 'Account' })).toBeInTheDocument(); }); - it('supports an explicit accessible name', () => { + it('supports explicit accessible names on the section and on a card', () => { render( - - Account - + + + + + + Email + , ); expect(screen.getByRole('region', { name: 'Account preferences' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Password' })).not.toHaveAttribute('aria-labelledby'); + expect(screen.getByRole('group', { name: 'Contact' })).toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument(); }); - it('composes a header over a nested list', () => { + it('renders a list of values as a real list under the header', () => { render( - - - Profile - - + + + Email + Add + + + + + + ada@example.com + + More + + - Email + grace@example.com - Edit - - - - - ada@example.com - - More - - - - - , + + + + , ); - 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('header')).not.toHaveAttribute('data-nested'); - expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); + const list = screen.getByRole('list'); + expect(list).toBe(screen.getByTestId('items')); + expect(list).toHaveClass('cl-section-items'); + expect(list.tagName).toBe('UL'); + expect(within(list).getAllByRole('listitem')).toHaveLength(2); + expect(screen.getByTestId('nested-item').tagName).toBe('LI'); expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); - }); - - 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'); + expect(screen.getByTestId('header')).not.toHaveAttribute('data-nested'); + expect(screen.getAllByText(/Add|More/)).toHaveLength(2); }); it('marks only items inside Section.Items as nested', () => { render( - - - - - Name - - - one@example.com - two@example.com - - - - , + + + + Name + + + one@example.com + two@example.com + + + , ); expect(screen.getByText('Name')).not.toHaveAttribute('data-nested'); + expect(screen.getByText('Name').tagName).toBe('DIV'); expect(screen.getByText('one@example.com')).toHaveAttribute('data-nested'); expect(screen.getByText('two@example.com')).toHaveAttribute('data-nested'); + expect(screen.queryByRole('listitem', { name: 'Name' })).not.toBeInTheDocument(); }); it('applies xstyle on every part and forwards refs and custom elements', () => { const rootRef = React.createRef(); const groupRef = React.createRef(); + const bodyRef = React.createRef(); const itemRef = React.createRef(); const contentRef = React.createRef(); const actionsRef = React.createRef(); @@ -193,8 +223,13 @@ describe('Section', () => { ref={groupRef} xstyle={overrides.group} > - Account - + + Account + + { - + , ); @@ -214,6 +249,7 @@ describe('Section', () => { expect(rootRef.current?.tagName).toBe('ARTICLE'); expect(rootRef.current).toHaveClass('cl-section', ...atoms(overrides.root)); expect(groupRef.current).toHaveClass('cl-section-group', ...atoms(overrides.group)); + expect(bodyRef.current).toHaveClass('cl-section-body', ...atoms(overrides.body)); expect(itemRef.current).toHaveClass('cl-section-item', ...atoms(overrides.item)); expect(contentRef.current).toHaveClass('cl-section-content'); expect(screen.getByText('Name')).toHaveClass('cl-section-label', ...atoms(overrides.label)); @@ -222,32 +258,28 @@ describe('Section', () => { it('merges a render-sourced className instead of clobbering its own', () => { render( - - }> - Name - - , + }> + Name + , ); - expect(screen.getByText('Name').parentElement).toHaveClass('cl-section-group', 'cl-section-row'); + expect(screen.getByText('Name').parentElement).toHaveClass('cl-section-body', 'cl-section-row'); }); it('renders a row-level error as a sibling of the item, with the alert glyph', () => { render( - - - - - - - Profile picture - - - File type not supported. - - - - , + + + + + + Profile picture + + + File type not supported. + + + , ); const error = screen.getByTestId('error'); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index cb7f6603e05..9cdf7e9e1e4 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -12,17 +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 { sectionNestedItemMarker } from './section.markers.stylex'; +import { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; import { styles } from './section.styles'; export type SectionRootProps = Omit, 'title'>; +export type SectionGroupProps = MosaicComponentProps<'div'>; +export type SectionHeaderProps = MosaicComponentProps<'div'>; export type SectionTitleProps = Omit; -export type SectionGroupVariant = 'default' | 'contained'; -export type SectionGroupProps = MosaicComponentProps<'div'> & { variant?: SectionGroupVariant }; +export type SectionBodyProps = MosaicComponentProps<'div'>; export type SectionRowProps = MosaicComponentProps<'div'>; -export type SectionSurfaceProps = MosaicComponentProps<'div'>; -export type SectionHeaderProps = MosaicComponentProps<'div'>; -export type SectionItemsProps = MosaicComponentProps<'div'>; +export type SectionItemsProps = MosaicComponentProps<'ul'>; export type SectionItemProps = MosaicComponentProps<'div'> & { wrap?: boolean }; export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMediaSize }; @@ -39,142 +38,123 @@ const mediaSizes = { xl: styles.mediaXl, }; -const SectionTitleContext = React.createContext> | null>(null); +const SectionGroupContext = React.createContext> | null>(null); +const SectionHeaderContext = React.createContext(false); const SectionItemsContext = React.createContext(false); const SectionItemWrapContext = React.createContext(false); -const Root = React.forwardRef(function SectionRoot( +const Root = React.forwardRef(function SectionRoot({ render, xstyle, ...rest }, ref) { + return useRender({ + defaultTagName: 'section', + render, + ref, + props: mergeStyleProps(themeProps('section'), stylex.props(reset.base, styles.root, xstyle), rest), + }); +}); + +const Group = React.forwardRef(function SectionGroup( { render, xstyle, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, ref, ) { - const [titleIds, setTitleIds] = React.useState([]); + const [titleId, setTitleId] = React.useState(); const element = useRender({ - defaultTagName: 'section', + defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('section'), stylex.props(reset.base, styles.root, xstyle), rest), + role: 'group', + ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group, xstyle), rest), 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleIds.join(' ') || undefined), + 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleId), }, }); - return {element}; + return {element}; +}); + +const Header = React.forwardRef(function SectionHeader( + { render, xstyle, ...rest }, + ref, +) { + const element = useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('section-header'), + stylex.props(reset.base, styles.item, styles.header, sectionHeaderMarker, xstyle), + rest, + ), + }, + }); + + return {element}; }); const Title = React.forwardRef(function SectionTitle( - { id: idProp, render, xstyle, ...rest }, + { id: idProp, xstyle, ...rest }, ref, ) { - const setTitleIds = React.useContext(SectionTitleContext); + const setTitleId = React.useContext(SectionGroupContext); const generatedId = React.useId(); const level = useHeadingLevel(); - const id = idProp ?? (setTitleIds ? `cl-section-${generatedId}-title` : undefined); + const id = idProp ?? (setTitleId ? `cl-section-${generatedId}-title` : undefined); useSafeLayoutEffect(() => { - if (!id || !setTitleIds) { + if (!id || !setTitleId) { return undefined; } - setTitleIds(ids => (ids.includes(id) ? ids : [...ids, id])); - return () => setTitleIds(ids => ids.filter(value => value !== id)); - }, [id, setTitleIds]); + setTitleId(id); + return () => setTitleId(current => (current === id ? undefined : current)); + }, [id, setTitleId]); return ( ); }); -const Group = React.forwardRef(function SectionGroup( - { variant = 'default', render, xstyle, ...rest }, - ref, -) { +const Body = React.forwardRef(function SectionBody({ 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, - ), - }, + props: mergeStyleProps(themeProps('section-body'), stylex.props(reset.base, styles.body, 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, -) { +const Row = React.forwardRef(function SectionRow({ render, xstyle, ...rest }, ref) { return useRender({ defaultTagName: 'div', render, ref, - props: { - ...mergeStyleProps( - themeProps('section-header'), - stylex.props(reset.base, styles.item, styles.header, xstyle), - rest, - ), - }, + props: mergeStyleProps(themeProps('section-row'), stylex.props(reset.base, styles.row, xstyle), rest), }); }); -const Items = React.forwardRef(function SectionItems( +const Items = React.forwardRef(function SectionItems( { render, xstyle, ...rest }, ref, ) { const element = useRender({ - defaultTagName: 'div', + defaultTagName: 'ul', render, ref, - props: { - ...mergeStyleProps( - themeProps('section-items', { nested: true }), - stylex.props(reset.base, styles.items, xstyle), - rest, - ), - }, + props: mergeStyleProps(themeProps('section-items'), stylex.props(reset.base, styles.items, xstyle), rest), }); return {element}; }); -const Row = React.forwardRef(function SectionRow({ render, xstyle, ...rest }, ref) { - return useRender({ - defaultTagName: 'div', - render, - ref, - props: { - ...mergeStyleProps(themeProps('section-row'), stylex.props(reset.base, styles.row, xstyle), rest), - }, - }); -}); - const Item = React.forwardRef(function SectionItem( { wrap = false, render, xstyle, ...rest }, ref, @@ -182,23 +162,21 @@ const Item = React.forwardRef(function Section const nested = React.useContext(SectionItemsContext); const element = useRender({ - defaultTagName: 'div', + defaultTagName: nested ? 'li' : 'div', render, ref, - props: { - ...mergeStyleProps( - themeProps('section-item', { nested, wrap }), - stylex.props( - reset.base, - styles.item, - nested && styles.nestedItem, - nested && sectionNestedItemMarker, - wrap && styles.itemWrap, - xstyle, - ), - rest, + props: mergeStyleProps( + themeProps('section-item', { nested, wrap }), + stylex.props( + reset.base, + styles.item, + nested && styles.nestedItem, + nested && sectionNestedItemMarker, + wrap && styles.itemWrap, + xstyle, ), - }, + rest, + ), }); return {element}; @@ -212,13 +190,11 @@ const Media = React.forwardRef(function Secti defaultTagName: 'div', render, ref, - props: { - ...mergeStyleProps( - themeProps('section-media', { size }), - stylex.props(reset.base, styles.mediaBase, mediaSizes[size], xstyle), - rest, - ), - }, + props: mergeStyleProps( + themeProps('section-media', { size }), + stylex.props(reset.base, styles.mediaBase, mediaSizes[size], xstyle), + rest, + ), }); }); @@ -232,13 +208,11 @@ const Content = React.forwardRef(function S defaultTagName: 'div', render, ref, - props: { - ...mergeStyleProps( - themeProps('section-content', { nested }), - stylex.props(reset.base, styles.content, xstyle), - rest, - ), - }, + props: mergeStyleProps( + themeProps('section-content', { nested }), + stylex.props(reset.base, styles.content, xstyle), + rest, + ), }); }); @@ -250,9 +224,7 @@ const Label = React.forwardRef(function Secti defaultTagName: 'div', render, ref, - props: { - ...mergeStyleProps(themeProps('section-label'), stylex.props(reset.base, styles.label, xstyle), rest), - }, + props: mergeStyleProps(themeProps('section-label'), stylex.props(reset.base, styles.label, xstyle), rest), }); }); @@ -260,13 +232,17 @@ const Description = React.forwardRef(fu { render, xstyle, ...rest }, ref, ) { + const inHeader = React.useContext(SectionHeaderContext); + return useRender({ defaultTagName: 'div', render, ref, - props: { - ...mergeStyleProps(themeProps('section-description'), stylex.props(reset.base, styles.description, xstyle), rest), - }, + props: mergeStyleProps( + themeProps('section-description'), + stylex.props(reset.base, styles.description, inHeader && styles.headerDescription, xstyle), + rest, + ), }); }); @@ -280,13 +256,11 @@ const Actions = React.forwardRef(function S defaultTagName: 'div', render, ref, - props: { - ...mergeStyleProps( - themeProps('section-actions'), - stylex.props(reset.base, styles.actions, wrap && styles.actionsWrap, xstyle), - rest, - ), - }, + props: mergeStyleProps( + themeProps('section-actions'), + stylex.props(reset.base, styles.actions, wrap && styles.actionsWrap, xstyle), + rest, + ), }); }); @@ -347,16 +321,17 @@ const SectionError = React.forwardRef(f }); /** - * A compound component that fixes section semantics, surface treatment, row grouping, - * and item layout while leaving each item's content composable. + * A compound component for a topic of settings. `Section.Root` stacks cards; each `Section.Group` + * is a card named by the `Section.Title` in its `Section.Header`, with a `Section.Body` holding + * either `Section.Row`s (one setting each) or a `Section.Items` list of values. */ export const Section = { Root, - Title, Group, - Surface, - Row, Header, + Title, + Body, + Row, 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 3979f40e634..29b6aaf86a7 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 @@ -27,8 +27,10 @@ export function OrganizationProfileDangerSectionView({ return ( - {m.sectionTitle} - + + {m.sectionTitle} + + {onLeave ? ( ) : 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 b7cfe351c7b..8d4031cb3bf 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 @@ -33,8 +33,10 @@ export function OrganizationProfileWorkspaceSectionView({ return ( - {m.sectionTitle} - + + {m.sectionTitle} + + - + ); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx index 3b51dd0350a..fa3df75c6cb 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-billing-panel.view.test.tsx @@ -55,7 +55,8 @@ describe('UserProfileBillingPanelView', () => { expect(screen.getByRole('heading', { level: 2, name: 'Billing' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Subscription' })).toBeInTheDocument(); - expect(screen.getByRole('region', { name: 'Payment methods' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Payment methods' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Payment methods' })).toBeInTheDocument(); expect(screen.getByText('Basic Plan')).toBeInTheDocument(); expect(screen.getByText('$12.00')).toBeInTheDocument(); expect(screen.getByText('Visa •••• 0644')).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx index d265a034b6d..c5996575d9d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx @@ -77,7 +77,8 @@ describe('UserProfileEnterpriseAccountsSectionView', () => { ]} />, ); - expect(screen.getByRole('region', { name: 'Enterprise accounts' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Enterprise accounts' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Enterprise accounts' })).toBeInTheDocument(); expect(screen.getByText('Acme Okta')).toBeInTheDocument(); expect(screen.getByText('test@acme.com')).toBeInTheDocument(); expect(screen.getByText('Requires action')).toBeInTheDocument(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx index 9ead5c56cd0..d5042353c3a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -42,7 +42,6 @@ describe('MFA section', () => { {} : undefined} addControl={add === 'custom' ? : undefined} @@ -61,7 +60,7 @@ describe('MFA section', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); const fallback = add === 'none' - ? screen.getByRole('region', { name: 'Authentication' }) + ? screen.getByRole('group', { name: '2-step verification' }) : screen.getByRole('button', { name: add === 'picker' ? 'Add verification method' : 'Add custom method' }); await waitFor(() => expect(fallback).toHaveFocus()); }, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx index fd65d4206b1..4de4c2d4f02 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-section.view.test.tsx @@ -29,10 +29,10 @@ function renderView(overrides: Partial = {} describe('passkeys section', () => { it('shows existing passkeys without actions when their callbacks are unavailable', () => { - renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined, sectionTitle: 'Authentication' }); + renderView({ onAdd: undefined, onRename: undefined, onRemove: undefined }); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); - expect(screen.getByText('Passkeys')).toBeVisible(); + expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible(); expect(screen.getByText('MacBook')).toBeVisible(); expect(screen.getByText('iPhone')).toBeVisible(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); @@ -120,10 +120,10 @@ describe('passkeys section', () => { }); it('keeps an empty section visible when Add is unavailable', () => { - renderView({ passkeys: [], onAdd: undefined, sectionTitle: 'Authentication' }); + renderView({ passkeys: [], onAdd: undefined }); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); - expect(screen.getByText('Passkeys')).toBeVisible(); + expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible(); expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); }); @@ -179,7 +179,7 @@ describe('passkeys section', () => { await user.click(screen.getByRole('button', { name: 'Add passkey' })); expect(screen.getByText('MacBook')).toBeVisible(); } else { - expect(screen.getByRole('region', { name: 'Passkeys' })).toHaveFocus(); + expect(screen.getByRole('group', { name: 'Passkeys' })).toHaveFocus(); } }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 9fa73ae723a..eaa782498ea 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -43,7 +43,8 @@ describe('UserProfilePasswordSectionView', () => { const user = userEvent.setup(); renderView({ hasPassword: false, onSubmitPassword }); - expect(screen.getByRole('heading', { level: 2, name: 'Authentication' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Password' })).toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.getByText('Password')).toBeVisible(); expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); expect(screen.getByText('No password set')).toBeVisible(); @@ -109,7 +110,7 @@ describe('UserProfilePasswordSectionView', () => { , ); - expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('group', { name: 'Password' })).toBeVisible(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); 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 7a1a0190f6e..94f16ce7583 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 @@ -137,8 +137,9 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Account' })).toContainElement( - document.querySelector('.cl-section-group'), + screen.getByRole('group', { name: 'Profile' }), ); + expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toHaveClass('cl-section-title'); expect(screen.getByText('Name')).toHaveClass('cl-section-label'); expect(screen.getByText('Username')).toHaveClass('cl-section-label'); expect(screen.getByText('Preston Booth')).toHaveClass('cl-section-description'); @@ -151,8 +152,8 @@ describe('UserProfileProfilePanelView', () => { 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.getByRole('heading', { level: 3, name: 'Email' })).toHaveClass('cl-section-title'); + expect(screen.getByRole('heading', { level: 3, name: 'Phone' })).toHaveClass('cl-section-title'); expect(screen.getByText('item1@clerk.dev').closest('.cl-section-description')).not.toBeNull(); expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); const profilePicture = screen.getByText('Profile picture').closest('.cl-section-item'); @@ -227,9 +228,10 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); expect(screen.queryByRole('region', { name: 'Phone' })).not.toBeInTheDocument(); - expect(emailSection).toHaveAttribute('data-variant', 'contained'); + expect(within(emailSection).getByRole('heading', { level: 3, name: 'Email' })).toBeInTheDocument(); expect(emailSection.querySelector('.cl-section-header')).toHaveTextContent('Email'); - expect(emailSection).toHaveTextContent('item1@clerk.dev'); + expect(within(emailSection).getByRole('list')).toContainElement(screen.getByText('item1@clerk.dev')); + expect(within(emailSection).getAllByRole('listitem')).toHaveLength(1); 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'); @@ -354,7 +356,7 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { level: 3, name: 'Web3 wallets' })).toBeInTheDocument(); expect(screen.getByText('MetaMask')).toBeInTheDocument(); expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); - expect(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); + expect(within(screen.getByRole('group', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Connect Coinbase Wallet' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toBeVisible(); 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 eca770a25c6..fe9e01f6930 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 @@ -61,14 +61,17 @@ describe('UserProfileSecurityPanelView', () => { renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) }); expect(screen.getByRole('heading', { level: 2, name: 'Security' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 3, name: 'Authentication' })).toBeInTheDocument(); + const authentication = screen.getByRole('region', { name: 'Authentication' }); + expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Active devices' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Danger zone' })).toBeInTheDocument(); expect(screen.getByText('Password')).toBeVisible(); - expect(screen.getByText('Passkeys')).toBeVisible(); - expect(screen.getByText('2-step verification')).toBeVisible(); - expect(screen.getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); - expect(screen.getByRole('group', { name: '2-step verification' })).toBeInTheDocument(); + expect(within(authentication).getByRole('group', { name: 'Password' })).toBeInTheDocument(); + expect(within(authentication).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeInTheDocument(); + expect(within(authentication).getByRole('heading', { level: 3, name: '2-step verification' })).toBeInTheDocument(); + expect(within(authentication).getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); + expect(within(authentication).getByRole('group', { name: '2-step verification' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: '2 other devices' })).toBeInTheDocument(); expect(screen.getByText('This device')).toBeInTheDocument(); expect(screen.getByText('2 other devices')).toBeInTheDocument(); expect( @@ -131,7 +134,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('group', { name: 'Other devices' }); + const otherDevices = screen.getByRole('group', { name: '2 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' })); @@ -179,7 +182,7 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('menuitem', { name: 'Sign out' })).not.toBeInTheDocument(); }); - it('keeps the authentication heading on MFA when existing passkeys are hidden', () => { + it('keeps the authentication section on MFA when existing passkeys are hidden', () => { renderView({ hasPassword: false, passkeysVisible: false, @@ -191,17 +194,16 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); expect(screen.queryByText('Passkey')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); - expect(screen.getByText('2-step verification')).toBeVisible(); + expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('heading', { level: 3, name: '2-step verification' })).toBeVisible(); }); - it('keeps one authentication heading when passkeys are empty and Add is unavailable', () => { + it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => { renderView({ hasPassword: false, passkeys: [], onAddPasskey: undefined }); const section = screen.getByRole('region', { name: 'Authentication' }); - expect(within(section).getByText('Passkeys')).toBeVisible(); + expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible(); 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('group', { name: '2-step verification' })).toBeVisible(); }); @@ -226,7 +228,7 @@ describe('UserProfileSecurityPanelView', () => { /> , ); - expect(screen.getByText('Authentication')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Authentication' })).toBeInTheDocument(); expect(screen.getByRole('alertdialog', { name: 'Remove passkey' })).toBeVisible(); await act(async () => { @@ -234,8 +236,8 @@ describe('UserProfileSecurityPanelView', () => { await removal.promise; }); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible(); - expect(screen.getByText('Passkeys')).toBeVisible(); + expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible(); expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); }); 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 0ec26bf8a0f..70683936c20 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 @@ -114,8 +114,10 @@ export function UserProfileAccountSectionView({ return ( - {m.sectionTitle} - + + {m.sectionTitle} + + {!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 73a0eb05615..61fbe70ee0e 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 @@ -42,36 +42,32 @@ export function UserProfileContactListRowView({ return ( - - - - {label} - - {addAction ? ( - {addAction} - ) : onAdd ? ( - - - - ) : null} - + /> + {m.add} + + + ) : null} + + {items.length === 0 ? ( @@ -123,7 +119,7 @@ export function UserProfileContactListRowView({ )} {children} - + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index 1be9f5b1369..1dea42f8954 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -86,8 +86,10 @@ export function UserProfileActiveDevicesSectionView({
- {m.title} - + + {m.title} + + {currentDevices.length > 0 ? ( currentDevices.map(device => ( @@ -107,36 +109,31 @@ export function UserProfileActiveDevicesSectionView({ )} - + {otherDevices.length > 0 ? ( - - - - - - {fill(otherDevices.length === 1 ? m.otherDevice : m.otherDevices, { - count: String(otherDevices.length), - })} - - - {onSignOutAllOtherDevices ? ( - - - - ) : null} - + + + + {fill(otherDevices.length === 1 ? m.otherDevice : m.otherDevices, { + count: String(otherDevices.length), + })} + + {onSignOutAllOtherDevices ? ( + + + + ) : null} + + {otherDevices.map(device => ( ))} - + ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts index 7d525a5f3d2..fa21c60cf49 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices.messages.ts @@ -1,6 +1,5 @@ export const userProfileActiveDevicesMessages = { title: 'Active devices', - otherDevicesTitle: 'Other devices', emptyCurrent: 'No current device available', otherDevice: '{count} other device', otherDevices: '{count} other devices', diff --git a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.styles.ts index 6e0c02675ac..5d6315c9e67 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.styles.ts @@ -11,6 +11,9 @@ export const styles = stylex.create({ fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: typeScaleVars['--cl-text-sm-leading'], }, + body: { + marginInline: 0, + }, cell: { paddingBlock: space['3'], paddingInline: space['4'], @@ -94,15 +97,6 @@ export const styles = stylex.create({ 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-background'], - width: '100%', - }, statusColumn: { width: '20%', }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.view.tsx index 3f066d55e08..b9f70aed68b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-billing-history-section.view.tsx @@ -40,149 +40,153 @@ export function UserProfileBillingHistorySectionView({ onView, }: UserProfileBillingHistorySectionViewProps) { return ( - - History -
- {/* TODO: Replace this inline implementation with the Mosaic Table component. */} -
- - - - - - - - - - {items.length > 0 ? ( - items.map(item => ( - + + + History + + + {/* TODO: Replace this inline implementation with the Mosaic Table component. */} +
+
- Invoice - - Amount - - Status - -
+ + + - - - - - )) - ) : ( - - - )} - -
- -
{item.dateLabel}
-
{item.invoiceLabel}
-
{item.amountLabel} - {item.statusLabel} - - {onView ? ( - - ) : null} -
+ - No invoices yet - -
-
- {pagination ? ( - // TODO: Replace this inline implementation with the Mosaic Pagination component. -
-
- - - -
- + Status + + + + + + {items.length > 0 ? ( + items.map(item => ( + + +
{item.dateLabel}
+
{item.invoiceLabel}
+ + {item.amountLabel} + + {item.statusLabel} + + + {onView ? ( + + ) : null} + + + )) + ) : ( + + + No invoices yet + + + )} + +
- ) : null} -
+ {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. +
+
+ + + +
+ +
+ ) : null} + +
); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx index 723833f96e7..273fb46c62d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx @@ -39,7 +39,7 @@ export function UserProfileConnectedAccountsSectionView({ onRemove, }: UserProfileConnectedAccountsSectionViewProps) { const m = useMessages('userProfileConnectedAccounts'); - const section = useRef(null); + const section = useRef(null); const removalFocus = useListRemovalFocus({ ids: accounts.map(account => account.id), onRemove, @@ -55,13 +55,15 @@ export function UserProfileConnectedAccountsSectionView({ return ( <> {hasRows ? ( - - - {m.title} - + + + + {m.title} + + {accounts.map(account => ( )) : 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 ed5ad9f78db..0922220d9a8 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 @@ -21,8 +21,10 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect return ( - {m.sectionTitle} - + + {m.sectionTitle} + + @@ -55,7 +57,7 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect - + ); 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 ce2cec3856d..35ab56040f0 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 @@ -26,8 +26,10 @@ export function UserProfileEnterpriseAccountsSectionView({ return ( - {m.title} - + + {m.title} + + {accounts.map(account => ( )) : 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 e09d512a622..e1cf0982664 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 @@ -26,9 +26,6 @@ export interface UserProfileMfaSectionViewProps { addableMethods?: readonly UserProfileMfaAddableMethod[]; 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; @@ -40,8 +37,6 @@ export function UserProfileMfaSectionView({ addableMethods, addButtonRef, addControl, - sectionTitle, - asGroup, onAdd, onRegenerateBackupCodes, onRemove, @@ -110,8 +105,6 @@ export function UserProfileMfaSectionView({ emptyLabel={m.empty} hasItems={methods.length > 0} label={m.label} - sectionTitle={sectionTitle} - asGroup={asGroup} > {methods.map(method => ( void; addError?: string; onRename?: (id: string, name: string) => void | Promise; @@ -31,8 +28,6 @@ export interface UserProfilePasskeysSectionViewProps { export function UserProfilePasskeysSectionView({ passkeys, - sectionTitle, - asGroup = false, onAdd, addError, onRename, @@ -47,19 +42,14 @@ export function UserProfilePasskeysSectionView({ }); const removePasskey = useMemo(() => Confirmation.createHandle(), []); - const group = ( - - {sectionTitle ? {sectionTitle} : null} - - - - {m.label} - + return ( + <> + + + {m.label} {onAdd ? ( - - ) : null} - + /> + Add + + + ) : null} + + {paymentMethods.length > 0 ? ( paymentMethods.map(paymentMethod => ( @@ -72,7 +70,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 ab576a72b63..b0f831ee975 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 @@ -5,9 +5,7 @@ import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; export function UserProfileSecurityList({ - sectionTitle, sectionRef, - asGroup = false, label, addLabel, emptyLabel, @@ -16,9 +14,7 @@ export function UserProfileSecurityList({ addControl, children, }: { - sectionTitle?: string; sectionRef?: Ref; - asGroup?: boolean; label: string; addLabel: string; emptyLabel: string; @@ -27,40 +23,35 @@ export function UserProfileSecurityList({ addControl?: ReactNode; children: ReactNode; }) { - const group = ( + return ( - {sectionTitle ? {sectionTitle} : null} - - - - {label} - - {addControl ? ( - {addControl} - ) : onAdd ? ( - - - - ) : null} - + /> + Add + + + ) : null} + + {hasItems ? ( children @@ -72,19 +63,7 @@ export function UserProfileSecurityList({ )} - + ); - - return asGroup ? ( - group - ) : ( - - {group} - - ); } 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 d1445ccf154..95dafc3cdfa 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 @@ -86,10 +86,9 @@ export function UserProfileSecurityPanelView({ Security {hasAuthentication ? ( - + {showPassword ? ( - Subscription - + + Subscription + + @@ -56,7 +58,7 @@ export function UserProfileSubscriptionSectionView({ - + ); 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 9259b1b0397..427f30c8e65 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 @@ -43,7 +43,7 @@ export function UserProfileWeb3WalletsSectionView({ onRemove, }: UserProfileWeb3WalletsSectionViewProps) { const m = useMessages('userProfileWeb3Wallets'); - const section = useRef(null); + const section = useRef(null); const removalFocus = useListRemovalFocus({ ids: wallets.map(wallet => wallet.id), onRemove, @@ -59,13 +59,15 @@ export function UserProfileWeb3WalletsSectionView({ return ( <> {hasRows ? ( - - - {m.title} - + + + + {m.title} + + {wallets.map(wallet => ( )) : null} - + ) : null} diff --git a/packages/mosaic/src/tokens.stylex.ts b/packages/mosaic/src/tokens.stylex.ts index 8011f3c821f..256a7957d35 100644 --- a/packages/mosaic/src/tokens.stylex.ts +++ b/packages/mosaic/src/tokens.stylex.ts @@ -296,10 +296,6 @@ 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/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index 304e2d9be31..381182477f9 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -333,7 +333,6 @@ export function useUserProfileMfaFixture({ section: { methods, addableMethods, - sectionTitle: 'Authentication', onAdd: open, onRegenerateBackupCodes: account.hasBackupCodes && onGenerateBackupCodes ? () => void generateBackupCodes('regenerate') : undefined, diff --git a/packages/swingset/src/stories/panel.component.mdx b/packages/swingset/src/stories/panel.component.mdx index c5eb644828b..fe7fd875c85 100644 --- a/packages/swingset/src/stories/panel.component.mdx +++ b/packages/swingset/src/stories/panel.component.mdx @@ -27,7 +27,12 @@ import { Panel } from '@clerk/mosaic/components/panel'; Account - Email addresses + + + Email addresses + + … + ; diff --git a/packages/swingset/src/stories/panel.component.stories.tsx b/packages/swingset/src/stories/panel.component.stories.tsx index 61a724555f0..ffeb33cdfa0 100644 --- a/packages/swingset/src/stories/panel.component.stories.tsx +++ b/packages/swingset/src/stories/panel.component.stories.tsx @@ -28,8 +28,10 @@ export function Default() { {sections.map(section => ( - {section.title} - + + {section.title} + + @@ -47,7 +49,7 @@ export function Default() { - + ))} diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index 1027ccb8778..101577557d3 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -119,8 +119,10 @@ function StubPage({ id, title }: { id: string; title: string }) { {stubSections[id]?.map(section => ( - {section.title} - + + {section.title} + + {section.rows.map(row => ( @@ -140,7 +142,7 @@ function StubPage({ id, title }: { id: string; title: string }) { ))} - + ))} diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index ec3481d4dda..5b9fd7cbe10 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -9,8 +9,11 @@ import { Section } from '@clerk/mosaic/components/section'; - Account - + + Account + {control} + + {icon} @@ -21,31 +24,53 @@ import { Section } from '@clerk/mosaic/components/section'; {control} - + ``` -`Section.Group` pairs an optional `Section.Title` with a `Section.Surface`. The surface is the outlined card and a query container named `cl-section`, so rows can adapt to its width. Its direct `Section.Row` children own separation and may stack one or more `Section.Item` entries. Each item lays out optional media, flexible content, and trailing actions. `Section.Media` accepts `sm`, `md`, `lg`, and `xl` sizes. `Section.Item` accepts `wrap`, which moves the actions under the content when the surface is narrower than `26rem`. `sectionCompactStyles.hidden` and `sectionCompactStyles.only` toggle inline content at the same width. +`Section.Root` holds one topic and stacks its cards. It has no title of its own; pass `aria-label` +to expose it as a named region. -For a nested list, place a `Section.Header` and a `Section.Items` directly inside the surface. The header draws the divider above the list, and nested items are separated by dividers of their own. +`Section.Group` is a card. `Section.Header` holds its `Section.Title`, an optional +`Section.Description`, and optional `Section.Actions`; the card takes its accessible name from +the title. `Section.Body` wraps everything under the header and draws the divider between the two. -A section can hold more than one `Section.Group`. Set `variant='contained'` on a group that holds a nested list, such as a user's email addresses inside the account section. A contained group renders with `role='group'` and `data-variant='contained'`, so it can take its own accessible name and be styled apart from the section's main group. +Inside the body, a `Section.Row` holds one setting as a `Section.Item`. `Section.Items` renders a +list of values, one `Section.Item` per entry, and each item becomes a list item. Items lay out +optional media, flexible content, and trailing actions. `Section.Media` accepts `sm`, `md`, `lg`, +and `xl` sizes. -`Section.Title` renders at the level from the nearest `HeadingLevelProvider`, or `h2` outside one. -On a page, put the sections in `Panel.Sections` so their titles sit one level below the page title. -See [Panel](/components/panel) and [Heading](/components/heading). +`Section.Group` is a query container named `cl-section`, so rows can adapt to the card's width. +`Section.Item` accepts `wrap`, which moves the actions under the content when the card is narrower +than `26rem`. `sectionCompactStyles.hidden` and `sectionCompactStyles.only` toggle inline content at +the same width. -```tsx - - Account - - - Email addresses - - - -``` +`Section.Title` renders at the level from the nearest `HeadingLevelProvider`, or `h2` outside one. +On a page, put the sections in `Panel.Sections` so the card titles sit one level below the page +title. See [Panel](/components/panel) and [Heading](/components/heading). + +## Parts + +| Part | Element | Class | Notes | +| --------------------- | ----------- | ------------------------- | --------------------------------------------------------------- | +| `Section.Root` | `
` | `.cl-section` | Stacks cards. Unnamed unless given `aria-label`. | +| `Section.Group` | `
` | `.cl-section-group` | The card, with `role='group'` named by its title. | +| `Section.Header` | `
` | `.cl-section-header` | Title, optional description, optional actions. | +| `Section.Title` | heading | `.cl-section-title` | The card's heading. | +| `Section.Body` | `
` | `.cl-section-body` | The card's content; draws the divider under the header. | +| `Section.Row` | `
` | `.cl-section-row` | One setting. | +| `Section.Items` | `
    ` | `.cl-section-items` | A list of values. | +| `Section.Item` | `
    ` | `.cl-section-item` | Media, content, and actions on one line. `
  • ` inside `Items`. | +| `Section.Media` | `
    ` | `.cl-section-media` | Reflects `data-size`. | +| `Section.Content` | `
    ` | `.cl-section-content` | Label and description. | +| `Section.Label` | `
    ` | `.cl-section-label` | | +| `Section.Description` | `
    ` | `.cl-section-description` | | +| `Section.Actions` | `
    ` | `.cl-section-actions` | | +| `Section.Error` | `

    ` | `.cl-section-error` | A row-level message, placed beside the item inside the row. | + +Every part takes `xstyle`, `ref`, and `render` along with its element's props. Items and content +inside `Section.Items` reflect `data-nested`. ## Examples @@ -58,19 +83,17 @@ See [Panel](/components/panel) and [Heading](/components/heading). ]} /> -### Contained groups +### Cards -### Flat contained groups - -Contained groups can be restyled from external CSS so each one reads as its own section. This example targets `.cl-section-group[data-variant='contained']`, `.cl-section-header`, and `.cl-section-items`, and sizes list items with `--cl-section-row-min-height`. +### Header description diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx index 0864215f880..ae139015520 100644 --- a/packages/swingset/src/stories/section.stories.tsx +++ b/packages/swingset/src/stories/section.stories.tsx @@ -48,8 +48,10 @@ export function Default() { return ( - Profile - + + Profile + + @@ -130,18 +132,20 @@ export function Default() { - + ); } -export function ContainedGroups() { +export function Cards() { return ( - Account - + + Profile + + @@ -176,38 +180,32 @@ export function ContainedGroups() { - + {[ { label: 'Email', values: ['item1@clerk.dev', 'item2@clerk.dev'] }, { label: 'Phone', values: ['+1 801-888-8181'] }, ].map(({ label, values }) => ( - - - - - {label} - - - - - + /> + Add + + + + {values.map((value, index) => ( @@ -231,146 +229,55 @@ export function ContainedGroups() { ))} - + ))} ); } -export function FlatContainedGroups() { +export function HeaderDescription() { return ( - <> - - - - Account - - - - - Name - Preston Booth - - - - - - - - + + + + Active devices + Devices signed in to this account. + + + + + + + {['Chrome on macOS', 'Safari on iOS'].map(name => ( + - Username - Prestonb.xyz + {name} + Last seen today - - - - {[ - { 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} - - - - - - - ))} - - - - ))} - - + ))} + + + + ); } @@ -378,8 +285,10 @@ export function ConnectedAccounts() { return ( - Connected accounts - + + Connected accounts + + @@ -422,7 +331,7 @@ export function ConnectedAccounts() { - + ); @@ -432,8 +341,10 @@ export function IconFrameMedia() { return ( - Team - + + Team + + @@ -450,7 +361,7 @@ export function IconFrameMedia() { - + ); @@ -460,8 +371,10 @@ export function Destructive() { return ( - Danger zone - + + Danger zone + + @@ -481,7 +394,7 @@ export function Destructive() { - + ); diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx index b36689c2f9c..40f74188b38 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.mdx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -14,14 +14,13 @@ import * as Stories from './user-profile-passkeys-section.stories'; ## Props -| Prop | Type | Default | Description | -| -------------- | ----------------------------------------------------- | ------------ | ---------------------------------------------------------------- | -| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | -| `sectionTitle` | `string` | — | Optional surrounding section heading. | -| `onAdd` | `() => void` | — | Shows Add when supplied. | -| `addError` | `string` | — | Inline creation error. | -| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | -| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | +| Prop | Type | Default | Description | +| ---------- | ----------------------------------------------------- | ------------ | ---------------------------------------------------------------- | +| `passkeys` | `UserProfilePasskey[]` | — (required) | Names and formatted creation/last-used labels. | +| `onAdd` | `() => void` | — | Shows Add when supplied. | +| `addError` | `string` | — | Inline creation error. | +| `onRename` | `(id: string, name: string) => void \| Promise` | — | Resolve to close; reject with an Error to display the failure. | +| `onRemove` | `(id: string) => void \| Promise` | — | Shared confirmation awaits removal and displays rejected errors. | ## Usage 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 f57448d1b14..9ea16aa95c0 100644 --- a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -18,12 +18,7 @@ export const meta: StoryMeta = { function PasskeysExample({ empty = false, failOnce = false }: { empty?: boolean; failOnce?: boolean }): ReactElement { const fixture = usePasskeysFixture({ empty, failOnce }); - return ( - - ); + return ; } export function Default(): ReactElement { @@ -39,7 +34,6 @@ export function CreationUnavailable(): ReactElement { return ( ); From 29b3ee619a2d2df53cc441e92c3ebeb18370a8ee Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 29 Sep 2026 15:52:03 -0600 Subject: [PATCH 3/7] fix(mosaic): lay the section header out as a grid Title and description stack in the first column, actions sit in the second on the title row, so a header with a description keeps its action on the right and a header without one stays vertically centered. Co-Authored-By: Claude Fable 5.1 --- .../src/components/section/section.styles.ts | 15 +++++++++++---- .../mosaic/src/components/section/section.tsx | 5 +++-- .../user-profile-passkeys-section.styles.ts | 3 ++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 02c937335da..444e76d7f4b 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -28,18 +28,25 @@ export const styles = stylex.create({ marginInline: space['4'], paddingBlock: space['3'], alignContent: 'center', - flexWrap: 'wrap', + alignItems: 'center', + columnGap: space['3'], + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', minHeight: space['13'], width: 'auto', }, title: { + gridColumn: '1', color: colorVars['--cl-color-foreground'], - flexGrow: 1, fontWeight: fontWeightVars['--cl-font-medium'], - minWidth: 0, }, headerDescription: { - flexBasis: '100%', + gridColumn: '1', + marginTop: space['0.5'], + }, + headerActions: { + gridColumn: '2', + gridRow: '1', }, body: { marginInline: space['4'], diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 9cdf7e9e1e4..36dd34fe1ea 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -84,7 +84,7 @@ const Header = React.forwardRef(function Sec props: { ...mergeStyleProps( themeProps('section-header'), - stylex.props(reset.base, styles.item, styles.header, sectionHeaderMarker, xstyle), + stylex.props(reset.base, styles.header, sectionHeaderMarker, xstyle), rest, ), }, @@ -251,6 +251,7 @@ const Actions = React.forwardRef(function S ref, ) { const wrap = React.useContext(SectionItemWrapContext); + const inHeader = React.useContext(SectionHeaderContext); return useRender({ defaultTagName: 'div', @@ -258,7 +259,7 @@ const Actions = React.forwardRef(function S ref, props: mergeStyleProps( themeProps('section-actions'), - stylex.props(reset.base, styles.actions, wrap && styles.actionsWrap, xstyle), + stylex.props(reset.base, styles.actions, wrap && styles.actionsWrap, inHeader && styles.headerActions, xstyle), rest, ), }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts index 26c3af5d496..dcc6c14a911 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.styles.ts @@ -4,7 +4,8 @@ import { colorVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ addError: { - flexBasis: '100%', + gridColumnEnd: '-1', + gridColumnStart: '1', marginTop: { default: space['2'], ':where(:not([data-open]), [data-starting-style])': 0, From 1ac087b1749225e07a6df7a0aaeb45b1559a0c27 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 29 Sep 2026 16:16:27 -0600 Subject: [PATCH 4/7] fix(mosaic): center header actions on the title block and size card titles at base Actions span both header rows when a description follows the title, so they center on the whole block; without one they stay on the title row. Card titles use the base type size at medium weight. Co-Authored-By: Claude Fable 5.1 --- .../src/components/section/section.markers.stylex.ts | 1 + .../mosaic/src/components/section/section.styles.ts | 8 ++++++-- packages/mosaic/src/components/section/section.tsx | 12 +++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/components/section/section.markers.stylex.ts b/packages/mosaic/src/components/section/section.markers.stylex.ts index 98c5c36209b..fac54253625 100644 --- a/packages/mosaic/src/components/section/section.markers.stylex.ts +++ b/packages/mosaic/src/components/section/section.markers.stylex.ts @@ -1,4 +1,5 @@ import * as stylex from '@stylexjs/stylex'; export const sectionHeaderMarker = stylex.defineMarker(); +export const sectionHeaderDescriptionMarker = 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 444e76d7f4b..597300da587 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; -import { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; +import { sectionHeaderDescriptionMarker, sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; const compact = '@container cl-section (width < 26rem)'; @@ -46,7 +46,11 @@ export const styles = stylex.create({ }, headerActions: { gridColumn: '2', - gridRow: '1', + gridRowEnd: { + default: 'auto', + [stylex.when.siblingBefore(':where(*)', sectionHeaderDescriptionMarker)]: 'span 2', + }, + gridRowStart: '1', }, body: { marginInline: space['4'], diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 36dd34fe1ea..4baeeca3ee7 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -12,7 +12,7 @@ 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 { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; +import { sectionHeaderDescriptionMarker, sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex'; import { styles } from './section.styles'; export type SectionRootProps = Omit, 'title'>; @@ -116,7 +116,7 @@ const Title = React.forwardRef(function S ref={ref} id={id} level={level} - size='sm' + size='base' xstyle={[styles.title, xstyle]} {...mergeStyleProps(themeProps('section-title'), rest)} /> @@ -240,7 +240,13 @@ const Description = React.forwardRef(fu ref, props: mergeStyleProps( themeProps('section-description'), - stylex.props(reset.base, styles.description, inHeader && styles.headerDescription, xstyle), + stylex.props( + reset.base, + styles.description, + inHeader && styles.headerDescription, + inHeader && sectionHeaderDescriptionMarker, + xstyle, + ), rest, ), }); From 08c4e0befda14a00498c3ea3ac5010eaab66116c Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 29 Sep 2026 16:24:30 -0600 Subject: [PATCH 5/7] feat(mosaic): one active devices card, a titled password card, and a flat section example Active devices renders one card: the sign-out-all action sits in the header as a ghost button and every device is a list item, current first. The password card gets a "Password" heading and keeps its action in the row. The Section page shows a flat variant restyled from external CSS. Co-Authored-By: Claude Fable 5.1 --- ...ser-profile-password-section.view.test.tsx | 3 +- .../user-profile-security-panel.view.test.tsx | 18 ++- ...er-profile-active-devices-section.view.tsx | 82 +++++------- .../user-profile-active-devices.messages.ts | 2 - .../user-profile-password-row.view.tsx | 1 - .../user-profile-password-section.view.tsx | 5 +- packages/swingset/src/stories/section.mdx | 9 ++ .../swingset/src/stories/section.stories.tsx | 126 ++++++++++++++++++ 8 files changed, 185 insertions(+), 61 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index eaa782498ea..2a9b023d80a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -44,8 +44,7 @@ describe('UserProfilePasswordSectionView', () => { renderView({ hasPassword: false, onSubmitPassword }); expect(screen.getByRole('group', { name: 'Password' })).toBeInTheDocument(); - expect(screen.queryByRole('heading')).not.toBeInTheDocument(); - expect(screen.getByText('Password')).toBeVisible(); + expect(screen.getByRole('heading', { level: 2, name: 'Password' })).toHaveClass('cl-section-title'); expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument(); expect(screen.getByText('No password set')).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Set password' })); 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 fe9e01f6930..ce8ed14c4eb 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 @@ -65,15 +65,16 @@ describe('UserProfileSecurityPanelView', () => { expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Active devices' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Danger zone' })).toBeInTheDocument(); - expect(screen.getByText('Password')).toBeVisible(); + expect(within(authentication).getByRole('heading', { level: 3, name: 'Password' })).toBeInTheDocument(); expect(within(authentication).getByRole('group', { name: 'Password' })).toBeInTheDocument(); expect(within(authentication).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeInTheDocument(); expect(within(authentication).getByRole('heading', { level: 3, name: '2-step verification' })).toBeInTheDocument(); expect(within(authentication).getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); expect(within(authentication).getByRole('group', { name: '2-step verification' })).toBeInTheDocument(); - expect(screen.getByRole('group', { name: '2 other devices' })).toBeInTheDocument(); + const activeDevices = screen.getByRole('group', { name: 'Active devices' }); + expect(within(activeDevices).getAllByRole('listitem')).toHaveLength(3); + expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.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(); @@ -118,7 +119,12 @@ describe('UserProfileSecurityPanelView', () => { }); await user.click(screen.getByRole('button', { name: 'Add passkey' })); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' }); + expect(signOutAll).toHaveAttribute('data-variant', 'ghost'); + expect(screen.getByRole('group', { name: 'Active devices' }).querySelector('.cl-section-header')).toContainElement( + signOutAll, + ); + await user.click(signOutAll); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); @@ -134,8 +140,8 @@ 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('group', { name: '2 other devices' }); - await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); + const activeDevices = screen.getByRole('group', { name: 'Active devices' }); + await user.click(within(activeDevices).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' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index 1dea42f8954..7f64cdcad10 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -73,9 +73,9 @@ export function UserProfileActiveDevicesSectionView({ } }; - // Confirming takes the whole card with it, trigger included — hence the dialog mounted outside - // it, and the current device as the place focus lands. Cancelling keeps the trigger, so focus - // goes back to it. + // Confirming removes the other devices and the trigger with them — hence the dialog mounted + // outside the card, and the current device as the place focus lands. Cancelling keeps the + // trigger, so focus goes back to it. const focusAfterSignOutAll = () => { const signedOut = signedOutAll.current; signedOutAll.current = false; @@ -88,66 +88,50 @@ export function UserProfileActiveDevicesSectionView({ {m.title} + {onSignOutAllOtherDevices && otherDevices.length > 0 ? ( + + + + ) : null} - {currentDevices.length > 0 ? ( - currentDevices.map(device => ( - + + {currentDevices.length > 0 ? ( + currentDevices.map(device => ( deviceDetails.open(device)} /> - - )) - ) : ( - + )) + ) : ( {m.emptyCurrent} - - )} + )} + {otherDevices.map(device => ( + deviceDetails.open(device)} + /> + ))} + - {otherDevices.length > 0 ? ( - - - - {fill(otherDevices.length === 1 ? m.otherDevice : m.otherDevices, { - count: String(otherDevices.length), - })} - - {onSignOutAllOtherDevices ? ( - - - - ) : null} - - - - {otherDevices.map(device => ( - deviceDetails.open(device)} - /> - ))} - - - - ) : null} {onSignOutAllOtherDevices ? ( - {m.label} {hasPassword ? m.masked : m.noPasswordSet} {managedBy ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index dcd4a657dbf..f941a18e8f4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -23,7 +23,10 @@ export function UserProfilePasswordSectionView({ } return ( - + + + {m.label} + + +### Flat cards + +External CSS can move the card from `.cl-section-group` to `.cl-section-body`; this example targets `.cl-section-group`, `.cl-section-header`, `.cl-section-body`, and the header's `.cl-button`. + + diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx index ae139015520..452c1002280 100644 --- a/packages/swingset/src/stories/section.stories.tsx +++ b/packages/swingset/src/stories/section.stories.tsx @@ -399,3 +399,129 @@ export function Destructive() { ); } + +export function Flat() { + return ( + <> + + + + + Profile + + + + + + 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} + + + + + + + ))} + + + + ))} + + + ); +} From a2992b2339344e1df4396b7ae6220e1a48e7a4c5 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 29 Sep 2026 16:32:13 -0600 Subject: [PATCH 6/7] fix(mosaic): tighten the password row and keep header actions outlined The password card shows its value as a single list item instead of a full-height row, a row's content centers within its minimum height, and the sign-out-all action uses the outline variant like other header actions. Co-Authored-By: Claude Fable 5.1 --- packages/mosaic/src/components/section/section.styles.ts | 1 + .../__tests__/user-profile-security-panel.view.test.tsx | 2 +- .../user-profile/user-profile-active-devices-section.view.tsx | 2 +- .../user-profile-password-row.view.tsx | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 597300da587..3cfef0569b5 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -74,6 +74,7 @@ export const styles = stylex.create({ }, display: 'flex', flexDirection: 'column', + justifyContent: 'center', rowGap: space['2'], minHeight: `calc(${space['18.5']} + 1px)`, width: '100%', 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 ce8ed14c4eb..052f8b22877 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 @@ -120,7 +120,7 @@ describe('UserProfileSecurityPanelView', () => { await user.click(screen.getByRole('button', { name: 'Add passkey' })); const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' }); - expect(signOutAll).toHaveAttribute('data-variant', 'ghost'); + expect(signOutAll).toHaveAttribute('data-variant', 'outline'); expect(screen.getByRole('group', { name: 'Active devices' }).querySelector('.cl-section-header')).toContainElement( signOutAll, ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx index 7f64cdcad10..f2f3325ca58 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section.view.tsx @@ -94,7 +94,7 @@ export function UserProfileActiveDevicesSectionView({ ref={signOutAllTrigger} color='neutral' size='sm' - variant='ghost' + variant='outline' onClick={() => setIsSignOutAllOpen(true)} > {m.signOutAll} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 6c120759066..45572922b09 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -22,7 +22,7 @@ export function UserProfilePasswordRowView({ }: UserProfilePasswordSectionViewProps) { const m = useMessages('userProfilePasswordSection'); return ( - + {hasPassword ? m.masked : m.noPasswordSet} @@ -41,7 +41,7 @@ export function UserProfilePasswordRowView({ ) : null} - + ); } From dd32ae02886c23ae47ab968a208509d9b98fdc80 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 29 Sep 2026 16:42:50 -0600 Subject: [PATCH 7/7] fix(mosaic): open the gap between a panel title and its sections to space-6 Co-Authored-By: Claude Fable 5.1 --- packages/mosaic/src/components/panel/panel.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mosaic/src/components/panel/panel.styles.ts b/packages/mosaic/src/components/panel/panel.styles.ts index 14f4dc9aa95..18b83f92f02 100644 --- a/packages/mosaic/src/components/panel/panel.styles.ts +++ b/packages/mosaic/src/components/panel/panel.styles.ts @@ -4,7 +4,7 @@ import { colorVars, radiusVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ root: { - gap: space['4'], + gap: space['6'], display: 'flex', flexDirection: 'column', },