diff --git a/.changeset/mosaic-fullscreen-profile-refactor.md b/.changeset/mosaic-fullscreen-profile-refactor.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-fullscreen-profile-refactor.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-fullscreen-profile.md b/.changeset/mosaic-fullscreen-profile.md new file mode 100644 index 00000000000..ac41bd89fea --- /dev/null +++ b/.changeset/mosaic-fullscreen-profile.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': minor +--- + +Add a `fullscreen` Dialog variant that presents a profile across the whole viewport at every width, with a localizable "Back to app" button above the navigation in place of the corner close button. Profile navigation items are now 32px tall, matching the default button height, and use a neutral 6% hover and 8% selected background in every profile presentation. diff --git a/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx index 0612e91a4c5..7a13072242e 100644 --- a/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx +++ b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx @@ -96,6 +96,17 @@ describe('Mosaic Dialog', () => { expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-variant', 'profile'); }); + it('reflects the fullscreen variant as data-variant on the popup and the viewport', () => { + render( + + Body + , + ); + + expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-variant', 'fullscreen'); + expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-variant', 'fullscreen'); + }); + it('composes consumer xstyle onto the popup', () => { const caller = stylex.create({ popup: { marginTop: '8px' } }); render( @@ -251,6 +262,17 @@ describe('nested Mosaic Dialogs', () => { warn.mockRestore(); }); + it('warns when a fullscreen profile opens inside another dialog', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('variant="fullscreen"')); + warn.mockRestore(); + }); + // A card over a profile is the delete-account confirmation: a `Card` inside a `card` dialog. it('does not warn for a card over a profile, or for the profile itself', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -595,6 +617,14 @@ describe('compactPlacement', () => { warn.mockRestore(); }); + it('warns when a fullscreen profile is given a placement', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderPlacement('sheet', 'fullscreen'); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('variant="fullscreen"')); + warn.mockRestore(); + }); + it('keeps a profile centered by default without warning', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); renderPlacement(undefined, 'profile'); diff --git a/packages/mosaic/src/components/dialog/dialog.styles.ts b/packages/mosaic/src/components/dialog/dialog.styles.ts index a27f1d662b9..40a3d88c315 100644 --- a/packages/mosaic/src/components/dialog/dialog.styles.ts +++ b/packages/mosaic/src/components/dialog/dialog.styles.ts @@ -269,6 +269,8 @@ export const styles = stylex.create({ }, }); +const profileCloseInset = { insetBlockStart: space['3'], insetInlineEnd: space['3'] }; + /** * Positions the ICON the surface's inset from the corner, not the button box: the `sm` circle * carries `(space[7] - space[4]) / 2` = `space[1.5]` of its own padding around the glyph, so each @@ -277,9 +279,27 @@ export const styles = stylex.create({ */ export const closeInsets = stylex.create({ card: { insetBlockStart: space['2.5'], insetInlineEnd: space['2.5'] }, - profile: { insetBlockStart: space['3'], insetInlineEnd: space['3'] }, + profile: profileCloseInset, + fullscreen: profileCloseInset, }); +const profileViewport = { + // A definite container height is NOT enough on its own: an `auto` grid row still sizes to its + // content and happily exceeds the container, which is how a profile of rows measured 2208px + // inside a 1251px overlay. `minmax(0, 1fr)` pins the single row to the content box, so the row + // is what an item stretches to and what its overflow is measured against. + // + // Deliberately NOT applied to the scrolling variants: it would clamp the row there too, which is + // exactly what has to stop happening for the popup to grow past the fold. + gridTemplateRows: 'minmax(0, 1fr)', + // A DEFINITE height, taken from the overlay (`position: fixed; inset: 0`), which makes the + // single grid row definite too. That is what lets `variants.profile` fill the content box with + // `align-self: stretch` alone — no `dvh` arithmetic, so nothing can disagree with the box a + // bottom-anchored sheet aligns to. They genuinely do diverge: on an emulated iPhone the + // overlay measures 1251px while `100dvh` reports 844. + height: '100%', +}; + /** * Named for what the surface IS rather than for a t-shirt step, because these are different * surfaces rather than one surface at two scales — the names stay honest if they later diverge @@ -314,22 +334,8 @@ export const closeInsets = stylex.create({ */ export const viewportVariants = stylex.create({ card: { minHeight: '100%' }, - profile: { - // A definite container height is NOT enough on its own: an `auto` grid row still sizes to its - // content and happily exceeds the container, which is how a profile of rows measured 2208px - // inside a 1251px overlay. `minmax(0, 1fr)` pins the single row to the content box, so the row - // is what an item stretches to and what its overflow is measured against. - // - // Deliberately NOT applied to the scrolling variants: it would clamp the row there too, which is - // exactly what has to stop happening for the popup to grow past the fold. - gridTemplateRows: 'minmax(0, 1fr)', - // A DEFINITE height, taken from the overlay (`position: fixed; inset: 0`), which makes the - // single grid row definite too. That is what lets `variants.profile` fill the content box with - // `align-self: stretch` alone — no `dvh` arithmetic, so nothing can disagree with the box a - // bottom-anchored sheet aligns to. They genuinely do diverge: on an emulated iPhone the - // overlay measures 1251px while `100dvh` reports 844. - height: '100%', - }, + profile: profileViewport, + fullscreen: profileViewport, }); /** Pins the box for a sheet, so `compactPlacements.sheet` can cap the popup at the overlay's own height. */ @@ -359,8 +365,35 @@ export const trackVariants = stylex.create({ // Same definite row as the viewport's, one level down, so the popup's `stretch` lands on it. gridTemplateRows: 'minmax(0, 1fr)', }, + fullscreen: { + '--_cl-dialog-inset': '0px', + paddingInline: 0, + gridTemplateRows: 'minmax(0, 1fr)', + }, }); +const profileSurface = { + borderColor: null, + borderRadius: null, + borderStyle: null, + borderWidth: null, + // The profile does NOT scroll itself, and that is the whole design. A fixed-height surface + // needs somewhere for overflow to go, but putting the scroll on the POPUP takes everything + // anchored to it along for the ride — the close button most obviously. So the popup clips, + // and the scroll region is the surface's own: `Profile` scrolls its content column. + // Deliberately a flex column with no `align-items` override, so the surface inside grows to + // the popup's height. + // + // `clip` rather than `hidden` for the same reason as the viewport: `hidden` would make the + // profile a scroll container, and focusing anything inside it that sits outside its box would + // scroll the profile itself. + overflow: 'clip', + // Fills the viewport's content box rather than computing a height from `dvh`. The grid row + // is definite (see `styles.viewport`), so `stretch` lands the profile's edges on exactly the + // lines a bottom-anchored sheet reaches with `align-self: end`, and clamps to them. + alignSelf: 'stretch', +}; + export const variants = stylex.create({ // The surface is a `Card` — so it comes from `Card`'s own `elevations.overlay`, and the popup // contributes geometry and motion. Compose it by rendering the card INSIDE the popup: @@ -397,27 +430,8 @@ export const variants = stylex.create({ * * Consequence worth knowing: `variant="profile"` with no surface inside renders an unpainted box. */ - profile: { - borderColor: null, - borderRadius: null, - borderStyle: null, - borderWidth: null, - // The profile does NOT scroll itself, and that is the whole design. A fixed-height surface - // needs somewhere for overflow to go, but putting the scroll on the POPUP takes everything - // anchored to it along for the ride — the close button most obviously. So the popup clips, - // and the scroll region is the surface's own: `Profile` scrolls its content column. - // Deliberately a flex column with no `align-items` override, so the surface inside grows to - // the popup's height. - // - // `clip` rather than `hidden` for the same reason as the viewport: `hidden` would make the - // profile a scroll container, and focusing anything inside it that sits outside its box would - // scroll the profile itself. - overflow: 'clip', - // Fills the viewport's content box rather than computing a height from `dvh`. The grid row - // is definite (see `styles.viewport`), so `stretch` lands the profile's edges on exactly the - // lines a bottom-anchored sheet reaches with `align-self: end`, and clamps to them. - alignSelf: 'stretch', - }, + profile: profileSurface, + fullscreen: profileSurface, }); /** @@ -500,6 +514,20 @@ export const trackCompactPlacements = stylex.create({ }, }); +const profileBackdropMotion = { + opacity: { + default: 1, + ':where([data-ending-style])': 0, + ':where([data-starting-style])': SCRIM_ENTER_OPACITY, + }, + transitionDuration: { + default: durationVars['--cl-duration-fast'], + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: 'opacity', + transitionTimingFunction: 'linear', +}; + /** * Enter/exit motion, keyed by size, because the two surfaces want opposite things. * @@ -544,19 +572,8 @@ export const backdropMotion = stylex.create({ }, /** Identical to `card` — the popup it accompanies fades on the same clock, it just does not scale. */ - profile: { - opacity: { - default: 1, - ':where([data-ending-style])': 0, - ':where([data-starting-style])': SCRIM_ENTER_OPACITY, - }, - transitionDuration: { - default: durationVars['--cl-duration-fast'], - ':where([data-ending-style])': durationVars['--cl-duration-fast'], - }, - transitionProperty: 'opacity', - transitionTimingFunction: 'linear', - }, + profile: profileBackdropMotion, + fullscreen: profileBackdropMotion, }); // The plain CSS `ease-out` — `cubic-bezier(0, 0, 0.58, 1)` — used ONLY for the sheet's slide out. @@ -597,6 +614,22 @@ const ENTER_SCALE = 0.94; const STACK_SCALE = 0.96; const STACK_LIFT = '-0.5rem'; +const profilePopupMotion = { + opacity: { + default: 1, + ':where([data-starting-style], [data-ending-style])': 0, + }, + transitionDuration: { + default: durationVars['--cl-duration-fast'], + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: 'opacity', + transitionTimingFunction: { + default: easingVars['--cl-ease-enter'], + ':where([data-ending-style])': easingVars['--cl-ease-exit'], + }, +}; + export const popupMotion = stylex.create({ /** * `compactPlacement='sheet'`: a card that scales from its center above the compact band, and slides up @@ -770,19 +803,6 @@ export const popupMotion = stylex.create({ * No reduced-motion branch, matching `card` — under `reduce` the two shed their transform and * are left with exactly this, so there is nothing here to drop. */ - profile: { - opacity: { - default: 1, - ':where([data-starting-style], [data-ending-style])': 0, - }, - transitionDuration: { - default: durationVars['--cl-duration-fast'], - ':where([data-ending-style])': durationVars['--cl-duration-fast'], - }, - transitionProperty: 'opacity', - transitionTimingFunction: { - default: easingVars['--cl-ease-enter'], - ':where([data-ending-style])': easingVars['--cl-ease-exit'], - }, - }, + profile: profilePopupMotion, + fullscreen: profilePopupMotion, }); diff --git a/packages/mosaic/src/components/dialog/dialog.tsx b/packages/mosaic/src/components/dialog/dialog.tsx index 586a71ec11b..bb76c64723a 100644 --- a/packages/mosaic/src/components/dialog/dialog.tsx +++ b/packages/mosaic/src/components/dialog/dialog.tsx @@ -33,12 +33,16 @@ import { guardKeyboardTouch } from './keyboard-touch'; /** * Which surface the dialog holds, and so the geometry it is given: `card` is a `Card` at the - * card's width, `profile` a `Profile` filling the height. Not a `size`, because these are - * different surfaces rather than one surface at two widths — a second card width would be a size - * of the `card` variant. + * card's width, `profile` a `Profile` filling the height, `fullscreen` a `Profile` filling the + * viewport. Not a `size`, because these are different surfaces rather than one surface at two + * widths — a second card width would be a size of the `card` variant. */ export type DialogVariant = keyof typeof variants; +function holdsProfile(variant: DialogVariant): boolean { + return variant !== 'card'; +} + /** * Where the surface sits in the compact band — a window under `40rem`. `center` * everywhere above it: there is no edge close enough for anchoring to mean anything at those @@ -317,11 +321,11 @@ function Viewport({ */ function useNestedVariantWarning(isNestedInDialog: boolean, variant: DialogVariant) { React.useEffect(() => { - if (process.env.NODE_ENV === 'production' || !isNestedInDialog || variant !== 'profile') { + if (process.env.NODE_ENV === 'production' || !isNestedInDialog || !holdsProfile(variant)) { return; } console.warn( - '[Clerk] a variant="profile" Dialog opened inside another Dialog. A profile is a root-level surface that hosts what opens over it; open a card instead.', + `[Clerk] a variant="${variant}" Dialog opened inside another Dialog. A profile is a root-level surface that hosts what opens over it; open a card instead.`, ); }, [isNestedInDialog, variant]); } @@ -336,14 +340,14 @@ function useCompactPlacementWarning(variant: DialogVariant, placement: DialogCom React.useEffect(() => { if ( process.env.NODE_ENV === 'production' || - variant !== 'profile' || + !holdsProfile(variant) || placement === undefined || placement === 'center' ) { return; } console.warn( - `[Clerk] — a profile fills the compact band and takes no placement. It was ignored.`, + `[Clerk] — a profile fills the compact band and takes no placement. It was ignored.`, ); }, [variant, placement]); } @@ -364,7 +368,7 @@ const Popup = React.forwardRef(function Dialog const { role, isStacked: isNestedInDialog, labelId, descriptionId } = useHeadlessDialogContext(); const isAlert = role === 'alertdialog'; // A profile has its own compact-band treatment and takes no placement; the warning says so. - const compactPlacement: DialogCompactPlacement = variant === 'profile' ? 'center' : (compactPlacementProp ?? 'sheet'); + const compactPlacement: DialogCompactPlacement = holdsProfile(variant) ? 'center' : (compactPlacementProp ?? 'sheet'); useCompactPlacementWarning(variant, compactPlacementProp); useNestedVariantWarning(isNestedInDialog, variant); @@ -375,7 +379,7 @@ const Popup = React.forwardRef(function Dialog // Observed through state rather than a plain ref, because the warnings have to re-run when the // node arrives and a ref mutation does not re-render. const [node, setNode] = React.useState(null); - useAccessibleNameWarning(node, 'Dialog', variant === 'profile' ? 'Profile.Title' : 'Card.Title'); + useAccessibleNameWarning(node, 'Dialog', holdsProfile(variant) ? 'Profile.Title' : 'Card.Title'); // A name alone is enough for an ordinary dialog; an alert is announced as an interruption and // its description is what says which decision is being asked for. useAccessibleDescriptionWarning(isAlert ? node : null, 'Dialog', 'Card.Description'); @@ -437,7 +441,7 @@ const Popup = React.forwardRef(function Dialog ); return ( - {variant === 'profile' ? {viewport} : viewport} + {holdsProfile(variant) ? {viewport} : viewport} ); }); diff --git a/packages/mosaic/src/components/profile/profile.context.ts b/packages/mosaic/src/components/profile/profile.context.ts index b1c92f779cc..0301a4f57fa 100644 --- a/packages/mosaic/src/components/profile/profile.context.ts +++ b/packages/mosaic/src/components/profile/profile.context.ts @@ -2,6 +2,8 @@ import React from 'react'; export type ProfileNavLayout = 'column' | 'select' | 'sheet'; +export type ProfileDismiss = 'back' | 'corner' | 'none'; + export interface ProfileContextValue { titleId: string; renderBranding: boolean; @@ -18,6 +20,7 @@ export interface ProfileContextValue { pageTitleRef: React.MutableRefObject; navTriggerRef: React.MutableRefObject; inline: boolean; + dismiss: ProfileDismiss; } export const ProfileContext = React.createContext(null); diff --git a/packages/mosaic/src/components/profile/profile.messages.ts b/packages/mosaic/src/components/profile/profile.messages.ts new file mode 100644 index 00000000000..71610dc528a --- /dev/null +++ b/packages/mosaic/src/components/profile/profile.messages.ts @@ -0,0 +1,3 @@ +export const profileMessages = { + backToApp: 'Back to app', +} as const; diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index a12606718c3..a7c1219e9cc 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -114,6 +114,19 @@ export const styles = stylex.create({ minHeight: 0, }, + rootFullscreen: { + inlineSize: '100cqi', + }, + + layoutFullscreen: { + borderRadius: 0, + boxShadow: 'none', + }, + + backItem: { + marginBlockEnd: space['4'], + }, + nav: { padding: space['4'], borderInlineEndColor: colorVars['--cl-color-border'], @@ -182,17 +195,16 @@ export const styles = stylex.create({ borderStyle: 'solid', borderWidth: '0px', gap: space['2'], - paddingBlock: space['2'], paddingInline: space['2.5'], alignItems: 'center', backgroundColor: { default: 'transparent', - ':focus-visible': colorVars['--cl-color-border-subtle'], - ':where([data-selected])': colorVars['--cl-color-border-subtle'], - ':active': colorVars['--cl-color-border-subtle'], + ':focus-visible': colorVars['--cl-color-neutral-alpha-100'], + ':where([data-selected])': colorVars['--cl-color-neutral-alpha-200'], + ':active': colorVars['--cl-color-neutral-alpha-200'], '@media (hover: hover)': { default: null, - ':hover:not(:active):not([data-selected])': colorVars['--cl-color-border-subtle'], + ':hover:not(:active):not([data-selected])': colorVars['--cl-color-neutral-alpha-100'], }, }, color: { @@ -210,6 +222,7 @@ export const styles = stylex.create({ whiteSpace: 'nowrap', // Above the neighbors, so the selected item's fill cannot cover the ring. zIndex: { default: null, ':focus-visible': 1 }, + height: space['8'], minHeight: { default: null, '@media (pointer: coarse)': targetVars['--cl-target-coarse'], diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index e2d2325c6c6..2c616952ddc 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -466,6 +466,28 @@ describe('Profile', () => { expect(headline).toHaveAccessibleName('Security'); }); + it.each([ + ['sheet', 3], + ['select', 2], + ])('falls back to the corner dismiss in a fullscreen dialog in the %s layout', (_, width) => { + render( + + + + + + + , + ); + expect(screen.getByRole('button', { name: 'Back to app' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + + act(() => observe?.(width)); + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Back to app' })).not.toBeInTheDocument(); + }); + it.each([ ['sheet', 3], ['select', 2], @@ -596,6 +618,59 @@ describe('Profile', () => { expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); + describe('fullscreen', () => { + function renderFullscreen( + onOpenChange = vi.fn(), + localization?: React.ComponentProps['localization'], + ) { + return render( + + + + + + + , + ); + } + + it('puts a back button first in the navigation in place of the corner dismiss', () => { + renderFullscreen(); + + const back = screen.getByRole('button', { name: 'Back to app' }); + expect(screen.getByRole('navigation', { name: 'User profile' })).toContainElement(back); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + expect(back.compareDocumentPosition(screen.getByRole('tablist'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('dismisses the dialog from the back button', async () => { + const onOpenChange = vi.fn(); + renderFullscreen(onOpenChange); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Back to app' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('reads the back button label from the catalog', () => { + renderFullscreen(vi.fn(), { overrides: { 'profile.backToApp': 'Retour à l’application' } }); + + expect(screen.getByRole('button', { name: 'Retour à l’application' })).toBeInTheDocument(); + }); + }); + + it('has no back button in a profile dialog, or standalone', () => { + const standalone = renderSurface(); + expect(screen.queryByRole('button', { name: 'Back to app' })).not.toBeInTheDocument(); + standalone.unmount(); + + renderInDialog(); + expect(screen.queryByRole('button', { name: 'Back to app' })).not.toBeInTheDocument(); + }); + it('carries no dismiss standalone', () => { renderSurface(); diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index 7678462555f..2a0e0efad94 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -2,6 +2,7 @@ import { useSafeLayoutEffect } from '@clerk/shared/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useMessages } from '../../localization'; import { Select as SelectPrimitive } from '../../primitives/select'; import type { TabsProps } from '../../primitives/tabs'; import { Tabs } from '../../primitives/tabs'; @@ -11,16 +12,18 @@ import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../styles/focus-outline.styles'; import { reset } from '../../styles/reset.styles'; +import { rtl } from '../../styles/rtl.styles'; import { truncationStyles } from '../../styles/typography.styles'; import { BadgeContext } from '../badge/badge.context'; import { Branding } from '../branding'; +import type { DialogContextValue } from '../dialog'; import { Dialog, DialogContext, isInDialog } from '../dialog'; import { Drawer } from '../drawer'; import { Heading, HeadingLevelProvider, useHeadingLevel } from '../heading'; import { Icon } from '../icon'; import { SelectPopup } from '../select'; import { VisuallyHidden } from '../visually-hidden'; -import type { ProfileContextValue, ProfileNavLayout } from './profile.context'; +import type { ProfileContextValue, ProfileDismiss, ProfileNavLayout } from './profile.context'; import { ContentPanelContext, ProfileContext } from './profile.context'; import { contentScroll, contentViewportScroll, styles } from './profile.styles'; @@ -32,6 +35,13 @@ function navLayoutFor(sentinelWidth: number): ProfileNavLayout { return sentinelWidth >= 2 ? 'select' : 'column'; } +function dismissOf(dialog: DialogContextValue | null, backInNav: boolean): ProfileDismiss { + if (!isInDialog(dialog) || dialog.role === 'alertdialog') { + return 'none'; + } + return backInNav ? 'back' : 'corner'; +} + function useProfileContext(part: string): ProfileContextValue { const context = React.useContext(ProfileContext); if (!context) { @@ -69,6 +79,7 @@ const Root = React.forwardRef(function Profile ) { const dialog = React.useContext(DialogContext); const inline = elevation === 'flush'; + const fullscreen = dialog?.variant === 'fullscreen'; const generatedTitleId = React.useId(); const titleId = dialog?.labelId ?? generatedTitleId; const [sentinel, setSentinel] = React.useState(null); @@ -80,6 +91,7 @@ const Root = React.forwardRef(function Profile return autoUpdate(sentinel, () => setNavLayout(navLayoutFor(getDimensions(sentinel).width))); }, [sentinel]); const compact = navLayout !== 'column'; + const dismiss = dismissOf(dialog, fullscreen && !compact); const pageTitleId = React.useId(); const pageTitleRef = React.useRef(null); const navTriggerRef = React.useRef(null); @@ -116,6 +128,7 @@ const Root = React.forwardRef(function Profile pageTitleRef, navTriggerRef, inline, + dismiss, }), [ titleId, @@ -130,6 +143,7 @@ const Root = React.forwardRef(function Profile navItems, pageTitleId, inline, + dismiss, ], ); const element = useRender({ @@ -139,7 +153,13 @@ const Root = React.forwardRef(function Profile props: { ...mergeStyleProps( themeProps('profile', { elevation: inline ? 'flush' : 'card' }), - stylex.props(reset.base, styles.root, isInDialog(dialog) && styles.rootInDialog, xstyle), + stylex.props( + reset.base, + styles.root, + isInDialog(dialog) && styles.rootInDialog, + fullscreen && styles.rootFullscreen, + xstyle, + ), rest, ), children: ( @@ -150,7 +170,7 @@ const Root = React.forwardRef(function Profile {...mergeStyleProps(themeProps('profile-sentinel'), stylex.props(reset.base, styles.sentinel))} /> {/* First in the DOM so it takes the dialog's opening focus, as in `Card.Header`. */} - {isInDialog(dialog) && dialog.role !== 'alertdialog' ? : null} + {dismiss === 'corner' ? : null}
(function Profile styles.layout, inline && styles.layoutInline, dialog !== null && !inline && styles.layoutInDialog, + fullscreen && styles.layoutFullscreen, ), )} > @@ -209,6 +230,32 @@ type NavItemMode = 'tab' | 'option'; // `Profile.Nav` children render again as the select's options. const NavItemModeContext = React.createContext('tab'); +function BackButton() { + const m = useMessages('profile'); + return ( + + + + + + {m.backToApp} + + + ); +} + function NavBranding() { return (
@@ -225,8 +272,18 @@ const Nav = React.forwardRef(function ProfileNav( { children, render, xstyle, ...rest }, ref, ) { - const { titleId, renderBranding, compact, navLayout, navOpen, closeNav, setNavItems, navTriggerRef, inline } = - useProfileContext('Profile.Nav'); + const { + titleId, + renderBranding, + compact, + navLayout, + navOpen, + closeNav, + setNavItems, + navTriggerRef, + inline, + dismiss, + } = useProfileContext('Profile.Nav'); useSafeLayoutEffect(() => { setNavItems(children); }, [children, setNavItems]); @@ -244,6 +301,7 @@ const Nav = React.forwardRef(function ProfileNav( ), children: ( <> + {dismiss === 'back' ? : null} {children} diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx index d26c79c8c8d..52dcbb5d6f2 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.view.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { expect, it, vi } from 'vitest'; +import { Dialog } from '../../../components/dialog'; import { MosaicProvider } from '../../../mosaic-provider'; import { OrganizationProfileView } from '../organization-profile.view'; @@ -37,3 +38,22 @@ it('warns when there are no pages to show', () => { expect(warn).toHaveBeenCalledWith('[Clerk] OrganizationProfile has no pages to show.'); warn.mockRestore(); }); + +it('replaces the corner dismiss with a back button in a fullscreen dialog', () => { + render( + + + + + + + , + ); + + expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('button', { name: 'Back to app' })); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index 429f4444d76..fb957f6646b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -153,6 +153,35 @@ describe('UserProfileView', () => { expect(popup).toContainElement(screen.getByRole('tab', { name: 'Security' })); }); + it('replaces the close button with a back button in a fullscreen dialog', async () => { + const onOpenChange = vi.fn(); + render( + + + + + + + , + ); + + const popup = screen.getByRole('dialog', { name: 'User profile' }); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + const back = screen.getByRole('button', { name: 'Back to app' }); + expect(popup).toContainElement(back); + + await userEvent.setup().click(back); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + it('warns when there are no pages to show', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 236c54566fc..772317ed242 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -1,4 +1,5 @@ import { formMessages } from '../components/form/form.messages'; +import { profileMessages } from '../components/profile/profile.messages'; import { apiKeysTableMessages } from '../features/api-keys/api-keys-table.messages'; import { invitationsTableTabMessages } from '../features/organization-profile/invitations-table-tab.messages'; import { membersTableTabMessages } from '../features/organization-profile/members-table-tab.messages'; @@ -40,6 +41,7 @@ export const mosaicMessages = { organizationProfileDangerSection: organizationProfileDangerSectionMessages, organizationProfileProfileSection: organizationProfileProfileSectionMessages, organizationProfileInviteMembers: organizationProfileInviteMembersMessages, + profile: profileMessages, reverification: reverificationMessages, userButton: userButtonMessages, userProfile: userProfileMessages, diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index 19d70d381d3..194739d8749 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -26,3 +26,13 @@ and animates the popup; the profile paints it, names it, and carries the dismiss name='Overlay' storyModule={Stories} /> + +## As a full screen overlay + +The same view inside a `fullscreen` dialog, which fills the viewport. A back button above the +navigation dismisses it in place of the corner close button. + + diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index c62fdbfe65a..5b6ebcbb853 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -51,3 +51,14 @@ export function Overlay() { ); } + +export function Fullscreen() { + return ( + + }>Manage organization + + + + + ); +} diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index 5fd35d80134..c9277186ba3 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -28,3 +28,13 @@ and animates the popup; the profile paints it, names it, and carries the dismiss name='Overlay' storyModule={Stories} /> + +## As a full screen overlay + +The same view inside a `fullscreen` dialog, which fills the viewport. A back button above the +navigation dismisses it in place of the corner close button. + + diff --git a/packages/swingset/src/stories/user-profile.stories.tsx b/packages/swingset/src/stories/user-profile.stories.tsx index 04a8034c036..0ef8a86a063 100644 --- a/packages/swingset/src/stories/user-profile.stories.tsx +++ b/packages/swingset/src/stories/user-profile.stories.tsx @@ -53,3 +53,19 @@ export function Overlay() { ); } + +export function Fullscreen() { + const { activePage, setActivePage, pages } = useUserProfileFixture(); + return ( + + }>Manage account + + + + + ); +}