diff --git a/.changeset/profile-page-title-ssr.md b/.changeset/profile-page-title-ssr.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-page-title-ssr.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/panel/panel.test.tsx b/packages/mosaic/src/components/panel/panel.test.tsx index 8af1d7676be..aeb80129b88 100644 --- a/packages/mosaic/src/components/panel/panel.test.tsx +++ b/packages/mosaic/src/components/panel/panel.test.tsx @@ -73,7 +73,7 @@ describe('Panel', () => { Account - + @@ -96,7 +96,7 @@ describe('Panel', () => { Account - + Account diff --git a/packages/mosaic/src/components/profile/profile.ssr.test.tsx b/packages/mosaic/src/components/profile/profile.ssr.test.tsx new file mode 100644 index 00000000000..14ac0e6e101 --- /dev/null +++ b/packages/mosaic/src/components/profile/profile.ssr.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment node + +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { MosaicProvider } from '../../mosaic-provider'; +import { Profile } from './profile'; + +describe('Profile SSR', () => { + it('names the page in the page title heading on the server', () => { + const html = renderToString( + + + User profile + + Account + Security + + + Account page + Security page + + + , + ); + + const pageTitle = html.match(/]*class="[^"]*\bcl-profile-page-title\b[^"]*"[^>]*>(.*?)<\/h3>/)?.[1]; + expect(pageTitle).toContain('Security'); + expect(pageTitle).not.toContain('Account'); + }); +}); diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index 6c1bfb4c6b0..e2d2325c6c6 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -15,6 +15,8 @@ import { Section } from '../section'; import type { ProfileRootProps } from './profile'; import { Profile } from './profile'; +const pageTitles: Record = { account: 'Account', security: 'Security' }; + function Surface(rootProps: Partial) { return ( ) { Security - + Account @@ -82,7 +84,7 @@ describe('Profile', () => { Account - + Account content , @@ -116,7 +118,7 @@ describe('Profile', () => { Security - + Account content Security content @@ -510,7 +512,7 @@ describe('Profile', () => { Account - + Account diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 2c6c22d246a..7678462555f 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -204,9 +204,9 @@ const Title = React.forwardRef(function P export type ProfileNavProps = MosaicComponentProps<'nav'>; -type NavItemMode = 'tab' | 'option' | 'label'; +type NavItemMode = 'tab' | 'option'; -// `Profile.Nav` children render again as the page title's label and as the select's options. +// `Profile.Nav` children render again as the select's options. const NavItemModeContext = React.createContext('tab'); function NavBranding() { @@ -293,11 +293,8 @@ const NavItem = React.forwardRef(functio { value, icon, badge, disabled, children, render, xstyle, onClick, ...rest }, ref, ) { - const { compact, closeNav, value: selected } = useProfileContext('Profile.NavItem'); + const { compact, closeNav } = useProfileContext('Profile.NavItem'); const mode = React.useContext(NavItemModeContext); - if (mode === 'label') { - return value === selected ? children : null; - } const styleProps = mergeStyleProps( themeProps('profile-nav-item'), stylex.props(reset.base, styles.navItem, focusOutline.visible, mode === 'option' && styles.navItemOption, xstyle), @@ -362,7 +359,7 @@ const NavItem = React.forwardRef(functio ); }); -function PageTitle() { +function PageTitle({ title }: { title: React.ReactNode }) { const { navLayout, navOpen, @@ -376,11 +373,7 @@ function PageTitle() { navTriggerRef, } = useProfileContext('Profile.Content'); const level = useHeadingLevel(); - const label = ( - - {navItems} - - ); + const label = {title}; const triggerProps = mergeStyleProps( themeProps('profile-nav-trigger'), stylex.props(reset.base, styles.navTrigger, focusOutline.visible), @@ -449,11 +442,14 @@ function PageTitle() { ); } -export type ProfileContentProps = MosaicComponentProps<'div'>; +export interface ProfileContentProps extends MosaicComponentProps<'div'> { + /** The selected page's name, shown as the page title. */ + pageTitle: React.ReactNode; +} // A plain `div`, not `main`: the profile often renders inside the host's `main` or a dialog. const Content = React.forwardRef(function ProfileContent( - { children, render, xstyle, ...rest }, + { pageTitle, children, render, xstyle, ...rest }, ref, ) { const { inline, compact, renderBranding } = useProfileContext('Profile.Content'); @@ -480,7 +476,7 @@ const Content = React.forwardRef(function P >
- + {children} {inline && renderBranding && !compact ? ( @@ -537,7 +533,7 @@ const ContentPanel = React.forwardRef( * * }>Account * - * + * * … * * diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx index 15d43add4f4..5dbf32f5a02 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -63,7 +63,8 @@ export const OrganizationProfileView = React.forwardRef entry.id === activePage) ? activePage : (firstPage?.id ?? 'general'); + const resolvedEntry = entries.find(entry => entry.id === activePage) ?? firstPage; + const resolvedPage = resolvedEntry?.id ?? 'general'; return ( ))} - + {entries.map(entry => ( entry.id === activePage) ? activePage : (firstPage?.id ?? 'account'); + const resolvedEntry = entries.find(entry => entry.id === activePage) ?? firstPage; + const resolvedPage = resolvedEntry?.id ?? 'account'; return ( ))} - + {entries.map(entry => ( - + Members diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx index a6222c0800c..bac6a1c6376 100644 --- a/packages/swingset/src/stories/profile.component.mdx +++ b/packages/swingset/src/stories/profile.component.mdx @@ -45,7 +45,7 @@ import { Profile } from '@clerk/mosaic/components/profile'; Security - + … … @@ -60,7 +60,7 @@ scrolls, the columns a gap apart, held to a reading width and centered. A leading mark and trailing status. A `Badge` passed to `badge` is `neutral` unless it sets its own `color`. `Title` is a visually hidden heading: it names the navigation, the compact sheet, and — inside a dialog — the dialog itself, the way `Card.Title` does. -`Content` renders the page title above the pages: the selected `NavItem`'s label, as a heading that +`Content` renders its `pageTitle` above the pages, the selected page's name, as a heading that scrolls with the page and names it. A page's `Panel.Title` renders nothing there, and a ref on it reaches the page title instead. diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index a5416ca54e7..878ddf14b8b 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -183,7 +183,7 @@ function Surface({ ))} - + item.id === page)?.label}> {pages.map(item => (