Skip to content
Draft
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-profile-profile-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 3 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')),
},
organization: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')),
Expand Down
11 changes: 11 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ import {
} from '../stories/text.stories';
import { meta as tooltipMeta } from '../stories/tooltip.stories';
import { meta as useDataTableMeta } from '../stories/use-data-table.stories';
import {
Default as UserProfileProfilePanelDefault,
meta as userProfileProfilePanelMeta,
} from '../stories/user-profile-profile-panel.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';

Expand Down Expand Up @@ -242,7 +246,14 @@ const scrollAreaModule: StoryModule = {

const useDataTableModule: StoryModule = { meta: useDataTableMeta };

const userProfileProfilePanelModule: StoryModule = {
meta: userProfileProfilePanelMeta,
Default: UserProfileProfilePanelDefault,
};

export const registry: StoryModule[] = [
// User
userProfileProfilePanelModule,
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
Expand Down
7 changes: 6 additions & 1 deletion packages/swingset/src/stories/icon.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const meta: StoryMeta = {
styleEngine: 'stylex',
styles: {
_variants: {
size: { sm: {}, md: {}, lg: {} },
size: { xs: {}, sm: {}, md: {}, lg: {} },
},
_defaultVariants: {
size: 'md',
Expand All @@ -44,6 +44,11 @@ export function Default(props: Record<string, unknown>) {
export function Sizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Icon
{...knobsAsProps(props)}
name='chevron-right'
size='xs'
/>
<Icon
{...knobsAsProps(props)}
name='chevron-right'
Expand Down
53 changes: 53 additions & 0 deletions packages/swingset/src/stories/user-profile-profile-panel.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as UserProfileProfilePanelStories from './user-profile-profile-panel.stories';

# UserProfileProfilePanel

The Profile panel from UserProfile, composed without the surrounding navigation shell. The
presentational view renders account details, connected accounts, and the danger zone from props
while its callbacks provide the seam for connected Clerk resources.

## Example

<Story
name='Default'
storyModule={UserProfileProfilePanelStories}
/>

## Usage

```tsx
import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view';

<UserProfileProfilePanelView
imageUrl={user.imageUrl}
name={user.fullName ?? ''}
username={user.username ?? ''}
emails={user.emailAddresses.map(email => ({
id: email.id,
value: email.emailAddress,
isDefault: email.id === user.primaryEmailAddressId,
isVerified: email.verification.status === 'verified',
}))}
phones={user.phoneNumbers.map(phone => ({
id: phone.id,
value: phone.phoneNumber,
isDefault: phone.id === user.primaryPhoneNumberId,
isVerified: phone.verification.status === 'verified',
}))}
connectedAccounts={user.externalAccounts.map(account => ({
id: account.id,
provider: account.provider,
identifier: account.emailAddress,
connected: true,
}))}
onNameChange={setName}
onUsernameChange={setUsername}
onVerifyEmail={verifyEmail}
onSetPrimaryEmail={setPrimaryEmail}
onRemoveEmail={removeEmail}
onVerifyPhone={verifyPhone}
onSetPrimaryPhone={setPrimaryPhone}
onRemovePhone={removePhone}
onRemoveConnectedAccount={removeConnectedAccount}
/>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** @jsxImportSource @emotion/react */
import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view';
import { useState } from 'react';

import type { StoryMeta } from '@/lib/types';

const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`;
const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4';

export { default as __source } from './user-profile-profile-panel.stories?raw';

export const meta: StoryMeta = {
group: 'User',
title: 'UserProfileProfilePanel',
source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx',
};

export function Default(_args: Record<string, unknown>) {
const [name, setName] = useState('Preston Booth');
const [username, setUsername] = useState('prestonxyz');

return (
<UserProfileProfilePanelView
emails={[
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
{ id: 'email_2', value: 'item2@clerk.dev', isVerified: true },
]}
connectedAccounts={[
{
id: 'google',
provider: 'Google',
identifier: 'test@google.com',
iconUrl: providerIconUrl('google'),
connected: true,
},
{ id: 'apple', provider: 'Apple', iconUrl: providerIconUrl('apple'), connected: false },
]}
imageUrl={profileImageUrl}
name={name}
phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]}
username={username}
onAddEmail={() => undefined}
onAddPhone={() => undefined}
onConnectAccount={() => undefined}
onDeleteAccount={() => undefined}
onEditProfilePicture={() => undefined}
onRemoveConnectedAccount={() => undefined}
onRemoveEmail={() => undefined}
onRemovePhone={() => undefined}
onSetPrimaryEmail={() => undefined}
onSetPrimaryPhone={() => undefined}
onVerifyEmail={() => undefined}
onVerifyPhone={() => undefined}
onNameChange={setName}
onUsernameChange={setUsername}
/>
);
}
1 change: 1 addition & 0 deletions packages/ui/src/mosaic/components/icon/icon.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const styles = stylex.create({
});

export const sizes = stylex.create({
xs: { height: space['3'], width: space['3'] },
sm: { height: space['3.5'], width: space['3.5'] },
md: { height: space['4'], width: space['4'] },
lg: { height: space['5'], width: space['5'] },
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/mosaic/components/icon/icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ describe('Mosaic Icon', () => {
expect(svg).toHaveStyle({ marginTop: '8px' });
});

it('renders the pen with its native 12px geometry', () => {
const { container } = wrap(
<Icon
name='pen'
size='xs'
/>,
);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('data-size', 'xs');
expect(svg).toHaveAttribute('viewBox', '0 0 12 12');
expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor');
});

it('emits no placement attribute when the icon is not placed', () => {
const { container } = wrap(<Icon name='chevron-right' />);
expect(container.querySelector('svg')).not.toHaveAttribute('data-icon');
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/components/icon/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { sizes, styles } from './icon.styles';

export interface IconProps extends React.ComponentPropsWithRef<'svg'> {
name: IconName;
size?: 'sm' | 'md' | 'lg';
size?: 'xs' | 'sm' | 'md' | 'lg';
placement?: 'inline-start' | 'inline-end';
}

Expand Down
23 changes: 21 additions & 2 deletions packages/ui/src/mosaic/icons/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ type IconComponent = React.ForwardRefExoticComponent<
* Builds a glyph from its inner `<path>` markup. Glyphs omit `width`/`height` so the `Icon` recipe
* controls size, and use `currentColor` so they inherit text color. Grow the set on demand.
*/
function glyph(children: React.ReactNode): IconComponent {
function glyph(children: React.ReactNode, viewBox = '0 0 16 16'): IconComponent {
return React.forwardRef<SVGSVGElement, React.ComponentPropsWithoutRef<'svg'>>((props, ref) => (
<svg
ref={ref}
viewBox='0 0 16 16'
viewBox={viewBox}
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
Expand Down Expand Up @@ -80,6 +80,23 @@ const Plus = glyph(
/>,
);

const ArrowRightTop = glyph(
<path
d='M6.35014 5.40727L10.8285 5.17157M10.8285 5.17157L10.5928 9.64991M10.8285 5.17157L5.17163 10.8284'
{...strokeProps}
/>,
);

const Pen = glyph(
<path
clipRule='evenodd'
d='M9.02209 2.97867C8.55345 2.50711 7.79511 2.50711 7.32647 2.97867L7.31857 2.98647L3.5273 6.65369C3.39533 6.78134 3.30375 6.94493 3.26392 7.12416L2.80602 9.1847L4.71264 8.73608C4.87966 8.69678 5.03269 8.6124 5.15506 8.49214L9.06427 4.65015C9.494 4.1715 9.47623 3.43565 9.02209 2.97867ZM6.53268 2.18146C7.44155 1.27145 8.91291 1.27285 9.82005 2.18566C10.7012 3.07231 10.7294 4.49927 9.88442 5.42042C9.87785 5.42757 9.87111 5.43455 9.86419 5.44135L5.94362 9.2945C5.67441 9.55909 5.33775 9.74472 4.97031 9.83117L2.19135 10.485C2.00293 10.5294 1.80496 10.4737 1.66724 10.3377C1.52953 10.2017 1.47142 10.0044 1.51341 9.81548L2.16571 6.88012C2.25333 6.48581 2.45481 6.12591 2.74514 5.84508L6.53268 2.18146Z'
fill='currentColor'
fillRule='evenodd'
/>,
'0 0 12 12',
);

const LogOut = glyph(
<path
d='M6.25 13.25H3.75V2.75H6.25M10.25 10.75L13 8L10.25 5.25M13 8H6.25'
Expand All @@ -105,12 +122,14 @@ const AlertCircle = glyph(
/** Runtime name → glyph map. `Icon`'s `name` prop is typed from these keys. */
export const iconRegistry = {
'alert-circle': AlertCircle,
'arrow-right-top': ArrowRightTop,
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-down': ChevronDown,
check: Check,
close: Close,
ellipsis: Ellipsis,
pen: Pen,
plus: Plus,
'log-out': LogOut,
} satisfies Record<string, IconComponent>;
Expand Down
Loading
Loading