Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/user-profile-section-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 4 additions & 1 deletion packages/mosaic/src/components/profile/profile.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,11 @@ export const panelStyles = stylex.create({
flexDirection: 'column',
},
sections: {
gap: space['10'],
gap: space['8'],
display: 'flex',
flexDirection: 'column',
},
groups: {
rowGap: space['8'],
},
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as stylex from '@stylexjs/stylex';

export const sectionNestedItemMarker = stylex.defineMarker();
export const sectionHeaderMarker = stylex.defineMarker();
16 changes: 10 additions & 6 deletions packages/mosaic/src/components/section/section.styles.ts
Original file line number Diff line number Diff line change
@@ -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 { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex';

export const styles = stylex.create({
root: {
Expand Down Expand Up @@ -46,16 +46,20 @@ export const styles = stylex.create({
header: {
marginInline: space['4'],
paddingBlock: space['3'],
borderBlockEndColor: colorVars['--cl-color-border'],
borderBlockEndStyle: 'solid',
borderBlockEndWidth: '1px',
minHeight: space['13'],
width: 'auto',
},
items: {
paddingInline: space['4'],
marginInline: space['4'],
borderBlockStartColor: colorVars['--cl-color-border'],
borderBlockStartStyle: 'solid',
borderBlockStartWidth: {
default: '0px',
[stylex.when.siblingBefore(':where(*)', sectionHeaderMarker)]: '1px',
},
display: 'flex',
flexDirection: 'column',
width: '100%',
width: 'auto',
},
item: {
alignItems: 'center',
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/src/components/section/section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from '../heading';
import { sectionNestedItemMarker } from './section.markers.stylex';
import { sectionHeaderMarker, sectionNestedItemMarker } from './section.markers.stylex';
import { styles } from './section.styles';

export type SectionRootProps = Omit<MosaicComponentProps<'section'>, 'title'>;
Expand Down Expand Up @@ -134,7 +134,7 @@ const Header = React.forwardRef<HTMLDivElement, SectionHeaderProps>(function Sec
props: {
...mergeStyleProps(
themeProps('section-header'),
stylex.props(reset.base, styles.item, styles.header, xstyle),
stylex.props(reset.base, styles.item, styles.header, sectionHeaderMarker, xstyle),
rest,
),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('UserProfileBillingPanelView', () => {
renderView();

expect(screen.getByRole('heading', { level: 3, name: 'Billing' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 4, name: 'Subscription' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Subscription' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Payment methods' })).toBeInTheDocument();
expect(screen.getByText('Basic Plan')).toBeInTheDocument();
expect(screen.getByText('$12.00')).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('UserProfileDeleteSectionView', () => {
it('renders the danger zone with the dialog closed', () => {
renderView();

expect(screen.getByRole('heading', { name: 'Danger zone' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Danger zone' })).toBeInTheDocument();
expect(
screen.getByText('Permanently delete this account and all its data. This cannot be undone.'),
).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ function renderView(overrides: Partial<UserProfilePasskeysSectionViewProps> = {}

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.getByRole('region', { name: 'Passkeys' })).toBeVisible();
expect(screen.getByText('Passkeys')).toBeVisible();
expect(screen.getByText('MacBook')).toBeVisible();
expect(screen.getByText('iPhone')).toBeVisible();
Expand Down Expand Up @@ -120,9 +120,9 @@ 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.getByRole('region', { name: 'Passkeys' })).toBeVisible();
expect(screen.getByText('Passkeys')).toBeVisible();
expect(screen.getByText('No passkeys added')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('UserProfilePasswordSectionView', () => {
const user = userEvent.setup();
renderView({ hasPassword: false, onSubmitPassword });

expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Password' })).toBeInTheDocument();
expect(screen.getByText('Password')).toBeVisible();
expect(screen.queryByText('••••••••••••••••••')).not.toBeInTheDocument();
expect(screen.getByText('No password set')).toBeVisible();
Expand Down Expand Up @@ -109,7 +109,7 @@ describe('UserProfilePasswordSectionView', () => {
</MosaicProvider>,
);

expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible();
expect(screen.getByRole('region', { name: 'Password' })).toBeVisible();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('UserProfileProfilePanelView', () => {
/>
</MosaicProvider>,
);
expect(screen.queryByRole('heading', { name: 'Connected accounts' })).not.toBeInTheDocument();
expect(screen.queryByRole('region', { name: 'Connected accounts' })).not.toBeInTheDocument();
expect(screen.getByRole('alertdialog', { name: 'Remove connected account' })).toBeInTheDocument();

await act(async () => {
Expand Down Expand Up @@ -94,7 +94,7 @@ describe('UserProfileProfilePanelView', () => {
/>
</MosaicProvider>,
);
expect(screen.queryByRole('heading', { name: 'Web3 wallets' })).not.toBeInTheDocument();
expect(screen.queryByRole('region', { name: 'Web3 wallets' })).not.toBeInTheDocument();
expect(screen.getByRole('alertdialog')).toHaveTextContent('0x1234');
await act(async () => {
removal.resolve();
Expand Down Expand Up @@ -293,9 +293,9 @@ describe('UserProfileProfilePanelView', () => {
onDeleteAccount,
});

expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Connected accounts' })).toBeInTheDocument();
expect(screen.getByText('Google')).toBeVisible();
expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Danger zone' })).toBeInTheDocument();
expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument();
expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass(
'cl-section-description',
Expand Down Expand Up @@ -349,7 +349,7 @@ describe('UserProfileProfilePanelView', () => {
onRemoveWeb3Wallet: vi.fn(),
});

expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument();
expect(screen.getByRole('region', { 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,16 @@ describe('UserProfileSecurityPanelView', () => {
renderView({ onDeleteAccount: vi.fn(() => Promise.resolve()) });

expect(screen.getByRole('heading', { level: 3, name: 'Security' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 4, name: 'Active devices' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Authentication' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Active devices' })).toBeInTheDocument();
expect(screen.getByRole('region', { 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(screen.getByText('This device')).toBeInTheDocument();
expect(screen.getByText('2 other devices')).toBeInTheDocument();
expect(screen.getByText('Clerk App on macOS')).toBeInTheDocument();
expect(
screen.getByText('Permanently delete this account and all its data. This cannot be undone.'),
).toBeInTheDocument();
Expand Down Expand Up @@ -131,8 +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('group', { name: 'Other devices' });
await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' }));
await user.click(screen.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());
Expand Down Expand Up @@ -179,7 +178,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,
Expand All @@ -191,17 +190,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.getByRole('region', { name: 'Authentication' })).toBeVisible();
expect(screen.getByText('2-step verification')).toBeVisible();
});

it('keeps one authentication heading when passkeys are empty and Add is unavailable', () => {
it('keeps one 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).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();
});
Expand All @@ -226,15 +224,15 @@ describe('UserProfileSecurityPanelView', () => {
/>
</MosaicProvider>,
);
expect(screen.getByText('Authentication')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Authentication' })).toBeInTheDocument();
expect(screen.getByRole('alertdialog', { name: 'Remove passkey' })).toBeVisible();

await act(async () => {
removal.resolve();
await removal.promise;
});
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
expect(screen.getByRole('heading', { name: 'Authentication' })).toBeVisible();
expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible();
expect(screen.getByText('Passkeys')).toBeVisible();
expect(screen.getByText('No passkeys added')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { panelStyles } from '../../../components/profile';
import { Section } from '../../../components/section';
import { useMessages } from '../../../localization';
import type { FileRejection } from '../../../primitives/file-upload';
Expand Down Expand Up @@ -112,10 +113,17 @@ export function UserProfileAccountSectionView({
);

return (
<Section.Root aria-label={m.sectionLabel}>
<Section.Root
aria-label={m.sectionLabel}
xstyle={panelStyles.groups}
>
<Section.Group>
<Section.Title>{m.sectionTitle}</Section.Title>
<Section.Surface>
<Section.Header>
<Section.Content>
<Section.Label>{m.sectionTitle}</Section.Label>
</Section.Content>
</Section.Header>
<UserProfilePictureRowView
name={name}
imageUrl={imageUrl}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 takes the trigger with the other devices — 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.
const focusAfterSignOutAll = () => {
const signedOut = signedOutAll.current;
signedOutAll.current = false;
Expand All @@ -84,10 +84,27 @@ export function UserProfileActiveDevicesSectionView({

return (
<div {...stylex.props(styles.sectionCards)}>
<Section.Root>
<Section.Root aria-label={m.title}>
<Section.Group>
<Section.Title>{m.title}</Section.Title>
<Section.Surface>
<Section.Header>
<Section.Content>
<Section.Label>{m.title}</Section.Label>
</Section.Content>
{onSignOutAllOtherDevices && otherDevices.length > 0 ? (
<Section.Actions>
<Button
ref={signOutAllTrigger}
color='neutral'
size='sm'
variant='outline'
onClick={() => setIsSignOutAllOpen(true)}
>
{m.signOutAll}
</Button>
</Section.Actions>
) : null}
</Section.Header>
{currentDevices.length > 0 ? (
currentDevices.map(device => (
<Section.Row key={device.id}>
Expand All @@ -107,50 +124,18 @@ export function UserProfileActiveDevicesSectionView({
</Section.Item>
</Section.Row>
)}
{otherDevices.map(device => (
<Section.Row key={device.id}>
<DeviceItem
device={device}
triggerRef={removalFocus.registerTrigger(device.id)}
onSignOut={openSignOut}
onViewDetails={device => deviceDetails.open(device)}
/>
</Section.Row>
))}
</Section.Surface>
</Section.Group>
{otherDevices.length > 0 ? (
<Section.Group
variant='contained'
aria-label={m.otherDevicesTitle}
>
<Section.Surface>
<Section.Header>
<Section.Content>
<Section.Label>
{fill(otherDevices.length === 1 ? m.otherDevice : m.otherDevices, {
count: String(otherDevices.length),
})}
</Section.Label>
</Section.Content>
{onSignOutAllOtherDevices ? (
<Section.Actions>
<Button
ref={signOutAllTrigger}
color='neutral'
size='sm'
variant='outline'
onClick={() => setIsSignOutAllOpen(true)}
>
{m.signOutAll}
</Button>
</Section.Actions>
) : null}
</Section.Header>
<Section.Items>
{otherDevices.map(device => (
<DeviceItem
key={device.id}
device={device}
triggerRef={removalFocus.registerTrigger(device.id)}
onSignOut={openSignOut}
onViewDetails={device => deviceDetails.open(device)}
/>
))}
</Section.Items>
</Section.Surface>
</Section.Group>
) : null}
</Section.Root>
{onSignOutAllOtherDevices ? (
<Confirmation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
export const userProfileActiveDevicesMessages = {
title: 'Active devices',
otherDevicesTitle: 'Other devices',
emptyCurrent: 'No current device available',
otherDevice: '{count} other device',
otherDevices: '{count} other devices',
signOutAll: 'Sign out of all devices',
signOutAllError: 'Something went wrong signing these devices out. Please try again.',
signOutAllDialog: {
Expand Down
Loading
Loading