diff --git a/.changeset/section-component.md b/.changeset/section-component.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/section-component.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-profile-account-section.md b/.changeset/user-profile-account-section.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-account-section.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-profile-section-style-alignment.md b/.changeset/user-profile-section-style-alignment.md new file mode 100644 index 00000000000..ba7da331734 --- /dev/null +++ b/.changeset/user-profile-section-style-alignment.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Align the Mosaic user profile sections with the latest profile design. diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 679269b376d..376bc86d595 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -12,6 +12,7 @@ import { ViewSource } from './ViewSource'; const docModules: Record> = { user: { 'user-button': dynamic(() => import('../stories/user-button.mdx')), + 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), }, organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), @@ -42,7 +43,7 @@ const docModules: Record> = { icon: dynamic(() => import('../stories/icon.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), - settings: dynamic(() => import('../stories/settings.mdx')), + section: dynamic(() => import('../stories/section.mdx')), tabs: dynamic(() => import('../stories/tabs.component.mdx')), text: dynamic(() => import('../stories/text.mdx')), field: dynamic(() => import('../stories/field.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index e721dabd689..8ba81c94136 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -106,14 +106,14 @@ import { ShadowIndicators as ScrollAreaShadowIndicators, ThemedScrollbar as ScrollAreaThemedScrollbar, } from '../stories/scroll-area.stories'; -import { meta as selectMeta } from '../stories/select.stories'; import { - ConnectedAccounts as SettingsConnectedAccounts, - Default as SettingsDefault, - Destructive as SettingsDestructive, - meta as settingsMeta, - MultipleEmailAndPhoneNumbers as SettingsMultipleEmailAndPhoneNumbers, -} from '../stories/settings.stories'; + ConnectedAccounts as SectionConnectedAccounts, + Default as SectionDefault, + Destructive as SectionDestructive, + meta as sectionMeta, + MultipleEmailAndPhoneNumbers as SectionMultipleEmailAndPhoneNumbers, +} from '../stories/section.stories'; +import { meta as selectMeta } from '../stories/select.stories'; import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; import { meta as tabsMeta } from '../stories/tabs.stories'; import { @@ -130,16 +130,20 @@ import { Organizations as UserButtonOrganizations, User as UserButtonUser, } from '../stories/user-button.stories'; +import { + Default as UserProfileAccountSectionDefault, + meta as userProfileAccountSectionMeta, +} from '../stories/user-profile-account-section.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault }; -const settingsModule: StoryModule = { - meta: settingsMeta, - Default: SettingsDefault, - MultipleEmailAndPhoneNumbers: SettingsMultipleEmailAndPhoneNumbers, - ConnectedAccounts: SettingsConnectedAccounts, - Destructive: SettingsDestructive, +const sectionModule: StoryModule = { + meta: sectionMeta, + Default: SectionDefault, + MultipleEmailAndPhoneNumbers: SectionMultipleEmailAndPhoneNumbers, + ConnectedAccounts: SectionConnectedAccounts, + Destructive: SectionDestructive, }; const organizationProfileLeaveSectionModule: StoryModule = { meta: organizationProfileLeaveSectionMeta, @@ -271,9 +275,15 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const userProfileAccountSectionModule: StoryModule = { + meta: userProfileAccountSectionMeta, + Default: UserProfileAccountSectionDefault, +}; + export const registry: StoryModule[] = [ // User userButtonModule, + userProfileAccountSectionModule, // Organization organizationProfileModule, organizationProfileGeneralPanelModule, @@ -297,7 +307,7 @@ export const registry: StoryModule[] = [ iconModule, menuComponentModule, popoverComponentModule, - settingsModule, + sectionModule, tabsComponentModule, textModule, fieldModule, diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx new file mode 100644 index 00000000000..4d469dfc575 --- /dev/null +++ b/packages/swingset/src/stories/section.mdx @@ -0,0 +1,72 @@ +import * as SectionStories from './section.stories'; + +# Section + +A section component that carries the canonical heading, surface, row grouping, and item layout. Use it for product sections while composing each item's media, content, and actions as needed. + +## Usage + +```tsx +import { Section } from '@clerk/ui/mosaic/components/section'; + + + Account + + + + {icon} + + Name + Shown throughout the application. + + {control} + + + + +``` + +`Section.Group` owns the outlined surface. 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. + +Use `Section.Items` for a nested value list beneath a row's header item. The row keeps an 8px header-to-list gap, nested items stack without gaps at a 44px minimum height, and their content receives 12px vertical padding. + +## Examples + + + +### Multiple email addresses and phone numbers + + + +### Connected accounts + + + +### Destructive section + + diff --git a/packages/swingset/src/stories/section.stories.tsx b/packages/swingset/src/stories/section.stories.tsx new file mode 100644 index 00000000000..60d34e67af7 --- /dev/null +++ b/packages/swingset/src/stories/section.stories.tsx @@ -0,0 +1,373 @@ +/** @jsxImportSource @emotion/react */ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; +import { Badge } from '@clerk/ui/mosaic/components/badge'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Section } from '@clerk/ui/mosaic/components/section'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './section.stories?raw'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; + +function ProviderIcon({ provider }: { provider: string }) { + return ( + + ); +} + +export const meta: StoryMeta = { + group: 'Components', + title: 'Section', + source: 'packages/ui/src/mosaic/components/section/section.tsx', + styleEngine: 'stylex', +}; + +export function Default() { + return ( + + Profile + + + + + + + PB + + + + Profile picture + Recommend size 1:1, up to 10MB. + + + + + + + + + + + Name + Preston Booth + + + + + + + + + + + Username + Prestonb.xyz + + + + + + + + + + + Email + item1@clerk.dev + + + + + + + + + ); +} + +export function MultipleEmailAndPhoneNumbers() { + return ( + + Profile + + + + + + + PB + + + + Profile picture + Recommend size 1:1, up to 10MB. + + + + + + + + + + + Name + Preston Booth + + + + + + + + + + + Username + Prestonb.xyz + + + + + + + + + + + Email + + + + + + + + + + item1@clerk.dev + Primary + + + + + + + + + item2@clerk.dev + + + + + + + + + + + + Phone + + + + + + + + + + +1 801-888-8181 + Primary + + + + + + + + + + + ); +} + +export function ConnectedAccounts() { + return ( + + Connected accounts + + + + + + + + Google + test@google.com + + + + + + + + + + + + + Apple + + + + + + + + + ); +} + +export function Destructive() { + return ( + + Danger zone + + + + + Delete account + + Permanently delete this profile and all its data. This cannot be undone. + + + + + + + + + + ); +} diff --git a/packages/swingset/src/stories/settings.mdx b/packages/swingset/src/stories/settings.mdx deleted file mode 100644 index dd3c149f737..00000000000 --- a/packages/swingset/src/stories/settings.mdx +++ /dev/null @@ -1,74 +0,0 @@ -import * as SettingsStories from './settings.stories'; - -# Settings - -A settings component that carries the canonical section, surface, row grouping, and item layout. Use it for product settings while composing each item's media, content, and actions as needed. - -## Usage - -```tsx -import { Settings } from '@clerk/ui/mosaic/components/settings'; - - - Account - - - - {icon} - - Name - Shown throughout the application. - - {control} - - - - -``` - -`Settings.Group` owns the outlined surface. Its direct `Settings.Row` children own separation and may stack one or more `Settings.Item` entries. Each item lays out optional media, flexible content, and trailing actions. `Settings.Media` accepts `md` and `lg` sizes. - -Use `Settings.Items` for a nested value list beneath a row's header item. The row keeps an 8px header-to-list gap, nested items stack without gaps at a 44px minimum height, and their content receives 12px vertical padding. - -Override `--cl-settings-items-gap` on `Settings.Root` or a specific `Settings.Row` to customize the space between the row header and its nested value list. It defaults to 8px. - -## Examples - - - -### Multiple email addresses and phone numbers - - - -### Connected accounts - - - -### Destructive setting - - diff --git a/packages/swingset/src/stories/settings.stories.tsx b/packages/swingset/src/stories/settings.stories.tsx deleted file mode 100644 index 11346f9593b..00000000000 --- a/packages/swingset/src/stories/settings.stories.tsx +++ /dev/null @@ -1,355 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { Avatar } from '@clerk/ui/mosaic/components/avatar'; -import { Badge } from '@clerk/ui/mosaic/components/badge'; -import { Button } from '@clerk/ui/mosaic/components/button'; -import { Icon } from '@clerk/ui/mosaic/components/icon'; -import { Settings } from '@clerk/ui/mosaic/components/settings'; - -import type { StoryMeta } from '@/lib/types'; - -export { default as __source } from './settings.stories?raw'; - -const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; - -function ProviderIcon({ provider }: { provider: string }) { - return ( - - ); -} - -export const meta: StoryMeta = { - group: 'Components', - title: 'Settings', - source: 'packages/ui/src/mosaic/components/settings/settings.tsx', - styleEngine: 'stylex', -}; - -export function Default() { - return ( - - Profile - - - - - Profile picture - PNG or JPEG, Recommended size 1:1, up to 10MB. - - - - - PB - - - - - - - - - Name - Preston Booth - - - - - - - - - - - Username - Prestonb.xyz - - - - - - - - - - - Email - item1@clerk.dev - - - - - - - - - ); -} - -export function MultipleEmailAndPhoneNumbers() { - return ( - - Profile - - - - - Profile picture - PNG or JPEG, Recommended size 1:1, up to 10MB. - - - - - PB - - - - - - - - - Name - Preston Booth - - - - - - - - - - - Username - Prestonb.xyz - - - - - - - - - - - Email - - - - - - - - - - item1@clerk.dev - Primary - - - - - - - - - item2@clerk.dev - - - - - - - - - - - - Phone - - - - - - - - - - +1 801-888-8181 - Primary - - - - - - - - - - - ); -} - -export function ConnectedAccounts() { - return ( - - Connected accounts - - - - - - - - Google - test@google.com - - - - - - - - - - - - - Apple - - - - - - - - - ); -} - -export function Destructive() { - return ( - - Danger zone - - - - - Delete account - - Permanently delete this profile and all its data. This cannot be undone. - - - - - - - - - - ); -} diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx new file mode 100644 index 00000000000..1b065919bd8 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -0,0 +1,14 @@ +import * as Stories from './user-profile-account-section.stories'; + +# UserProfileAccountSection + +Account details, profile image, email addresses, and phone numbers composed with `Section`. + + diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx new file mode 100644 index 00000000000..d516078430b --- /dev/null +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -0,0 +1,35 @@ +/** @jsxImportSource @emotion/react */ +import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-account-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileAccountSection', + source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx', + styleEngine: 'stylex', +}; + +export function Default() { + return ( + undefined} + onAddPhone={() => undefined} + onEditProfilePicture={() => undefined} + onManageEmail={() => undefined} + onManagePhone={() => undefined} + onNameChange={() => undefined} + onUsernameChange={() => undefined} + /> + ); +} diff --git a/packages/ui/src/mosaic/components/reset.test.tsx b/packages/ui/src/mosaic/components/reset.test.tsx index 5f84c5de704..8c3055fbef7 100644 --- a/packages/ui/src/mosaic/components/reset.test.tsx +++ b/packages/ui/src/mosaic/components/reset.test.tsx @@ -13,7 +13,7 @@ import { Icon } from './icon'; import { Input } from './input'; import { Item } from './item'; import { reset } from './reset.styles'; -import { Settings } from './settings'; +import { Section } from './section'; import { Text } from './text'; // StyleX generates the same atom for the same property+value across separate `create` calls, so a @@ -61,17 +61,17 @@ const cases: Array<[string, React.ReactElement]> = [ ['Item', ], ['Item.Group', ], ['Item.Separator', ], - ['Settings', ], - ['Settings.Title', Account], - ['Settings.Group', ], - ['Settings.Row', ], - ['Settings.Items', ], - ['Settings.Item', ], - ['Settings.Media', ], - ['Settings.Content', ], - ['Settings.Label', ], - ['Settings.Description', ], - ['Settings.Actions', ], + ['Section', ], + ['Section.Title', Account], + ['Section.Group', ], + ['Section.Row', ], + ['Section.Items', ], + ['Section.Item', ], + ['Section.Media', ], + ['Section.Content', ], + ['Section.Label', ], + ['Section.Description', ], + ['Section.Actions', ], ['Text', Body copy], ]; diff --git a/packages/ui/src/mosaic/components/section/index.ts b/packages/ui/src/mosaic/components/section/index.ts new file mode 100644 index 00000000000..8b920fdc6fe --- /dev/null +++ b/packages/ui/src/mosaic/components/section/index.ts @@ -0,0 +1,15 @@ +export { Section } from './section'; +export type { + SectionActionsProps, + SectionContentProps, + SectionDescriptionProps, + SectionGroupProps, + SectionItemProps, + SectionItemsProps, + SectionLabelProps, + SectionMediaProps, + SectionMediaSize, + SectionRootProps, + SectionRowProps, + SectionTitleProps, +} from './section'; diff --git a/packages/ui/src/mosaic/components/section/section.markers.stylex.ts b/packages/ui/src/mosaic/components/section/section.markers.stylex.ts new file mode 100644 index 00000000000..b0755da6f9c --- /dev/null +++ b/packages/ui/src/mosaic/components/section/section.markers.stylex.ts @@ -0,0 +1,3 @@ +import * as stylex from '@stylexjs/stylex'; + +export const sectionItemsMarker = stylex.defineMarker(); diff --git a/packages/ui/src/mosaic/components/section/section.test.tsx b/packages/ui/src/mosaic/components/section/section.test.tsx new file mode 100644 index 00000000000..9a31513a612 --- /dev/null +++ b/packages/ui/src/mosaic/components/section/section.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Section } from './section'; + +describe('Section', () => { + it('renders an accessible section and every compound part', () => { + render( + + Account + + + + + Icon + + + Name + Shown throughout the application. + + Control + + + + , + ); + + expect(screen.getByRole('region', { name: 'Account' })).toHaveClass('cl-section'); + expect(screen.getByRole('heading', { level: 4, name: 'Account' })).toHaveClass('cl-section-title'); + expect(screen.getByTestId('group')).toHaveClass('cl-section-group'); + expect(screen.getByTestId('row')).toHaveClass('cl-section-row'); + expect(screen.getByTestId('item')).toHaveClass('cl-section-item'); + expect(screen.getByTestId('media')).toHaveClass('cl-section-media'); + expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'lg'); + expect(screen.getByTestId('content')).toHaveClass('cl-section-content'); + expect(screen.getByTestId('label')).toHaveClass('cl-section-label'); + expect(screen.getByTestId('description')).toHaveClass('cl-section-description'); + expect(screen.getByTestId('actions')).toHaveClass('cl-section-actions'); + }); + + it('supports an explicit accessible name', () => { + render( + + Account + + , + ); + + expect(screen.getByRole('region', { name: 'Account preferences' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Account' })).toBeInTheDocument(); + }); + + it('composes multiple items in one row', () => { + render( + + Profile + + + + + Email + + 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('items')).toHaveClass('cl-section-items'); + expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); + }); + + it('lets consumer props win and forwards refs and custom elements', () => { + const rootRef = React.createRef(); + const groupRef = React.createRef(); + const itemRef = React.createRef(); + const contentRef = React.createRef(); + const actionsRef = React.createRef(); + + render( +
} + className='custom-root' + > + Account + + + + + Name + + + + + + , + ); + + expect(rootRef.current?.tagName).toBe('ARTICLE'); + expect(rootRef.current).toHaveClass('cl-section', 'custom-root'); + expect(groupRef.current).toHaveClass('cl-section-group', 'custom-group'); + expect(groupRef.current).toHaveStyle({ borderWidth: '2px' }); + expect(itemRef.current).toHaveClass('cl-section-item'); + expect(itemRef.current).toHaveStyle({ minHeight: '80px' }); + expect(contentRef.current).toHaveClass('cl-section-content'); + expect(screen.getByText('Name')).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + expect(actionsRef.current).toHaveClass('cl-section-actions'); + }); +}); diff --git a/packages/ui/src/mosaic/components/settings/settings.tsx b/packages/ui/src/mosaic/components/section/section.tsx similarity index 60% rename from packages/ui/src/mosaic/components/settings/settings.tsx rename to packages/ui/src/mosaic/components/section/section.tsx index 7573f55100e..86ba2ca478d 100644 --- a/packages/ui/src/mosaic/components/settings/settings.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -9,26 +9,23 @@ import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../ import type { HeadingProps } from '../heading'; import { Heading } from '../heading'; import { reset } from '../reset.styles'; -import { settingsItemsMarker } from './settings.markers.stylex'; -import { settingsVars } from './settings.vars.stylex'; +import { sectionItemsMarker } from './section.markers.stylex'; -export { settingsVars } from './settings.vars.stylex'; - -export type SettingsRootProps = Omit, 'title'>; -export type SettingsTitleProps = Omit; -export type SettingsGroupProps = MosaicComponentProps<'div'>; -export type SettingsRowProps = MosaicComponentProps<'div'>; -export type SettingsItemsProps = MosaicComponentProps<'div'>; -export type SettingsItemProps = MosaicComponentProps<'div'>; -export type SettingsMediaSize = 'sm' | 'md' | 'lg'; -export type SettingsMediaProps = MosaicComponentProps<'div'> & { size?: SettingsMediaSize }; -export type SettingsContentProps = MosaicComponentProps<'div'>; -export type SettingsLabelProps = MosaicComponentProps<'div'>; -export type SettingsDescriptionProps = MosaicComponentProps<'div'>; -export type SettingsActionsProps = MosaicComponentProps<'div'>; +export type SectionRootProps = Omit, 'title'>; +export type SectionTitleProps = Omit; +export type SectionGroupProps = MosaicComponentProps<'div'>; +export type SectionRowProps = MosaicComponentProps<'div'>; +export type SectionItemsProps = MosaicComponentProps<'div'>; +export type SectionItemProps = MosaicComponentProps<'div'>; +export type SectionMediaSize = 'sm' | 'md' | 'lg' | 'xl'; +export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMediaSize }; +export type SectionContentProps = MosaicComponentProps<'div'>; +export type SectionLabelProps = MosaicComponentProps<'div'>; +export type SectionDescriptionProps = MosaicComponentProps<'div'>; +export type SectionActionsProps = MosaicComponentProps<'div'>; /* eslint-disable @stylexjs/no-lookahead-selectors -- Mosaic's supported browsers include :has(); - the marker keeps this selector scoped to Settings.Items. */ + the marker keeps this selector scoped to Section.Items. */ const styles = stylex.create({ root: { display: 'flex', @@ -36,13 +33,19 @@ const styles = stylex.create({ rowGap: space['2'], width: '100%', }, + title: { + alignItems: 'center', + display: 'flex', + fontWeight: fontWeightVars['--cl-font-medium'], + minHeight: space['8'], + }, group: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-xl'], borderStyle: 'solid', borderWidth: '1px', overflow: 'hidden', - backgroundColor: settingsVars['--cl-settings-background'], + backgroundColor: colorVars['--cl-color-card'], width: '100%', }, row: { @@ -57,10 +60,10 @@ const styles = stylex.create({ flexDirection: 'column', paddingBlockEnd: { default: space['4'], - [stylex.when.descendant('[data-nested]', settingsItemsMarker)]: space['1'], + [stylex.when.descendant('[data-nested]', sectionItemsMarker)]: space['1'], }, paddingBlockStart: space['4'], - rowGap: settingsVars['--cl-settings-items-gap'], + rowGap: space['2'], width: 'auto', }, items: { @@ -99,6 +102,10 @@ const styles = stylex.create({ height: space['8'], width: space['8'], }, + mediaXl: { + height: space['10'], + width: space['10'], + }, content: { display: 'flex', flexDirection: 'column', @@ -119,7 +126,7 @@ const styles = stylex.create({ description: { color: colorVars['--cl-color-neutral-faded'], fontSize: typeScaleVars['--cl-text-sm-size'], - fontWeight: fontWeightVars['--cl-font-medium'], + fontWeight: fontWeightVars['--cl-font-normal'], lineHeight: typeScaleVars['--cl-text-sm-leading'], textWrap: 'balance', }, @@ -137,12 +144,13 @@ const mediaSizes = { sm: styles.mediaSm, md: styles.mediaMd, lg: styles.mediaLg, + xl: styles.mediaXl, }; -const SettingsTitleContext = React.createContext> | null>(null); -const SettingsItemsContext = React.createContext(false); +const SectionTitleContext = React.createContext> | null>(null); +const SectionItemsContext = React.createContext(false); -const Root = React.forwardRef(function SettingsRoot( +const Root = React.forwardRef(function SectionRoot( { render, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, ref, ) { @@ -153,23 +161,23 @@ const Root = React.forwardRef(function SettingsR render, ref, props: { - ...mergeStyleProps(themeProps('settings'), stylex.props(reset.base, styles.root), className, style), + ...mergeStyleProps(themeProps('section'), stylex.props(reset.base, styles.root), className, style), ...rest, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy ?? (ariaLabel ? undefined : titleIds.join(' ') || undefined), }, }); - return {element}; + return {element}; }); -const Title = React.forwardRef(function SettingsTitle( +const Title = React.forwardRef(function SectionTitle( { id: idProp, render, className, style, ...rest }, ref, ) { - const setTitleIds = React.useContext(SettingsTitleContext); + const setTitleIds = React.useContext(SectionTitleContext); const generatedId = React.useId(); - const id = idProp ?? (setTitleIds ? `cl-settings-${generatedId}-title` : undefined); + const id = idProp ?? (setTitleIds ? `cl-section-${generatedId}-title` : undefined); useSafeLayoutEffect(() => { if (!id || !setTitleIds) { @@ -186,13 +194,13 @@ const Title = React.forwardRef(function id={id} render={render ?? (props =>

)} size='sm' - {...mergeStyleProps(themeProps('settings-title'), className, style)} + {...mergeStyleProps(themeProps('section-title'), stylex.props(styles.title), className, style)} {...rest} /> ); }); -const Group = React.forwardRef(function SettingsGroup( +const Group = React.forwardRef(function SectionGroup( { render, className, style, ...rest }, ref, ) { @@ -201,13 +209,13 @@ const Group = React.forwardRef(function Sett render, ref, props: { - ...mergeStyleProps(themeProps('settings-group'), stylex.props(reset.base, styles.group), className, style), + ...mergeStyleProps(themeProps('section-group'), stylex.props(reset.base, styles.group), className, style), ...rest, }, }); }); -const Row = React.forwardRef(function SettingsRow( +const Row = React.forwardRef(function SectionRow( { render, className, style, ...rest }, ref, ) { @@ -216,13 +224,13 @@ const Row = React.forwardRef(function Settings render, ref, props: { - ...mergeStyleProps(themeProps('settings-row'), stylex.props(reset.base, styles.row), className, style), + ...mergeStyleProps(themeProps('section-row'), stylex.props(reset.base, styles.row), className, style), ...rest, }, }); }); -const Items = React.forwardRef(function SettingsItems( +const Items = React.forwardRef(function SectionItems( { render, className, style, ...rest }, ref, ) { @@ -232,8 +240,8 @@ const Items = React.forwardRef(function Sett ref, props: { ...mergeStyleProps( - themeProps('settings-items', { nested: true }), - stylex.props(reset.base, styles.items, settingsItemsMarker), + themeProps('section-items', { nested: true }), + stylex.props(reset.base, styles.items, sectionItemsMarker), className, style, ), @@ -241,14 +249,14 @@ const Items = React.forwardRef(function Sett }, }); - return {element}; + return {element}; }); -const Item = React.forwardRef(function SettingsItem( +const Item = React.forwardRef(function SectionItem( { render, className, style, ...rest }, ref, ) { - const nested = React.useContext(SettingsItemsContext); + const nested = React.useContext(SectionItemsContext); return useRender({ defaultTagName: 'div', @@ -256,7 +264,7 @@ const Item = React.forwardRef(function Settin ref, props: { ...mergeStyleProps( - themeProps('settings-item', { nested }), + themeProps('section-item', { nested }), stylex.props(reset.base, styles.item, nested && styles.nestedItem), className, style, @@ -266,7 +274,7 @@ const Item = React.forwardRef(function Settin }); }); -const Media = React.forwardRef(function SettingsMedia( +const Media = React.forwardRef(function SectionMedia( { size = 'md', render, className, style, ...rest }, ref, ) { @@ -276,7 +284,7 @@ const Media = React.forwardRef(function Sett ref, props: { ...mergeStyleProps( - themeProps('settings-media', { size }), + themeProps('section-media', { size }), stylex.props(reset.base, styles.mediaBase, mediaSizes[size]), className, style, @@ -286,11 +294,11 @@ const Media = React.forwardRef(function Sett }); }); -const Content = React.forwardRef(function SettingsContent( +const Content = React.forwardRef(function SectionContent( { render, className, style, ...rest }, ref, ) { - const nested = React.useContext(SettingsItemsContext); + const nested = React.useContext(SectionItemsContext); return useRender({ defaultTagName: 'div', @@ -298,7 +306,7 @@ const Content = React.forwardRef(function ref, props: { ...mergeStyleProps( - themeProps('settings-content', { nested }), + themeProps('section-content', { nested }), stylex.props(reset.base, styles.content, nested && styles.nestedContent), className, style, @@ -308,7 +316,7 @@ const Content = React.forwardRef(function }); }); -const Label = React.forwardRef(function SettingsLabel( +const Label = React.forwardRef(function SectionLabel( { render, className, style, ...rest }, ref, ) { @@ -317,13 +325,13 @@ const Label = React.forwardRef(function Sett render, ref, props: { - ...mergeStyleProps(themeProps('settings-label'), stylex.props(reset.base, styles.label), className, style), + ...mergeStyleProps(themeProps('section-label'), stylex.props(reset.base, styles.label), className, style), ...rest, }, }); }); -const Description = React.forwardRef(function SettingsDescription( +const Description = React.forwardRef(function SectionDescription( { render, className, style, ...rest }, ref, ) { @@ -333,7 +341,7 @@ const Description = React.forwardRef(f ref, props: { ...mergeStyleProps( - themeProps('settings-description'), + themeProps('section-description'), stylex.props(reset.base, styles.description), className, style, @@ -343,7 +351,7 @@ const Description = React.forwardRef(f }); }); -const Actions = React.forwardRef(function SettingsActions( +const Actions = React.forwardRef(function SectionActions( { render, className, style, ...rest }, ref, ) { @@ -352,14 +360,14 @@ const Actions = React.forwardRef(function render, ref, props: { - ...mergeStyleProps(themeProps('settings-actions'), stylex.props(reset.base, styles.actions), className, style), + ...mergeStyleProps(themeProps('section-actions'), stylex.props(reset.base, styles.actions), className, style), ...rest, }, }); }); /** - * A settings component that fixes section semantics, surface treatment, row grouping, + * A compound component that fixes section semantics, surface treatment, row grouping, * and item layout while leaving each item's content composable. */ -export const Settings = { Root, Title, Group, Row, Items, Item, Media, Content, Label, Description, Actions }; +export const Section = { Root, Title, Group, Row, Items, Item, Media, Content, Label, Description, Actions }; diff --git a/packages/ui/src/mosaic/components/settings/index.ts b/packages/ui/src/mosaic/components/settings/index.ts deleted file mode 100644 index e259553aa4d..00000000000 --- a/packages/ui/src/mosaic/components/settings/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { Settings, settingsVars } from './settings'; -export type { - SettingsActionsProps, - SettingsContentProps, - SettingsDescriptionProps, - SettingsGroupProps, - SettingsItemProps, - SettingsItemsProps, - SettingsLabelProps, - SettingsMediaProps, - SettingsMediaSize, - SettingsRootProps, - SettingsRowProps, - SettingsTitleProps, -} from './settings'; diff --git a/packages/ui/src/mosaic/components/settings/settings.markers.stylex.ts b/packages/ui/src/mosaic/components/settings/settings.markers.stylex.ts deleted file mode 100644 index ce4b0c07ebd..00000000000 --- a/packages/ui/src/mosaic/components/settings/settings.markers.stylex.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -export const settingsItemsMarker = stylex.defineMarker(); diff --git a/packages/ui/src/mosaic/components/settings/settings.test.tsx b/packages/ui/src/mosaic/components/settings/settings.test.tsx deleted file mode 100644 index 6c239b70a06..00000000000 --- a/packages/ui/src/mosaic/components/settings/settings.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -import { colorVars } from '../../tokens.stylex'; -import { Settings, settingsVars } from './settings'; - -describe('Settings', () => { - it('aliases its background to the global background token', () => { - expect(colorVars).toMatchObject({ - '--cl-color-background': 'var(--cl-color-background)', - }); - expect(settingsVars).toMatchObject({ - '--cl-settings-background': 'var(--cl-settings-background)', - '--cl-settings-items-gap': 'var(--cl-settings-items-gap)', - }); - }); - - it('renders an accessible settings section and every compound part', () => { - render( - - Account - - - - - Icon - - - Name - Shown throughout the application. - - Control - - - - , - ); - - expect(screen.getByRole('region', { name: 'Account' })).toHaveClass('cl-settings'); - expect(screen.getByRole('heading', { level: 4, name: 'Account' })).toHaveClass('cl-settings-title'); - expect(screen.getByTestId('group')).toHaveClass('cl-settings-group'); - expect(screen.getByTestId('row')).toHaveClass('cl-settings-row'); - expect(screen.getByTestId('item')).toHaveClass('cl-settings-item'); - expect(screen.getByTestId('media')).toHaveClass('cl-settings-media'); - expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'lg'); - expect(screen.getByTestId('content')).toHaveClass('cl-settings-content'); - expect(screen.getByTestId('label')).toHaveClass('cl-settings-label'); - expect(screen.getByTestId('description')).toHaveClass('cl-settings-description'); - expect(screen.getByTestId('actions')).toHaveClass('cl-settings-actions'); - }); - - it('supports an explicit accessible name', () => { - render( - - Account - - , - ); - - expect(screen.getByRole('region', { name: 'Account preferences' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 4, name: 'Account' })).toBeInTheDocument(); - }); - - it('composes multiple items in one row', () => { - render( - - Profile - - - - - Email - - 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('items')).toHaveClass('cl-settings-items'); - expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); - expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); - expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); - }); - - it('lets consumer props win and forwards refs and custom elements', () => { - const rootRef = React.createRef(); - const groupRef = React.createRef(); - const itemRef = React.createRef(); - const contentRef = React.createRef(); - const actionsRef = React.createRef(); - - render( -
} - className='custom-root' - > - Account - - - - - Name - - - - - - , - ); - - expect(rootRef.current?.tagName).toBe('ARTICLE'); - expect(rootRef.current).toHaveClass('cl-settings', 'custom-root'); - expect(groupRef.current).toHaveClass('cl-settings-group', 'custom-group'); - expect(groupRef.current).toHaveStyle({ borderWidth: '2px' }); - expect(itemRef.current).toHaveClass('cl-settings-item'); - expect(itemRef.current).toHaveStyle({ minHeight: '80px' }); - expect(contentRef.current).toHaveClass('cl-settings-content'); - expect(screen.getByText('Name')).toHaveStyle({ color: 'rgb(255, 0, 0)' }); - expect(actionsRef.current).toHaveClass('cl-settings-actions'); - }); -}); diff --git a/packages/ui/src/mosaic/components/settings/settings.vars.stylex.ts b/packages/ui/src/mosaic/components/settings/settings.vars.stylex.ts deleted file mode 100644 index fc417af7a38..00000000000 --- a/packages/ui/src/mosaic/components/settings/settings.vars.stylex.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { colorVars, space } from '../../tokens.stylex'; - -export const settingsVars = stylex.defineVars({ - '--cl-settings-background': colorVars['--cl-color-background'], - '--cl-settings-items-gap': space['2'], -}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index c04992eb588..1c306a7efe5 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -48,21 +48,21 @@ export type { } from '../components/menu'; export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; export type { ScrollAreaGutter } from '../components/scroll-area'; -export { Settings, settingsVars } from '../components/settings'; +export { Section } from '../components/section'; export type { - SettingsActionsProps, - SettingsContentProps, - SettingsDescriptionProps, - SettingsGroupProps, - SettingsItemProps, - SettingsItemsProps, - SettingsLabelProps, - SettingsMediaProps, - SettingsMediaSize, - SettingsRootProps, - SettingsRowProps, - SettingsTitleProps, -} from '../components/settings'; + SectionActionsProps, + SectionContentProps, + SectionDescriptionProps, + SectionGroupProps, + SectionItemProps, + SectionItemsProps, + SectionLabelProps, + SectionMediaProps, + SectionMediaSize, + SectionRootProps, + SectionRowProps, + SectionTitleProps, +} from '../components/section'; export { Spinner } from '../components/spinner'; export type { SpinnerProps } from '../components/spinner'; export { Text, TextContext } from '../components/text'; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx new file mode 100644 index 00000000000..a4152a4878c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -0,0 +1,281 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Avatar } from '../components/avatar'; +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { space } from '../tokens.stylex'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; + +const styles = stylex.create({ + profilePictureItem: { + columnGap: space['4'], + }, +}); + +export interface UserProfileEmail { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} + +export interface UserProfilePhone { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} + +export interface UserProfileAccountSectionViewProps { + imageUrl?: string; + name: string; + username: string; + emails: UserProfileEmail[]; + phones: UserProfilePhone[]; + onEditProfilePicture?: () => void; + onNameChange?: (value: string) => void; + onUsernameChange?: (value: string) => void; + onAddEmail?: () => void; + onManageEmail?: (id: string) => void; + onVerifyEmail?: (id: string) => void; + onSetPrimaryEmail?: (id: string) => void; + onRemoveEmail?: (id: string) => void; + onAddPhone?: () => void; + onManagePhone?: (id: string) => void; + onVerifyPhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void; + onRemovePhone?: (id: string) => void; +} + +export function UserProfileAccountSectionView({ + imageUrl, + name, + username, + emails, + phones, + onEditProfilePicture, + onNameChange, + onUsernameChange, + onAddEmail, + onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onAddPhone, + onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, +}: UserProfileAccountSectionViewProps) { + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + const updateName = onNameChange ? () => onNameChange(name) : undefined; + const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined; + + return ( + + + + + + + + {initials} + + + + Profile picture + Recommend size 1:1, up to 10MB. + + {onEditProfilePicture ? ( + + + + ) : null} + + + + + + Name + {name} + + {updateName ? ( + + + + ) : null} + + + + + + Username + {username} + + {updateUsername ? ( + + + + ) : null} + + + + + + + ); +} + +function ContactSection({ + kind, + label, + items, + onAdd, + onManage, + onVerify, + onSetPrimary, + onRemove, +}: { + kind: 'email' | 'phone'; + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; + onVerify?: (id: string) => void; + onSetPrimary?: (id: string) => void; + onRemove?: (id: string) => void; +}) { + const labelId = `user-profile-profile-panel-${label.toLowerCase()}`; + + return ( + ( +
+ )} + > + + + {label} + + {onAdd ? ( + + + + ) : null} + + + {items.map(item => { + const actions: UserProfileMenuAction[] = []; + const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); + } + + if (onRemove && item.canRemove !== false) { + actions.push({ + label: kind === 'email' ? 'Remove email' : 'Remove phone number', + color: 'negative', + onClick: () => onRemove(item.id), + }); + } + + if (!hasExplicitActions && onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); + } + + return ( + + + + {item.value} + {item.isDefault ? Primary : null} + + + {actions.length > 0 ? ( + + + + ) : null} + + ); + })} + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx new file mode 100644 index 00000000000..cd63cddf5b3 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx @@ -0,0 +1,29 @@ +import { Menu } from '../components/menu'; + +export interface UserProfileMenuAction { + label: string; + color?: 'neutral' | 'negative'; + onClick: () => void; +} + +export function UserProfileActionMenu({ label, actions }: { label: string; actions: UserProfileMenuAction[] }) { + if (actions.length === 0) { + return null; + } + + return ( + + + + {actions.map(action => ( + + ))} + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts new file mode 100644 index 00000000000..23d0cdb4f00 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -0,0 +1,24 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../tokens.stylex'; + +export const styles = stylex.create({ + contactValue: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + minWidth: 0, + }, + providerIcon: { + flexShrink: 0, + objectFit: 'contain', + height: space['6'], + width: space['6'], + }, + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +});