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/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx index ff7309a4c9a..7a13072242e 100644 --- a/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx +++ b/packages/mosaic/src/components/dialog/__tests__/dialog.test.tsx @@ -617,11 +617,10 @@ describe('compactPlacement', () => { warn.mockRestore(); }); - it('ignores a placement on a fullscreen profile, and warns', () => { + it('warns when a fullscreen profile is given a placement', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); renderPlacement('sheet', 'fullscreen'); - expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.anchored))); expect(warn).toHaveBeenCalledWith(expect.stringContaining('variant="fullscreen"')); warn.mockRestore(); }); @@ -636,28 +635,6 @@ describe('compactPlacement', () => { }); }); -describe('fullscreen track', () => { - const probe = stylex.create({ noInset: { paddingInline: 0 }, fill: { width: '100%' } }); - - it('fills the track, where other variants fit their content', () => { - const fullscreen = renderVariant('fullscreen'); - expect(classesOf('.cl-dialog-popup')).toEqual(expect.arrayContaining(atomFor(probe.fill))); - fullscreen.unmount(); - - renderVariant('profile'); - expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.fill))); - }); - - it('has no inset around the popup, where a profile keeps one', () => { - const fullscreen = renderVariant('fullscreen'); - expect(classesOf('.cl-dialog-track')).toEqual(expect.arrayContaining(atomFor(probe.noInset))); - fullscreen.unmount(); - - renderVariant('profile'); - expect(classesOf('.cl-dialog-track')).not.toEqual(expect.arrayContaining(atomFor(probe.noInset))); - }); -}); - describe('viewport scroll behaviour', () => { // The inside/outside scroll split. A pinned `height: 100%` cannot grow, so an over-tall popup // spills past the viewport's padding box and loses the bottom inset; `min-height: 100%` lets the diff --git a/packages/mosaic/src/components/dialog/dialog.styles.ts b/packages/mosaic/src/components/dialog/dialog.styles.ts index 7ab4113b020..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. */ @@ -362,14 +368,31 @@ export const trackVariants = stylex.create({ fullscreen: { '--_cl-dialog-inset': '0px', paddingInline: 0, + gridTemplateRows: 'minmax(0, 1fr)', }, }); -export const fullscreenPopup = stylex.create({ - fill: { - width: '100%', - }, -}); +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 @@ -407,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, }); /** @@ -510,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. * @@ -554,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. @@ -607,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 @@ -780,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 65a490dd90e..bb76c64723a 100644 --- a/packages/mosaic/src/components/dialog/dialog.tsx +++ b/packages/mosaic/src/components/dialog/dialog.tsx @@ -20,7 +20,6 @@ import { backdropMotion, closeInsets, compactPlacements, - fullscreenPopup, popupMotion, styles, trackCompactPlacements, @@ -38,10 +37,10 @@ import { guardKeyboardTouch } from './keyboard-touch'; * 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 | 'fullscreen'; +export type DialogVariant = keyof typeof variants; -function surfaceOf(variant: DialogVariant): keyof typeof variants { - return variant === 'fullscreen' ? 'profile' : variant; +function holdsProfile(variant: DialogVariant): boolean { + return variant !== 'card'; } /** @@ -228,7 +227,7 @@ const CloseButton = React.forwardRef( const variant = surface?.variant ?? 'card'; useCloseButtonWarning(role === 'alertdialog'); return ( - + ); @@ -299,7 +293,7 @@ function Viewport({ stylex.props( reset.base, styles.viewport, - viewportVariants[surfaceOf(variant)], + viewportVariants[variant], viewportCompactPlacements[compactPlacement], ), )} @@ -307,13 +301,7 @@ function Viewport({
@@ -333,7 +321,7 @@ function Viewport({ */ function useNestedVariantWarning(isNestedInDialog: boolean, variant: DialogVariant) { React.useEffect(() => { - if (process.env.NODE_ENV === 'production' || !isNestedInDialog || surfaceOf(variant) !== 'profile') { + if (process.env.NODE_ENV === 'production' || !isNestedInDialog || !holdsProfile(variant)) { return; } console.warn( @@ -352,7 +340,7 @@ function useCompactPlacementWarning(variant: DialogVariant, placement: DialogCom React.useEffect(() => { if ( process.env.NODE_ENV === 'production' || - surfaceOf(variant) !== 'profile' || + !holdsProfile(variant) || placement === undefined || placement === 'center' ) { @@ -380,8 +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 = - surfaceOf(variant) === 'profile' ? 'center' : (compactPlacementProp ?? 'sheet'); + const compactPlacement: DialogCompactPlacement = holdsProfile(variant) ? 'center' : (compactPlacementProp ?? 'sheet'); useCompactPlacementWarning(variant, compactPlacementProp); useNestedVariantWarning(isNestedInDialog, variant); @@ -392,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', surfaceOf(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'); @@ -420,13 +407,12 @@ const Popup = React.forwardRef(function Dialog stylex.props( reset.base, styles.popup, - variants[surfaceOf(variant)], - variant === 'fullscreen' && fullscreenPopup.fill, + variants[variant], compactPlacements[compactPlacement], // One cell per (variant, placement) that exists, selected rather than layered: StyleX // dedupes by PROPERTY across a `stylex.props` call, so a thin "sheet only" atom would // replace the centered cell's `transform` wholesale and take the desktop scale with it. - compactPlacement === 'sheet' ? popupMotion.cardSheet : popupMotion[surfaceOf(variant)], + compactPlacement === 'sheet' ? popupMotion.cardSheet : popupMotion[variant], xstyle, ), rest, @@ -455,9 +441,7 @@ const Popup = React.forwardRef(function Dialog ); return ( - - {surfaceOf(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 55566b0c624..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,7 +20,7 @@ export interface ProfileContextValue { pageTitleRef: React.MutableRefObject; navTriggerRef: React.MutableRefObject; inline: boolean; - fullscreen: boolean; + dismiss: ProfileDismiss; } export const ProfileContext = React.createContext(null); diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index 471f2d31911..a7c1219e9cc 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -115,16 +115,12 @@ export const styles = stylex.create({ }, rootFullscreen: { - width: '100%', + inlineSize: '100cqi', }, layoutFullscreen: { borderRadius: 0, - borderWidth: '0px', - blockSize: 'auto', boxShadow: 'none', - flexGrow: 1, - minHeight: 0, }, backItem: { diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index be808a1bb5c..2a0e0efad94 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -16,13 +16,14 @@ 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'; @@ -34,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) { @@ -83,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); @@ -119,7 +128,7 @@ const Root = React.forwardRef(function Profile pageTitleRef, navTriggerRef, inline, - fullscreen, + dismiss, }), [ titleId, @@ -134,7 +143,7 @@ const Root = React.forwardRef(function Profile navItems, pageTitleId, inline, - fullscreen, + dismiss, ], ); const element = useRender({ @@ -161,9 +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' && !(fullscreen && !compact) ? ( - - ) : null} + {dismiss === 'corner' ? : null}
(function Profile reset.base, styles.layout, inline && styles.layoutInline, - dialog !== null && !inline && !fullscreen && styles.layoutInDialog, + dialog !== null && !inline && styles.layoutInDialog, fullscreen && styles.layoutFullscreen, ), )} @@ -275,7 +282,7 @@ const Nav = React.forwardRef(function ProfileNav( setNavItems, navTriggerRef, inline, - fullscreen, + dismiss, } = useProfileContext('Profile.Nav'); useSafeLayoutEffect(() => { setNavItems(children); @@ -294,7 +301,7 @@ const Nav = React.forwardRef(function ProfileNav( ), children: ( <> - {fullscreen && navLayout === 'column' ? : null} + {dismiss === 'back' ? : null} {children}