From 3432ac63c1ce23376b4e9f0295afcaab37164f1a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 13:09:18 -0600 Subject: [PATCH 01/31] feat(ui): formalize the Mosaic Profile surface and add a Drawer Replaces the ad-hoc ProfilePage with a generic `Profile` component (Root / Nav / NavItem / PageTitle / Content / Page), adopted by the user profile as `UserProfileView` with custom pages and ordering. Renames the dialog size `panel` to `profile`, hoists the Clerk branding into a shared `Branding` component, and adds a `--cl-radius-2xl` and `--cl-shadow-card` token. Adds a styled `Drawer` (bottom sheet) on the headless primitive. Compact, the profile's navigation moves into it, opened from each page's headline. Headless fixes found along the way: `useRender` emits state attributes before other props so a CSS anchor on `[data-selected]` transitions in both directions; the drawer's drag gate no longer reads a scrolled page as inner scroll, rubber-bands upward drags at rest, mirrors its swipe vars onto the backdrop, and can no longer be left mid-gesture by a lost release. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .changeset/mosaic-profile-component.md | 2 + .../headless/src/primitives/drawer/README.md | 4 +- .../src/primitives/drawer/drawer-root.tsx | 3 + .../src/primitives/drawer/drawer.test.tsx | 135 ++++- .../src/primitives/drawer/use-drawer-drag.ts | 85 +++- .../headless/src/utils/use-render.test.tsx | 26 + packages/headless/src/utils/use-render.tsx | 8 +- .../swingset/src/components/DocsViewer.tsx | 4 +- packages/swingset/src/lib/registry.ts | 32 +- .../swingset/src/stories/dialog.component.mdx | 62 +-- .../src/stories/dialog.component.stories.tsx | 38 +- .../swingset/src/stories/drawer.component.mdx | 61 +++ .../src/stories/drawer.component.stories.tsx | 107 ++++ .../{user-page.ts => user-profile.ts} | 15 +- .../src/stories/profile.component.mdx | 99 ++++ .../src/stories/profile.component.stories.tsx | 180 +++++++ .../{user-page.mdx => user-profile.mdx} | 9 +- ...e.stories.tsx => user-profile.stories.tsx} | 25 +- .../components/branding/branding.styles.ts | 22 + .../components/branding/branding.test.tsx | 19 + .../mosaic/components/branding/branding.tsx | 48 ++ .../src/mosaic/components/branding/index.ts | 2 + .../src/mosaic/components/card/card.styles.ts | 26 +- .../src/mosaic/components/card/card.test.tsx | 3 +- .../ui/src/mosaic/components/card/card.tsx | 19 +- .../components/dialog/alert-dialog.test.tsx | 4 +- .../mosaic/components/dialog/dialog.styles.ts | 93 ++-- .../mosaic/components/dialog/dialog.test.tsx | 81 +-- .../src/mosaic/components/dialog/dialog.tsx | 20 +- .../components/dialog/keyboard-inset.ts | 2 +- .../mosaic/components/drawer/drawer.styles.ts | 152 ++++++ .../mosaic/components/drawer/drawer.test.tsx | 81 +++ .../src/mosaic/components/drawer/drawer.tsx | 115 +++++ .../ui/src/mosaic/components/drawer/index.ts | 9 + .../ui/src/mosaic/components/profile/index.ts | 9 + .../components/profile/profile.styles.ts | 256 ++++++++++ .../components/profile/profile.test.tsx | 312 ++++++++++++ .../src/mosaic/components/profile/profile.tsx | 464 ++++++++++++++++++ packages/ui/src/mosaic/profile-page.styles.ts | 187 ------- packages/ui/src/mosaic/profile-page.tsx | 227 --------- packages/ui/src/mosaic/styles/index.ts | 32 +- packages/ui/src/mosaic/tokens.stylex.ts | 13 + .../mosaic/user-button/user-button.pages.tsx | 35 +- .../mosaic/user-button/user-button.utils.ts | 23 +- .../__tests__/user-page.view.test.tsx | 225 --------- .../__tests__/user-profile.layout.test.ts | 40 ++ .../__tests__/user-profile.view.test.tsx | 161 ++++++ .../mosaic/user-profile/user-page.view.tsx | 119 ----- .../user-profile-api-keys-panel.view.tsx | 9 +- .../user-profile-billing-panel.view.tsx | 9 +- .../user-profile-profile-panel.view.tsx | 9 +- .../user-profile-security-panel.view.tsx | 9 +- .../user-profile/user-profile-sidebar.tsx | 33 -- .../user-profile/user-profile.layout.ts | 40 ++ .../user-profile/user-profile.messages.ts | 15 + .../mosaic/user-profile/user-profile.types.ts | 43 ++ .../mosaic/user-profile/user-profile.view.tsx | 101 ++++ packages/ui/src/mosaic/utils/apply-order.ts | 22 + 58 files changed, 2883 insertions(+), 1101 deletions(-) create mode 100644 .changeset/mosaic-profile-component.md create mode 100644 packages/swingset/src/stories/drawer.component.mdx create mode 100644 packages/swingset/src/stories/drawer.component.stories.tsx rename packages/swingset/src/stories/fixtures/{user-page.ts => user-profile.ts} (88%) create mode 100644 packages/swingset/src/stories/profile.component.mdx create mode 100644 packages/swingset/src/stories/profile.component.stories.tsx rename packages/swingset/src/stories/{user-page.mdx => user-profile.mdx} (58%) rename packages/swingset/src/stories/{user-page.stories.tsx => user-profile.stories.tsx} (92%) create mode 100644 packages/ui/src/mosaic/components/branding/branding.styles.ts create mode 100644 packages/ui/src/mosaic/components/branding/branding.test.tsx create mode 100644 packages/ui/src/mosaic/components/branding/branding.tsx create mode 100644 packages/ui/src/mosaic/components/branding/index.ts create mode 100644 packages/ui/src/mosaic/components/drawer/drawer.styles.ts create mode 100644 packages/ui/src/mosaic/components/drawer/drawer.test.tsx create mode 100644 packages/ui/src/mosaic/components/drawer/drawer.tsx create mode 100644 packages/ui/src/mosaic/components/drawer/index.ts create mode 100644 packages/ui/src/mosaic/components/profile/index.ts create mode 100644 packages/ui/src/mosaic/components/profile/profile.styles.ts create mode 100644 packages/ui/src/mosaic/components/profile/profile.test.tsx create mode 100644 packages/ui/src/mosaic/components/profile/profile.tsx delete mode 100644 packages/ui/src/mosaic/profile-page.styles.ts delete mode 100644 packages/ui/src/mosaic/profile-page.tsx delete mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile.view.test.tsx delete mode 100644 packages/ui/src/mosaic/user-profile/user-page.view.tsx delete mode 100644 packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile.layout.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile.messages.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile.types.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile.view.tsx create mode 100644 packages/ui/src/mosaic/utils/apply-order.ts diff --git a/.changeset/mosaic-profile-component.md b/.changeset/mosaic-profile-component.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-profile-component.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/drawer/README.md b/packages/headless/src/primitives/drawer/README.md index b6d71ac5034..81dd19decf3 100644 --- a/packages/headless/src/primitives/drawer/README.md +++ b/packages/headless/src/primitives/drawer/README.md @@ -157,12 +157,12 @@ The headless parts emit raw inputs only — the styled layer composes them. The (`swipe-movement-y`, `snap-point-offset`, `swipe-progress`) are registered as non-inheriting custom properties via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` is unavailable). -### CSS custom properties (on `Drawer.Popup`) +### CSS custom properties (on `Drawer.Popup`, mirrored onto `Drawer.Backdrop`) | Variable | Written by | Meaning | | ---------------------------------- | ------------- | ------------------------------------------------------------------------------------- | | `--cl-drawer-swipe-movement-y` | drag engine | px live drag delta on the Y axis (0 at rest) | -| `--cl-drawer-swipe-progress` | drag engine | 0..1 dismiss progress (drives backdrop fade) | +| `--cl-drawer-swipe-progress` | drag engine | 0..1 dismiss progress (drives backdrop fade; also written to the backdrop, a sibling) | | `--cl-drawer-snap-point-offset` | snap layer | px resting translateY of the active snap point | | `--cl-drawer-swipe-strength` | drag engine | 0.1..1 from release velocity (scales exit speed) | | `--cl-drawer-nested-drawers` | nesting layer | count of open nested children | diff --git a/packages/headless/src/primitives/drawer/drawer-root.tsx b/packages/headless/src/primitives/drawer/drawer-root.tsx index 34e400fdaeb..8f5bca9a934 100644 --- a/packages/headless/src/primitives/drawer/drawer-root.tsx +++ b/packages/headless/src/primitives/drawer/drawer-root.tsx @@ -132,8 +132,11 @@ function DrawerInner(props: DrawerProps) { // CSS-var writers. `setSwipe` is the single writer of the live swipe-y, keeping // the var and the `curSwipe` ref in lockstep so drag decisions can read the ref. const curSwipe = useRef(0); + // Written to the backdrop as well: it is the popup's sibling, so nothing it needs — the dismiss + // progress its fade follows — would otherwise reach it through inheritance. const setVar = useCallback((name: string, value: string) => { popupRef.current?.style.setProperty(name, value); + backdropRef.current?.style.setProperty(name, value); }, []); const setSwipe = useCallback( (px: number) => { diff --git a/packages/headless/src/primitives/drawer/drawer.test.tsx b/packages/headless/src/primitives/drawer/drawer.test.tsx index 8f45ee9069d..427526e8f37 100644 --- a/packages/headless/src/primitives/drawer/drawer.test.tsx +++ b/packages/headless/src/primitives/drawer/drawer.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -568,6 +568,22 @@ describe('Drawer', () => { expect(onOpenChange).not.toHaveBeenCalledWith(false); }); + it('mirrors the swipe vars onto the backdrop, which cannot inherit them from the popup', () => { + render(); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(popup, { pointerId: 1, clientY: 0, button: 0, isPrimary: true, pointerType: 'touch' }); + clock.t += 50; + fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 }); + + const backdrop = screen.getByTestId('backdrop'); + expect(swipeProgress(backdrop)).toBe(swipeProgress(popup)); + expect(swipeY(backdrop)).toBe('100px'); + + fireEvent.pointerUp(popup, { pointerId: 1, clientY: 100 }); + }); + it('updates the swipe-progress var and swiping attribute during a drag', () => { render(); const popup = screen.getByRole('dialog'); @@ -585,6 +601,97 @@ describe('Drawer', () => { expect(popup).not.toHaveAttribute('data-swiping'); }); + it('rubber-bands an upward drag at rest when nothing under the finger can scroll', () => { + render(); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'touch' }); + clock.t += 30; + fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 }); // up 200px, never dragged down + + expect(parseFloat(swipeY(popup))).toBeLessThan(0); + expect(popup).toHaveAttribute('data-swiping'); + + fireEvent.pointerUp(popup, { pointerId: 1, clientY: 100 }); + expect(swipeY(popup)).toBe('0px'); + }); + + // The styled sheet bleeds below the screen, so its viewport measures taller than it shows; that + // is not inner content, and must not swallow the upward drag. + it('rubber-bands upward at rest even when the viewport above the sheet overflows', () => { + render(); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + makeScrollable(screen.getByTestId('viewport'), { scrollHeight: 940, clientHeight: 844, scrollTop: 0 }); + + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'touch' }); + clock.t += 30; + fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 }); + + expect(parseFloat(swipeY(popup))).toBeLessThan(0); + + fireEvent.pointerUp(popup, { pointerId: 1, clientY: 100 }); + }); + + it('lets inner content scroll on an upward drag at rest when it has room to', () => { + render(); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + const list = screen.getByTestId('scrollable'); + makeScrollable(list, { scrollHeight: 500, clientHeight: 100, scrollTop: 0 }); + + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(list, { pointerId: 1, clientY: 300, button: 0, pointerType: 'touch' }); + clock.t += 30; + fireEvent.pointerMove(list, { pointerId: 1, clientY: 100 }); + + expect(swipeY(popup)).toBe(''); + + fireEvent.pointerUp(list, { pointerId: 1, clientY: 100 }); + }); + + // Pointer capture is asked for, not guaranteed. A release the popup never receives used to leave + // the engine armed: the sheet held its drag offset and `data-swiping`, and the next open started + // that way too, until a fresh press on the sheet released it. + it('ends the gesture on a release that reaches only the window', () => { + render(); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'mouse' }); + clock.t += 30; + fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 }); + expect(popup).toHaveAttribute('data-swiping'); + + fireEvent.pointerUp(window, { pointerId: 1, clientY: 100 }); + + expect(popup).not.toHaveAttribute('data-swiping'); + expect(swipeY(popup)).toBe('0px'); + }); + + it('does not carry a lost gesture into the next open', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('trigger')); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'mouse' }); + clock.t += 30; + fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 }); + // No release at all; close from the keyboard instead. + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + await user.click(screen.getByTestId('trigger')); + expect(screen.getByRole('dialog')).not.toHaveAttribute('data-swiping'); + }); + it('rubber-bands upward over-drag without ever moving the sheet downward', () => { render(); const popup = screen.getByRole('dialog'); @@ -753,6 +860,32 @@ describe('Drawer', () => { expect(swipeY(popup)).toBe(''); }); + // A portalled sheet's ancestors above the viewport are the page itself; the walk used to reach a + // scrolled `` and read it as inner content, so a drawer over a scrolled page could not be + // dragged at all unless the sheet happened to scroll. + it('drags when the page behind the sheet is scrolled', () => { + const onOpenChange = vi.fn(); + render( + , + ); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + makeScrollable(document.documentElement, { scrollHeight: 3000, clientHeight: 800, scrollTop: 900 }); + + try { + drag(popup, 0, 120, 200); + } finally { + delete (document.documentElement as unknown as Record).scrollHeight; + delete (document.documentElement as unknown as Record).clientHeight; + document.documentElement.scrollTop = 0; + } + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + it('ignores cross-axis (horizontal) jitter during a vertical drag', () => { const onOpenChange = vi.fn(); render( diff --git a/packages/headless/src/primitives/drawer/use-drawer-drag.ts b/packages/headless/src/primitives/drawer/use-drawer-drag.ts index cb2386c6a7a..ca2b2a0616f 100644 --- a/packages/headless/src/primitives/drawer/use-drawer-drag.ts +++ b/packages/headless/src/primitives/drawer/use-drawer-drag.ts @@ -72,6 +72,10 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { // Removes the current iOS `touchend` fallback listener (see `onPointerDown`), // so it never outlives its gesture or piles up across gestures. const removeTouchEnd = useRef<(() => void) | null>(null); + // Removes the window-level release listeners armed for the current gesture (see `onPointerDown`). + const removeWindowRelease = useRef<(() => void) | null>(null); + // `onRelease` is defined after `onPointerDown`, which needs to arm it; read through a ref. + const onReleaseRef = useRef<((e: { clientY: number }) => void) | null>(null); // Latest options, read at event time so the handlers can stay referentially stable. const cfg = useRef(opts); @@ -86,7 +90,27 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { }, [open, now]); // Drop any pending iOS touchend fallback if the drawer unmounts mid-gesture. - useEffect(() => () => removeTouchEnd.current?.(), []); + useEffect( + () => () => { + removeTouchEnd.current?.(); + removeWindowRelease.current?.(); + }, + [], + ); + + // A gesture never outlives the open state. Should a release be lost anyway, closing (or the + // sheet unmounting under the finger) must not leave the engine armed for the next open. + useEffect(() => { + if (open) { + return; + } + draggingRef.current = false; + allowed.current = false; + pid.current = null; + removeWindowRelease.current?.(); + removeWindowRelease.current = null; + setIsDragging(false); + }, [open]); const shouldDrag = useCallback((target: HTMLElement, down: boolean): boolean => { const { now: clock, curSwipe, snap } = cfg.current; @@ -104,8 +128,18 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { if (target.closest(`[${DrawerAttrs.noDrag}]`)) { return false; } - // A text selection is in progress (contenteditable / regular DOM text). - if (window.getSelection()?.toString().length) { + // A text selection is in progress INSIDE the sheet (contenteditable / regular DOM text). One + // elsewhere on the page is none of the sheet's business, and would otherwise veto every drag + // for as long as it stood. + const selection = window.getSelection(); + const sheet = cfg.current.popupRef.current; + if ( + selection && + !selection.isCollapsed && + selection.toString().length && + selection.anchorNode && + sheet?.contains(selection.anchorNode) + ) { return false; } // A focused input/textarea with a non-collapsed selection: dragging is @@ -130,11 +164,27 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { lastScrollAt.current = clock(); return false; } - // Upward at rest: let inner content scroll instead. + // Upward at rest: inner content with room left to scroll takes the gesture; otherwise the sheet + // rubber-bands, so the drag is never simply swallowed. if (!down) { - return false; + const sheet = cfg.current.popupRef.current; + for (let el: HTMLElement | null = target; el; el = el.parentElement) { + if (el.scrollHeight > el.clientHeight && el.scrollTop + el.clientHeight < el.scrollHeight - 1) { + return false; + } + // Nothing above the sheet is inner content — its box may well be taller than the screen. + if (el === sheet) { + break; + } + } + return true; } for (let el: HTMLElement | null = target; el; el = el.parentElement) { + // The page behind the sheet is never inner content: a scrolled document must not veto the + // drag, which it otherwise would whenever the sheet itself has nothing to scroll. + if (el === document.body || el === document.documentElement) { + return true; + } if (el.scrollHeight > el.clientHeight) { if (el.scrollTop !== 0) { lastScrollAt.current = clock(); @@ -186,6 +236,23 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { captured.current = target; safeCapture(target, e.pointerId, 'setPointerCapture'); + // The release is expected on the captured target, but capture is not a guarantee — a release + // the popup never sees would leave the sheet held mid-drag, across opens. Whatever lands on + // `window` for this pointer ends the gesture; the popup's own handler then finds nothing to do. + removeWindowRelease.current?.(); + const pointerId = e.pointerId; + const onWindowRelease = (ev: PointerEvent): void => { + if (ev.pointerId === pointerId) { + onReleaseRef.current?.(ev); + } + }; + window.addEventListener('pointerup', onWindowRelease, true); + window.addEventListener('pointercancel', onWindowRelease, true); + removeWindowRelease.current = () => { + window.removeEventListener('pointerup', onWindowRelease, true); + window.removeEventListener('pointercancel', onWindowRelease, true); + }; + // iOS doesn't dispatch pointerup after a scroll-cancelled gesture, so reset // `allowed` on touchend. Track the listener (and drop any stale one from a // prior gesture that never fired) so it's removed on release/unmount instead @@ -234,13 +301,15 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { [shouldDrag, sample], ); - const onRelease = useCallback((e: ReactPointerEvent): void => { + const onRelease = useCallback((e: { clientY: number }): void => { if (!draggingRef.current) { return; } - // Normal release: the iOS touchend fallback is no longer needed. + // Normal release: neither fallback is needed any more. removeTouchEnd.current?.(); removeTouchEnd.current = null; + removeWindowRelease.current?.(); + removeWindowRelease.current = null; const { snapPoints, snap, @@ -300,6 +369,8 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { onNestedRelease?.(!dismiss); }, []); + onReleaseRef.current = onRelease; + return { onPointerDown, onPointerMove, diff --git a/packages/headless/src/utils/use-render.test.tsx b/packages/headless/src/utils/use-render.test.tsx index da1411caad9..dfaeba5c2fa 100644 --- a/packages/headless/src/utils/use-render.test.tsx +++ b/packages/headless/src/utils/use-render.test.tsx @@ -234,4 +234,30 @@ describe('useRender', () => { }), ); }); + + // React writes changed attributes in key order, and Chrome flushes style when `tabindex` changes on + // the focused element. A state marker that lands after that write is absent from the flush, which + // is how a CSS anchor on `[data-selected]` loses its transition. So the markers lead, and still win. + it('emits state attributes ahead of the other props, and lets them win', () => { + function Probe() { + return useRender({ + defaultTagName: 'button', + state: { selected: true }, + stateAttributesMapping: { selected: (v: boolean) => (v ? { 'data-selected': '' } : null) }, + props: { tabIndex: 0, 'data-selected': 'stale', 'data-testid': 'probe' }, + }); + } + render(); + const element = screen.getByTestId('probe'); + expect(element).toHaveAttribute('data-selected', ''); + expect( + Array.from(element.attributes) + .map(attribute => attribute.name) + .indexOf('data-selected'), + ).toBeLessThan( + Array.from(element.attributes) + .map(attribute => attribute.name) + .indexOf('tabindex'), + ); + }); }); diff --git a/packages/headless/src/utils/use-render.tsx b/packages/headless/src/utils/use-render.tsx index b314ea49396..548edcb1377 100644 --- a/packages/headless/src/utils/use-render.tsx +++ b/packages/headless/src/utils/use-render.tsx @@ -215,7 +215,13 @@ export function useRender< } } - const computedProps = { ...props, ...dataAttrs }; + // State attributes lead, and still win: a key's position is fixed by its first spread, its value + // by its last. React writes changed attributes in key order, and Chrome flushes style when + // `tabindex` changes on the focused element, so a state marker written after a roving `tabindex` + // would be missing from that flush — an anchor named on `[data-selected]` resolves to nothing for + // one recalc and its transition snaps. Ahead of the rest, the marker is in place before any write + // that can flush. + const computedProps = { ...dataAttrs, ...props, ...dataAttrs }; if (typeof render === 'function') { return render({ ...computedProps, ref: mergedRef }); diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3c7a94f7b9b..8c6b741eea3 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -14,7 +14,7 @@ const docModules: Record> = { 'user-button': dynamic(() => import('../stories/user-button.mdx')), }, 'user-profile': { - 'user-page': dynamic(() => import('../stories/user-page.mdx')), + 'user-profile': dynamic(() => import('../stories/user-profile.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), @@ -55,12 +55,14 @@ const docModules: Record> = { input: dynamic(() => import('../stories/input.mdx')), item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), + drawer: dynamic(() => import('../stories/drawer.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), 'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), otp: dynamic(() => import('../stories/otp.component.mdx')), popover: dynamic(() => import('../stories/popover.component.mdx')), + profile: dynamic(() => import('../stories/profile.component.mdx')), section: dynamic(() => import('../stories/section.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 4b08de77663..b3b8eb0b984 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -32,6 +32,11 @@ import { } from '../stories/destructive.stories'; import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories'; import { meta as dialogMeta } from '../stories/dialog.stories'; +import { + Default as DrawerComponentDefault, + InsideProfile as DrawerComponentInsideProfile, + meta as drawerComponentMeta, +} from '../stories/drawer.component.stories'; import { meta as drawerMeta } from '../stories/drawer.stories'; import { Default as FieldDefault, @@ -92,6 +97,11 @@ import { Placement as PopoverComponentPlacement, } from '../stories/popover.component.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; +import { + Customized as ProfileCustomized, + Default as ProfileDefault, + meta as profileComponentMeta, +} from '../stories/profile.component.stories'; import { Default as ReverificationBackupCodeDefault, meta as reverificationBackupCodeMeta, @@ -150,7 +160,7 @@ import { Organizations as UserButtonOrganizations, User as UserButtonUser, } from '../stories/user-button.stories'; -import { Default as UserPageDefault, meta as userPageMeta } from '../stories/user-page.stories'; +import { Default as UserProfileDefault, meta as userProfileMeta } from '../stories/user-profile.stories'; import { Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, @@ -235,6 +245,11 @@ const sectionModule: StoryModule = { Destructive: SectionDestructive, }; const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const drawerComponentModule: StoryModule = { + meta: drawerComponentMeta, + Default: DrawerComponentDefault, + InsideProfile: DrawerComponentInsideProfile, +}; const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault }; @@ -272,6 +287,11 @@ const popoverComponentModule: StoryModule = { Placement: PopoverComponentPlacement, Alignment: PopoverComponentAlignment, }; +const profileComponentModule: StoryModule = { + meta: profileComponentMeta, + Default: ProfileDefault, + Customized: ProfileCustomized, +}; const itemModule: StoryModule = { meta: itemMeta, @@ -370,9 +390,9 @@ const userProfileApiKeysPanelModule: StoryModule = { Default: UserProfileApiKeysPanelDefault, Empty: UserProfileApiKeysPanelEmpty, }; -const userPageModule: StoryModule = { - meta: userPageMeta, - Default: UserPageDefault, +const userProfileModule: StoryModule = { + meta: userProfileMeta, + Default: UserProfileDefault, }; const userProfileAccountSectionModule: StoryModule = { @@ -473,7 +493,7 @@ export const registry: StoryModule[] = [ // User Button userButtonModule, // User Profile - userPageModule, + userProfileModule, // User Profile · Panels userProfileProfilePanelModule, userProfileSecurityPanelModule, @@ -508,12 +528,14 @@ export const registry: StoryModule[] = [ inputModule, itemModule, dialogComponentModule, + drawerComponentModule, headingModule, iconModule, iconFrameModule, menuComponentModule, otpComponentModule, popoverComponentModule, + profileComponentModule, sectionModule, textModule, fieldModule, diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index aaaf023a069..c4eb6ca4228 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -68,18 +68,18 @@ committing. Your own `setOpen(false)` skips that; use `Dialog.Close` when a clos ### Size -| Value | Width | For | -| -------- | ---------------------------------------- | ----------------------------------------------------- | -| `prompt` | `23.75rem`, height from content | One question or one field (default) | -| `card` | `25rem`, height from content | Sign-in / sign-up | -| `panel` | Width from its surface, fills the height | Account profile and settings — a surface you navigate | - -### `card` and `panel` bring their own surface - -Only `prompt` paints itself. A `card` holds a `Card`, and a `panel` holds a `ProfilePage` (or -`UserPageView`): the dialog positions and animates the popup, and the surface inside paints it. -Both surfaces read `DialogContext` and are self-contained — `Card.Title` and the page's label name -the dialog, and `Card.Header` and `ProfilePage.Root` carry the dismiss — so `Dialog.CloseButton` +| Value | Width | For | +| --------- | ---------------------------------------- | ----------------------------------------------------- | +| `prompt` | `23.75rem`, height from content | One question or one field (default) | +| `card` | `25rem`, height from content | Sign-in / sign-up | +| `profile` | Width from its surface, fills the height | Account profile and settings — a surface you navigate | + +### `card` and `profile` bring their own surface + +Only `prompt` paints itself. A `card` holds a `Card`, and a `profile` holds a `Profile` (or +`UserProfileView`): the dialog positions and animates the popup, and the surface inside paints it. +Both surfaces read `DialogContext` and are self-contained — `Card.Title` and the profile's label name +the dialog, and `Card.Header` and `Profile.Root` carry the dismiss — so `Dialog.CloseButton` and `Dialog.Title` are only for a `prompt`. -A `panel` composes the same way, with the user page in place of the card. The page names the +A `profile` composes the same way, with the user page in place of the card. The page names the dialog, carries the dismiss, scrolls its own content column, and collapses its own sidebar — the -full example is under [A panel](#a-panel): +full example is under [A profile](#a-profile): ```tsx import { Button } from '@clerk/ui/mosaic/components/button'; import { Dialog } from '@clerk/ui/mosaic/components/dialog'; -import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import { UserProfileView } from '@clerk/ui/mosaic/user-profile/user-profile.view'; }>Manage account - - + ; @@ -169,7 +169,7 @@ const onOpenChange = useConfirmedClose({ ### Inline `inline` on the root renders the dialog in its host instead of over the page: no portal, scrim, -scroll lock or focus trap, and nothing dismisses it. For the account panel mounted in a page slot. +scroll lock or focus trap, and nothing dismisses it. For the account profile mounted in a page slot. -Add an email address, type into the field and try to close it: the prompt opens over the panel +Add an email address, type into the field and try to close it: the prompt opens over the profile (nested, with its own lighter scrim), and a confirmation stacks on the prompt (no second scrim; the prompt recedes). The same stack on its own: diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index ebebe39f18c..f7553d2fb0e 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -6,12 +6,12 @@ import { createConfirmHandle, Dialog, useConfirmedClose } from '@clerk/ui/mosaic import { Heading } from '@clerk/ui/mosaic/components/heading'; import { Input } from '@clerk/ui/mosaic/components/input'; import { Text } from '@clerk/ui/mosaic/components/text'; -import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import { UserProfileView } from '@clerk/ui/mosaic/user-profile/user-profile.view'; import React from 'react'; import type { StoryMeta } from '@/lib/types'; -import { useUserPageFixture } from './fixtures/user-page'; +import { useUserProfileFixture } from './fixtures/user-profile'; // Exposes this file's own source (via the `?raw` webpack rule) so each `` example // renders a code footer with its function's source. See `StoryModule.__source`. @@ -23,7 +23,7 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/dialog/dialog.tsx', styles: { _variants: { - size: { prompt: {}, card: {}, panel: {} }, + size: { prompt: {}, card: {}, profile: {} }, }, _defaultVariants: { size: 'prompt', @@ -171,8 +171,8 @@ export function DiscardChanges() { const accountTrigger = (props: RenderProps) => ; /** - * The "add email address" prompt the account panel opens, driven by `open` rather than a trigger. - * Closing it with a value typed asks first — `panel -> prompt -> prompt`. + * The "add email address" prompt the account profile opens, driven by `open` rather than a trigger. + * Closing it with a value typed asks first — `profile -> prompt -> prompt`. */ function AddEmailDialog({ open, @@ -249,25 +249,25 @@ function AddEmailDialog({ } /** - * The real user page inside a `panel` dialog. The dialog positions it and the page paints + * The real user page inside a `profile` dialog. The dialog positions it and the page paints * itself — the same composition as a `Card` inside a `card` dialog — so the page names the * dialog, scrolls its own content column, collapses its own sidebar, and carries the dismiss. - * Adding an email opens a prompt over the panel; the danger zone's delete confirmation is the + * Adding an email opens a prompt over the profile; the danger zone's delete confirmation is the * page's own. */ export function Nested() { const [addEmailOpen, setAddEmailOpen] = React.useState(false); - const { activePanel, setActivePanel, panels, addEmail } = useUserPageFixture({ + const { activePage, setActivePage, pages, addEmail } = useUserProfileFixture({ onAddEmail: () => setAddEmailOpen(true), }); return ( - - + setAddEmailOpen(true), }); return ( @@ -307,11 +307,11 @@ export function Inline() { }} > - - + + +## Usage + +```tsx +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Drawer } from '@clerk/ui/mosaic/components/drawer'; + + + }>Sort + + Sort members + Choose how the list is ordered. + Done + +; +``` + +`Drawer.Popup` renders the portal, the scrim, the box the sheet rises in, and the grip; its +children are the sheet's content. `Drawer.Root` takes every headless option — `open` / +`onOpenChange`, `snapPoints`, `dismissible`, `handleOnly`, `autoFocus`. + +## Inside a profile + +Opened from inside a `profile` dialog the sheet takes the nested scrim, the way a prompt opened +there does, so it reads as a surface over the profile rather than as the profile dimming. + + + +## Parts + +| Part | Element | Slot | +| -------------- | ------------------ | ------------------------------------- | +| `Drawer.Popup` | `div[role=dialog]` | `cl-drawer-popup` | +| — | scrim | `cl-drawer-backdrop` | +| — | box | `cl-drawer-viewport` | +| — | grip area / pill | `cl-drawer-handle` / `cl-drawer-grip` | + +## Motion + +The sheet moves on `translate`, composed from the headless layer's `--cl-drawer-snap-point-offset` +and `--cl-drawer-swipe-movement-y`. While `data-swiping` is present the transition is off and it +follows the finger; on release the exit duration is scaled by `--cl-drawer-swipe-strength`, so a +flick leaves faster than a slow drag. The scrim thins with `--cl-drawer-swipe-progress` during a +drag. Under `prefers-reduced-motion: reduce` only the scrim fades. diff --git a/packages/swingset/src/stories/drawer.component.stories.tsx b/packages/swingset/src/stories/drawer.component.stories.tsx new file mode 100644 index 00000000000..c9c3538055a --- /dev/null +++ b/packages/swingset/src/stories/drawer.component.stories.tsx @@ -0,0 +1,107 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Dialog } from '@clerk/ui/mosaic/components/dialog'; +import { Drawer } from '@clerk/ui/mosaic/components/drawer'; +import { Heading } from '@clerk/ui/mosaic/components/heading'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Profile } from '@clerk/ui/mosaic/components/profile'; +import { Text } from '@clerk/ui/mosaic/components/text'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './drawer.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Drawer', + source: 'packages/ui/src/mosaic/components/drawer/drawer.tsx', +}; + +function SheetContent() { + return ( + <> + }>Sort members + }>Choose how the list is ordered. +
+ }>Name, A to Z + }>Joined, newest first + }>Role +
+ + ); +} + +export function Default() { + return ( + + }>Sort + + + + + ); +} + +/** + * Opened from inside a `profile` dialog: the sheet rises over the profile and takes the nested + * scrim, the way a prompt opened there does. Narrow the window below the phone band to see the + * profile fill the screen first. + */ +export function InsideProfile() { + const [page, setPage] = useState('members'); + return ( + + }>Manage organization + + + + + } + > + General + + + } + > + Members + + + + + General + + +
+ Members + + }>Sort + + + + +
+
+
+
+
+
+ ); +} diff --git a/packages/swingset/src/stories/fixtures/user-page.ts b/packages/swingset/src/stories/fixtures/user-profile.ts similarity index 88% rename from packages/swingset/src/stories/fixtures/user-page.ts rename to packages/swingset/src/stories/fixtures/user-profile.ts index eb1a0c93d4e..859d34d2c57 100644 --- a/packages/swingset/src/stories/fixtures/user-page.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -1,24 +1,23 @@ -import type { UserPageViewProps } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import type { UserProfileViewProps } from '@clerk/ui/mosaic/user-profile/user-profile.view'; import type { UserProfileEmail, UserProfilePhone } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; import type { UserProfileDevice, UserProfileMfaMethod, UserProfilePasskey, } from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; -import type { UserProfilePanelId } from '@clerk/ui/mosaic/user-profile/user-profile-sidebar'; import { useState } from 'react'; -export interface UserPageFixtureOptions { +export interface UserProfileFixtureOptions { /** Replaces the default "append an address" behaviour, e.g. to open a real prompt. */ onAddEmail?: () => void; } /** - * The account and security panels of the user page, backed by local state so the actions on them + * The account and security pages of the user page, backed by local state so the actions on them * do something. For stories that need a realistic profile surface without being about it. */ -export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) { - const [activePanel, setActivePanel] = useState('account'); +export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) { + const [activePage, setActivePage] = useState('account'); const [emails, setEmails] = useState([ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, { id: 'email_2', value: 'preston.booth@gmail.com', isVerified: true }, @@ -63,7 +62,7 @@ export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) const addEmail = (value: string) => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); - const panels: UserPageViewProps['panels'] = { + const pages: UserProfileViewProps['pages'] = { account: { allowMultipleAccounts: true, imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', @@ -123,5 +122,5 @@ export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) }, }; - return { activePanel, setActivePanel, panels, addEmail, devices }; + return { activePage, setActivePage, pages, addEmail, devices }; } diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx new file mode 100644 index 00000000000..2b8b8d495d6 --- /dev/null +++ b/packages/swingset/src/stories/profile.component.mdx @@ -0,0 +1,99 @@ +import * as ProfileStories from './profile.component.stories'; + +# Profile + +A surface you navigate: a column of destinations beside the page each one opens. The user profile +and the organization profile are both one of these — `UserProfileView` composes it from the pages an +instance has content for. Rendered as the content of a `profile` dialog it names the dialog, carries +its dismiss, and fills the popup; standalone it paints the same frame and takes its own height. + +## Playground + + + +## Props + + + +## Usage + +```tsx +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { Profile } from '@clerk/ui/mosaic/components/profile'; + + + + } + > + Account + + } + > + Security + + + + + + +; +``` + +`Root` is controlled: `value` names the open page and `onValueChange` reports a selection. A +`NavItem` and a `Page` pair by `value`. `icon` takes any node, so a page of the consumer's own can +bring its own mark. + +## Parts + +| Part | Element | Slot | +| ----------------- | --------------------- | --------------------- | +| `Profile.Root` | `div` (the container) | `cl-profile` | +| `Profile.Nav` | `nav` (labelled) | `cl-profile-nav` | +| — | tablist | `cl-profile-nav-list` | +| `Profile.NavItem` | `button[role=tab]` | `cl-profile-nav-item` | +| `Profile.Content` | `div` (scroll region) | `cl-profile-content` | +| `Profile.Page` | `div[role=tabpanel]` | `cl-profile-page` | + +`Content` is a plain `div`, not a `main`: the profile is usually the content of the host's own +`main`, or of a dialog. + +## Compact layout + +Below `48rem` of the profile's **own** width the frame goes — the profile is the page there, flush +with whatever holds it — and the navigation leaves the column for a sheet: `Profile.PageTitle` +grows a caret that opens a `Drawer` holding the tablist, and a choice closes it. The width is +measured on the root rather than queried, because where the tablist renders is a DOM decision: one +tablist, in the column or in the sheet, never both. The same surface collapses in a narrow layout +slot, an inline dialog, or a phone alike. + +## Customising the marks + +The selected and hover fills are plain backgrounds on `.cl-profile-nav-item[data-selected]` and +`:hover`. The tablist is positioned and isolated so a consumer can hang marks off it: below, the +selected item and the hovered item each publish a CSS anchor name, and the tablist's `::after` and +`::before` follow them through anchor positioning — sliding between destinations, in either layout, +with no script. The rules sit inside `@supports (anchor-name: --probe)`, so browsers without anchors +keep the default fills, and inside a prelude-less `@scope`, which confines a style element's sheet to +its parent. + + + +## Page transitions + +`Page` keeps the tabs primitive's transition contract — `data-hidden` today, `data-starting-style` +/ `data-ending-style` and `--cl-tab-transition-direction` once pages are force-mounted — so a page +transition is a styling change here rather than a new part. diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx new file mode 100644 index 00000000000..8ab6d20906d --- /dev/null +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -0,0 +1,180 @@ +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import type { ProfileRootProps } from '@clerk/ui/mosaic/components/profile'; +import { Profile } from '@clerk/ui/mosaic/components/profile'; +import { Text } from '@clerk/ui/mosaic/components/text'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './profile.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Profile', + layout: 'wide', + source: 'packages/ui/src/mosaic/components/profile/profile.tsx', + styles: { + _variants: { + renderBranding: { true: {}, false: {} }, + }, + _defaultVariants: { + renderBranding: true, + }, + }, +}; + +function knobsAsProps(props: Record) { + return props as unknown as Partial; +} + +const pages = [ + { id: 'general', label: 'General', icon: 'user-circle' as const }, + { id: 'security', label: 'Security', icon: 'shield-check' as const }, + { id: 'billing', label: 'Billing', icon: 'credit-card' as const }, +]; + +function Placeholder({ title }: { title: string }) { + return ( +
+ {title} + Content for the {title.toLowerCase()} page. +
+ ); +} + +function Surface(props: Partial) { + const [page, setPage] = useState('general'); + return ( + + + {pages.map(item => ( + + } + > + {item.label} + + ))} + + + {pages.map(item => ( + + + + ))} + + + ); +} + +export function Default(props: Record) { + return ; +} + +/** + * Sliding selected and hover marks, in CSS alone: the selected item and the hovered item each + * publish an anchor name, and two pseudo-elements of the tablist follow them through CSS anchor + * positioning — no measuring, no script. Both are the neutral colour at a low opacity, so on the + * selected item the hover mark stacks on the selected one and the fill deepens rather than + * changing hue. Where anchors are unsupported the rules inside `@supports` never apply and the + * items keep their own fills. + * + * `@scope` with no prelude scopes the sheet to the style element's parent, so it reaches this + * example and nothing else on the page. Swingset injects the component's styles at a specificity a + * plain rule cannot beat, hence the `!important`s on the two fills; an app importing the layered + * stylesheet does not need them. + */ +export function Customized() { + return ( + <> + + + + ); +} diff --git a/packages/swingset/src/stories/user-page.mdx b/packages/swingset/src/stories/user-profile.mdx similarity index 58% rename from packages/swingset/src/stories/user-page.mdx rename to packages/swingset/src/stories/user-profile.mdx index 8592ea9f463..3b04dd811a9 100644 --- a/packages/swingset/src/stories/user-page.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -1,9 +1,10 @@ -import * as Stories from './user-page.stories'; +import * as Stories from './user-profile.stories'; -# UserPage +# UserProfile -The complete User page. It owns the profile navigation and composes the Account, Security, Billing, -and API Keys panels without imposing a modal height or scroll container. +The complete user profile: a `Profile` whose navigation lists the Account, Security, Billing, and +API Keys pages it was given content for, then any pages of the consumer's own, in the order asked +for. Rendered inside a `profile` dialog it names it, carries its dismiss, and fills its height. ('account'); + const [activePage, setActivePage] = useState('account'); const [emails, setEmails] = useState([ { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, @@ -99,7 +98,7 @@ export function Default() { [apiKeys, searchValue], ); - const panels: UserPageViewProps['panels'] = { + const pages: UserProfileViewProps['pages'] = { account: { allowMultipleAccounts: true, imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', @@ -242,10 +241,10 @@ export function Default() { }; return ( - ); } diff --git a/packages/ui/src/mosaic/components/branding/branding.styles.ts b/packages/ui/src/mosaic/components/branding/branding.styles.ts new file mode 100644 index 00000000000..41e2afd7b7e --- /dev/null +++ b/packages/ui/src/mosaic/components/branding/branding.styles.ts @@ -0,0 +1,22 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + // The mark only: text and link. Where it sits (a card's foot, a sidebar's) is the host's call. + base: { + color: colorVars['--cl-color-neutral-faded'], + display: 'inline-block', + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + textWrap: 'pretty', + }, + link: { + borderRadius: radiusVars['--cl-radius-sm'], + alignItems: 'center', + color: 'inherit', + display: 'inline-flex', + verticalAlign: 'top', + height: space['4'], + }, +}); diff --git a/packages/ui/src/mosaic/components/branding/branding.test.tsx b/packages/ui/src/mosaic/components/branding/branding.test.tsx new file mode 100644 index 00000000000..97e2019a04f --- /dev/null +++ b/packages/ui/src/mosaic/components/branding/branding.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Branding } from './branding'; + +describe('Branding', () => { + // The logo names the link, so the mark is what a screen reader reaches rather than an unnamed link. + it('signs with Clerk, in a tab of its own', () => { + render(); + + expect(screen.getByTestId('branding')).toHaveTextContent('Secured by'); + expect(screen.getByTestId('branding')).toHaveClass('cl-branding'); + const logo = screen.getByRole('link', { name: 'Clerk' }); + expect(logo).toHaveClass('cl-branding-link'); + expect(logo).toHaveAttribute('href', 'https://go.clerk.com/components'); + expect(logo).toHaveAttribute('target', '_blank'); + expect(logo).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); diff --git a/packages/ui/src/mosaic/components/branding/branding.tsx b/packages/ui/src/mosaic/components/branding/branding.tsx new file mode 100644 index 00000000000..25d0fd76249 --- /dev/null +++ b/packages/ui/src/mosaic/components/branding/branding.tsx @@ -0,0 +1,48 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../utils/focus-outline.styles'; +import { reset } from '../../utils/reset.styles'; +import { ClerkLogo } from '../clerk-logo'; +import { styles } from './branding.styles'; + +export type BrandingProps = Omit, 'children'>; + +/** + * "Secured by Clerk". The one mark every branded surface signs with, so `Card` and `Profile` read + * the same and an instance that has paid the branding off drops it in one place: the host's + * `renderBranding`. The logo names the link, so a screen reader reaches "Clerk", not an unnamed link. + */ +export const Branding = React.forwardRef(function Branding( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps(themeProps('branding'), stylex.props(reset.base, styles.base), className, style), + ...rest, + children: ( + <> + Secured by{' '} + + + + + ), + }, + }); +}); diff --git a/packages/ui/src/mosaic/components/branding/index.ts b/packages/ui/src/mosaic/components/branding/index.ts new file mode 100644 index 00000000000..47480e9c162 --- /dev/null +++ b/packages/ui/src/mosaic/components/branding/index.ts @@ -0,0 +1,2 @@ +export { Branding } from './branding'; +export type { BrandingProps } from './branding'; diff --git a/packages/ui/src/mosaic/components/card/card.styles.ts b/packages/ui/src/mosaic/components/card/card.styles.ts index 47d2e044add..625b9687ca7 100644 --- a/packages/ui/src/mosaic/components/card/card.styles.ts +++ b/packages/ui/src/mosaic/components/card/card.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { colorVars, fontWeightVars, radiusVars, shadowVars, space, typeScaleVars } from '../../tokens.stylex'; import { cardContentMarker } from './card.markers.stylex'; export const root = stylex.create({ @@ -15,9 +15,7 @@ export const root = stylex.create({ borderRadius: radiusVars['--cl-radius-xl'], overflow: 'hidden', backgroundColor: colorVars['--cl-color-card'], - boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), - 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), - 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + boxShadow: shadowVars['--cl-shadow-card'], }, flush: { borderRadius: radiusVars['--cl-radius-xl'], @@ -29,9 +27,7 @@ export const root = stylex.create({ borderRadius: radiusVars['--cl-radius-xl'], overflow: 'hidden', backgroundColor: colorVars['--cl-color-card'], - boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), - 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), - 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + boxShadow: shadowVars['--cl-shadow-card'], }, }); @@ -96,6 +92,7 @@ export const footer = stylex.create({ }); export const branding = stylex.create({ + // Placement only; the mark itself is `Branding`. base: { paddingBlock: space['3'], paddingInline: space['6'], @@ -104,19 +101,4 @@ export const branding = stylex.create({ borderBlockStartWidth: '1px', textAlign: 'center', }, - text: { - color: colorVars['--cl-color-neutral-faded'], - display: 'inline-block', - fontSize: typeScaleVars['--cl-text-xs-size'], - lineHeight: typeScaleVars['--cl-text-xs-leading'], - textWrap: 'pretty', - }, - link: { - borderRadius: radiusVars['--cl-radius-sm'], - alignItems: 'center', - color: 'inherit', - display: 'inline-flex', - verticalAlign: 'top', - height: space['4'], - }, }); diff --git a/packages/ui/src/mosaic/components/card/card.test.tsx b/packages/ui/src/mosaic/components/card/card.test.tsx index 9bad722b9da..34fc5277948 100644 --- a/packages/ui/src/mosaic/components/card/card.test.tsx +++ b/packages/ui/src/mosaic/components/card/card.test.tsx @@ -118,8 +118,7 @@ describe('Mosaic Card', () => { , ); - // The mark closes the card out. Held by position rather than by a class: the branding - // carries no slot for a consumer to reach, so a test has none to reach for either. + // The mark closes the card out. const branding = screen.getByTestId('root').lastElementChild; expect(branding).toHaveTextContent('Secured by'); diff --git a/packages/ui/src/mosaic/components/card/card.tsx b/packages/ui/src/mosaic/components/card/card.tsx index 6083bca0718..e51d2fef56d 100644 --- a/packages/ui/src/mosaic/components/card/card.tsx +++ b/packages/ui/src/mosaic/components/card/card.tsx @@ -4,10 +4,9 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; -import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; +import { Branding } from '../branding'; import { Button } from '../button'; -import { ClerkLogo } from '../clerk-logo'; import { Dialog, DialogContext } from '../dialog'; import { Icon } from '../icon'; import { cardContentMarker } from './card.markers.stylex'; @@ -19,20 +18,10 @@ const DEFAULT_ELEVATION: CardElevation = 'card'; const CardElevationContext = React.createContext(DEFAULT_ELEVATION); -function Branding() { +function CardBranding() { return (
- - Secured by{' '} - - - - +
); } @@ -68,7 +57,7 @@ const Root = React.forwardRef(function CardRoot( children: ( <> {children} - {renderBranding ? : null} + {renderBranding ? : null} ), }, diff --git a/packages/ui/src/mosaic/components/dialog/alert-dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/alert-dialog.test.tsx index ebc93f4df4a..a6f6272aa34 100644 --- a/packages/ui/src/mosaic/components/dialog/alert-dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/alert-dialog.test.tsx @@ -75,7 +75,7 @@ describe('role="alertdialog"', () => { defaultOpen role='alertdialog' > - + Discard changes? This address has not been saved. @@ -83,7 +83,7 @@ describe('role="alertdialog"', () => { ); expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'prompt'); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="panel"')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="profile"')); warn.mockRestore(); }); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 0d20e81e9f0..b336ee520df 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -37,7 +37,7 @@ export const styles = stylex.create({ // The scrim. Black in both schemes. A grey veil was tried for dark mode — lightening a dark page rather // than darkening it — and it read as haze over the page rather than as a surface lifting off it. // - // A dialog opened over a `panel` or a `card` paints its OWN scrim, lighter than the base because + // A dialog opened over a `profile` or a `card` paints its OWN scrim, lighter than the base because // the two COMPOSITE: alpha over alpha is `1 − (1 − a)(1 − b)`, so the nested value is solved for // the intended total rather than picked by eye — `1 − 0.32/0.6 = 0.4667` lands two levels on // 0.68. That is what separates a surface from the one it was opened from. @@ -65,10 +65,10 @@ export const styles = stylex.create({ * A prompt stacked on a prompt paints NO scrim — one serves the whole stack. * * The two cases are different relationships, not one at two strengths. A prompt opened over a - * panel is a new surface over a page-like one, and a scrim of its own is what says so. A prompt + * profile is a new surface over a page-like one, and a scrim of its own is what says so. A prompt * over a prompt is the same conversation continuing one step further in, and darkening the page * again for it makes depth a function of stack count: the composite compounds, so the - * three-deep `panel -> prompt -> alert` this exists for would land on 0.83 against the 0.68 the + * three-deep `profile -> prompt -> alert` this exists for would land on 0.83 against the 0.68 the * nested value above was solved for. The stack reads through the surface beneath receding and * dimming instead. * @@ -146,7 +146,7 @@ export const styles = stylex.create({ // so it is inert until `acquireKeyboardInset` has something to report. paddingBlockEnd: 'calc(var(--_cl-dialog-inset) + var(--_cl-keyboard-inset, 0px))', // A grid item's automatic minimum would otherwise hold this to its content and defeat the - // definite row `viewportSizes.panel` pins. + // definite row `viewportSizes.profile` pins. minHeight: 0, width: '100%', }, @@ -163,7 +163,7 @@ export const styles = stylex.create({ paddingInline: 0, }, - // The dialog surface. Unlike `Popover`, this one paints, because a `prompt` and a `panel` take + // The dialog surface. Unlike `Popover`, this one paints, because a `prompt` and a `profile` take // raw content rather than a `Card` and the surface has to come from somewhere. `sizes.card` // nulls the painting properties back out — see the note there. popup: { @@ -187,7 +187,7 @@ export const styles = stylex.create({ * stacked dialog holds focus, and `pointer-events: none` keeps it that way regardless. * * The variable itself is set per size — only `prompt` sets it, in `sizes` below — so this - * reads `0` on a `panel` or a `card`, which have a scrim of their own to separate them from + * reads `0` on a `profile` or a `card`, which have a scrim of their own to separate them from * what they host and would double up. */ padding: space['6'], @@ -260,9 +260,9 @@ export const styles = stylex.create({ * participates in the column's `gap` and a consumer can render it anywhere in the children * without the layout moving. * - * It stays put on a `panel` because the popup itself never scrolls — see `sizes.panel`. An + * It stays put on a `profile` because the popup itself never scrolls — see `sizes.profile`. An * absolutely positioned child of a scroll container scrolls away with the content, so the - * scroll region has to live in the panel's children, not on the popup. + * scroll region has to live in the profile's children, not on the popup. * * Carried by a wrapper rather than by the button itself: `Button`'s touch target sets * `position` inside a media query, which compiles to a class the button's own `stylex.props` @@ -293,12 +293,12 @@ export const styles = stylex.create({ * 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 * inset runs that much shy of the distance the eye should read (`4` for prompt/card, `4.5` for - * panel). The hit target hangs past the icon toward the corner, which only helps. + * profile). The hit target hangs past the icon toward the corner, which only helps. */ export const closeInsets = stylex.create({ prompt: { insetBlockStart: space['2.5'], insetInlineEnd: space['2.5'] }, card: { insetBlockStart: space['2.5'], insetInlineEnd: space['2.5'] }, - panel: { insetBlockStart: space['3'], insetInlineEnd: space['3'] }, + profile: { insetBlockStart: space['3'], insetInlineEnd: space['3'] }, }); /** @@ -308,12 +308,12 @@ export const closeInsets = stylex.create({ * * `prompt` asks one thing and returns: a confirmation, or a single-field form like "add an email * address". `card` is the sign-in / sign-up surface, and matches the width of the legacy card - * (`theme.sizes.$100`). `panel` is the account-profile and settings surface, which you navigate. + * (`theme.sizes.$100`). `profile` is the account-profile and settings surface, which you navigate. * * `card` sets only `max-width`; the popup is `width: 100%` and its height is whatever the * content needs, which is right for a confirmation or a two-field form. * - * `panel` fixes the height. Its content NAVIGATES — a settings surface switches sections in + * `profile` fixes the height. Its content NAVIGATES — a settings surface switches sections in * place — and a content-driven height would resize the window on every section change, in both * directions at once since the viewport centres it. Its width is the surface's own. */ @@ -322,7 +322,7 @@ export const closeInsets = stylex.create({ * "outside scroll" split, decided by size rather than by a prop because it follows from what each * surface already is. * - * A `panel` is a fixed-height window you navigate inside, so it scrolls INSIDE: the viewport stays + * A `profile` is a fixed-height window you navigate inside, so it scrolls INSIDE: the viewport stays * pinned to the overlay and the surface scrolls its own region. A `prompt` and a `card` take their * height from their content and have no obvious region to scroll, so they scroll OUTSIDE: the * whole dialog moves within the overlay. @@ -337,9 +337,9 @@ export const closeInsets = stylex.create({ export const viewportSizes = stylex.create({ prompt: { minHeight: '100%' }, card: { minHeight: '100%' }, - panel: { + 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 panel of rows measured 2208px + // 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. // @@ -347,7 +347,7 @@ export const viewportSizes = stylex.create({ // 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 `sizes.panel` fill the content box with + // single grid row definite too. That is what lets `sizes.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. @@ -379,14 +379,27 @@ export const trackSizes = stylex.create({ overflow: { [PHONE]: 'clip', default: null }, }, card: {}, - // Same definite row as the viewport's, one level down, so the popup's `stretch` lands on it. - panel: { gridTemplateRows: 'minmax(0, 1fr)' }, + profile: { + // Under the phone band a profile takes the whole screen: it is the page there, not a surface + // over one, and the frame it would float in is the surface's own. Both the var and the one + // longhand that reads it are restated in full — StyleX replaces a property's declaration + // wholesale, so the ladder from `styles.track` cannot be extended, only rewritten. + '--_cl-dialog-inset': { + [DESK]: space['8'], + [PHONE]: '0px', + [WIDE]: space['12'], + default: space['5'], + }, + paddingInline: { [ABOVE_PHONE]: 'var(--_cl-dialog-inset)', [PHONE]: 0, default: space['4'] }, + // Same definite row as the viewport's, one level down, so the popup's `stretch` lands on it. + gridTemplateRows: 'minmax(0, 1fr)', + }, }); export const sizes = stylex.create({ prompt: { // Read by the veil on `styles.popup`. Set here rather than there so it applies to `prompt` - // alone: a `panel` or a `card` hosting a dialog gets a scrim between the two instead, and + // alone: a `profile` or a `card` hosting a dialog gets a scrim between the two instead, and // would otherwise dim as well as darken. '--_cl-stack-veil': { default: 0, ':where([data-stack-base])': STACK_VEIL_OPACITY }, // Tighter than the popup's default 1.5rem. A prompt asks one thing, so its content box is @@ -431,40 +444,40 @@ export const sizes = stylex.create({ maxWidth: '25rem', }, /** - * Like `card`, the panel does NOT paint itself. It is the account-profile and settings surface, - * which is a `ProfilePage` — so the frame comes from `ProfilePage.Root`'s own styles and the - * popup contributes geometry and motion only. Compose it by rendering the page INSIDE the popup: + * Like `card`, the profile does NOT paint itself. It is the account-profile and settings surface, + * which is a `Profile` — so the frame comes from `Profile.Root`'s own styles and the popup + * contributes geometry and motion only. Compose it by rendering the profile INSIDE the popup: * - * + * * - * The page reads `DialogContext` from there — it names the dialog, carries its dismiss, and + * The profile reads `DialogContext` from there — it names the dialog, carries its dismiss, and * fills the popup's height — and that is also what makes `inline` a non-event for the surface: * modal or in a page slot, the page paints itself the same way, and the dialog only decides * where it sits. The `null`s remove the popup's own atoms outright — see the note on `card`. * The width cap matches the page's, the way `card` matches the `Card`. * - * Consequence worth knowing: `size="panel"` with no surface inside renders an unpainted box. + * Consequence worth knowing: `size="profile"` with no surface inside renders an unpainted box. */ - panel: { + profile: { padding: null, borderColor: null, borderRadius: null, borderStyle: null, borderWidth: null, gap: null, - // The panel does NOT scroll itself, and that is the whole design. A fixed-height surface + // 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: `ProfilePage` scrolls its content column. + // 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 stretches // to the popup's width and grows to its height. // // `clip` rather than `hidden` for the same reason as the viewport: `hidden` would make the - // panel a scroll container, and focusing anything inside it that sits outside its box would - // scroll the panel itself. + // 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 panel's edges on exactly the + // is definite (see `styles.viewport`), so `stretch` lands the profile's edges on exactly the // lines a bottom-anchored `prompt` sheet reaches with `align-self: end`, and clamps to them. alignSelf: 'stretch', backgroundColor: null, @@ -476,10 +489,10 @@ export const sizes = stylex.create({ /** * Enter/exit motion, keyed by size, because the two surfaces want opposite things. * - * `card` scales from its centre. `panel` fades without scaling — it is most of the + * `card` scales from its centre. `profile` fades without scaling — it is most of the * viewport, and the larger a surface is the worse a scale reads on it: the absolute travel * is `(1 − scale) ×` its own dimensions, so the same 2% that is a few pixels on a card is - * tens of pixels on a panel, and it arrives as a zoom rather than an emergence. + * tens of pixels on a profile, and it arrives as a zoom rather than an emergence. * * Both maps are keyed by SIZE rather than by a shared "animated" cell. StyleX dedupes by * PROPERTY across a `stylex.props` call, so a thin "mobile only" atom declaring `transform` would @@ -490,7 +503,7 @@ export const sizes = stylex.create({ * headless transition watches the POPUP's animations to decide when to unmount, and the * whole subtree goes at once — so a backdrop that outlives its popup gets cut off * mid-fade. Every size therefore fades its scrim over the same duration its popup runs for, - * `panel` included — which is why `popupMotion.panel` fades rather than being left inert. + * `profile` included — which is why `popupMotion.profile` fades rather than being left inert. */ export const backdropMotion = stylex.create({ /** @@ -534,7 +547,7 @@ export const backdropMotion = stylex.create({ }, /** Identical to `card` — the popup it accompanies fades on the same clock, it just does not scale. */ - panel: { + profile: { opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0, @@ -565,7 +578,7 @@ const SHEET_EXIT_EASE = 'ease-out'; // everything else, so a surface at 0.94 draws its corners at 94% of their value for the length of // the transition — about 0.77px on a 12px radius. An earlier version cancelled that by dividing // the popup's radius by the same factor, which only reaches corners the POPUP paints: since a -// `card` and a `panel` are painted by the surface inside, the correction had stopped reaching the +// `card` and a `profile` are painted by the surface inside, the correction had stopped reaching the // corners that matter and was dropped rather than pushed into every surface's API. If it comes // back, it should come back self-contained — the popup publishing its current scale as a custom // property a surface can read to counter its own radius — not as a composition rule. @@ -679,9 +692,9 @@ export const popupMotion = stylex.create({ // read as one muddy one. There is already a surface there, so the fade has nothing left to do // and the slide can carry the arrival alone. // - // Keyed on `data-stacked` — over any open dialog, panel included — rather than on the narrower + // Keyed on `data-stacked` — over any open dialog, profile included — rather than on the narrower // prompt-on-prompt stack the backdrop cares about. What makes the long fade wrong here is - // arriving over something opaque, and a panel is as opaque as a prompt. + // arriving over something opaque, and a profile is as opaque as a prompt. // // The combined exiting branch restates `base` because `@stylexjs/sort-keys` puts it after the // plain `data-stacked` one, which would otherwise hand a stacked sheet the three-value entrance @@ -776,12 +789,12 @@ export const popupMotion = stylex.create({ * Fade only, no scale — see the note above this map for why a surface this size should not * scale. The fade is not optional the way an inert cell would be: the headless transition * watches the POPUP to decide when to unmount, so with nothing running here the whole subtree, - * scrim included, is pulled on close before `backdropMotion.panel` can fade. + * scrim included, is pulled on close before `backdropMotion.profile` can fade. * * 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. */ - panel: { + profile: { opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0, diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 5f8575ef7ac..f64848c53b0 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -73,12 +73,12 @@ describe('Mosaic Dialog', () => { it('reflects an explicit size as data-size on the popup and the viewport', () => { render( - Body + Body , ); - expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'panel'); - expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-size', 'panel'); + expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'profile'); + expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-size', 'profile'); }); it('merges consumer className and style onto the popup', () => { @@ -142,7 +142,7 @@ describe('Mosaic Dialog', () => { }); }); -// A `panel` dialog (account profile) opening a `prompt` dialog (add an email address) is a real +// A `profile` dialog (account profile) opening a `prompt` dialog (add an email address) is a real // shape, so the `FloatingTree` nesting the headless README claims is exercised here rather than // assumed. Dismissal must reach the topmost dialog only, and the body must stay locked until the // last one closes. @@ -150,7 +150,7 @@ describe('nested Mosaic Dialogs', () => { function Nested({ innerSize }: { innerSize?: DialogSize } = {}) { return ( - + Account
Outer body
@@ -218,28 +218,31 @@ describe('nested Mosaic Dialogs', () => { expect(document.body.style.overflow).toBe(''); }); - it('warns when a panel opens inside another dialog', async () => { + it('warns when a profile opens inside another dialog', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: 'Add email' })); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="panel"')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="profile"')); warn.mockRestore(); }); - // A card over a panel is the delete-account confirmation: a `Card` inside a `card` dialog. - it.each(['prompt', 'card'] as const)('does not warn for a %s over a panel, or for the panel itself', async size => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const user = userEvent.setup(); - render(); + // A card over a profile is the delete-account confirmation: a `Card` inside a `card` dialog. + it.each(['prompt', 'card'] as const)( + 'does not warn for a %s over a profile, or for the profile itself', + async size => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const user = userEvent.setup(); + render(); - await user.click(screen.getByRole('button', { name: 'Add email' })); + await user.click(screen.getByRole('button', { name: 'Add email' })); - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); - }); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }, + ); }); describe('stacked backdrops', () => { @@ -272,24 +275,24 @@ describe('stacked backdrops', () => { return className; } - it('drops the scrim for a prompt over a prompt, and keeps it for one over a panel', async () => { + it('drops the scrim for a prompt over a prompt, and keeps it for one over a profile', async () => { const overPrompt = await innerBackdropClass({ size: 'prompt' }); - const overPanel = await innerBackdropClass({ size: 'panel' }); + const overPanel = await innerBackdropClass({ size: 'profile' }); expect(overPrompt).not.toBe(overPanel); }); - it('keeps a prompt over a card on the nested scrim, same as over a panel', async () => { + it('keeps a prompt over a card on the nested scrim, same as over a profile', async () => { const overCard = await innerBackdropClass({ size: 'card' }); - const overPanel = await innerBackdropClass({ size: 'panel' }); + const overPanel = await innerBackdropClass({ size: 'profile' }); expect(overCard).toBe(overPanel); }); // The nested scrim is solved to composite over the host's own, and an inline host has none. - it('paints the base scrim, not the nested one, for a prompt over an inline panel', async () => { - const overInline = await innerBackdropClass({ size: 'panel', inline: true }); - const overPanel = await innerBackdropClass({ size: 'panel' }); + it('paints the base scrim, not the nested one, for a prompt over an inline profile', async () => { + const overInline = await innerBackdropClass({ size: 'profile', inline: true }); + const overPanel = await innerBackdropClass({ size: 'profile' }); expect(overInline).not.toBe(overPanel); }); @@ -298,7 +301,7 @@ describe('stacked backdrops', () => { const user = userEvent.setup(); render( - + Account @@ -498,11 +501,11 @@ describe('popup padding', () => { expect(prompt).not.toEqual(expect.arrayContaining(atomFor(probe.six))); }); - // A `card` takes its padding from the `Card` rendered as the popup, and a `panel` from the - // `ProfilePage`, so the popup must emit NO padding atom at all — a competing value would put + // A `card` takes its padding from the `Card` rendered as the popup, and a `profile` from the + // `Profile`, so the popup must emit NO padding atom at all — a competing value would put // two atoms for the same property on the element, and StyleX cannot dedupe across the two // `stylex.props` calls involved. - it.each(['card', 'panel'] as const)('emits no padding at all for a %s, deferring to its surface', size => { + it.each(['card', 'profile'] as const)('emits no padding at all for a %s, deferring to its surface', size => { const classes = popupClassesFor(size); for (const value of [probe.zero, probe.four, probe.six]) { @@ -512,7 +515,7 @@ describe('popup padding', () => { }); describe('popup surface', () => { - // `card` and `panel` are painted by what renders as the popup, so the popup itself must emit + // `card` and `profile` are painted by what renders as the popup, so the popup itself must emit // no paint of its own — the same cross-call dedupe problem as the padding above. StyleX names // an atom from its property and value, so a probe with the popup's own values yields the very // atoms `styles.popup` declares. @@ -528,13 +531,13 @@ describe('popup surface', () => { expect(classesOf('.cl-dialog-popup')).toEqual(expect.arrayContaining(atomFor(probe.radius))); }); - it.each(['card', 'panel'] as const)('emits no background for a %s, deferring to its surface', size => { + it.each(['card', 'profile'] as const)('emits no background for a %s, deferring to its surface', size => { renderSize(size); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.background))); }); - it.each(['card', 'panel'] as const)('leaves the radius to the surface for a %s', size => { + it.each(['card', 'profile'] as const)('leaves the radius to the surface for a %s', size => { renderSize(size); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.radius))); @@ -566,8 +569,8 @@ describe('viewport scroll behaviour', () => { expect(viewport).not.toEqual(expect.arrayContaining(atomFor(probe.fixed))); }); - it('pins the viewport for a panel, which scrolls inside instead', () => { - const viewport = viewportClassesFor('panel'); + it('pins the viewport for a profile, which scrolls inside instead', () => { + const viewport = viewportClassesFor('profile'); expect(viewport).toEqual(expect.arrayContaining(atomFor(probe.fixed))); expect(viewport).not.toEqual(expect.arrayContaining(atomFor(probe.grows))); @@ -603,7 +606,7 @@ describe('sizing container', () => { }); it('keeps the container inline, where the host width is what the bands should follow', () => { - renderSize('panel', true); + renderSize('profile', true); expect(classesOf('.cl-dialog-viewport')).toEqual(expect.arrayContaining(atomFor(probe.container))); }); @@ -617,7 +620,7 @@ describe('inline presentation', () => { inline onOpenChange={onOpenChange} > - + Account @@ -675,7 +678,7 @@ describe('inline presentation', () => { it('drops the inset so the surface fills its host', () => { const probe = stylex.create({ flush: { paddingInline: 0 } }); - renderSize('panel', true); + renderSize('profile', true); expect(classesOf('.cl-dialog-track')).toEqual(expect.arrayContaining(atomFor(probe.flush))); }); @@ -684,7 +687,7 @@ describe('inline presentation', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - + Account @@ -696,14 +699,14 @@ describe('inline presentation', () => { warn.mockRestore(); }); - // The shape the account profile takes when mounted in a page: the panel is the page, and the + // The shape the account profile takes when mounted in a page: the profile is the page, and the // prompts it opens are modal over everything. it('still portals and dismisses a dialog opened from inside it', async () => { const user = userEvent.setup(); render(
- + Account diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index 2959ff4fde7..ef1e5fbb044 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -22,7 +22,7 @@ import { type ConfirmHandle, createConfirmHandle } from './confirm-handle'; import { backdropMotion, closeInsets, popupMotion, sizes, styles, trackSizes, viewportSizes } from './dialog.styles'; import { acquireKeyboardInset } from './keyboard-inset'; -/** Width of the dialog surface, and for `panel` its height too. */ +/** Width of the dialog surface, and for `profile` its height too. */ export type DialogSize = keyof typeof sizes; /** @@ -38,14 +38,14 @@ export type DialogSize = keyof typeof sizes; * * It is also how a dialog learns about the one it renders inside: `Dialog.Popup` reads it before * publishing its own, and that is what decides whether two dialogs form a STACK — successive - * prompts — or a nested dialog over a `panel` or `card`. The two want opposite backdrops. + * prompts — or a nested dialog over a `profile` or `card`. The two want opposite backdrops. */ export interface DialogContextValue { /** Id the popup points `aria-labelledby` at. The part that names the dialog takes it. */ labelId: string; /** Id the popup points `aria-describedby` at. The part that describes the dialog takes it. */ descriptionId: string; - /** Width, and for `panel` also height, of the surface. */ + /** Width, and for `profile` also height, of the surface. */ size: DialogSize; /** Whether the surface is presented in its host rather than over the page — see `Dialog.Root`. */ inline: boolean; @@ -94,7 +94,7 @@ export type DialogActionsProps = MosaicComponentProps<'div'>; export interface DialogPopupProps extends MosaicComponentProps<'div'> { /** - * Width, and for `panel` also height, of the dialog surface. Ignored under + * Width, and for `profile` also height, of the dialog surface. Ignored under * `role="alertdialog"`, which is always a `prompt`. @default 'prompt' */ size?: DialogSize; @@ -112,7 +112,7 @@ type DialogRootBaseProps = Omit, 'role' | /** * Presents the dialog in its host rather than over the page: no portal, no scrim, no scroll * lock, no focus trap, and nothing dismisses it — it is open for as long as it is mounted. - * For a surface that is the page's content, such as an account panel mounted in a layout slot. + * For a surface that is the page's content, such as an account profile mounted in a layout slot. * * Implies `open`, `modal={false}` and `closedBy='none'`; those props are ignored. A dialog * opened from inside an inline one presents normally, over the page. @@ -339,20 +339,20 @@ function Viewport({ size, inline, children }: { size: DialogSize; inline: boolea } /** - * Warns when a `panel` opens inside another dialog. + * Warns when a `profile` opens inside another dialog. * - * A `panel` is a root-level surface: it hosts what opens over it and is never the thing that + * A `profile` is a root-level surface: it hosts what opens over it and is never the thing that * opens. Inside a dialog it renders at a size that assumes it owns the viewport, over a surface it * was meant to replace. A `prompt` or a `card` — a confirmation holding a `Card`, say — is what - * opens over a panel, and either is fine. + * opens over a profile, and either is fine. */ function useNestedSizeWarning(isNestedInDialog: boolean, size: DialogSize) { React.useEffect(() => { - if (process.env.NODE_ENV === 'production' || !isNestedInDialog || size !== 'panel') { + if (process.env.NODE_ENV === 'production' || !isNestedInDialog || size !== 'profile') { return; } console.warn( - '[clerk] a size="panel" Dialog opened inside another Dialog. A panel is a root-level surface that hosts what opens over it; open a prompt or a card instead.', + '[clerk] a size="profile" Dialog opened inside another Dialog. A profile is a root-level surface that hosts what opens over it; open a prompt or a card instead.', ); }, [isNestedInDialog, size]); } diff --git a/packages/ui/src/mosaic/components/dialog/keyboard-inset.ts b/packages/ui/src/mosaic/components/dialog/keyboard-inset.ts index 3f10c2cde48..0aec23bbd01 100644 --- a/packages/ui/src/mosaic/components/dialog/keyboard-inset.ts +++ b/packages/ui/src/mosaic/components/dialog/keyboard-inset.ts @@ -13,7 +13,7 @@ * - `prompt` is `align-self: end`, so it rises to sit exactly on top of the keyboard. * - `card` is centred, so it re-centres in the space that is left — it moves up, and its height is * still driven by its content, so nothing is squashed. - * - `panel` is `align-self: stretch`, so it shrinks — which is right for the one surface that + * - `profile` is `align-self: stretch`, so it shrinks — which is right for the one surface that * already composes its own scroll region. * * And `place-items: safe center` on the viewport means a card taller than the remaining space diff --git a/packages/ui/src/mosaic/components/drawer/drawer.styles.ts b/packages/ui/src/mosaic/components/drawer/drawer.styles.ts new file mode 100644 index 00000000000..46533c54e10 --- /dev/null +++ b/packages/ui/src/mosaic/components/drawer/drawer.styles.ts @@ -0,0 +1,152 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, easingVars, radiusVars, space } from '../../tokens.stylex'; + +// The dialog's scrim, and the nested value a dialog paints over a `profile` or a `card` — see +// `dialog.styles.ts` for the composite arithmetic. A sheet opening from inside a profile dialog is +// the same relationship as a prompt opening there, and takes the same scrim. +const BASE_SCRIM = 'color-mix(in oklab, oklch(0 0 0) 40%, transparent)'; +const NESTED_SCRIM = 'color-mix(in oklab, oklch(0 0 0) 46.67%, transparent)'; + +/** The live drag delta and the resting snap offset the headless layer writes; the sheet rides both. */ +const SWIPE = 'var(--cl-drawer-swipe-movement-y, 0px)'; +const SNAP = 'var(--cl-drawer-snap-point-offset, 0px)'; +/** 0..1 dismiss progress of a drag; the scrim thins with it. */ +const PROGRESS = 'var(--cl-drawer-swipe-progress, 0)'; +/** 0.1..1 from release velocity; a flick leaves faster than a slow drag. */ +const STRENGTH = 'var(--cl-drawer-swipe-strength, 1)'; +/** + * How far the sheet extends below the screen. A drag past the open position rubber-bands the sheet + * upward, and without this the scrim would show beneath its bottom edge. The extra is padding, + * pulled back off-screen by the matching negative margin, so the content still ends its usual + * distance above the visible edge. + */ +const BLEED = space['24']; + +export const styles = stylex.create({ + backdrop: { + inset: 0, + backgroundColor: BASE_SCRIM, + opacity: { + default: 1, + ':where([data-starting-style], [data-ending-style])': 0, + ':where([data-swiping])': `calc(1 - ${PROGRESS})`, + }, + position: 'fixed', + // The scrim answers the tap: it lands first, and the sheet arrives into a dimmed page. While a + // drag is in progress it follows the finger instead, with no easing in the way. + transitionDuration: { + default: durationVars['--cl-duration-fast'], + ':where([data-swiping])': '0s', + }, + transitionProperty: 'opacity', + transitionTimingFunction: 'linear', + }, + + backdropNested: { + backgroundColor: NESTED_SCRIM, + }, + + /** + * The fixed box the sheet is aligned in: bottom edge, full width. The headless viewport is the + * `FloatingOverlay` that owns the scroll lock; this only lays the popup out inside it. + */ + viewport: { + inset: 0, + // The sheet bleeds below the box and enters from below it; neither may make the box scroll. + overflow: 'clip', + alignItems: 'end', + display: 'grid', + justifyItems: 'stretch', + position: 'fixed', + }, + + /** + * The sheet. The prompt's surface — same background, shadow ring and padding — flush with the + * sides and the bottom, rounded at the top only, and never wider than the screen. + * + * It moves on `translate`, composed from what the headless layer writes: the resting snap offset + * plus the live drag delta. Closed, it sits a full height below the box — completely out of view + * — and slides up on open. During a drag the transition is off so it follows the finger 1:1; on + * release the exit is scaled by the flick's strength, so a decisive swipe leaves faster than a + * slow drag past the threshold. + */ + popup: { + borderColor: { default: null, '@media (forced-colors: active)': 'CanvasText' }, + borderStyle: { default: null, '@media (forced-colors: active)': 'solid' }, + borderWidth: { default: null, '@media (forced-colors: active)': '1px' }, + outline: 'none', + overscrollBehavior: 'contain', + backgroundColor: colorVars['--cl-color-card'], + borderStartEndRadius: radiusVars['--cl-radius-2xl'], + borderStartStartRadius: radiusVars['--cl-radius-2xl'], + // No drop shadow — the sheet sits on the screen edge, so there is nothing for it to float over. + // The hairline ring stays: a faint dark edge in light, and the light edge that separates a dark + // sheet from a dark page. + boxShadow: `0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + flexDirection: 'column', + marginBlockEnd: `calc(-1 * ${BLEED})`, + // Tall content scrolls inside the sheet; the drag engine yields to inner scroll away from the + // top, so the two do not fight over the same gesture. + maxBlockSize: `calc(100% - ${space['12']} + ${BLEED})`, + overflowWrap: 'anywhere', + // The bottom keeps clear of the home indicator, and carries the bleed. Inline padding belongs to + // `content`, so the grip can span the sheet's own width. + paddingBlockEnd: `calc(env(safe-area-inset-bottom, 0px) + ${BLEED})`, + transitionDuration: { + default: durationVars['--cl-duration-slow'], + ':where([data-ending-style])': `calc(${durationVars['--cl-duration-base']} * ${STRENGTH})`, + ':where([data-swiping])': '0s', + }, + transitionProperty: { + default: 'translate', + '@media (prefers-reduced-motion: reduce)': 'none', + }, + // A surface this size should land rather than settle on the way in; on the way out the plain + // `ease-out` the prompt's sheet uses, for the same reason — over a full height, the exit + // curve's slow start reads as lag. + transitionTimingFunction: { + default: easingVars['--cl-ease-enter'], + ':where([data-ending-style])': 'ease-out', + }, + // Never above the bleed: whatever the drag engine hands over, the scrim cannot show beneath. + translate: { + default: `0 max(calc(${SNAP} + ${SWIPE}), calc(-1 * ${BLEED}))`, + ':where([data-starting-style], [data-ending-style])': '0 100%', + }, + // A mouse drag that crosses text would otherwise select it, and the engine will not start a + // drag while a selection stands inside the sheet — every pull after that would feel dead until + // a click cleared it. `data-swiping` lands on pointerdown, before the first move. + userSelect: { + default: null, + ':where([data-swiping])': 'none', + }, + overflowY: 'auto', + }, + + /** The drag affordance: a short pill, centred, with a hit area taller than it looks. */ + handle: { + placeItems: 'center', + display: 'grid', + flexShrink: 0, + paddingBlockEnd: space['2.5'], + paddingBlockStart: space['3'], + userSelect: 'none', + }, + grip: { + borderRadius: radiusVars['--cl-radius-full'], + backgroundColor: colorVars['--cl-color-border'], + blockSize: space['1.5'], + inlineSize: space['11.5'], + }, + + /** The sheet's content, under the grip. */ + content: { + padding: space['4'], + gap: space['3'], + display: 'flex', + flexDirection: 'column', + }, +}); diff --git a/packages/ui/src/mosaic/components/drawer/drawer.test.tsx b/packages/ui/src/mosaic/components/drawer/drawer.test.tsx new file mode 100644 index 00000000000..b83bc6f7de0 --- /dev/null +++ b/packages/ui/src/mosaic/components/drawer/drawer.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { Dialog } from '../dialog'; +import { Drawer } from './drawer'; + +function Sheet({ defaultOpen = true }: { defaultOpen?: boolean }) { + return ( + + Open + + Filters + Narrow the list. + Done + + + ); +} + +describe('Drawer', () => { + it('renders a named, described sheet with a grip, over a scrim', () => { + render( + + + , + ); + + const sheet = screen.getByRole('dialog', { name: 'Filters' }); + expect(sheet).toHaveAccessibleDescription('Narrow the list.'); + expect(sheet).toHaveClass('cl-drawer-popup'); + expect(sheet.querySelector('[data-drawer-handle]')).toHaveClass('cl-drawer-handle'); + expect(sheet.querySelector('.cl-drawer-grip')).toBeInTheDocument(); + expect(document.querySelector('.cl-drawer-backdrop')).not.toHaveAttribute('data-nested'); + expect(document.querySelector('.cl-drawer-viewport')).toContainElement(sheet); + }); + + it('opens from its trigger and closes from inside', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Done' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + // A sheet opened from inside a profile dialog is the same relationship as a prompt opened there, + // and takes the same, nested scrim. + it('takes the nested scrim inside a modal dialog, and the base one inside an inline dialog', () => { + const modal = render( + + + + + + + , + ); + expect(document.querySelector('.cl-drawer-backdrop')).toHaveAttribute('data-nested'); + modal.unmount(); + + render( + + + + + + + , + ); + expect(document.querySelector('.cl-drawer-backdrop')).not.toHaveAttribute('data-nested'); + }); +}); diff --git a/packages/ui/src/mosaic/components/drawer/drawer.tsx b/packages/ui/src/mosaic/components/drawer/drawer.tsx new file mode 100644 index 00000000000..0ac108ba02c --- /dev/null +++ b/packages/ui/src/mosaic/components/drawer/drawer.tsx @@ -0,0 +1,115 @@ +import type { DrawerProps as HeadlessDrawerProps } from '@clerk/headless/drawer'; +import { Drawer as Primitive, registerDrawerCssVars } from '@clerk/headless/drawer'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { DialogContext } from '../dialog'; +import { styles } from './drawer.styles'; + +export type DrawerRootProps = HeadlessDrawerProps; +export type DrawerTriggerProps = React.ComponentPropsWithoutRef; +export type DrawerCloseProps = React.ComponentPropsWithoutRef; +export type DrawerTitleProps = React.ComponentPropsWithoutRef; +export type DrawerDescriptionProps = React.ComponentPropsWithoutRef; +export type DrawerPopupProps = MosaicComponentProps<'div'>; + +/** + * The controlled/uncontrolled root: open state, dismissal, snap points, drag policy — all the + * headless options, passed through. Registers the drag's custom properties once so the browser can + * type and animate them cheaply. + */ +function Root(props: DrawerRootProps) { + React.useEffect(() => { + registerDrawerCssVars(); + }, []); + return ; +} + +const Trigger = React.forwardRef(function DrawerTrigger(props, ref) { + return ( + + ); +}); + +const Close = React.forwardRef(function DrawerClose(props, ref) { + return ( + + ); +}); + +const Title = React.forwardRef(function DrawerTitle(props, ref) { + return ( + + ); +}); + +const Description = React.forwardRef( + function DrawerDescription(props, ref) { + return ( + + ); + }, +); + +/** + * The sheet, and everything it needs to be one: the portal, the scrim, the box it rises in, and + * the grip at its top. Closed, it sits entirely below the screen. Opened from inside a `profile` or + * `card` dialog it takes the nested scrim, the way a prompt does there. + */ +const Popup = React.forwardRef(function DrawerPopup( + { children, render, className, style, ...rest }, + ref, +) { + const host = React.useContext(DialogContext); + const nested = host !== null && !host.inline; + return ( + + + + + + + +
+ {children} +
+
+
+
+ ); +}); + +/** + * A bottom sheet: `Drawer.Root` holds the state, `Drawer.Trigger` opens it, and `Drawer.Popup` + * renders the sheet with its scrim, portal and grip. `Drawer.Title` and `Drawer.Description` name + * and describe it; `Drawer.Close` dismisses it from inside. Drag it down, press Escape, or press + * outside to dismiss. + */ +export const Drawer = { Root, Trigger, Popup, Title, Description, Close }; diff --git a/packages/ui/src/mosaic/components/drawer/index.ts b/packages/ui/src/mosaic/components/drawer/index.ts new file mode 100644 index 00000000000..b214acaf766 --- /dev/null +++ b/packages/ui/src/mosaic/components/drawer/index.ts @@ -0,0 +1,9 @@ +export { Drawer } from './drawer'; +export type { + DrawerCloseProps, + DrawerDescriptionProps, + DrawerPopupProps, + DrawerRootProps, + DrawerTitleProps, + DrawerTriggerProps, +} from './drawer'; diff --git a/packages/ui/src/mosaic/components/profile/index.ts b/packages/ui/src/mosaic/components/profile/index.ts new file mode 100644 index 00000000000..25521cb49f7 --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/index.ts @@ -0,0 +1,9 @@ +export { Profile } from './profile'; +export type { + ProfileContentProps, + ProfileNavItemProps, + ProfileNavProps, + ProfilePageProps, + ProfilePageTitleProps, + ProfileRootProps, +} from './profile'; diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts new file mode 100644 index 00000000000..982832da0ff --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -0,0 +1,256 @@ +import * as stylex from '@stylexjs/stylex'; + +import { + colorVars, + fontWeightVars, + radiusVars, + shadowVars, + space, + targetVars, + typeScaleVars, +} from '../../tokens.stylex'; +import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; + +/** + * The compact layout — navigation on top, as a row — queried against the profile's OWN width + * rather than the window's, so the same surface collapses in a narrow layout slot, an inline + * dialog, or a phone alike. A container cannot query itself, which is why the grid lives on an + * inner element: the root is the container, the layout inside it is what the query reshapes. + */ +const compact = '@container cl-profile (max-width: 48rem)' as const; + +/** How far the content's clip edge sits inside the frame's corners — a hair past the radius. */ +const SCROLL_INSET = space['4']; + +const NAV_GAP = space['0.5']; +const HALF_GAP = `calc(-1 * ${NAV_GAP} / 2)`; + +export const styles = stylex.create({ + /** + * The query container, and the flex column the frame fills. It paints NOTHING and carries no + * band of its own — an element is never its own query container, so every compact rule lives on + * `layout`, one level inside. It is also the containing block for the dismiss the root carries + * inside a dialog. `maxWidth` here so the frame inside is what the width clamps. + */ + root: { + // Centred where the host is wider — a `profile` dialog's popup spans the viewport. + marginInline: 'auto', + containerName: 'cl-profile', + containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', + position: 'relative', + maxWidth: '80rem', + width: '100%', + }, + + /** + * Over the page the popup decides the height: the root grows to fill it (the popup is a column + * flex) and the frame inside follows. Not inline — an inline dialog is in flow and has no height + * of its own to hand down, so the frame keeps its fixed one. + */ + rootInDialog: { + flexGrow: 1, + minHeight: 0, + }, + + /** + * The frame: border, radius and background, so the profile looks the same standalone and as the + * content of a `profile` dialog — that size paints nothing itself. Compact, the frame goes: the + * profile is the page there, flush with whatever holds it — a full-screen popup or an inline host. + * + * The height is FIXED, not content-driven: switching pages must never resize the surface or shift + * the page around it. Standalone and inline it is `45rem` — compact, the viewport's height — + * and a host with a definite slot overrides it with one rule. Over the page the popup decides + * instead; see `layoutInDialog`. + * + * The grid inside: a definite row is what lets the content column scroll instead of growing — an + * `auto` row sizes to its content and happily exceeds the container. + */ + layout: { + borderColor: colorVars['--cl-color-border'], + borderRadius: { + [compact]: 0, + default: radiusVars['--cl-radius-xl'], + }, + borderStyle: 'solid', + borderWidth: { + [compact]: '0px', + default: '1px', + }, + // `clip` rather than `hidden`: the surface must never become a scroll container itself, or + // focusing something in the content column would scroll the whole surface instead of the column. + overflow: 'clip', + backgroundColor: colorVars['--cl-color-card'], + blockSize: { + [compact]: '100dvh', + default: '45rem', + }, + // The card's elevation, so the two surfaces sit on a page the same way. Flush compact. + boxShadow: { + [compact]: 'none', + default: shadowVars['--cl-shadow-card'], + }, + color: colorVars['--cl-color-card-foreground'], + display: 'grid', + gridTemplateColumns: { + [compact]: 'minmax(0, 1fr)', + default: `calc(${space['40']} + ${space['15']}) minmax(0, 1fr)`, + }, + gridTemplateRows: 'minmax(0, 1fr)', + minHeight: 0, + }, + + layoutInDialog: { + blockSize: 'auto', + flexGrow: 1, + minHeight: 0, + }, + + nav: { + padding: space['4'], + borderInlineEndColor: colorVars['--cl-color-border'], + borderInlineEndStyle: 'solid', + borderInlineEndWidth: '1px', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + minWidth: 0, + }, + + /** Inside the sheet: no column edge, and the sheet's own content padding frames it. */ + navInSheet: { + padding: 0, + borderInlineEndWidth: '0px', + }, + + navList: { + gap: NAV_GAP, + display: 'flex', + flexDirection: 'column', + // Positioned, and a stacking context of its own, so a consumer can hang marks off it — an + // anchor-positioned highlight as `::before` / `::after` at `z-index: -1` lands under the items' + // text and above this surface's background. See the Profile docs' customisation example. + isolation: 'isolate', + position: 'relative', + minWidth: 0, + }, + + navItem: { + borderColor: 'transparent', + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '0px', + gap: space['2'], + paddingBlock: space['2'], + paddingInline: space['2.5'], + alignItems: 'center', + backgroundColor: { + default: 'transparent', + ':where([data-selected])': colorVars['--cl-color-border-faded'], + ':active': colorVars['--cl-color-border-faded'], + '@media (hover: hover)': { + default: null, + ':hover:not(:active):not([data-selected])': colorVars['--cl-color-border-faded'], + }, + }, + color: { + default: colorVars['--cl-color-neutral-faded'], + ':where([data-selected])': colorVars['--cl-color-card-foreground'], + }, + cursor: 'pointer', + display: 'flex', + flexShrink: 0, + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + // The containing block for the hit target below, and for nothing else. + position: 'relative', + textAlign: 'start', + whiteSpace: 'nowrap', + minHeight: { + default: null, + '@media (pointer: coarse)': targetVars['--cl-target-coarse'], + }, + width: '100%', + // Spans half the gap to each neighbour, so the pointer never falls between destinations. The + // ends stay flush with the list. + '::before': { + insetInline: 0, + content: '""', + insetBlockEnd: { + default: HALF_GAP, + ':last-of-type': 0, + }, + insetBlockStart: { + default: HALF_GAP, + ':first-of-type': 0, + }, + position: 'absolute', + }, + }, + + navItemIcon: { + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + }, + + branding: { + display: 'block', + marginBlockStart: 'auto', + }, + + // The content column is the scroll region — composed from the `ScrollArea` atoms, so the + // scrollbar and edge fade land on the column's edge and the padding scrolls with the content. + // + // The column, not the scroller, carries a little of the block padding: a scroll container clips + // at its padding edge, so padding on the scroller would not keep content out of the frame's + // rounded corners. Here the clip edge sits inside them, and the scroller gives the same amount + // back so the page's own padding reads unchanged. + content: { + paddingBlock: { + [compact]: 0, + default: SCROLL_INSET, + }, + minWidth: 0, + }, + contentViewport: { + paddingBlock: { + [compact]: space['6'], + default: `calc(${space['16']} - ${SCROLL_INSET})`, + }, + paddingInline: { + [compact]: space['6'], + default: space['16'], + }, + }, + + /** The headline row. */ + pageTitle: { + display: 'block', + }, + + /** + * The headline as a button: the heading's own type, inline so the caret can align to its + * x-height, with a little room around it for the focus ring. + */ + navTrigger: { + font: 'inherit', + borderRadius: radiusVars['--cl-radius-md'], + marginInline: `calc(-1 * ${space['1']})`, + paddingInline: space['1'], + backgroundColor: 'transparent', + color: 'inherit', + cursor: 'pointer', + display: 'inline', + textAlign: 'start', + }, + caret: { + color: colorVars['--cl-color-neutral-faded'], + marginInlineStart: '0.25em', + }, +}); + +export const contentScroll = scrollAreaRoot; +export const contentViewportScroll = scrollAreaViewport(); diff --git a/packages/ui/src/mosaic/components/profile/profile.test.tsx b/packages/ui/src/mosaic/components/profile/profile.test.tsx new file mode 100644 index 00000000000..b1e4579ed9e --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/profile.test.tsx @@ -0,0 +1,312 @@ +import * as stylex from '@stylexjs/stylex'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { Dialog } from '../dialog'; +import { Icon } from '../icon'; +import type { ProfileRootProps } from './profile'; +import { Profile } from './profile'; + +function Surface(rootProps: Partial) { + return ( + + + } + > + Account + + Security + + + + Account + Account page + + Security page + + + ); +} + +function renderSurface(props: Partial = {}) { + return render( + + + , + ); +} + +function atomsOf(style: stylex.StyleXStyles): string[] { + return stylex + .props(style) + .className!.split(' ') + .filter(name => !name.includes('__')); +} + +describe('Profile', () => { + it('is a labelled navigation of tabs beside the selected page', () => { + renderSurface(); + + expect(screen.getByRole('navigation', { name: 'User profile' })).toBeInTheDocument(); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + const account = screen.getByRole('tab', { name: 'Account' }); + const page = screen.getByRole('tabpanel'); + expect(account).toHaveAttribute('aria-selected', 'true'); + expect(account).toHaveAttribute('aria-controls', page.id); + expect(page).toHaveTextContent('Account page'); + expect(screen.getByText('Security page')).not.toBeVisible(); + }); + + it('reports a selection, and moves it with the arrow keys', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + renderSurface({ onValueChange }); + + await user.click(screen.getByRole('tab', { name: 'Security' })); + expect(onValueChange).toHaveBeenCalledWith('security'); + + screen.getByRole('tab', { name: 'Account' }).focus(); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('tab', { name: 'Security' })).toHaveFocus(); + }); + + it('exposes its parts through stable slots and state attributes', () => { + const { container } = renderSurface({ value: 'security', className: 'custom', style: { maxWidth: 900 } }); + + expect(container.firstChild).toHaveClass('cl-profile', 'custom'); + expect(container.firstChild).toHaveStyle({ maxWidth: '900px' }); + expect(screen.getByRole('navigation')).toHaveClass('cl-profile-nav'); + expect(screen.getByRole('tablist')).toHaveClass('cl-profile-nav-list'); + const security = screen.getByRole('tab', { name: 'Security' }); + expect(security).toHaveClass('cl-profile-nav-item'); + expect(security).toHaveAttribute('data-selected'); + expect(screen.getByRole('tab', { name: 'Account' })).not.toHaveAttribute('data-selected'); + expect(screen.getByRole('tabpanel')).toHaveClass('cl-profile-page'); + expect(screen.getByRole('tabpanel')).toHaveAttribute('data-value', 'security'); + expect(container.querySelector('.cl-profile-content')).toContainElement(screen.getByRole('tabpanel')); + }); + + // The profile is often the content of the host's own `main`, or of a dialog. + it('claims no main landmark', () => { + renderSurface(); + + expect(screen.queryByRole('main')).not.toBeInTheDocument(); + }); + + it('hides a destination icon from assistive tech', () => { + renderSurface(); + + const icon = screen.getByRole('tab', { name: 'Account' }).querySelector('.cl-profile-nav-item-icon'); + expect(icon).toHaveAttribute('aria-hidden', 'true'); + expect(icon).toContainElement(document.querySelector('.cl-icon')); + }); + + it('signs the navigation with Clerk unless told not to', () => { + const branded = renderSurface(); + expect(screen.getByRole('navigation')).toContainElement(screen.getByText(/Secured by/)); + expect(screen.getByRole('link', { name: 'Clerk' })).toBeInTheDocument(); + branded.unmount(); + + renderSurface({ renderBranding: false }); + expect(screen.queryByText(/Secured by/)).not.toBeInTheDocument(); + }); + + // The compact layout is a container query against the profile itself, so the profile has to BE + // a container — drop that and it never collapses, at any width. The rules it drives live one + // level in, on the frame: an element is never its own query container. + it('is the named container its compact layout queries', () => { + const probe = stylex.create({ container: { containerName: 'cl-profile', containerType: 'inline-size' } }); + const { container } = renderSurface(); + + expect(Array.from((container.firstChild as HTMLElement).classList)).toEqual( + expect.arrayContaining(atomsOf(probe.container)), + ); + }); + + describe('page title', () => { + it('is a level-3 heading, and alone outside a profile', () => { + render( + + Account + , + ); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toHaveClass('cl-heading'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('is a plain heading while the navigation is beside the content', () => { + renderSurface(); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Account' })).not.toBeInTheDocument(); + }); + }); + + // Compact is measured, not styled: which box the tablist renders in is a DOM decision. + describe('compact', () => { + let observe: ((width: number) => void) | null = null; + const original = globalThis.ResizeObserver; + + beforeEach(() => { + observe = null; + class FakeResizeObserver { + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + observe = width => { + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({ width } as DOMRect); + this.callback([], this as unknown as ResizeObserver); + }; + } + disconnect() {} + unobserve() {} + } + globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + globalThis.ResizeObserver = original; + }); + + it('moves the tablist into a sheet the page title opens, and closes it on a choice', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + renderSurface({ onValueChange }); + act(() => observe?.(400)); + + // Nothing in the column; the headline is the way in, and still a heading. + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + const headline = screen.getByRole('button', { name: 'Account' }); + expect(headline).toHaveAttribute('aria-expanded', 'false'); + expect(headline).toHaveAttribute('aria-haspopup', 'dialog'); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toContainElement(headline); + + await user.click(headline); + const sheet = screen.getByRole('dialog', { name: 'User profile' }); + expect(sheet).toHaveClass('cl-drawer-popup'); + expect(sheet).toContainElement(screen.getByRole('tablist')); + expect(screen.queryByText(/Secured by/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'Security' })); + expect(onValueChange).toHaveBeenCalledWith('security'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('hands focus to the headline of the page that is showing once the sheet closes', async () => { + const user = userEvent.setup(); + function Controlled() { + const [value, setValue] = React.useState('account'); + return ( + + + Account + Security + + + + Account + + + Security + + + + ); + } + render( + + + , + ); + act(() => observe?.(400)); + + await user.click(screen.getByRole('button', { name: 'Account' })); + await user.click(screen.getByRole('tab', { name: 'Security' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Security' })).toHaveFocus()); + }); + + it('returns the tablist to the column when the width comes back', () => { + renderSurface(); + act(() => observe?.(400)); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + act(() => observe?.(1000)); + expect(screen.getByRole('navigation', { name: 'User profile' })).toContainElement(screen.getByRole('tablist')); + }); + }); + + describe('inside a dialog', () => { + function renderInDialog(inline = false) { + return render( + + + + + + + , + ); + } + + // Named from inside, the way `Card.Title` names a card dialog — nothing is passed in. And the + // dismiss comes from the profile too, the way `Card.Header` carries a card's. + it('names the dialog and carries its dismiss', () => { + renderInDialog(); + + const popup = screen.getByRole('dialog', { name: 'User profile' }); + expect(popup).toContainElement(document.querySelector('.cl-profile')); + expect(popup).toContainElement(screen.getByRole('button', { name: 'Close' })); + expect(popup).toContainElement(screen.getByRole('tab', { name: 'Security' })); + }); + + it('carries no dismiss standalone, or inline', () => { + const standalone = renderSurface(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'User profile' })).not.toBeInTheDocument(); + standalone.unmount(); + + renderInDialog(true); + expect(screen.getByRole('dialog', { name: 'User profile' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + }); + + // Switching pages must never resize the surface: standalone and inline it holds a fixed height + // and scrolls inside; over the page the popup's height is the one that counts. + it('holds a fixed height standalone and inline, and hands it to the popup over the page', () => { + const probe = stylex.create({ fixed: { blockSize: '45rem' }, handed: { blockSize: 'auto' } }); + const fixed = atomsOf(probe.fixed); + const handed = atomsOf(probe.handed); + + const frame = () => Array.from(document.querySelector('.cl-profile-layout')!.classList); + + const standalone = renderSurface(); + expect(frame()).toEqual(expect.arrayContaining(fixed)); + standalone.unmount(); + + const inline = renderInDialog(true); + expect(frame()).toEqual(expect.arrayContaining(fixed)); + inline.unmount(); + + renderInDialog(); + expect(frame()).toEqual(expect.arrayContaining(handed)); + }); + }); +}); diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx new file mode 100644 index 00000000000..b4cc1383a7f --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -0,0 +1,464 @@ +import type { TabsProps } from '@clerk/headless/tabs'; +import { Tabs } from '@clerk/headless/tabs'; +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { focusOutline } from '../../utils/focus-outline.styles'; +import { reset } from '../../utils/reset.styles'; +import { Branding } from '../branding'; +import { Dialog, DialogContext } from '../dialog'; +import { Drawer } from '../drawer'; +import { Heading } from '../heading'; +import { Icon } from '../icon'; +import { VisuallyHidden } from '../visually-hidden'; +import { contentScroll, contentViewportScroll, styles } from './profile.styles'; + +interface ProfileContextValue { + label: string | undefined; + renderBranding: boolean; + /** Below `COMPACT_WIDTH`: the navigation lives in a sheet, opened from a page's title. */ + compact: boolean; + navOpen: boolean; + openNav: () => void; + closeNav: () => void; +} + +const ProfileContext = React.createContext(null); + +/** + * The width below which the layout is compact — the same `48rem` the container query in + * `profile.styles.ts` reads, measured here because WHERE the navigation renders is a DOM decision + * CSS cannot make: one tablist, in the column or in the sheet, never both. + */ +const COMPACT_WIDTH_REM = 48; + +function useProfileContext(part: string): ProfileContextValue { + const context = React.useContext(ProfileContext); + if (!context) { + throw new Error(`${part} must be rendered inside Profile.Root`); + } + return context; +} + +function useCompact(node: HTMLElement | null): boolean { + const [compact, setCompact] = React.useState(false); + React.useLayoutEffect(() => { + if (!node || typeof ResizeObserver === 'undefined') { + return; + } + const measure = () => { + const width = node.getBoundingClientRect().width; + // Not laid out (hidden, or a test document): nothing to conclude, keep what was known. + if (width === 0) { + return; + } + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + setCompact(width < COMPACT_WIDTH_REM * rem); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [node]); + return compact; +} + +export interface ProfileRootProps extends Omit, 'children'> { + /** + * What the surface is called — "User profile", "Organization". Names the navigation landmark, and + * inside a dialog names the dialog too, through a visually hidden heading carrying the popup's + * `labelId`: the counterpart of `Card.Title`, for a surface whose visible headings belong to its + * pages. + */ + label?: string; + /** The selected page, by the `value` of its `Profile.NavItem` and `Profile.Page`. */ + value: string; + onValueChange?: (value: string) => void; + /** + * Arrow-key direction in the navigation. Vertical, since the navigation is a column; the compact + * row is a container query the keyboard model cannot see. + * + * @default 'vertical' + */ + orientation?: TabsProps['orientation']; + activationMode?: TabsProps['activationMode']; + /** + * Signs the foot of the navigation with "Secured by Clerk". An instance that has paid the + * branding off carries none of it, so a connected surface passes `displayConfig.branded` here. + * + * @default true + */ + renderBranding?: boolean; + children: React.ReactNode; +} + +/** + * A surface you navigate: a column of destinations beside the page each one opens. The account + * profile and the organization profile are both one of these. + * + * Rendered as the content of a `profile` dialog's popup, it fills it and paints it — the dialog + * positions, the profile paints, the way a `Card` does inside a `card` dialog. Like `Card`, it + * reads `DialogContext` to name the dialog and carry its dismiss, so the composition needs nothing + * passed in; standalone it renders neither. + */ +const Root = React.forwardRef(function ProfileRoot( + { + label, + value, + onValueChange, + orientation = 'vertical', + activationMode, + renderBranding = true, + children, + render, + className, + style, + ...rest + }, + ref, +) { + const dialog = React.useContext(DialogContext); + const [node, setNode] = React.useState(null); + const compact = useCompact(node); + const [navOpen, setNavOpen] = React.useState(false); + const openNav = React.useCallback(() => setNavOpen(true), []); + const closeNav = React.useCallback(() => setNavOpen(false), []); + // The caret that opened the sheet belongs to the page the choice just left, so the sheet's own + // return-focus lands on nothing. Focus goes to the caret of the page that is now showing — the + // same control, on the destination. + const wasNavOpen = React.useRef(false); + React.useEffect(() => { + if (navOpen) { + wasNavOpen.current = true; + return; + } + if (!wasNavOpen.current || !node) { + return; + } + wasNavOpen.current = false; + const caret = node.querySelector('.cl-profile-page:not([hidden]) .cl-profile-nav-trigger'); + caret?.focus(); + }, [navOpen, node]); + const context = React.useMemo( + () => ({ label, renderBranding, compact, navOpen, openNav, closeNav }), + [label, renderBranding, compact, navOpen, openNav, closeNav], + ); + const mergedRef = React.useCallback( + (element: HTMLDivElement | null) => { + setNode(element); + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + const element = useRender({ + defaultTagName: 'div', + render, + ref: mergedRef, + props: { + ...mergeStyleProps( + themeProps('profile'), + stylex.props(reset.base, styles.root, dialog !== null && !dialog.inline && styles.rootInDialog), + className, + style, + ), + ...rest, + children: ( + <> + {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening + focus — the same reason `Card.Header` renders its dismiss first. Never inline, which + nothing closes. */} + {dialog && !dialog.inline ? : null} + {dialog && label ? }>{label} : null} +
+ {children} +
+ + ), + }, + }); + + return ( + + + {element} + + + ); +}); + +export type ProfileNavProps = MosaicComponentProps<'nav'>; + +function NavBranding() { + return ( +
+ +
+ ); +} + +/** + * The navigation: the destinations, and the branding at their foot. Its children are + * `Profile.NavItem`s; they render inside the tablist, so nothing else belongs among them. + * + * Beside the content it is a column. Compact, it renders nothing in place: the tablist moves into + * a sheet that a page's title opens (`Profile.PageTitle`), and closes on a choice — the branding + * stays behind, since a sheet is not the surface. One tablist, wherever it lives — two would be + * two sets of tabs for one set of pages. + */ +const Nav = React.forwardRef(function ProfileNav( + { children, render, className, style, ...rest }, + ref, +) { + const profile = useProfileContext('Profile.Nav'); + const { label, renderBranding, compact, navOpen, closeNav } = profile; + const list = ( + + {children} + + ); + const element = useRender({ + defaultTagName: 'nav', + render, + ref, + props: { + 'aria-label': label, + ...mergeStyleProps( + themeProps('profile-nav', { compact }), + stylex.props(reset.base, styles.nav, compact && styles.navInSheet), + className, + style, + ), + ...rest, + children: ( + <> + {list} + {renderBranding && !compact ? : null} + + ), + }, + }); + + if (!compact) { + return element; + } + return ( + { + if (!open) { + closeNav(); + } + }} + > + {element} + + ); +}); + +export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { + /** Matches the `value` of the `Profile.Page` this destination opens. */ + value: string; + /** Leads the label. Any node, so a page of the consumer's own can bring its own mark. */ + icon?: React.ReactNode; + disabled?: boolean; +} + +/** A destination. Selecting it shows the `Profile.Page` sharing its `value`. */ +const NavItem = React.forwardRef(function ProfileNavItem( + { value, icon, disabled, children, render, className, style, onClick, ...rest }, + ref, +) { + const { compact, closeNav } = useProfileContext('Profile.NavItem'); + return ( + { + onClick?.(event); + // A choice in the sheet is the end of the visit; arrowing through the list is not. + if (compact && !event.defaultPrevented && !disabled) { + closeNav(); + } + }} + {...mergeStyleProps( + themeProps('profile-nav-item'), + stylex.props(reset.base, styles.navItem, focusOutline.visible), + className, + style, + )} + {...rest} + > + {icon ? ( + + {icon} + + ) : null} + {children} + + ); +}); + +export type ProfilePageTitleProps = MosaicComponentProps<'div'>; + +/** + * A page's headline. Inside a profile that has gone compact the headline IS the way to the other + * pages: the heading holds a button — the title, and a caret beside it — that opens the navigation + * sheet. Anywhere else — the wide layout, or a page rendered on its own — it is the heading alone. + * + * The caret sits `vertical-align: middle`, which CSS defines as the box's midpoint on the parent's + * baseline plus half its x-height: optically centred on the lowercase letters rather than on the + * line box. That needs an inline formatting context, so the button is `display: inline`. + */ +const PageTitle = React.forwardRef(function ProfilePageTitle( + { children, render, className, style, ...rest }, + ref, +) { + const profile = React.useContext(ProfileContext); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('profile-page-title'), + stylex.props(reset.base, styles.pageTitle), + className, + style, + ), + ...rest, + children: ( + } + size='2xl' + > + {profile?.compact ? ( + + ) : ( + children + )} + + ), + }, + }); +}); + +export type ProfileContentProps = MosaicComponentProps<'div'>; + +/** + * The column the pages render in. It is the surface's scroll region — the navigation stays put + * while a long page scrolls — and a plain `div`: the profile is often the content of the host's + * own `main`, or of a dialog, so it claims no landmark. + */ +const Content = React.forwardRef(function ProfileContent( + { children, render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('profile-content'), + stylex.props(reset.base, styles.content, contentScroll), + className, + style, + ), + ...rest, + children: ( +
+ {children} +
+ ), + }, + }); +}); + +export interface ProfilePageProps extends MosaicComponentProps<'div'> { + /** Matches the `value` of the `Profile.NavItem` that opens this page. */ + value: string; +} + +/** + * One destination's content, shown while its `value` is selected and `hidden` otherwise. It keeps + * the tabs primitive's transition contract (`data-hidden` today; `data-starting-style` / + * `data-ending-style` and the direction variable once pages are force-mounted), so a page + * transition is a styling change here rather than a new part. + */ +const Page = React.forwardRef(function ProfilePage( + { value, className, style, ...rest }, + ref, +) { + return ( + + ); +}); + +/** + * A surface you navigate, composed through `Profile.Root`, `Profile.Nav`, `Profile.NavItem`, + * `Profile.Content`, `Profile.Page`, and `Profile.PageTitle`. Every part accepts the Mosaic + * `render` prop and forwards its ref. + * + * ```tsx + * + * + * }>Account + * + * + * + * + * + * ``` + */ +export const Profile = { Root, Nav, NavItem, PageTitle, Content, Page }; diff --git a/packages/ui/src/mosaic/profile-page.styles.ts b/packages/ui/src/mosaic/profile-page.styles.ts deleted file mode 100644 index 5b052541d60..00000000000 --- a/packages/ui/src/mosaic/profile-page.styles.ts +++ /dev/null @@ -1,187 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { scrollAreaRoot, scrollAreaViewport } from './components/scroll-area'; -import { colorVars, fontWeightVars, radiusVars, space, targetVars, typeScaleVars } from './tokens.stylex'; - -/** - * The compact layout — sidebar on top, navigation as a row — queried against the page's OWN - * width rather than the window's. The root is the container (`cl-profile-page`), so the same - * page collapses in a narrow layout slot, an inline dialog, or a phone alike. A container cannot - * query itself, which is why the grid lives on an inner element: the root is the container, the - * layout inside it is what the query reshapes. - */ -const profilePageCompact = '@container cl-profile-page (max-width: 48rem)' as const; - -export const styles = stylex.create({ - /** - * The surface, and the query container. Paints the frame (border, radius, background) so the - * page looks the same standalone and as the popup of a `panel` dialog — that size paints - * nothing itself, and is composed by rendering the popup AS this root. - * - * A column flex so the layout below can take the remaining height: standalone that is the - * `minHeight`, in a dialog it is the popup's stretched height, and either way the content - * column scrolls inside it rather than growing past it. - */ - root: { - borderColor: colorVars['--cl-color-border'], - borderRadius: radiusVars['--cl-radius-xl'], - borderStyle: 'solid', - borderWidth: '1px', - // `clip` rather than `hidden`: the page must never become a scroll container itself, or - // focusing something in the content column would scroll the whole page instead of the column. - overflow: 'clip', - backgroundColor: colorVars['--cl-color-card'], - color: colorVars['--cl-color-card-foreground'], - containerName: 'cl-profile-page', - containerType: 'inline-size', - display: 'flex', - flexDirection: 'column', - // The containing block for the dismiss the root carries inside a dialog. - position: 'relative', - maxWidth: '66rem', - minHeight: '37.5rem', - width: '100%', - }, - - /** - * Inside a dialog the popup decides the height: the page grows to fill it (the popup is a - * column flex) and drops the standalone floor, which would only overflow it. - */ - rootInDialog: { - flexGrow: 1, - minHeight: 0, - }, - - /** - * Compact, the navigation is a row across the top — under the corner the dialog's dismiss - * sits in. Room for it, so the last destination cannot run beneath the button. - */ - sidebarInDialog: { - paddingInlineEnd: { - default: null, - [profilePageCompact]: space['12'], - }, - }, - - layout: { - display: 'grid', - flexGrow: 1, - gridTemplateColumns: { - default: `calc(${space['40']} + ${space['15']}) minmax(0, 1fr)`, - [profilePageCompact]: 'minmax(0, 1fr)', - }, - // A definite row is what lets the content column scroll instead of growing: an `auto` row - // sizes to its content and happily exceeds the container. - gridTemplateRows: { - default: 'minmax(0, 1fr)', - [profilePageCompact]: 'auto minmax(0, 1fr)', - }, - minHeight: 0, - }, - sidebar: { - padding: space['4'], - borderBlockEndColor: { - default: 'transparent', - [profilePageCompact]: colorVars['--cl-color-border'], - }, - borderBlockEndStyle: 'solid', - borderBlockEndWidth: { - default: '0px', - [profilePageCompact]: '1px', - }, - borderInlineEndColor: colorVars['--cl-color-border'], - borderInlineEndStyle: 'solid', - borderInlineEndWidth: { - default: '1px', - [profilePageCompact]: '0px', - }, - display: 'flex', - flexDirection: { - default: 'column', - [profilePageCompact]: 'row', - }, - minHeight: 0, - minWidth: 0, - }, - navigation: { - gap: space['1'], - display: 'flex', - flexDirection: { - default: 'column', - [profilePageCompact]: 'row', - }, - minWidth: 0, - overflowX: { - default: 'visible', - [profilePageCompact]: 'auto', - }, - }, - navigationItem: { - borderColor: 'transparent', - borderRadius: radiusVars['--cl-radius-md'], - borderStyle: 'solid', - borderWidth: '0px', - gap: space['2'], - paddingBlock: space['2'], - paddingInline: space['2.5'], - alignItems: 'center', - backgroundColor: { - default: 'transparent', - ':where([data-selected])': colorVars['--cl-color-border-faded'], - ':active': colorVars['--cl-color-border-faded'], - '@media (hover: hover)': { - default: null, - ':hover:not(:active):not([data-selected])': colorVars['--cl-color-border-faded'], - }, - }, - color: { - default: colorVars['--cl-color-neutral-faded'], - ':where([data-selected])': colorVars['--cl-color-card-foreground'], - }, - cursor: 'pointer', - display: 'flex', - flexShrink: 0, - fontSize: typeScaleVars['--cl-text-sm-size'], - fontWeight: fontWeightVars['--cl-font-medium'], - lineHeight: typeScaleVars['--cl-text-sm-leading'], - textAlign: 'start', - whiteSpace: 'nowrap', - minHeight: { - default: null, - '@media (pointer: coarse)': targetVars['--cl-target-coarse'], - }, - width: { - default: '100%', - [profilePageCompact]: 'auto', - }, - }, - branding: { - gap: space['1'], - alignItems: 'center', - color: colorVars['--cl-color-neutral-faded'], - display: { - default: 'flex', - [profilePageCompact]: 'none', - }, - fontSize: typeScaleVars['--cl-text-xs-size'], - lineHeight: typeScaleVars['--cl-text-xs-leading'], - marginBlockStart: 'auto', - }, - brandingLink: { - borderRadius: radiusVars['--cl-radius-sm'], - alignItems: 'center', - color: 'inherit', - display: 'inline-flex', - height: space['4'], - }, - // The content column is the scroll region — composed from the `ScrollArea` atoms, so the - // scrollbar and edge fade land on the column's true edge and the padding scrolls with the content. - main: { minWidth: 0 }, - content: { - paddingBlock: space['16'], - paddingInline: space['16'], - }, -}); - -export const mainScroll = scrollAreaRoot; -export const contentScroll = scrollAreaViewport(); diff --git a/packages/ui/src/mosaic/profile-page.tsx b/packages/ui/src/mosaic/profile-page.tsx deleted file mode 100644 index 9a4a52c9e69..00000000000 --- a/packages/ui/src/mosaic/profile-page.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import type { TabsProps } from '@clerk/headless/tabs'; -import { Tabs } from '@clerk/headless/tabs'; -import { useRender } from '@clerk/headless/utils'; -import * as stylex from '@stylexjs/stylex'; -import React from 'react'; - -import { ClerkLogo } from './components/clerk-logo'; -import { Dialog, DialogContext } from './components/dialog'; -import { Icon } from './components/icon'; -import { VisuallyHidden } from './components/visually-hidden'; -import type { IconName } from './icons/registry'; -import { contentScroll, mainScroll, styles } from './profile-page.styles'; -import type { MosaicComponentProps } from './props'; -import { mergeStyleProps, themeProps } from './props'; -import { focusOutline } from './utils/focus-outline.styles'; -import { reset } from './utils/reset.styles'; - -export interface ProfilePageItem { - value: string; - label: string; - icon: IconName; -} - -export interface ProfilePageRootProps extends Omit, 'children'> { - /** - * What the page is called. Inside a dialog it names the dialog, through a visually hidden - * heading carrying the popup's `labelId` — the counterpart of `Card.Title`, for a surface - * whose visible headings belong to its panels. - */ - label?: string; - value: string; - onValueChange?: (value: string) => void; - orientation?: TabsProps['orientation']; - activationMode?: TabsProps['activationMode']; - children: React.ReactNode; -} - -/** - * The page: a surface holding a sidebar and a content column. Rendered inside a `panel` dialog's - * popup, it fills it and paints it — the dialog positions, the page paints, the way a `Card` does - * inside a `card` dialog. Like `Card`, it reads `DialogContext` to name the dialog and carry its - * dismiss, so the composition needs nothing passed in; standalone it renders neither. - */ -const ProfilePageRoot = React.forwardRef(function ProfilePageRoot( - { - label, - value, - onValueChange, - orientation = 'vertical', - activationMode, - children, - render, - className, - style, - ...rest - }, - ref, -) { - const dialog = React.useContext(DialogContext); - const element = useRender({ - defaultTagName: 'div', - render, - ref, - props: { - ...mergeStyleProps( - themeProps('profile-page'), - stylex.props(reset.base, styles.root, dialog !== null && styles.rootInDialog), - className, - style, - ), - ...rest, - children: ( - <> - {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening - focus — the same reason `Card.Header` renders its dismiss first. Never inline, which - nothing closes. */} - {dialog && !dialog.inline ? : null} - {dialog && label ? }>{label} : null} -
- {children} -
- - ), - }, - }); - - return ( - - {element} - - ); -}); - -export interface ProfilePageSidebarProps extends Omit, 'children'> { - items: readonly ProfilePageItem[]; - navigationLabel: string; - renderBranding?: boolean; -} - -const ProfilePageSidebar = React.forwardRef(function ProfilePageSidebar( - { items, navigationLabel, renderBranding = true, render, className, style, ...rest }, - ref, -) { - const dialog = React.useContext(DialogContext); - return useRender({ - defaultTagName: 'aside', - render, - ref, - props: { - ...mergeStyleProps( - themeProps('profile-page-sidebar'), - stylex.props(reset.base, styles.sidebar, dialog !== null && !dialog.inline && styles.sidebarInDialog), - className, - style, - ), - ...rest, - children: ( - <> - - {renderBranding ? ( -
- Secured by - - - -
- ) : null} - - ), - }, - }); -}); - -export interface ProfilePageContentProps extends Omit, 'children'> { - children: React.ReactNode; -} - -const ProfilePageContent = React.forwardRef(function ProfilePageContent( - { children, render, className, style, ...rest }, - ref, -) { - return useRender({ - defaultTagName: 'main', - render, - ref, - props: { - ...mergeStyleProps( - themeProps('profile-page-main'), - stylex.props(reset.base, styles.main, mainScroll), - className, - style, - ), - ...rest, - children: ( -
- {children} -
- ), - }, - }); -}); - -export interface ProfilePagePanelProps extends MosaicComponentProps<'div'> { - value: string; -} - -const ProfilePagePanel = React.forwardRef(function ProfilePagePanel( - { value, className, style, ...rest }, - ref, -) { - return ( - - ); -}); - -export const ProfilePage = { - Root: ProfilePageRoot, - Sidebar: ProfilePageSidebar, - Content: ProfilePageContent, - Panel: ProfilePagePanel, -}; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 8baeb4ed16a..4b5d49429d9 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -5,25 +5,27 @@ // as components migrate. export type { MosaicComponentProps, MosaicElementProps } from '../props'; -export { ProfilePage } from '../profile-page'; -export type { - ProfilePageContentProps, - ProfilePageItem, - ProfilePagePanelProps, - ProfilePageRootProps, - ProfilePageSidebarProps, -} from '../profile-page'; - export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from '../components/avatar'; export { Badge } from '../components/badge'; export type { BadgeProps } from '../components/badge'; export { Banner } from '../components/banner'; +export { Branding } from '../components/branding'; +export type { BrandingProps } from '../components/branding'; export type { BannerDescriptionProps, BannerLabelProps, BannerRootProps } from '../components/banner'; export { Button, SubmitButton } from '../components/button'; export type { ButtonProps, SpinDelayOptions, SubmitButtonProps } from '../components/button'; export { Card } from '../components/card'; export type { CardProps } from '../components/card'; +export { Drawer } from '../components/drawer'; +export type { + DrawerCloseProps, + DrawerDescriptionProps, + DrawerPopupProps, + DrawerRootProps, + DrawerTitleProps, + DrawerTriggerProps, +} from '../components/drawer'; export { Dialog, createConfirmHandle, useConfirmedClose } from '../components/dialog'; export type { ConfirmHandle, @@ -96,6 +98,15 @@ export type { PopoverTitleProps, PopoverTriggerProps, } from '../components/popover'; +export { Profile } from '../components/profile'; +export type { + ProfileContentProps, + ProfileNavItemProps, + ProfileNavProps, + ProfilePageProps, + ProfilePageTitleProps, + ProfileRootProps, +} from '../components/profile'; import { colorVars, @@ -107,6 +118,7 @@ import { radiusVars, scrollbarVars, scrollFadeVars, + shadowVars, space, spacingVars, targetVars, @@ -123,6 +135,7 @@ export { radiusVars, scrollbarVars, scrollFadeVars, + shadowVars, space, spacingVars, targetVars, @@ -136,6 +149,7 @@ export type ColorVarName = keyof typeof colorVars; export type DurationVarName = keyof typeof durationVars; export type EasingVarName = keyof typeof easingVars; export type FocusVarName = keyof typeof focusVars; +export type ShadowVarName = keyof typeof shadowVars; export type FontFamilyVarName = keyof typeof fontFamilyVars; export type FontWeightVarName = keyof typeof fontWeightVars; export type RadiusVarName = keyof typeof radiusVars; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 9085dbd66ad..75dccbfb798 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -72,6 +72,7 @@ const radiusDefaults = { '--cl-radius-md': '0.375rem', '--cl-radius-lg': '0.5rem', '--cl-radius-xl': '0.75rem', + '--cl-radius-2xl': '1.5rem', '--cl-radius-full': 'calc(infinity * 1px)', } as const; @@ -412,3 +413,15 @@ const focusDefaults = { } as const; export const focusVars = stylex.defineVars(focusDefaults); + +// Elevation. The one card shadow, as a token so every surface at that elevation reads the same: +// two drop layers that fall away in dark, and a hairline ring that is dark on light and light on +// dark. Branched per colour via `light-dark()` since a shadow's geometry cannot branch — see the +// note on `Dialog`'s popup for why `@media (prefers-color-scheme)` is not the escape hatch. +const shadowDefaults = { + '--cl-shadow-card': `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, +}; + +export const shadowVars = stylex.defineVars(shadowDefaults); diff --git a/packages/ui/src/mosaic/user-button/user-button.pages.tsx b/packages/ui/src/mosaic/user-button/user-button.pages.tsx index 070444d9d74..a2473a15454 100644 --- a/packages/ui/src/mosaic/user-button/user-button.pages.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.pages.tsx @@ -9,10 +9,15 @@ import { useCallback, useState } from 'react'; import { createPortal } from 'react-dom'; import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; +import type { + CustomProfileItem, + CustomProfileLink, + CustomProfilePage, + UserProfilePageId, +} from '../user-profile/user-profile.types'; import { applyOrder } from './user-button.utils'; -/** A page the UserProfile brings itself, named by the id its navigation knows it as. */ -export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys'; +export type { CustomProfileItem, CustomProfileLink, CustomProfilePage, UserProfilePageId }; /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned @@ -37,32 +42,6 @@ export function useUserProfilePages(): UserProfilePageId[] { return pages; } -/** A page of your own inside the profile, reached from its navigation. */ -export interface CustomProfilePage { - /** Names the page in the profile's navigation. */ - label: string; - /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */ - path: string; - href?: never; - icon?: ReactNode; - /** Rendered as the page itself. */ - content: ReactNode; -} - -/** A row in the profile's navigation that leaves for somewhere else. */ -export interface CustomProfileLink { - /** Names the row in the profile's navigation. */ - label: string; - /** Identifies the row, for ordering. */ - path: string; - /** Where the row goes. */ - href: string; - icon?: ReactNode; - content?: never; -} - -export type CustomProfileItem = CustomProfilePage | CustomProfileLink; - export interface CustomPagesOptions { /** Pages and links of the consumer's own. */ items: CustomProfileItem[] | undefined; diff --git a/packages/ui/src/mosaic/user-button/user-button.utils.ts b/packages/ui/src/mosaic/user-button/user-button.utils.ts index a5f638ff83d..afed1b2a31c 100644 --- a/packages/ui/src/mosaic/user-button/user-button.utils.ts +++ b/packages/ui/src/mosaic/user-button/user-button.utils.ts @@ -1,22 +1 @@ -/** - * The one ordering rule every list a consumer can reorder follows: the ids `order` names lead, in - * the order it names them, and whatever it leaves out keeps its default place behind them. - * - * A name matching no item is dropped rather than held open, since which items a surface carries - * depends on how it was configured and naming one it has not got is ordinary rather than a mistake. - * Two items sharing an id are one item: the first wins, so a consumer's own row shadows the built-in - * it was given the name of instead of both answering to it. - */ -export function applyOrder( - order: readonly string[] | undefined, - items: readonly T[], - idOf: (item: T) => string, -): T[] { - const unique = items.filter((item, index, all) => all.findIndex(other => idOf(other) === idOf(item)) === index); - if (!order?.length) { - return unique; - } - - const named = [...new Set(order)].flatMap(id => unique.filter(item => idOf(item) === id)); - return [...named, ...unique.filter(item => !named.includes(item))]; -} +export { applyOrder } from '../utils/apply-order'; diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx deleted file mode 100644 index da80c3493b9..00000000000 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { Dialog } from '../../components/dialog'; -import { MosaicProvider } from '../../MosaicProvider'; -import type { UserPageViewProps } from '../user-page.view'; -import { UserPageView } from '../user-page.view'; - -const panels: UserPageViewProps['panels'] = { - account: { name: 'Preston Booth', username: 'prestonxyz' }, - security: { hasPassword: true }, - billing: { - subscription: { - planName: 'Basic Plan', - priceLabel: '$12 / Month', - totalDueLabel: '$12.00', - renewsAtLabel: 'Renews Aug 26', - }, - paymentMethods: [], - historyItems: [], - }, - apiKeys: { - apiKeys: [], - searchValue: '', - selectedIds: [], - onSearchChange: vi.fn(), - onSelectionChange: vi.fn(), - }, -}; - -function renderView(overrides: Partial = {}) { - const props: UserPageViewProps = { - activePanel: 'account', - panels, - onPanelChange: vi.fn(), - ...overrides, - }; - - return { - ...render( - - - , - ), - props, - }; -} - -describe('UserPageView', () => { - it('renders the active panel and all available destinations', () => { - renderView(); - - expect(screen.getByRole('navigation', { name: 'User profile' })).toBeInTheDocument(); - expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical'); - const accountTab = screen.getByRole('tab', { name: 'Account' }); - const accountPanel = screen.getByRole('tabpanel'); - - expect(accountTab).toHaveAttribute('aria-selected', 'true'); - expect(accountTab).toHaveAttribute('aria-controls', accountPanel.id); - expect(screen.getByRole('tab', { name: 'Security' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Billing' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'API Keys' })).toBeInTheDocument(); - expect(accountPanel).toHaveAccessibleName('Account'); - expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); - expect(screen.getByText('Secured by')).toBeInTheDocument(); - }); - - it('forwards panel changes', async () => { - const onPanelChange = vi.fn(); - const user = userEvent.setup(); - renderView({ onPanelChange }); - - await user.click(screen.getByRole('tab', { name: 'Security' })); - - expect(onPanelChange).toHaveBeenCalledWith('security'); - expect(screen.queryByRole('button', { name: 'Close user profile' })).not.toBeInTheDocument(); - }); - - it('supports sidebar keyboard navigation through the tabs primitive', async () => { - const onPanelChange = vi.fn(); - const user = userEvent.setup(); - renderView({ onPanelChange }); - - screen.getByRole('tab', { name: 'Account' }).focus(); - await user.keyboard('{ArrowDown}'); - - expect(screen.getByRole('tab', { name: 'Security' })).toHaveFocus(); - expect(onPanelChange).toHaveBeenCalledWith('security'); - }); - - it('reflects navigation state through stable Mosaic styling hooks', () => { - renderView({ activePanel: 'security' }); - - expect(screen.getByRole('tab', { name: 'Security' })).toHaveClass('cl-profile-page-navigation-item'); - expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute('data-selected'); - expect(screen.getByRole('tab', { name: 'Account' })).not.toHaveAttribute('data-selected'); - }); - - it('merges consumer styling props onto the page root', () => { - const { container } = renderView({ className: 'custom-page', style: { maxWidth: 900 } }); - - expect(container.firstChild).toHaveClass('cl-profile-page', 'custom-page'); - expect(container.firstChild).toHaveStyle({ maxWidth: '900px' }); - }); - - it('only exposes supplied optional panels', () => { - renderView({ panels: { account: panels.account } }); - - expect(screen.queryByRole('tab', { name: 'Security' })).not.toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: 'Billing' })).not.toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: 'API Keys' })).not.toBeInTheDocument(); - }); - - it('falls back to Account when the requested panel is unavailable', () => { - renderView({ activePanel: 'billing', panels: { account: panels.account } }); - - expect(screen.getByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); - }); - - it('can omit Clerk branding', () => { - renderView({ renderBranding: false }); - - expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); - }); - - // The compact layout is a container query against the page itself, so the page has to BE a - // container — drop that and it never collapses, at any width. - it('is the named container its compact layout queries', () => { - const probe = stylex.create({ container: { containerName: 'cl-profile-page', containerType: 'inline-size' } }); - const atoms = stylex - .props(probe.container) - .className!.split(' ') - .filter(name => !name.includes('__')); - const { container } = renderView(); - - expect(Array.from((container.firstChild as HTMLElement).classList)).toEqual(expect.arrayContaining(atoms)); - }); - - // The shape the account profile takes as a modal: the page inside the popup, self-contained. - it('names a panel dialog and carries its dismiss from inside the popup', () => { - render( - - - - - - - , - ); - - // Named from inside, the way `Card.Title` names a card dialog — nothing is passed in. - const popup = screen.getByRole('dialog', { name: 'User profile' }); - expect(popup).toContainElement(document.querySelector('.cl-profile-page')); - // And the dismiss comes from the page too, the way `Card.Header` carries a card's. - expect(popup).toContainElement(screen.getByRole('button', { name: 'Close' })); - expect(popup).toContainElement(screen.getByRole('tab', { name: 'Security' })); - }); - - it('carries no dismiss standalone, or inline', () => { - const standalone = renderView(); - expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); - standalone.unmount(); - - render( - - - - - - - , - ); - // `inline` forces the dialog open, so the page is on screen — and still carries no dismiss. - expect(screen.getByRole('dialog', { name: 'User profile' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); - }); - - it('drops its standalone minimum height inside a dialog, where the popup decides', () => { - const probe = stylex.create({ floor: { minHeight: '37.5rem' } }); - const atoms = stylex - .props(probe.floor) - .className!.split(' ') - .filter(name => !name.includes('__')); - - const standalone = renderView(); - expect(Array.from((standalone.container.firstChild as HTMLElement).classList)).toEqual( - expect.arrayContaining(atoms), - ); - standalone.unmount(); - - render( - - - - - - - , - ); - expect(Array.from(document.querySelector('.cl-profile-page')!.classList)).not.toEqual( - expect.arrayContaining(atoms), - ); - }); - - it('renders no heading for the dialog standalone', () => { - renderView(); - - expect(screen.queryByRole('heading', { name: 'User profile' })).not.toBeInTheDocument(); - }); -}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts b/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts new file mode 100644 index 00000000000..b98bd611b36 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { getAvailableUserProfilePages, resolveUserProfilePages } from '../user-profile.layout'; +import type { CustomProfilePage } from '../user-profile.types'; + +const terms: CustomProfilePage = { label: 'Terms', path: 'terms', content: null }; +const help: CustomProfilePage = { label: 'Help', path: 'help', content: null }; + +describe('getAvailableUserProfilePages', () => { + it('keeps the built-in order and drops pages without content', () => { + expect(getAvailableUserProfilePages({ account: {}, apiKeys: { apiKeys: [] } })).toEqual(['account', 'apiKeys']); + expect(getAvailableUserProfilePages({ account: {}, security: {}, billing: {} })).toEqual([ + 'account', + 'security', + 'billing', + ]); + }); +}); + +describe('resolveUserProfilePages', () => { + it('lists the built-ins, then the custom pages by path', () => { + expect(resolveUserProfilePages(['account', 'security'], [terms, help])).toEqual([ + { id: 'account' }, + { id: 'security' }, + { id: 'terms', custom: terms }, + { id: 'help', custom: help }, + ]); + }); + + it('moves the named ids to the front, in the order named, and drops names matching nothing', () => { + expect( + resolveUserProfilePages(['account', 'security'], [terms], ['terms', 'billing', 'account']).map(e => e.id), + ).toEqual(['terms', 'account', 'security']); + }); + + it('lets a custom page shadow a built-in it shares an id with', () => { + const shadow: CustomProfilePage = { label: 'Mine', path: 'security', content: null }; + expect(resolveUserProfilePages(['account', 'security'], [shadow])).toEqual([{ id: 'account' }, { id: 'security' }]); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile.view.test.tsx new file mode 100644 index 00000000000..e7225444ee4 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile.view.test.tsx @@ -0,0 +1,161 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { Dialog } from '../../components/dialog'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileViewProps } from '../user-profile.view'; +import { UserProfileView } from '../user-profile.view'; + +const pages: UserProfileViewProps['pages'] = { + account: { name: 'Preston Booth', username: 'prestonxyz' }, + security: { hasPassword: true }, + billing: { + subscription: { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }, + paymentMethods: [], + historyItems: [], + }, + apiKeys: { + apiKeys: [], + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + }, +}; + +function renderView(overrides: Partial = {}) { + const props: UserProfileViewProps = { + activePage: 'account', + pages, + onPageChange: vi.fn(), + ...overrides, + }; + + return { + ...render( + + + , + ), + props, + }; +} + +describe('UserProfileView', () => { + it('renders the active page and every available destination, in order', () => { + renderView(); + + expect(screen.getByRole('navigation', { name: 'User profile' })).toBeInTheDocument(); + expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual([ + 'Account', + 'Security', + 'Billing', + 'API Keys', + ]); + expect(screen.getByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tabpanel')).toHaveAccessibleName('Account'); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); + expect(screen.getByText(/Secured by/)).toBeInTheDocument(); + }); + + it('forwards page changes', async () => { + const onPageChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPageChange }); + + await user.click(screen.getByRole('tab', { name: 'Security' })); + + expect(onPageChange).toHaveBeenCalledWith('security'); + }); + + it('only lists the pages it was given content for', () => { + renderView({ pages: { account: pages.account, apiKeys: pages.apiKeys } }); + + expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Account', 'API Keys']); + }); + + it('falls back to the first page when the requested one is unavailable', () => { + renderView({ activePage: 'billing', pages: { account: pages.account } }); + + expect(screen.getByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); + }); + + it('adds custom pages after the built-ins and renders their content', async () => { + const user = userEvent.setup(); + const onPageChange = vi.fn(); + renderView({ + onPageChange, + customPages: [ + { label: 'Terms', path: 'terms', icon: , content:

Terms of service

}, + ], + }); + + const terms = screen.getByRole('tab', { name: 'Terms' }); + expect(screen.getAllByRole('tab').at(-1)).toBe(terms); + expect(terms).toContainElement(screen.getByTestId('terms-icon')); + expect(screen.getByText('Terms of service')).not.toBeVisible(); + + await user.click(terms); + expect(onPageChange).toHaveBeenCalledWith('terms'); + }); + + it('shows a custom page when it is the active one', () => { + renderView({ + activePage: 'terms', + customPages: [{ label: 'Terms', path: 'terms', content:

Terms of service

}], + }); + + expect(screen.getByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Terms of service')).toBeVisible(); + }); + + it('reorders the navigation by id, leaving the unnamed behind the named', () => { + renderView({ + customPages: [{ label: 'Terms', path: 'terms', content:

Terms of service

}], + pageOrder: ['terms', 'security'], + }); + + expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual([ + 'Terms', + 'Security', + 'Account', + 'Billing', + 'API Keys', + ]); + }); + + it('can omit Clerk branding, and be renamed', () => { + renderView({ renderBranding: false, label: 'Mon compte' }); + + expect(screen.queryByText(/Secured by/)).not.toBeInTheDocument(); + expect(screen.getByRole('navigation', { name: 'Mon compte' })).toBeInTheDocument(); + }); + + // The shape the account profile takes as a modal: the profile inside the popup, self-contained. + it('names a profile dialog and carries its dismiss from inside the popup', () => { + render( + + + + + + + , + ); + + const popup = screen.getByRole('dialog', { name: 'User profile' }); + expect(popup).toContainElement(screen.getByRole('button', { name: 'Close' })); + expect(popup).toContainElement(screen.getByRole('tab', { name: 'Security' })); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-page.view.tsx b/packages/ui/src/mosaic/user-profile/user-page.view.tsx deleted file mode 100644 index ea9e0755fc7..00000000000 --- a/packages/ui/src/mosaic/user-profile/user-page.view.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react'; - -import type { ProfilePageRootProps } from '../profile-page'; -import { ProfilePage } from '../profile-page'; -import type { UserProfileApiKeysPanelViewProps } from './user-profile-api-keys-panel.view'; -import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view'; -import type { UserProfileBillingPanelViewProps } from './user-profile-billing-panel.view'; -import { UserProfileBillingPanelView } from './user-profile-billing-panel.view'; -import type { UserProfileProfilePanelViewProps } from './user-profile-profile-panel.view'; -import { UserProfileProfilePanelView } from './user-profile-profile-panel.view'; -import type { UserProfileSecurityPanelViewProps } from './user-profile-security-panel.view'; -import { UserProfileSecurityPanelView } from './user-profile-security-panel.view'; -import type { UserProfilePanelId } from './user-profile-sidebar'; -import { UserProfileSidebar } from './user-profile-sidebar'; - -export interface UserPagePanels { - account: UserProfileProfilePanelViewProps; - security?: UserProfileSecurityPanelViewProps; - billing?: UserProfileBillingPanelViewProps; - apiKeys?: UserProfileApiKeysPanelViewProps; -} - -export interface UserPageViewProps extends Omit { - activePanel: UserProfilePanelId; - panels: UserPagePanels; - onPanelChange: (panel: UserProfilePanelId) => void; - renderBranding?: boolean; - /** Names the page, and the dialog it is rendered in. Defaults to English; pass a localized string once one is available. */ - label?: string; -} - -function getAvailablePanels(panels: UserPagePanels): UserProfilePanelId[] { - return [ - 'account', - ...(panels.security ? (['security'] as const) : []), - ...(panels.billing ? (['billing'] as const) : []), - ...(panels.apiKeys ? (['api-keys'] as const) : []), - ]; -} - -function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPagePanels }): React.ReactElement { - switch (panel) { - case 'security': - return panels.security ? ( - - ) : ( - - ); - case 'billing': - return panels.billing ? ( - - ) : ( - - ); - case 'api-keys': - return panels.apiKeys ? ( - - ) : ( - - ); - case 'account': - return ; - } -} - -export const UserPageView = React.forwardRef(function UserPageView( - { - activePanel, - panels, - onPanelChange, - renderBranding = true, - label = 'User profile', - render, - className, - style, - ...rest - }, - ref, -) { - const availablePanels = getAvailablePanels(panels); - const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'account'; - const handlePanelChange = (value: string) => { - const panel = availablePanels.find(candidate => candidate === value); - if (panel) { - onPanelChange(panel); - } - }; - - return ( - - - - {availablePanels.map(panel => ( - - - - ))} - - - ); -}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx index a403a9ab629..910812c71a9 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx @@ -3,10 +3,10 @@ import type { ReactElement } from 'react'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; -import { Heading } from '../components/heading'; import { Icon } from '../components/icon'; import { Input } from '../components/input'; import { Menu } from '../components/menu'; +import { Profile } from '../components/profile'; import { mergeStyleProps, themeProps } from '../props'; import { styles } from './user-profile-api-keys-panel.styles'; @@ -65,12 +65,7 @@ export function UserProfileApiKeysPanelView({ return (
-

} - size='2xl' - > - API Keys - + API Keys
-

} - size='2xl' - > - Billing - + Billing
-

} - size='2xl' - > - Account - + Account
-

} - size='2xl' - > - Security - + Security
{hasAuthentication ? (
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx deleted file mode 100644 index 1d1b6f9cafd..00000000000 --- a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import type { IconName } from '../icons/registry'; -import type { ProfilePageSidebarProps } from '../profile-page'; -import { ProfilePage } from '../profile-page'; - -export type UserProfilePanelId = 'account' | 'security' | 'billing' | 'api-keys'; - -const destinations: Record = { - account: { label: 'Account', icon: 'user-circle' }, - security: { label: 'Security', icon: 'shield-check' }, - billing: { label: 'Billing', icon: 'credit-card' }, - 'api-keys': { label: 'API Keys', icon: 'code' }, -}; - -export interface UserProfileSidebarProps extends Omit { - panels: readonly UserProfilePanelId[]; - renderBranding?: boolean; -} - -export const UserProfileSidebar = React.forwardRef(function UserProfileSidebar( - { panels, ...rest }, - ref, -) { - return ( - ({ value, ...destinations[value] }))} - navigationLabel='User profile' - {...rest} - /> - ); -}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile.layout.ts b/packages/ui/src/mosaic/user-profile/user-profile.layout.ts new file mode 100644 index 00000000000..26dd74e6ae0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.layout.ts @@ -0,0 +1,40 @@ +import type { IconName } from '../icons/registry'; +import { applyOrder } from '../utils/apply-order'; +import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; + +/** The built-in pages in the order the profile lists them before a consumer reorders anything. */ +export const USER_PROFILE_PAGE_IDS: readonly UserProfilePageId[] = ['account', 'security', 'billing', 'apiKeys']; + +export const USER_PROFILE_PAGE_ICONS: Record = { + account: 'user-circle', + security: 'shield-check', + billing: 'credit-card', + apiKeys: 'code', +}; + +/** One row of the navigation: a built-in page by id, or a page of the consumer's own. */ +export type UserProfileNavEntry = + | { id: UserProfilePageId; custom?: undefined } + | { id: string; custom: CustomProfilePage }; + +/** The built-in pages this instance was given content for. `account` is always among them. */ +export function getAvailableUserProfilePages(pages: UserProfilePages): UserProfilePageId[] { + return USER_PROFILE_PAGE_IDS.filter(id => pages[id] !== undefined); +} + +/** + * The navigation, in order: the built-ins the instance shows, then the consumer's pages, with + * `order` moving any of them by id (a custom page's id is its `path`). Same rule as the + * UserButton's menu — see `applyOrder`. + */ +export function resolveUserProfilePages( + builtIn: readonly UserProfilePageId[], + customPages: readonly CustomProfilePage[] = [], + order?: readonly string[], +): UserProfileNavEntry[] { + const entries: UserProfileNavEntry[] = [ + ...builtIn.map(id => ({ id })), + ...customPages.map(page => ({ id: page.path, custom: page })), + ]; + return applyOrder(order, entries, entry => entry.id); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile.messages.ts new file mode 100644 index 00000000000..74edb95e4e0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.messages.ts @@ -0,0 +1,15 @@ +/** + * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so + * localizing this component is a matter of registering the namespace and swapping the reads for + * `useMessages('userProfile', userProfileBase)`, not of hunting the literals down first. + */ +export const userProfileBase = { + /** Names the surface: its navigation landmark, and the dialog it opens in. */ + label: 'User profile', + pages: { + account: 'Account', + security: 'Security', + billing: 'Billing', + apiKeys: 'API Keys', + }, +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile.types.ts b/packages/ui/src/mosaic/user-profile/user-profile.types.ts new file mode 100644 index 00000000000..a7d042e6ee5 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.types.ts @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react'; + +import type { UserProfileApiKeysPanelViewProps } from './user-profile-api-keys-panel.view'; +import type { UserProfileBillingPanelViewProps } from './user-profile-billing-panel.view'; +import type { UserProfileProfilePanelViewProps } from './user-profile-profile-panel.view'; +import type { UserProfileSecurityPanelViewProps } from './user-profile-security-panel.view'; + +/** A page the UserProfile brings itself, named by the id its navigation knows it as. */ +export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys'; + +/** The built-in pages an instance shows: `account` always, the rest as the environment allows. */ +export interface UserProfilePages { + account: UserProfileProfilePanelViewProps; + security?: UserProfileSecurityPanelViewProps; + billing?: UserProfileBillingPanelViewProps; + apiKeys?: UserProfileApiKeysPanelViewProps; +} + +/** A page of your own inside the profile, reached from its navigation. */ +export interface CustomProfilePage { + /** Names the page in the profile's navigation. */ + label: string; + /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */ + path: string; + href?: never; + icon?: ReactNode; + /** Rendered as the page itself. */ + content: ReactNode; +} + +/** A row in the profile's navigation that leaves for somewhere else. */ +export interface CustomProfileLink { + /** Names the row in the profile's navigation. */ + label: string; + /** Identifies the row, for ordering. */ + path: string; + /** Where the row goes. */ + href: string; + icon?: ReactNode; + content?: never; +} + +export type CustomProfileItem = CustomProfilePage | CustomProfileLink; diff --git a/packages/ui/src/mosaic/user-profile/user-profile.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile.view.tsx new file mode 100644 index 00000000000..ef8bf187cbc --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.view.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +import { Icon } from '../components/icon'; +import type { ProfileRootProps } from '../components/profile'; +import { Profile } from '../components/profile'; +import { getAvailableUserProfilePages, resolveUserProfilePages, USER_PROFILE_PAGE_ICONS } from './user-profile.layout'; +import { userProfileBase as m } from './user-profile.messages'; +import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; +import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view'; +import { UserProfileBillingPanelView } from './user-profile-billing-panel.view'; +import { UserProfileProfilePanelView } from './user-profile-profile-panel.view'; +import { UserProfileSecurityPanelView } from './user-profile-security-panel.view'; + +export interface UserProfileViewProps extends Omit { + /** The open page: a built-in page's id, or a custom page's `path`. */ + activePage: UserProfilePageId | (string & {}); + pages: UserProfilePages; + /** Pages of the consumer's own, added to the navigation after the built-ins. */ + customPages?: readonly CustomProfilePage[]; + /** + * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids + * left out keep their default place behind the ones named. + */ + pageOrder?: readonly (UserProfilePageId | (string & {}))[]; + onPageChange: (page: UserProfilePageId | (string & {})) => void; +} + +function BuiltInPage({ id, pages }: { id: UserProfilePageId; pages: UserProfilePages }): React.ReactElement | null { + switch (id) { + case 'account': + return ; + case 'security': + return pages.security ? : null; + case 'billing': + return pages.billing ? : null; + case 'apiKeys': + return pages.apiKeys ? : null; + } +} + +/** + * The user profile as a `Profile`: the built-in pages the instance has content for, the + * consumer's own pages after them, in the order asked for. An `activePage` the navigation does + * not list falls back to the first one, so a page turned off by the environment cannot leave the + * surface blank. + */ +export const UserProfileView = React.forwardRef(function UserProfileView( + { activePage, pages, customPages, pageOrder, onPageChange, label = m.label, ...rest }, + ref, +) { + const entries = resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder); + const resolvedPage = entries.some(entry => entry.id === activePage) ? activePage : entries[0].id; + + return ( + + + {entries.map(entry => ( + + ) + } + > + {entry.custom ? entry.custom.label : m.pages[entry.id]} + + ))} + + + {entries.map(entry => ( + + {entry.custom ? ( + entry.custom.content + ) : ( + + )} + + ))} + + + ); +}); diff --git a/packages/ui/src/mosaic/utils/apply-order.ts b/packages/ui/src/mosaic/utils/apply-order.ts new file mode 100644 index 00000000000..a5f638ff83d --- /dev/null +++ b/packages/ui/src/mosaic/utils/apply-order.ts @@ -0,0 +1,22 @@ +/** + * The one ordering rule every list a consumer can reorder follows: the ids `order` names lead, in + * the order it names them, and whatever it leaves out keeps its default place behind them. + * + * A name matching no item is dropped rather than held open, since which items a surface carries + * depends on how it was configured and naming one it has not got is ordinary rather than a mistake. + * Two items sharing an id are one item: the first wins, so a consumer's own row shadows the built-in + * it was given the name of instead of both answering to it. + */ +export function applyOrder( + order: readonly string[] | undefined, + items: readonly T[], + idOf: (item: T) => string, +): T[] { + const unique = items.filter((item, index, all) => all.findIndex(other => idOf(other) === idOf(item)) === index); + if (!order?.length) { + return unique; + } + + const named = [...new Set(order)].flatMap(id => unique.filter(item => idOf(item) === id)); + return [...named, ...unique.filter(item => !named.includes(item))]; +} From 91ab0407828c781629cb9e7873e1d063dfa43fdd Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 15:52:26 -0600 Subject: [PATCH 02/31] feat(ui): name the Profile with a hidden title, tighten its scroll inset `Profile.Title` replaces the `label` prop: a visually hidden heading that names the navigation, the compact sheet, and the dialog the profile opens in. The content's clip edge sits 8px inside the frame rather than 16px. Swingset: the drawer's "inside a profile" example is the real user profile, and the user profile story gains an overlay example. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- packages/swingset/src/lib/registry.ts | 7 +- .../swingset/src/stories/drawer.component.mdx | 6 +- .../src/stories/drawer.component.stories.tsx | 66 ++++--------------- .../src/stories/profile.component.mdx | 5 +- .../src/stories/profile.component.stories.tsx | 2 +- .../swingset/src/stories/user-profile.mdx | 10 +++ .../src/stories/user-profile.stories.tsx | 24 +++++++ .../ui/src/mosaic/components/profile/index.ts | 1 + .../components/profile/profile.styles.ts | 4 +- .../components/profile/profile.test.tsx | 11 ++-- .../src/mosaic/components/profile/profile.tsx | 64 ++++++++++++------ packages/ui/src/mosaic/styles/index.ts | 1 + .../mosaic/user-profile/user-profile.view.tsx | 4 +- 13 files changed, 117 insertions(+), 88 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index b3b8eb0b984..c6784f98e7f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -160,7 +160,11 @@ import { Organizations as UserButtonOrganizations, User as UserButtonUser, } from '../stories/user-button.stories'; -import { Default as UserProfileDefault, meta as userProfileMeta } from '../stories/user-profile.stories'; +import { + Default as UserProfileDefault, + meta as userProfileMeta, + Overlay as UserProfileOverlay, +} from '../stories/user-profile.stories'; import { Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, @@ -393,6 +397,7 @@ const userProfileApiKeysPanelModule: StoryModule = { const userProfileModule: StoryModule = { meta: userProfileMeta, Default: UserProfileDefault, + Overlay: UserProfileOverlay, }; const userProfileAccountSectionModule: StoryModule = { diff --git a/packages/swingset/src/stories/drawer.component.mdx b/packages/swingset/src/stories/drawer.component.mdx index fbe8ff74b19..615fa3b24b1 100644 --- a/packages/swingset/src/stories/drawer.component.mdx +++ b/packages/swingset/src/stories/drawer.component.mdx @@ -35,8 +35,10 @@ children are the sheet's content. `Drawer.Root` takes every headless option — ## Inside a profile -Opened from inside a `profile` dialog the sheet takes the nested scrim, the way a prompt opened -there does, so it reads as a surface over the profile rather than as the profile dimming. +The profile's own use of the sheet: narrow the window below the phone band and its navigation +leaves the column for a drawer that each page's headline opens. Inside a `profile` dialog the sheet +takes the nested scrim, the way a prompt opened there does, so it reads as a surface over the +profile rather than as the profile dimming. ` example // renders a code footer with its function's source. See `StoryModule.__source`. export { default as __source } from './drawer.component.stories?raw'; @@ -45,62 +45,20 @@ export function Default() { } /** - * Opened from inside a `profile` dialog: the sheet rises over the profile and takes the nested - * scrim, the way a prompt opened there does. Narrow the window below the phone band to see the - * profile fill the screen first. + * The user profile in a `profile` dialog. Narrow the window below the phone band: the profile fills + * the screen, and its navigation moves into a sheet that each page's headline opens. */ export function InsideProfile() { - const [page, setPage] = useState('members'); + const { activePage, setActivePage, pages } = useUserProfileFixture(); return ( - }>Manage organization + }>Manage account - - - - } - > - General - - - } - > - Members - - - - - General - - -
- Members - - }>Sort - - - - -
-
-
-
+
); diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx index 2b8b8d495d6..939db6888a9 100644 --- a/packages/swingset/src/stories/profile.component.mdx +++ b/packages/swingset/src/stories/profile.component.mdx @@ -25,10 +25,10 @@ import { Icon } from '@clerk/ui/mosaic/components/icon'; import { Profile } from '@clerk/ui/mosaic/components/profile'; + User profile - + Account Account page - - Security page + + Security page ); @@ -94,7 +94,7 @@ describe('Profile', () => { expect(security).toHaveClass('cl-profile-nav-item'); expect(security).toHaveAttribute('data-selected'); expect(screen.getByRole('tab', { name: 'Account' })).not.toHaveAttribute('data-selected'); - expect(screen.getByRole('tabpanel')).toHaveClass('cl-profile-page'); + expect(screen.getByRole('tabpanel')).toHaveClass('cl-profile-tab-panel'); expect(screen.getByRole('tabpanel')).toHaveAttribute('data-value', 'security'); expect(container.querySelector('.cl-profile-content')).toContainElement(screen.getByRole('tabpanel')); }); @@ -221,12 +221,12 @@ describe('Profile', () => { Security - + Account - - + + Security - + ); diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index 29859f9d612..26b0bd87fe1 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -70,7 +70,7 @@ function useCompact(node: HTMLElement | null): boolean { } export interface ProfileRootProps extends Omit, 'children'> { - /** The selected page, by the `value` of its `Profile.NavItem` and `Profile.Page`. */ + /** The selected page, by the `value` of its `Profile.NavItem` and `Profile.TabPanel`. */ value: string; onValueChange?: (value: string) => void; /** @@ -145,7 +145,7 @@ const Root = React.forwardRef(function Profile } wasNavOpen.current = false; const caret = node.querySelector( - '.cl-profile-page:not([hidden]):not([inert]) .cl-profile-nav-trigger', + '.cl-profile-tab-panel:not([hidden]):not([inert]) .cl-profile-nav-trigger', ); caret?.focus(); }, [navOpen, node]); @@ -309,14 +309,14 @@ const Nav = React.forwardRef(function ProfileNav( }); export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { - /** Matches the `value` of the `Profile.Page` this destination opens. */ + /** Matches the `value` of the `Profile.TabPanel` this destination opens. */ value: string; /** Leads the label. Any node, so a page of the consumer's own can bring its own mark. */ icon?: React.ReactNode; disabled?: boolean; } -/** A destination. Selecting it shows the `Profile.Page` sharing its `value`. */ +/** A destination. Selecting it shows the `Profile.TabPanel` sharing its `value`. */ const NavItem = React.forwardRef(function ProfileNavItem( { value, icon, disabled, children, render, className, style, onClick, ...rest }, ref, @@ -458,7 +458,7 @@ const Content = React.forwardRef(function P }); }); -export interface ProfilePageProps extends MosaicComponentProps<'div'> { +export interface ProfileTabPanelProps extends MosaicComponentProps<'div'> { /** Matches the `value` of the `Profile.NavItem` that opens this page. */ value: string; /** @@ -475,7 +475,7 @@ export interface ProfilePageProps extends MosaicComponentProps<'div'> { * — `data-open` / `data-closed`, `data-starting-style` / `data-ending-style`, and * `--cl-tab-transition-direction` — so a page transition is a styling change rather than a new part. */ -const Page = React.forwardRef(function ProfilePage( +const TabPanel = React.forwardRef(function ProfileTabPanel( { value, shouldForceMount, className, style, ...rest }, ref, ) { @@ -484,7 +484,7 @@ const Page = React.forwardRef(function Profile ref={ref} value={value} shouldForceMount={shouldForceMount} - {...mergeStyleProps(themeProps('profile-page', { value }), className, style)} + {...mergeStyleProps(themeProps('profile-tab-panel', { value }), className, style)} {...rest} /> ); @@ -492,8 +492,8 @@ const Page = React.forwardRef(function Profile /** * A surface you navigate, composed through `Profile.Root`, `Profile.Title`, `Profile.Nav`, - * `Profile.NavItem`, `Profile.Content`, `Profile.Page`, and `Profile.PageTitle`. Every part accepts the Mosaic - * `render` prop and forwards its ref. + * `Profile.NavItem`, `Profile.Content`, `Profile.TabPanel`, and `Profile.PageTitle`. Every part + * accepts the Mosaic `render` prop and forwards its ref. * * ```tsx * @@ -502,9 +502,9 @@ const Page = React.forwardRef(function Profile * }>Account * * - * + * * * * ``` */ -export const Profile = { Root, Title, Nav, NavItem, PageTitle, Content, Page }; +export const Profile = { Root, Title, Nav, NavItem, PageTitle, Content, TabPanel }; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 4d561f38f8b..a9b064ef4d5 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -104,7 +104,7 @@ export type { ProfileContentProps, ProfileNavItemProps, ProfileNavProps, - ProfilePageProps, + ProfileTabPanelProps, ProfilePageTitleProps, ProfileRootProps, ProfileTitleProps, diff --git a/packages/ui/src/mosaic/user-profile/user-profile.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile.view.tsx index b6a584efc7c..4fb4b5cf2c0 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile.view.tsx @@ -83,7 +83,7 @@ export const UserProfileView = React.forwardRef {entries.map(entry => ( - @@ -95,7 +95,7 @@ export const UserProfileView = React.forwardRef )} - + ))} From c784cb51dc38f028730e1deb1df386db66b72286 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 16:55:59 -0600 Subject: [PATCH 14/31] chore(repo): stub page content in the Profile transition example Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/stories/profile.component.stories.tsx | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index 38ff0c0e574..840ed8d646e 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -1,6 +1,8 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; import { Icon } from '@clerk/ui/mosaic/components/icon'; import type { ProfileRootProps } from '@clerk/ui/mosaic/components/profile'; import { Profile } from '@clerk/ui/mosaic/components/profile'; +import { Section } from '@clerk/ui/mosaic/components/section'; import { Text } from '@clerk/ui/mosaic/components/text'; import { useState } from 'react'; @@ -44,7 +46,107 @@ function Placeholder({ title }: { title: string }) { ); } -function Surface({ forceMountPages, ...props }: Partial & { forceMountPages?: boolean }) { +/** Stand-in content with the shape of a real page: a headline, then sections of rows. */ +const stubSections: Record = { + general: [ + { + title: 'Profile', + rows: [ + { label: 'Name', description: 'Preston Booth' }, + { label: 'Username', description: 'prestonxyz' }, + { label: 'Email addresses', description: 'preston@clerk.dev, preston.booth@gmail.com' }, + ], + }, + { + title: 'Preferences', + rows: [ + { label: 'Language', description: 'English (US)' }, + { label: 'Time zone', description: 'Mountain Time' }, + ], + }, + ], + security: [ + { + title: 'Sign in', + rows: [ + { label: 'Password', description: 'Last changed 3 months ago' }, + { label: 'Passkeys', description: 'MacBook Pro · iPhone' }, + { label: 'Two-step verification', description: 'Authenticator app, SMS backup' }, + ], + }, + { + title: 'Devices', + rows: [ + { label: 'Safari on macOS', description: 'This device · Salt Lake City, UT' }, + { label: 'Safari on iOS', description: 'Last seen 2 weeks ago · Orem, UT' }, + { label: 'Chrome on Windows', description: 'Last seen 3 months ago · Denver, CO' }, + ], + }, + ], + billing: [ + { + title: 'Subscription', + rows: [ + { label: 'Plan', description: 'Basic · $12 / month' }, + { label: 'Next payment', description: 'Aug 26' }, + ], + }, + { + title: 'Payment methods', + rows: [{ label: 'Visa •••• 0644', description: 'Expires 02/2029 · Default' }], + }, + { + title: 'History', + rows: [ + { label: 'May 26, 2026', description: '$25.00 · Paid' }, + { label: 'Apr 26, 2026', description: '$25.00 · Paid' }, + { label: 'Mar 26, 2026', description: '$12.00 · Paid' }, + ], + }, + ], +}; + +function StubPage({ id, title }: { id: string; title: string }) { + return ( +
+ {title} + {stubSections[id]?.map(section => ( + + {section.title} + + + + {section.rows.map(row => ( + + + {row.label} + {row.description} + + + + + + ))} + + + + + ))} +
+ ); +} + +function Surface({ + forceMountPages, + stub, + ...props +}: Partial & { forceMountPages?: boolean; stub?: boolean }) { const [page, setPage] = useState('general'); return ( & { fo value={item.id} shouldForceMount={forceMountPages} > - + {stub ? ( + + ) : ( + + )} ))} @@ -221,7 +330,10 @@ export function Transitions() { } } `} - + ); } From 79770b15397bf62d5b1d48b9101aa672fc4a99f4 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 17:00:16 -0600 Subject: [PATCH 15/31] fix(ui): hold the Profile content's scrollbar gutter open Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- packages/ui/src/mosaic/components/profile/profile.styles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index 79af5e916f7..da940fcfd02 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -264,4 +264,5 @@ export const styles = stylex.create({ }); export const contentScroll = scrollAreaRoot; -export const contentViewportScroll = scrollAreaViewport(); +// A held gutter: switching to a page that does not scroll must not reflow the one that did. +export const contentViewportScroll = scrollAreaViewport('stable'); From 69df38af2f5d8dbbb99aa6227899804e9cf621f5 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 17:05:36 -0600 Subject: [PATCH 16/31] chore(repo): make the Profile transition example pure CSS over the hidden attribute Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/stories/profile.component.mdx | 12 +++--- .../src/stories/profile.component.stories.tsx | 42 +++++++++---------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx index a910766006a..550aa3c48c6 100644 --- a/packages/swingset/src/stories/profile.component.mdx +++ b/packages/swingset/src/stories/profile.component.mdx @@ -95,11 +95,13 @@ its parent. ## Page transitions -With `shouldForceMount`, a `Page` stays in the document while another is selected — `inert`, and -carrying the tabs primitive's transition attributes: `data-open` / `data-closed`, then -`data-starting-style` on the frame it enters and `data-ending-style` while it leaves, plus -`--cl-tab-transition-direction` for a directional move. A transition is a styling change, not a -new part. Below, the pages share one grid cell and cross-fade with a little scale and blur. +An unselected page carries the `hidden` attribute, and a transition across it is CSS alone — +the CSS a customer adds against a `` they do not render. `@starting-style` gives the +page that just lost `hidden` a frame to enter from; `transition-behavior: allow-discrete` on +`display` holds the page that just gained it until its exit finishes. Stack the pages in one grid +cell and the two cross-fade in place. `shouldForceMount` on `TabPanel` is there for a transition +that needs the primitive's own timing attributes instead (`data-starting-style` / +`data-ending-style`, `--cl-tab-transition-direction`). ` they do + * not render themselves. The primitive hides an unselected page with the `hidden` attribute, and + * CSS can now transition across that: `@starting-style` gives the page that just lost `hidden` a + * frame to enter from, and `transition-behavior: allow-discrete` holds the page that just gained + * it on screen until its exit finishes. The pages share one grid cell so the two cross-fade in + * place. Under `prefers-reduced-motion: reduce` the swap is instant. */ export function Transitions() { return ( @@ -306,22 +307,24 @@ export function Transitions() { } .cl-profile-tab-panel { grid-area: 1 / 1; - transition-property: opacity, scale, filter; - transition-duration: var(--cl-duration-slow); - transition-timing-function: var(--cl-ease-enter); - } - .cl-profile-tab-panel[data-ending-style] { - transition-duration: var(--cl-duration-base); - transition-timing-function: var(--cl-ease-exit); + transition: + opacity var(--cl-duration-slow) var(--cl-ease-enter), + scale var(--cl-duration-slow) var(--cl-ease-enter), + filter var(--cl-duration-slow) var(--cl-ease-enter), + display var(--cl-duration-slow) allow-discrete; + + @starting-style { + opacity: 0; + scale: 0.98; + filter: blur(4px); + } } - .cl-profile-tab-panel[data-starting-style], - .cl-profile-tab-panel[data-ending-style] { + .cl-profile-tab-panel[hidden] { opacity: 0; scale: 0.98; filter: blur(4px); - } - .cl-profile-tab-panel:not([data-open]):not([data-ending-style]) { - display: none; + transition-duration: var(--cl-duration-base); + transition-timing-function: var(--cl-ease-exit); } @media (prefers-reduced-motion: reduce) { .cl-profile-tab-panel { @@ -330,10 +333,7 @@ export function Transitions() { } } `} - + ); } From ce075d3b26515dda845f9e615af1a97e821e6c3f Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 17:08:43 -0600 Subject: [PATCH 17/31] chore(repo): hand off rather than cross-fade in the Profile transition example Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- packages/swingset/src/stories/profile.component.stories.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index 2b506726ca1..1a78dd2ddfa 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -312,6 +312,10 @@ export function Transitions() { scale var(--cl-duration-slow) var(--cl-ease-enter), filter var(--cl-duration-slow) var(--cl-ease-enter), display var(--cl-duration-slow) allow-discrete; + /* The entering page waits for the leaving one to mostly clear — a hand-off, not a + cross-fade. Not on \`display\`, which must show the page at once for the entrance to + have a frame to start from. */ + transition-delay: var(--cl-duration-fast), var(--cl-duration-fast), var(--cl-duration-fast), 0s; @starting-style { opacity: 0; @@ -325,6 +329,7 @@ export function Transitions() { filter: blur(4px); transition-duration: var(--cl-duration-base); transition-timing-function: var(--cl-ease-exit); + transition-delay: 0s; } @media (prefers-reduced-motion: reduce) { .cl-profile-tab-panel { From 5e24fef4624d3cb427f01d774abe753ab6b9b8b4 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 17:11:02 -0600 Subject: [PATCH 18/31] chore(repo): quicken the Profile transition example Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/stories/profile.component.stories.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index 1a78dd2ddfa..67d488c7e8f 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -307,15 +307,19 @@ export function Transitions() { } .cl-profile-tab-panel { grid-area: 1 / 1; + /* Seven tenths of the scale's steps: quick enough to feel like a swap, slow enough to see. */ + --enter: calc(var(--cl-duration-slow) * 0.7); + --exit: calc(var(--cl-duration-base) * 0.7); + --wait: calc(var(--cl-duration-fast) * 0.7); transition: - opacity var(--cl-duration-slow) var(--cl-ease-enter), - scale var(--cl-duration-slow) var(--cl-ease-enter), - filter var(--cl-duration-slow) var(--cl-ease-enter), - display var(--cl-duration-slow) allow-discrete; + opacity var(--enter) var(--cl-ease-enter), + scale var(--enter) var(--cl-ease-enter), + filter var(--enter) var(--cl-ease-enter), + display var(--enter) allow-discrete; /* The entering page waits for the leaving one to mostly clear — a hand-off, not a cross-fade. Not on \`display\`, which must show the page at once for the entrance to have a frame to start from. */ - transition-delay: var(--cl-duration-fast), var(--cl-duration-fast), var(--cl-duration-fast), 0s; + transition-delay: var(--wait), var(--wait), var(--wait), 0s; @starting-style { opacity: 0; @@ -327,7 +331,7 @@ export function Transitions() { opacity: 0; scale: 0.98; filter: blur(4px); - transition-duration: var(--cl-duration-base); + transition-duration: var(--exit); transition-timing-function: var(--cl-ease-exit); transition-delay: 0s; } From 260daf263f479bd3a704d616cb8efb446a45e853 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 11:17:22 -0600 Subject: [PATCH 19/31] fix(ui): act on review of the Profile and Drawer - Drawer: a swipe dismiss no longer leaves its offset for the next open, which made the first drag beat inner scroll. - Profile: a custom page that shares a built-in's id replaces it in place, as applyOrder documents; the compact threshold is inclusive like the container query; widening closes the navigation sheet; compact panels are named by their own title while the tablist is away. - Card test: the dialog size is 'profile'. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/primitives/drawer/drawer.test.tsx | 24 ++++++++++++ .../src/primitives/drawer/use-drawer-drag.ts | 5 +++ .../src/mosaic/components/card/card.test.tsx | 2 +- .../components/profile/profile.test.tsx | 24 ++++++++++++ .../src/mosaic/components/profile/profile.tsx | 37 +++++++++++++++---- .../__tests__/user-profile.layout.test.ts | 8 +++- .../user-profile/user-profile.layout.ts | 14 +++++-- 7 files changed, 99 insertions(+), 15 deletions(-) diff --git a/packages/headless/src/primitives/drawer/drawer.test.tsx b/packages/headless/src/primitives/drawer/drawer.test.tsx index 427526e8f37..ffba6e6bf54 100644 --- a/packages/headless/src/primitives/drawer/drawer.test.tsx +++ b/packages/headless/src/primitives/drawer/drawer.test.tsx @@ -673,6 +673,30 @@ describe('Drawer', () => { expect(swipeY(popup)).toBe('0px'); }); + // A dismiss leaves the swipe offset in place for the exit; the next open must not inherit it, + // or `shouldDrag` short-circuits past the inner-scroll check and drags a list that should scroll. + it('starts the next open at rest after a swipe dismiss', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + await user.click(screen.getByTestId('trigger')); + stubHeight(screen.getByRole('dialog'), 400); + drag(screen.getByRole('dialog'), 0, 200, 200); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + await user.click(screen.getByTestId('trigger')); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + const list = screen.getByTestId('scrollable'); + makeScrollable(list, { scrollHeight: 500, clientHeight: 100, scrollTop: 50 }); + + drag(list, 0, 120, 60); + + expect(swipeY(popup)).toBe(''); + expect(popup).toBeInTheDocument(); + }); + it('does not carry a lost gesture into the next open', async () => { const user = userEvent.setup(); render(); diff --git a/packages/headless/src/primitives/drawer/use-drawer-drag.ts b/packages/headless/src/primitives/drawer/use-drawer-drag.ts index ca2b2a0616f..cdfcf5f0a0f 100644 --- a/packages/headless/src/primitives/drawer/use-drawer-drag.ts +++ b/packages/headless/src/primitives/drawer/use-drawer-drag.ts @@ -109,6 +109,11 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { pid.current = null; removeWindowRelease.current?.(); removeWindowRelease.current = null; + // A dismiss leaves the swipe where the finger let go, on purpose — the exit slides from there. + // The next open starts at rest, and `shouldDrag` reads this ref before it looks at inner + // scroll, so a stale value would drag the sheet where a list should have scrolled. + cfg.current.setSwipe(0); + cfg.current.setVar(DrawerCssVars.swipeProgress, '0'); setIsDragging(false); }, [open]); diff --git a/packages/ui/src/mosaic/components/card/card.test.tsx b/packages/ui/src/mosaic/components/card/card.test.tsx index 97eb1a98d78..2e1d6eec3f0 100644 --- a/packages/ui/src/mosaic/components/card/card.test.tsx +++ b/packages/ui/src/mosaic/components/card/card.test.tsx @@ -317,7 +317,7 @@ describe('Mosaic Card', () => { it('carries no dismiss button in an inline dialog, which nothing closes', () => { render( - + Account diff --git a/packages/ui/src/mosaic/components/profile/profile.test.tsx b/packages/ui/src/mosaic/components/profile/profile.test.tsx index a960ec9b445..e12ba6311e1 100644 --- a/packages/ui/src/mosaic/components/profile/profile.test.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.test.tsx @@ -245,6 +245,30 @@ describe('Profile', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Security' })).toHaveFocus()); }); + // The tab a panel is normally named by exists only while the sheet is open, so compact the + // panel is named by its own title. + it('names the visible panel by its title while the tablist is away', () => { + renderSurface(); + act(() => observe?.(400)); + const panel = screen.getByRole('tabpanel'); + const title = screen.getByRole('heading', { level: 3, name: 'Account' }); + expect(panel).toHaveAttribute('aria-labelledby', title.id); + expect(panel).toHaveAccessibleName('Account'); + }); + + it('closes the sheet when the layout widens, and does not bring it back on narrowing', async () => { + const user = userEvent.setup(); + renderSurface(); + act(() => observe?.(400)); + await user.click(screen.getByRole('button', { name: 'Account' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + act(() => observe?.(1000)); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + act(() => observe?.(400)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + it('returns the tablist to the column when the width comes back', () => { renderSurface(); act(() => observe?.(400)); diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index 26b0bd87fe1..8b8968a3066 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -31,6 +31,9 @@ interface ProfileContextValue { const ProfileContext = React.createContext(null); +/** The id a `Profile.PageTitle` renders under, so the panel around it can be named by it. */ +const TabPanelContext = React.createContext(undefined); + /** * The width below which the layout is compact — the same `48rem` the container query in * `profile.styles.ts` reads, measured here because WHERE the navigation renders is a DOM decision @@ -59,7 +62,8 @@ function useCompact(node: HTMLElement | null): boolean { return; } const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - setCompact(width < COMPACT_WIDTH_REM * rem); + // `<=`: the container query is `max-width`, which is inclusive. + setCompact(width <= COMPACT_WIDTH_REM * rem); }; measure(); const observer = new ResizeObserver(measure); @@ -131,6 +135,13 @@ const Root = React.forwardRef(function Profile const [navOpen, setNavOpen] = React.useState(false); const openNav = React.useCallback(() => setNavOpen(true), []); const closeNav = React.useCallback(() => setNavOpen(false), []); + // Widening past the threshold unmounts the sheet; the state must go with it, or the sheet would + // be back the moment the layout narrowed again, unasked. + React.useEffect(() => { + if (!compact) { + setNavOpen(false); + } + }, [compact]); // The caret that opened the sheet belongs to the page the choice just left, so the sheet's own // return-focus lands on nothing. Focus goes to the caret of the page that is now showing — the // same control, on the destination. @@ -372,6 +383,7 @@ const PageTitle = React.forwardRef(functi ref, ) { const profile = React.useContext(ProfileContext); + const panelTitleId = React.useContext(TabPanelContext); return useRender({ defaultTagName: 'div', render, @@ -386,6 +398,7 @@ const PageTitle = React.forwardRef(functi ...rest, children: ( } size='2xl' > @@ -479,14 +492,22 @@ const TabPanel = React.forwardRef(function { value, shouldForceMount, className, style, ...rest }, ref, ) { + const { compact } = useProfileContext('Profile.TabPanel'); + const titleId = React.useId(); return ( - + + + ); }); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts b/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts index b98bd611b36..80b64582025 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts @@ -33,8 +33,12 @@ describe('resolveUserProfilePages', () => { ).toEqual(['terms', 'account', 'security']); }); - it('lets a custom page shadow a built-in it shares an id with', () => { + it('lets a custom page replace a built-in it shares an id with, in its place', () => { const shadow: CustomProfilePage = { label: 'Mine', path: 'security', content: null }; - expect(resolveUserProfilePages(['account', 'security'], [shadow])).toEqual([{ id: 'account' }, { id: 'security' }]); + expect(resolveUserProfilePages(['account', 'security', 'billing'], [shadow])).toEqual([ + { id: 'account' }, + { id: 'security', custom: shadow }, + { id: 'billing' }, + ]); }); }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile.layout.ts b/packages/ui/src/mosaic/user-profile/user-profile.layout.ts index 26dd74e6ae0..f819adb83ac 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile.layout.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile.layout.ts @@ -24,17 +24,23 @@ export function getAvailableUserProfilePages(pages: UserProfilePages): UserProfi /** * The navigation, in order: the built-ins the instance shows, then the consumer's pages, with - * `order` moving any of them by id (a custom page's id is its `path`). Same rule as the - * UserButton's menu — see `applyOrder`. + * `order` moving any of them by id (a custom page's id is its `path`). A custom page given a + * built-in's id REPLACES it, in its place — `applyOrder`'s rule, applied before the order is. */ export function resolveUserProfilePages( builtIn: readonly UserProfilePageId[], customPages: readonly CustomProfilePage[] = [], order?: readonly string[], ): UserProfileNavEntry[] { + const customById = new Map(customPages.map(page => [page.path, page])); const entries: UserProfileNavEntry[] = [ - ...builtIn.map(id => ({ id })), - ...customPages.map(page => ({ id: page.path, custom: page })), + ...builtIn.map((id): UserProfileNavEntry => { + const custom = customById.get(id); + return custom ? { id, custom } : { id }; + }), + ...customPages + .filter(page => !builtIn.includes(page.path as UserProfilePageId)) + .map(page => ({ id: page.path, custom: page })), ]; return applyOrder(order, entries, entry => entry.id); } From d86e68cbd696e46991329d3c9182de5452120aa6 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:19:00 -0600 Subject: [PATCH 20/31] =?UTF-8?q?refactor(ui):=20act=20on=20Profile=20revi?= =?UTF-8?q?ew=20=E2=80=94=20measuring=20hook,=20shared=20focus=20targets,?= =?UTF-8?q?=20no=20inline=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useContainerMaxWidth: the JavaScript half of a max-width container query, extracted from Profile with its own tests. - The dialog's initialFocus/finalFocus resolvers move to a shared headless hook; Drawer.Popup gains finalFocus, and Profile hands the sheet the headline of the showing page instead of restoring focus itself. The function form now resolves when focus is restored, so a controlled close is covered too. - Profile.Root merges refs through useRender's array form. - The headline caret is sized and aligned in StyleX via Icon's new `inherit` size, not an inline style. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- packages/headless/src/hooks/index.ts | 1 + .../headless/src/hooks/use-focus-target.ts | 120 ++++++++++++++++++ .../src/primitives/dialog/dialog-popup.tsx | 116 +---------------- .../headless/src/primitives/drawer/README.md | 6 + .../src/primitives/drawer/drawer-popup.tsx | 15 ++- .../src/primitives/drawer/drawer.test.tsx | 51 ++++++++ .../headless/src/primitives/drawer/index.ts | 1 + .../src/mosaic/components/drawer/drawer.tsx | 7 +- .../ui/src/mosaic/components/drawer/index.ts | 1 + .../src/mosaic/components/icon/icon.styles.ts | 2 + .../ui/src/mosaic/components/icon/icon.tsx | 2 +- .../components/profile/profile.styles.ts | 10 +- .../src/mosaic/components/profile/profile.tsx | 82 +++--------- .../__tests__/useContainerMaxWidth.test.tsx | 67 ++++++++++ .../src/mosaic/hooks/useContainerMaxWidth.ts | 32 +++++ 15 files changed, 332 insertions(+), 181 deletions(-) create mode 100644 packages/headless/src/hooks/use-focus-target.ts create mode 100644 packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx create mode 100644 packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts diff --git a/packages/headless/src/hooks/index.ts b/packages/headless/src/hooks/index.ts index f75839cbe8a..7d98b770bf7 100644 --- a/packages/headless/src/hooks/index.ts +++ b/packages/headless/src/hooks/index.ts @@ -19,3 +19,4 @@ export { type UseTransitionReturn, } from './use-transition'; export { type TransitionStatus, useTransitionStatus } from './use-transition-status'; +export { type FocusTarget, useFinalFocus, useInitialFocus } from './use-focus-target'; diff --git a/packages/headless/src/hooks/use-focus-target.ts b/packages/headless/src/hooks/use-focus-target.ts new file mode 100644 index 00000000000..506cab332f6 --- /dev/null +++ b/packages/headless/src/hooks/use-focus-target.ts @@ -0,0 +1,120 @@ +'use client'; + +import type { FloatingContext } from '@floating-ui/react'; +import React from 'react'; + +import { type InteractionType, interactionTypeFromEvent } from '../utils/interaction-modality'; + +/** + * Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`), + * mirroring Base UI: + * + * - `true` or omitted — the default: first tabbable element on open, the trigger (with the + * pointer-close downgrade `useReturnFocus` applies) on close + * - `false` — do not move focus + * - a ref — focus that element + * - a function of the interaction type behind the open/close (`''` when programmatic) — + * returns any of the above, with `void`/`null` meaning the default + */ +export type FocusTarget = + | boolean + | React.RefObject + | ((interactionType: InteractionType) => boolean | void | HTMLElement | null); + +/** + * Resolves `initialFocus` into the `number | ref` form `FloatingFocusManager` takes (a negative + * index disables the focus move). The function form reads the open event floating-ui has + * already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it. + */ +export function useInitialFocus( + initialFocus: FocusTarget | undefined, + open: boolean, + floatingContext: FloatingContext, +): number | React.MutableRefObject { + const elementRef = React.useRef(null); + return React.useMemo(() => { + if (!open || initialFocus === undefined || initialFocus === true) { + return 0; + } + if (initialFocus === false) { + return -1; + } + if (typeof initialFocus !== 'function') { + return initialFocus as React.MutableRefObject; + } + const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent)); + if (result === false) { + return -1; + } + if (result instanceof HTMLElement) { + elementRef.current = result; + return elementRef; + } + return 0; + }, [open, initialFocus, floatingContext]); +} + +/** + * Resolves `finalFocus` into the `boolean | ref` form `FloatingFocusManager`'s `returnFocus` + * takes. + * + * The function form needs the event behind the close, so it runs inside floating-ui's + * synchronous `openchange` emit — the root routes every close through + * `floatingContext.onOpenChange`, and the emit precedes both the state commit and any focus + * restoration. Only the function's decision is stored; the ref handed to the focus manager + * materialises it lazily, at restore time, by which point `useReturnFocus` has applied its + * pointer-close downgrade to the default. + */ +export function useFinalFocus( + finalFocus: FocusTarget | undefined, + returnFocusRef: React.MutableRefObject, + floatingContext: FloatingContext, +): boolean | React.MutableRefObject { + const finalFocusRef = React.useRef(finalFocus); + React.useLayoutEffect(() => { + finalFocusRef.current = finalFocus; + }); + + // The function form is resolved when focus is restored, not when the close is requested: a + // controlled close never passes through floating-ui's `openchange` emit, and a decision taken + // early would be taken against the page as it was. What the emit does carry — the event behind a + // close it drove — is kept for the interaction type, and consumed by the restore that follows. + const closeEventRef = React.useRef(undefined); + const resolvedRef = React.useMemo( + () => ({ + get current() { + const target = finalFocusRef.current; + if (typeof target !== 'function') { + return returnFocusRef.current; + } + const event = closeEventRef.current; + closeEventRef.current = undefined; + const result = target(interactionTypeFromEvent(event)); + if (result instanceof HTMLElement) { + return result; + } + return result === false ? null : returnFocusRef.current; + }, + }), + [returnFocusRef], + ); + + React.useLayoutEffect(() => { + function onOpenChange({ open, event }: { open: boolean; event?: Event }) { + closeEventRef.current = open ? undefined : event; + } + floatingContext.events.on('openchange', onOpenChange); + return () => floatingContext.events.off('openchange', onOpenChange); + }, [floatingContext.events]); + + if (finalFocus === undefined || finalFocus === true) { + return returnFocusRef; + } + if (finalFocus === false) { + return false; + } + if (typeof finalFocus === 'function') { + return resolvedRef; + } + return finalFocus as React.MutableRefObject; +} diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 77fb76d710a..d2e12fbcf11 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -1,122 +1,14 @@ 'use client'; -import { type FloatingContext, FloatingFocusManager } from '@floating-ui/react'; +import { FloatingFocusManager } from '@floating-ui/react'; import React from 'react'; +import { type FocusTarget, useFinalFocus, useInitialFocus } from '../../hooks/use-focus-target'; import { type ComponentProps, type DefaultProps, Freeze, mergeProps, useRender } from '../../utils'; -import { type InteractionType, interactionTypeFromEvent } from '../../utils/interaction-modality'; import { useDialogContext } from './dialog-context'; -/** - * Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`), - * mirroring Base UI: - * - * - `true` or omitted — the default: first tabbable element on open, the trigger (with the - * pointer-close downgrade `useReturnFocus` applies) on close - * - `false` — do not move focus - * - a ref — focus that element - * - a function of the interaction type behind the open/close (`''` when programmatic) — - * returns any of the above, with `void`/`null` meaning the default - */ -export type DialogFocusTarget = - | boolean - | React.RefObject - | ((interactionType: InteractionType) => boolean | void | HTMLElement | null); - -/** - * Resolves `initialFocus` into the `number | ref` form `FloatingFocusManager` takes (a negative - * index disables the focus move). The function form reads the open event floating-ui has - * already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it. - */ -function useInitialFocus( - initialFocus: DialogFocusTarget | undefined, - open: boolean, - floatingContext: FloatingContext, -): number | React.MutableRefObject { - const elementRef = React.useRef(null); - return React.useMemo(() => { - if (!open || initialFocus === undefined || initialFocus === true) { - return 0; - } - if (initialFocus === false) { - return -1; - } - if (typeof initialFocus !== 'function') { - return initialFocus as React.MutableRefObject; - } - const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent)); - if (result === false) { - return -1; - } - if (result instanceof HTMLElement) { - elementRef.current = result; - return elementRef; - } - return 0; - }, [open, initialFocus, floatingContext]); -} - -/** - * Resolves `finalFocus` into the `boolean | ref` form `FloatingFocusManager`'s `returnFocus` - * takes. - * - * The function form needs the event behind the close, so it runs inside floating-ui's - * synchronous `openchange` emit — the root routes every close through - * `floatingContext.onOpenChange`, and the emit precedes both the state commit and any focus - * restoration. Only the function's decision is stored; the ref handed to the focus manager - * materialises it lazily, at restore time, by which point `useReturnFocus` has applied its - * pointer-close downgrade to the default. - */ -function useFinalFocus( - finalFocus: DialogFocusTarget | undefined, - returnFocusRef: React.MutableRefObject, - floatingContext: FloatingContext, -): boolean | React.MutableRefObject { - const finalFocusRef = React.useRef(finalFocus); - React.useLayoutEffect(() => { - finalFocusRef.current = finalFocus; - }); - - // The function's last decision: an element, `false` for "don't move focus", `true` for the - // default (the trigger, via `returnFocusRef`). - const decisionRef = React.useRef(true); - const resolvedRef = React.useMemo( - () => ({ - get current() { - const decision = decisionRef.current; - if (decision instanceof HTMLElement) { - return decision; - } - return decision ? returnFocusRef.current : null; - }, - }), - [returnFocusRef], - ); - - React.useLayoutEffect(() => { - function onOpenChange({ open, event }: { open: boolean; event?: Event }) { - const target = finalFocusRef.current; - if (open || typeof target !== 'function') { - return; - } - const result = target(interactionTypeFromEvent(event)); - decisionRef.current = result instanceof HTMLElement ? result : result !== false; - } - floatingContext.events.on('openchange', onOpenChange); - return () => floatingContext.events.off('openchange', onOpenChange); - }, [floatingContext.events]); - - if (finalFocus === undefined || finalFocus === true) { - return returnFocusRef; - } - if (finalFocus === false) { - return false; - } - if (typeof finalFocus === 'function') { - return resolvedRef; - } - return finalFocus as React.MutableRefObject; -} +/** Where a popup's focus goes on open (`initialFocus`) or close (`finalFocus`); see `useFocusTarget`. */ +export type DialogFocusTarget = FocusTarget; /** Props for {@link DialogPopup}. */ export interface DialogPopupProps extends ComponentProps<'div'> { diff --git a/packages/headless/src/primitives/drawer/README.md b/packages/headless/src/primitives/drawer/README.md index 81dd19decf3..a55401f1776 100644 --- a/packages/headless/src/primitives/drawer/README.md +++ b/packages/headless/src/primitives/drawer/README.md @@ -136,6 +136,12 @@ sheet resists overshooting. | -------- | -------------- | ---------------------------------------------------------------- | | `handle` | `DrawerHandle` | Drive a detached handle instead of the surrounding `Drawer.Root` | +### `Drawer.Popup` + +| Prop | Type | Default | Description | +| ------------ | ----------------------------------------------------------- | ----------- | --------------------------------------------------------------- | +| `finalFocus` | `boolean \| RefObject \| (interactionType) => Element \| …` | the trigger | Where focus returns on close; same contract as `Dialog.Popup`'s | + ### `Drawer.Viewport` | Prop | Type | Default | Description | diff --git a/packages/headless/src/primitives/drawer/drawer-popup.tsx b/packages/headless/src/primitives/drawer/drawer-popup.tsx index 5388e23153f..eb9249d01c4 100644 --- a/packages/headless/src/primitives/drawer/drawer-popup.tsx +++ b/packages/headless/src/primitives/drawer/drawer-popup.tsx @@ -3,12 +3,19 @@ import { FloatingFocusManager } from '@floating-ui/react'; import React, { useEffect } from 'react'; +import { type FocusTarget, useFinalFocus } from '../../hooks/use-focus-target'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { DrawerAttrs, DrawerCssVars } from './css-vars'; import { useDrawerContext } from './drawer-context'; /** Props for {@link DrawerPopup}. */ -export type DrawerPopupProps = ComponentProps<'div'>; +export interface DrawerPopupProps extends ComponentProps<'div'> { + /** + * Where focus returns when the drawer closes. Default: the trigger, via `useReturnFocus`. The + * function form is called with the close's interaction type and may return an element. + */ + finalFocus?: FocusTarget; +} /** * The drawer sheet (`role="dialog"`). Hosts the drag gesture, focus trapping @@ -17,7 +24,7 @@ export type DrawerPopupProps = ComponentProps<'div'>; * opening on touch does not summon the keyboard. */ export const DrawerPopup = React.forwardRef(function DrawerPopup(props, ref) { - const { render, ...otherProps } = props; + const { render, finalFocus, ...otherProps } = props; const { popupRef, refs, @@ -98,6 +105,8 @@ export const DrawerPopup = React.forwardRef(fu props: mergeProps<'div'>(defaultProps, otherProps), }); + const resolvedReturnFocus = useFinalFocus(finalFocus, returnFocusRef, floatingContext); + if (!element) { return null; } @@ -108,7 +117,7 @@ export const DrawerPopup = React.forwardRef(fu modal={modal} outsideElementsInert={modal} initialFocus={autoFocus ? undefined : popupRef} - returnFocus={returnFocusRef} + returnFocus={resolvedReturnFocus} > {element} diff --git a/packages/headless/src/primitives/drawer/drawer.test.tsx b/packages/headless/src/primitives/drawer/drawer.test.tsx index ffba6e6bf54..759ecc2f346 100644 --- a/packages/headless/src/primitives/drawer/drawer.test.tsx +++ b/packages/headless/src/primitives/drawer/drawer.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import React from 'react'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; @@ -292,6 +293,56 @@ describe('Drawer', () => { }); }); + describe('final focus', () => { + // A close driven from outside — a controlled `open` flipping — never passes through + // floating-ui's own emit, so the function is consulted when focus is restored instead. + it('returns focus where the finalFocus function points, on a controlled close', async () => { + function Controlled() { + const [open, setOpen] = React.useState(true); + const target = React.useRef(null); + return ( + <> + + + + + + target.current}> + Sheet + + + + + + + ); + } + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('inside-close')); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('target')).toHaveFocus()); + }); + }); + describe('dismiss behavior', () => { it('outside press closes a modal drawer', async () => { const onOpenChange = vi.fn(); diff --git a/packages/headless/src/primitives/drawer/index.ts b/packages/headless/src/primitives/drawer/index.ts index 87ad1d67951..f6d128a63f3 100644 --- a/packages/headless/src/primitives/drawer/index.ts +++ b/packages/headless/src/primitives/drawer/index.ts @@ -7,6 +7,7 @@ export { createDrawerHandle } from './drawer-handle'; export type { DrawerHandle } from './drawer-handle'; export { DrawerCssVars, DrawerAttrs, registerDrawerCssVars } from './css-vars'; +export type { FocusTarget as DrawerFocusTarget } from '../../hooks/use-focus-target'; export type { DrawerBackdropProps, diff --git a/packages/ui/src/mosaic/components/drawer/drawer.tsx b/packages/ui/src/mosaic/components/drawer/drawer.tsx index 7e95cc48e61..104ab4544e0 100644 --- a/packages/ui/src/mosaic/components/drawer/drawer.tsx +++ b/packages/ui/src/mosaic/components/drawer/drawer.tsx @@ -1,4 +1,4 @@ -import type { DrawerProps as HeadlessDrawerProps } from '@clerk/headless/drawer'; +import type { DrawerFocusTarget, DrawerProps as HeadlessDrawerProps } from '@clerk/headless/drawer'; import { Drawer as Primitive, registerDrawerCssVars } from '@clerk/headless/drawer'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -22,6 +22,8 @@ export interface DrawerPopupProps extends MosaicComponentProps<'div'> { * screen less a hand's width. @default 'content' */ height?: DrawerHeight; + /** Where focus returns when the sheet closes. Default: the trigger. */ + finalFocus?: DrawerFocusTarget; } /** @@ -80,7 +82,7 @@ const Description = React.forwardRef(function DrawerPopup( - { height = 'content', children, render, className, style, ...rest }, + { height = 'content', finalFocus, children, render, className, style, ...rest }, ref, ) { const host = React.useContext(DialogContext); @@ -99,6 +101,7 @@ const Popup = React.forwardRef(function Drawer { name: IconName; - size?: 'sm' | 'md' | 'lg'; + size?: 'sm' | 'md' | 'lg' | 'inherit'; placement?: 'inline-start' | 'inline-end'; } diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index da940fcfd02..673a539389c 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -257,9 +257,17 @@ export const styles = stylex.create({ display: 'inline', textAlign: 'start', }, + /** + * Beside the title, `vertical-align: middle`: the caret's midpoint on the baseline plus half the + * x-height, which centres it on the lowercase letters rather than the line box. Sized in `em` + * through the icon's `inherit` size, so it scales with the heading; coloured through the icon's + * own variable rather than `color`, which the icon sets itself. + */ caret: { - color: colorVars['--cl-color-neutral-faded'], + '--_cl-icon-color': colorVars['--cl-color-neutral-faded'], + fontSize: '0.6em', marginInlineStart: '0.25em', + verticalAlign: 'middle', }, }); diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index 8b8968a3066..3c214eb4d21 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -4,6 +4,7 @@ import { useRender } from '@clerk/headless/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import { useContainerMaxWidth } from '../../hooks/useContainerMaxWidth'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../utils/focus-outline.styles'; @@ -27,6 +28,8 @@ interface ProfileContextValue { openNav: () => void; closeNav: () => void; navSheetHeight: DrawerHeight; + /** The root element, for parts that have to find something inside the profile. */ + root: HTMLElement | null; } const ProfileContext = React.createContext(null); @@ -39,7 +42,7 @@ const TabPanelContext = React.createContext(undefined); * `profile.styles.ts` reads, measured here because WHERE the navigation renders is a DOM decision * CSS cannot make: one tablist, in the column or in the sheet, never both. */ -const COMPACT_WIDTH_REM = 48; +const COMPACT_MAX_WIDTH_REM = 48; function useProfileContext(part: string): ProfileContextValue { const context = React.useContext(ProfileContext); @@ -49,30 +52,6 @@ function useProfileContext(part: string): ProfileContextValue { return context; } -function useCompact(node: HTMLElement | null): boolean { - const [compact, setCompact] = React.useState(false); - React.useLayoutEffect(() => { - if (!node || typeof ResizeObserver === 'undefined') { - return; - } - const measure = () => { - const width = node.getBoundingClientRect().width; - // Not laid out (hidden, or a test document): nothing to conclude, keep what was known. - if (width === 0) { - return; - } - const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - // `<=`: the container query is `max-width`, which is inclusive. - setCompact(width <= COMPACT_WIDTH_REM * rem); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(node); - return () => observer.disconnect(); - }, [node]); - return compact; -} - export interface ProfileRootProps extends Omit, 'children'> { /** The selected page, by the `value` of its `Profile.NavItem` and `Profile.TabPanel`. */ value: string; @@ -131,7 +110,7 @@ const Root = React.forwardRef(function Profile const generatedTitleId = React.useId(); const titleId = dialog?.labelId ?? generatedTitleId; const [node, setNode] = React.useState(null); - const compact = useCompact(node); + const compact = useContainerMaxWidth(node, COMPACT_MAX_WIDTH_REM); const [navOpen, setNavOpen] = React.useState(false); const openNav = React.useCallback(() => setNavOpen(true), []); const closeNav = React.useCallback(() => setNavOpen(false), []); @@ -142,43 +121,14 @@ const Root = React.forwardRef(function Profile setNavOpen(false); } }, [compact]); - // The caret that opened the sheet belongs to the page the choice just left, so the sheet's own - // return-focus lands on nothing. Focus goes to the caret of the page that is now showing — the - // same control, on the destination. - const wasNavOpen = React.useRef(false); - React.useEffect(() => { - if (navOpen) { - wasNavOpen.current = true; - return; - } - if (!wasNavOpen.current || !node) { - return; - } - wasNavOpen.current = false; - const caret = node.querySelector( - '.cl-profile-tab-panel:not([hidden]):not([inert]) .cl-profile-nav-trigger', - ); - caret?.focus(); - }, [navOpen, node]); const context = React.useMemo( - () => ({ titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight }), - [titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight], - ); - const mergedRef = React.useCallback( - (element: HTMLDivElement | null) => { - setNode(element); - if (typeof ref === 'function') { - ref(element); - } else if (ref) { - ref.current = element; - } - }, - [ref], + () => ({ titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, root: node }), + [titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, node], ); const element = useRender({ defaultTagName: 'div', render, - ref: mergedRef, + ref: [setNode, ref], props: { ...mergeStyleProps( themeProps('profile'), @@ -269,7 +219,16 @@ const Nav = React.forwardRef(function ProfileNav( ref, ) { const profile = useProfileContext('Profile.Nav'); - const { titleId, renderBranding, compact, navOpen, closeNav, navSheetHeight } = profile; + const { titleId, renderBranding, compact, navOpen, closeNav, navSheetHeight, root } = profile; + // The headline that opened the sheet belongs to the page a choice just left, so the sheet's own + // return-focus would land on nothing. The headline of the page now showing is the same control, + // on the destination. + const finalFocus = React.useCallback( + () => + root?.querySelector('.cl-profile-tab-panel:not([hidden]):not([inert]) .cl-profile-nav-trigger') ?? + null, + [root], + ); const list = ( {children} @@ -312,6 +271,7 @@ const Nav = React.forwardRef(function ProfileNav( {element} @@ -417,10 +377,8 @@ const PageTitle = React.forwardRef(functi ) : ( diff --git a/packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx new file mode 100644 index 00000000000..86db5c5ea1e --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx @@ -0,0 +1,67 @@ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useContainerMaxWidth } from '../useContainerMaxWidth'; + +function Probe({ maxWidthRem }: { maxWidthRem: number }) { + const [node, setNode] = React.useState(null); + const narrow = useContainerMaxWidth(node, maxWidthRem); + return ( +
+ ); +} + +describe('useContainerMaxWidth', () => { + let resize: ((width: number) => void) | null = null; + const original = globalThis.ResizeObserver; + + beforeEach(() => { + resize = null; + class FakeResizeObserver { + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + resize = width => { + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({ width } as DOMRect); + this.callback([], this as unknown as ResizeObserver); + }; + } + disconnect() {} + unobserve() {} + } + globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + globalThis.ResizeObserver = original; + }); + + it('answers like an inclusive max-width container query, in rem', () => { + render(); + const probe = screen.getByTestId('probe'); + expect(probe).toHaveAttribute('data-narrow', 'false'); + + act(() => resize?.(769)); + expect(probe).toHaveAttribute('data-narrow', 'false'); + act(() => resize?.(768)); + expect(probe).toHaveAttribute('data-narrow', 'true'); + act(() => resize?.(1024)); + expect(probe).toHaveAttribute('data-narrow', 'false'); + }); + + it('keeps its last answer while the element has no layout', () => { + render(); + const probe = screen.getByTestId('probe'); + act(() => resize?.(400)); + expect(probe).toHaveAttribute('data-narrow', 'true'); + act(() => resize?.(0)); + expect(probe).toHaveAttribute('data-narrow', 'true'); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts b/packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts new file mode 100644 index 00000000000..75077706d80 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts @@ -0,0 +1,32 @@ +import React from 'react'; + +/** + * Whether an element is at most `maxWidthRem` wide — the JavaScript half of a + * `@container (max-width: …)` query, for the decisions CSS cannot make: what to render, not how + * to paint it. Measures the element itself rather than the viewport, so it holds in a narrow + * layout slot, an inline dialog, or a phone alike. Inclusive, like the query. + * + * A width of `0` is "not laid out" (hidden, or a test document), not "narrow": the last answer + * stands. Before the first measurement the answer is `false`. + */ +export function useContainerMaxWidth(node: HTMLElement | null, maxWidthRem: number): boolean { + const [matches, setMatches] = React.useState(false); + React.useLayoutEffect(() => { + if (!node || typeof ResizeObserver === 'undefined') { + return; + } + const measure = () => { + const width = node.getBoundingClientRect().width; + if (width === 0) { + return; + } + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + setMatches(width <= maxWidthRem * rem); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [node, maxWidthRem]); + return matches; +} From f2ecaae560276fa78899040d7ad0eb1b00d47d6c Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:26:11 -0600 Subject: [PATCH 21/31] refactor(ui): measure the Profile with a general useMeasure hook Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../components/profile/profile.test.tsx | 9 +-- .../src/mosaic/components/profile/profile.tsx | 16 ++++- .../__tests__/useContainerMaxWidth.test.tsx | 67 ------------------- .../hooks/__tests__/useMeasure.test.tsx | 59 ++++++++++++++++ .../src/mosaic/hooks/useContainerMaxWidth.ts | 32 --------- packages/ui/src/mosaic/hooks/useMeasure.ts | 43 ++++++++++++ 6 files changed, 120 insertions(+), 106 deletions(-) delete mode 100644 packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx create mode 100644 packages/ui/src/mosaic/hooks/__tests__/useMeasure.test.tsx delete mode 100644 packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts create mode 100644 packages/ui/src/mosaic/hooks/useMeasure.ts diff --git a/packages/ui/src/mosaic/components/profile/profile.test.tsx b/packages/ui/src/mosaic/components/profile/profile.test.tsx index e12ba6311e1..5a3ee4a085b 100644 --- a/packages/ui/src/mosaic/components/profile/profile.test.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.test.tsx @@ -167,10 +167,11 @@ describe('Profile', () => { this.callback = callback; } observe(target: Element) { - observe = width => { - vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({ width } as DOMRect); - this.callback([], this as unknown as ResizeObserver); - }; + observe = width => + this.callback( + [{ target, contentRect: { width, height: 600 } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); } disconnect() {} unobserve() {} diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index 3c214eb4d21..c2dea4e31dd 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -4,7 +4,7 @@ import { useRender } from '@clerk/headless/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; -import { useContainerMaxWidth } from '../../hooks/useContainerMaxWidth'; +import { useMeasure } from '../../hooks/useMeasure'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../utils/focus-outline.styles'; @@ -44,6 +44,15 @@ const TabPanelContext = React.createContext(undefined); */ const COMPACT_MAX_WIDTH_REM = 48; +/** `width <= 48rem`, the way the container query reads it. Unmeasured, or not laid out, is wide. */ +function isCompact(width: number | null): boolean { + if (width === null || width === 0) { + return false; + } + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + return width <= COMPACT_MAX_WIDTH_REM * rem; +} + function useProfileContext(part: string): ProfileContextValue { const context = React.useContext(ProfileContext); if (!context) { @@ -110,7 +119,8 @@ const Root = React.forwardRef(function Profile const generatedTitleId = React.useId(); const titleId = dialog?.labelId ?? generatedTitleId; const [node, setNode] = React.useState(null); - const compact = useContainerMaxWidth(node, COMPACT_MAX_WIDTH_REM); + const [measure, { width }] = useMeasure(); + const compact = isCompact(width); const [navOpen, setNavOpen] = React.useState(false); const openNav = React.useCallback(() => setNavOpen(true), []); const closeNav = React.useCallback(() => setNavOpen(false), []); @@ -128,7 +138,7 @@ const Root = React.forwardRef(function Profile const element = useRender({ defaultTagName: 'div', render, - ref: [setNode, ref], + ref: [setNode, measure, ref], props: { ...mergeStyleProps( themeProps('profile'), diff --git a/packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx deleted file mode 100644 index 86db5c5ea1e..00000000000 --- a/packages/ui/src/mosaic/hooks/__tests__/useContainerMaxWidth.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { useContainerMaxWidth } from '../useContainerMaxWidth'; - -function Probe({ maxWidthRem }: { maxWidthRem: number }) { - const [node, setNode] = React.useState(null); - const narrow = useContainerMaxWidth(node, maxWidthRem); - return ( -
- ); -} - -describe('useContainerMaxWidth', () => { - let resize: ((width: number) => void) | null = null; - const original = globalThis.ResizeObserver; - - beforeEach(() => { - resize = null; - class FakeResizeObserver { - private readonly callback: ResizeObserverCallback; - constructor(callback: ResizeObserverCallback) { - this.callback = callback; - } - observe(target: Element) { - resize = width => { - vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({ width } as DOMRect); - this.callback([], this as unknown as ResizeObserver); - }; - } - disconnect() {} - unobserve() {} - } - globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; - }); - - afterEach(() => { - globalThis.ResizeObserver = original; - }); - - it('answers like an inclusive max-width container query, in rem', () => { - render(); - const probe = screen.getByTestId('probe'); - expect(probe).toHaveAttribute('data-narrow', 'false'); - - act(() => resize?.(769)); - expect(probe).toHaveAttribute('data-narrow', 'false'); - act(() => resize?.(768)); - expect(probe).toHaveAttribute('data-narrow', 'true'); - act(() => resize?.(1024)); - expect(probe).toHaveAttribute('data-narrow', 'false'); - }); - - it('keeps its last answer while the element has no layout', () => { - render(); - const probe = screen.getByTestId('probe'); - act(() => resize?.(400)); - expect(probe).toHaveAttribute('data-narrow', 'true'); - act(() => resize?.(0)); - expect(probe).toHaveAttribute('data-narrow', 'true'); - }); -}); diff --git a/packages/ui/src/mosaic/hooks/__tests__/useMeasure.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useMeasure.test.tsx new file mode 100644 index 00000000000..38547fbe487 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useMeasure.test.tsx @@ -0,0 +1,59 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { useMeasure } from '../useMeasure'; + +function Probe() { + const [ref, { width, height }] = useMeasure(); + return ( +
+ ); +} + +describe('useMeasure', () => { + let report: ((width: number, height: number) => void) | null = null; + const original = globalThis.ResizeObserver; + + beforeEach(() => { + report = null; + class FakeResizeObserver { + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + report = (width, height) => + this.callback( + [{ target, contentRect: { width, height } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + disconnect() {} + unobserve() {} + } + globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + globalThis.ResizeObserver = original; + }); + + it('is unmeasured until the observer reports, then tracks the content box', () => { + render(); + const probe = screen.getByTestId('probe'); + expect(probe).toHaveAttribute('data-width', 'null'); + expect(probe).toHaveAttribute('data-height', 'null'); + + act(() => report?.(400, 300)); + expect(probe).toHaveAttribute('data-width', '400'); + expect(probe).toHaveAttribute('data-height', '300'); + + act(() => report?.(1024, 300)); + expect(probe).toHaveAttribute('data-width', '1024'); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts b/packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts deleted file mode 100644 index 75077706d80..00000000000 --- a/packages/ui/src/mosaic/hooks/useContainerMaxWidth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; - -/** - * Whether an element is at most `maxWidthRem` wide — the JavaScript half of a - * `@container (max-width: …)` query, for the decisions CSS cannot make: what to render, not how - * to paint it. Measures the element itself rather than the viewport, so it holds in a narrow - * layout slot, an inline dialog, or a phone alike. Inclusive, like the query. - * - * A width of `0` is "not laid out" (hidden, or a test document), not "narrow": the last answer - * stands. Before the first measurement the answer is `false`. - */ -export function useContainerMaxWidth(node: HTMLElement | null, maxWidthRem: number): boolean { - const [matches, setMatches] = React.useState(false); - React.useLayoutEffect(() => { - if (!node || typeof ResizeObserver === 'undefined') { - return; - } - const measure = () => { - const width = node.getBoundingClientRect().width; - if (width === 0) { - return; - } - const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - setMatches(width <= maxWidthRem * rem); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(node); - return () => observer.disconnect(); - }, [node, maxWidthRem]); - return matches; -} diff --git a/packages/ui/src/mosaic/hooks/useMeasure.ts b/packages/ui/src/mosaic/hooks/useMeasure.ts new file mode 100644 index 00000000000..b3034163677 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useMeasure.ts @@ -0,0 +1,43 @@ +import React from 'react'; + +export interface Measurement { + /** Content-box width, or `null` before the first measurement. */ + width: number | null; + /** Content-box height, or `null` before the first measurement. */ + height: number | null; +} + +const UNMEASURED: Measurement = { width: null, height: null }; + +/** + * An element's size, kept current by a `ResizeObserver`. Attach the returned ref to the element to + * measure; the measurement is `null` until it has been laid out once. Content box, the box a + * container query measures, so a `width` compared against a breakpoint answers the way + * `@container (max-width: …)` would. + * + * ```tsx + * const [ref, { width }] = useMeasure(); + * const compact = width !== null && width <= 768; + * ``` + */ +export function useMeasure(): [(node: T | null) => void, Measurement] { + const [node, setNode] = React.useState(null); + const [measurement, setMeasurement] = React.useState(UNMEASURED); + + React.useLayoutEffect(() => { + if (!node || typeof ResizeObserver === 'undefined') { + return; + } + // The observer reports once on `observe`, so there is no separate first read. + const observer = new ResizeObserver(entries => { + const rect = entries[0]?.contentRect; + if (rect) { + setMeasurement({ width: rect.width, height: rect.height }); + } + }); + observer.observe(node); + return () => observer.disconnect(); + }, [node]); + + return [setNode, measurement]; +} From 7716619cec75f723ed376d66aaa266abd33d0b5b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:32:27 -0600 Subject: [PATCH 22/31] feat(ui): the inline Profile is the page's content Flush with its host: no frame, background, fixed height or scroll region of its own; the page scrolls, and the branding closes out the pages' column rather than the navigation's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../components/profile/profile.styles.ts | 29 +++++++++++++ .../components/profile/profile.test.tsx | 26 +++++++++--- .../src/mosaic/components/profile/profile.tsx | 42 ++++++++++++++----- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index 673a539389c..475190eda40 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -100,6 +100,20 @@ export const styles = stylex.create({ minHeight: 0, }, + /** + * Inline, the profile is the page's own content: no frame, no background, no fixed height, and + * no scroll region of its own — the page scrolls. Flush with whatever holds it. + */ + layoutInline: { + borderRadius: 0, + borderWidth: '0px', + overflow: 'visible', + backgroundColor: 'transparent', + blockSize: 'auto', + boxShadow: 'none', + gridTemplateRows: 'auto', + }, + layoutInDialog: { blockSize: 'auto', // Lifted off the page like a card in a dialog: the card's elevation, none compact, where the @@ -123,6 +137,11 @@ export const styles = stylex.create({ minWidth: 0, }, + /** Inline there is no frame, so no edge between the column and the pages either. */ + navInline: { + borderInlineEndWidth: '0px', + }, + /** Inside the sheet: no column edge, and the sheet's own content padding frames it. */ navInSheet: { padding: 0, @@ -220,6 +239,16 @@ export const styles = stylex.create({ }, minWidth: 0, }, + /** Inline the column is not a scroll region, so it carries no inset for a clip edge. */ + contentInline: { + paddingBlock: 0, + }, + + /** Inline, the branding closes out the pages' column instead of the navigation's. */ + contentBranding: { + marginBlockStart: space['8'], + }, + contentViewport: { paddingBlock: { [compact]: space['6'], diff --git a/packages/ui/src/mosaic/components/profile/profile.test.tsx b/packages/ui/src/mosaic/components/profile/profile.test.tsx index 5a3ee4a085b..6a6af9edad4 100644 --- a/packages/ui/src/mosaic/components/profile/profile.test.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.test.tsx @@ -318,7 +318,7 @@ describe('Profile', () => { // Switching pages must never resize the surface: standalone and inline it holds a fixed height // and scrolls inside; over the page the popup's height is the one that counts. - it('holds a fixed height standalone and inline, and hands it to the popup over the page', () => { + it('holds a fixed height standalone, and hands it to the popup over the page', () => { const probe = stylex.create({ fixed: { blockSize: '45rem' }, handed: { blockSize: 'auto' } }); const fixed = atomsOf(probe.fixed); const handed = atomsOf(probe.handed); @@ -329,12 +329,28 @@ describe('Profile', () => { expect(frame()).toEqual(expect.arrayContaining(fixed)); standalone.unmount(); - const inline = renderInDialog(true); - expect(frame()).toEqual(expect.arrayContaining(fixed)); - inline.unmount(); - renderInDialog(); expect(frame()).toEqual(expect.arrayContaining(handed)); }); + + // Inline the profile is the page's content: no frame, no scroll region of its own, and the + // branding closes the pages' column out rather than the navigation's. + it('is flush and unframed inline, and scrolls with the page', () => { + const probe = stylex.create({ + frameless: { borderWidth: '0px', overflow: 'visible', backgroundColor: 'transparent', blockSize: 'auto' }, + scroller: { overflowY: 'auto' }, + }); + renderInDialog(true); + + const frame = document.querySelector('.cl-profile-layout')!; + expect(Array.from(frame.classList)).toEqual(expect.arrayContaining(atomsOf(probe.frameless))); + const viewport = document.querySelector('.cl-profile-content-viewport')!; + expect(Array.from(viewport.classList)).not.toEqual(expect.arrayContaining(atomsOf(probe.scroller))); + expect(document.querySelector('.cl-profile-content')).toHaveAttribute('data-inline'); + + const branding = screen.getByText(/Secured by/); + expect(document.querySelector('.cl-profile-content-body')).toContainElement(branding); + expect(screen.getByRole('navigation')).not.toContainElement(branding); + }); }); }); diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index c2dea4e31dd..af5578f672e 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -30,6 +30,8 @@ interface ProfileContextValue { navSheetHeight: DrawerHeight; /** The root element, for parts that have to find something inside the profile. */ root: HTMLElement | null; + /** Presented as the page's content rather than over it — see `Dialog.Root`'s `inline`. */ + inline: boolean; } const ProfileContext = React.createContext(null); @@ -114,6 +116,7 @@ const Root = React.forwardRef(function Profile ref, ) { const dialog = React.useContext(DialogContext); + const inline = dialog?.inline ?? false; // Inside a dialog the title takes the id the popup points `aria-labelledby` at, so the surface // names the dialog without knowing it is in one — the way `Card.Title` does. const generatedTitleId = React.useId(); @@ -132,8 +135,8 @@ const Root = React.forwardRef(function Profile } }, [compact]); const context = React.useMemo( - () => ({ titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, root: node }), - [titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, node], + () => ({ titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, root: node, inline }), + [titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, node, inline], ); const element = useRender({ defaultTagName: 'div', @@ -156,7 +159,12 @@ const Root = React.forwardRef(function Profile
{children} @@ -229,7 +237,7 @@ const Nav = React.forwardRef(function ProfileNav( ref, ) { const profile = useProfileContext('Profile.Nav'); - const { titleId, renderBranding, compact, navOpen, closeNav, navSheetHeight, root } = profile; + const { titleId, renderBranding, compact, navOpen, closeNav, navSheetHeight, root, inline } = profile; // The headline that opened the sheet belongs to the page a choice just left, so the sheet's own // return-focus would land on nothing. The headline of the page now showing is the same control, // on the destination. @@ -252,7 +260,7 @@ const Nav = React.forwardRef(function ProfileNav( 'aria-labelledby': titleId, ...mergeStyleProps( themeProps('profile-nav', { compact }), - stylex.props(reset.base, styles.nav, compact && styles.navInSheet), + stylex.props(reset.base, styles.nav, inline && styles.navInline, compact && styles.navInSheet), className, style, ), @@ -260,7 +268,7 @@ const Nav = React.forwardRef(function ProfileNav( children: ( <> {list} - {renderBranding && !compact ? : null} + {renderBranding && !compact && !inline ? : null} ), }, @@ -403,22 +411,24 @@ const PageTitle = React.forwardRef(functi export type ProfileContentProps = MosaicComponentProps<'div'>; /** - * The column the pages render in. It is the surface's scroll region — the navigation stays put - * while a long page scrolls — and a plain `div`: the profile is often the content of the host's + * The column the pages render in. Standalone and over the page it is the surface's scroll region + * — the navigation stays put while a long page scrolls; inline the page itself scrolls and the + * branding closes the column out. A plain `div`: the profile is often the content of the host's * own `main`, or of a dialog, so it claims no landmark. */ const Content = React.forwardRef(function ProfileContent( { children, render, className, style, ...rest }, ref, ) { + const { inline, compact, renderBranding } = useProfileContext('Profile.Content'); return useRender({ defaultTagName: 'div', render, ref, props: { ...mergeStyleProps( - themeProps('profile-content'), - stylex.props(reset.base, styles.content, contentScroll), + themeProps('profile-content', { inline }), + stylex.props(reset.base, styles.content, inline ? styles.contentInline : contentScroll), className, style, ), @@ -427,11 +437,21 @@ const Content = React.forwardRef(function P
{children} + {inline && renderBranding && !compact ? ( +
+ +
+ ) : null}
), From 0c89a67629034205de7a5c7d4e669e6e33f0e548 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:37:39 -0600 Subject: [PATCH 23/31] chore(repo): show the user profile story inline Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../swingset/src/stories/user-profile.mdx | 4 +++- .../src/stories/user-profile.stories.tsx | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index fc1cf05eb27..56155a1da3c 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -4,7 +4,9 @@ import * as Stories from './user-profile.stories'; The complete user profile: a `Profile` whose navigation lists the Account, Security, Billing, and API Keys pages it was given content for, then any pages of the consumer's own, in the order asked -for. Rendered inside a `profile` dialog it names it, carries its dismiss, and fills its height. +for. Below it is a page's content — an `inline` dialog — so it is flush with the page, unframed, and +scrolls with it. Rendered over the page instead, it names the dialog, carries its dismiss, and +fills its height. ) { } /** - * The profile in a page. The fixture stands in for the model and controller: every page's data, + * The profile as a page's content: an `inline` dialog, so it is flush with its host, unframed, and + * scrolls with the page. The fixture stands in for the model and controller: every page's data, * and actions that update it, so the surface behaves. */ export function Default(props: Record) { const { activePage, setActivePage, pages } = useUserProfileFixture(); return ( - + + + + + ); } From 30227ec3d7e70c18cb47f61956b98a9ac4409642 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:41:58 -0600 Subject: [PATCH 24/31] fix(ui): level the inline Profile's headline with its navigation Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../ui/src/mosaic/components/profile/profile.styles.ts | 8 ++++++++ packages/ui/src/mosaic/components/profile/profile.tsx | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index 475190eda40..2f7b1ea0049 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -244,6 +244,14 @@ export const styles = stylex.create({ paddingBlock: 0, }, + /** + * Inline the pages start level with the navigation: the same padding above the headline as + * above the first destination, so the two read as one row. + */ + contentViewportInline: { + paddingBlockStart: space['4'], + }, + /** Inline, the branding closes out the pages' column instead of the navigation's. */ contentBranding: { marginBlockStart: space['8'], diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index af5578f672e..7fad451b5f6 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -437,7 +437,11 @@ const Content = React.forwardRef(function P
From 5eb7f1d9e19e4bafbdf03f0441844e152a85d3d7 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:52:11 -0600 Subject: [PATCH 25/31] fix(ui): inline Profile columns carry no padding, a 2.5rem gap apart Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/mosaic/components/profile/profile.styles.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index 2f7b1ea0049..5ab7542f6d3 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -111,6 +111,8 @@ export const styles = stylex.create({ backgroundColor: 'transparent', blockSize: 'auto', boxShadow: 'none', + // No frame to inset from, so neither column carries padding; a gap holds them apart. + columnGap: space['10'], gridTemplateRows: 'auto', }, @@ -139,6 +141,7 @@ export const styles = stylex.create({ /** Inline there is no frame, so no edge between the column and the pages either. */ navInline: { + padding: 0, borderInlineEndWidth: '0px', }, @@ -244,12 +247,10 @@ export const styles = stylex.create({ paddingBlock: 0, }, - /** - * Inline the pages start level with the navigation: the same padding above the headline as - * above the first destination, so the two read as one row. - */ + /** Inline the pages carry no padding of their own: the headline starts level with the first destination. */ contentViewportInline: { - paddingBlockStart: space['4'], + paddingBlock: 0, + paddingInline: 0, }, /** Inline, the branding closes out the pages' column instead of the navigation's. */ From 2d5644fe0cb5f356e7f1ed28f7426f3aadf8a324 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:57:10 -0600 Subject: [PATCH 26/31] feat(ui): Profile takes an elevation, flush being the page's own content Like Card: `card` is framed with the pages scrolling inside; `flush` is unframed, scrolls with the page, and is held to navigation + gap + reading column, centred. An inline dialog still implies flush. The user profile story renders flush without a dialog around it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/stories/profile.component.mdx | 5 +++- .../src/stories/profile.component.stories.tsx | 2 ++ .../swingset/src/stories/user-profile.mdx | 6 ++--- .../src/stories/user-profile.stories.tsx | 23 ++++++++----------- .../ui/src/mosaic/components/profile/index.ts | 1 + .../components/profile/profile.styles.ts | 11 +++++++-- .../components/profile/profile.test.tsx | 17 ++++++++++++++ .../src/mosaic/components/profile/profile.tsx | 18 ++++++++++++--- packages/ui/src/mosaic/styles/index.ts | 1 + 9 files changed, 62 insertions(+), 22 deletions(-) diff --git a/packages/swingset/src/stories/profile.component.mdx b/packages/swingset/src/stories/profile.component.mdx index 550aa3c48c6..2617eb9e59f 100644 --- a/packages/swingset/src/stories/profile.component.mdx +++ b/packages/swingset/src/stories/profile.component.mdx @@ -50,7 +50,10 @@ import { Profile } from '@clerk/ui/mosaic/components/profile'; ; ``` -`Root` is controlled: `value` names the open page and `onValueChange` reports a selection. A +`Root` is controlled: `value` names the open page and `onValueChange` reports a selection. Its +`elevation` picks how it sits, the way `Card`'s does: `card` (default) is framed, with a fixed +height and the pages scrolling inside; `flush` is the page's own content — no frame, the page +scrolls, the columns a gap apart, held to a reading width and centred. A `NavItem` and a `Page` pair by `value`. `icon` takes any node, so a page of the consumer's own can bring its own mark. `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. diff --git a/packages/swingset/src/stories/profile.component.stories.tsx b/packages/swingset/src/stories/profile.component.stories.tsx index 67d488c7e8f..1e505c62403 100644 --- a/packages/swingset/src/stories/profile.component.stories.tsx +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -19,9 +19,11 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/profile/profile.tsx', styles: { _variants: { + elevation: { card: {}, flush: {} }, renderBranding: { true: {}, false: {} }, }, _defaultVariants: { + elevation: 'card', renderBranding: true, }, }, diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index 56155a1da3c..11ca74f0813 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -4,9 +4,9 @@ import * as Stories from './user-profile.stories'; The complete user profile: a `Profile` whose navigation lists the Account, Security, Billing, and API Keys pages it was given content for, then any pages of the consumer's own, in the order asked -for. Below it is a page's content — an `inline` dialog — so it is flush with the page, unframed, and -scrolls with it. Rendered over the page instead, it names the dialog, carries its dismiss, and -fills its height. +for. Below it is a page's content — `elevation='flush'` — unframed, flush with the page, scrolling +with it. Rendered over the page instead, it names the dialog, carries its dismiss, and fills its +height. ) { } /** - * The profile as a page's content: an `inline` dialog, so it is flush with its host, unframed, and - * scrolls with the page. The fixture stands in for the model and controller: every page's data, - * and actions that update it, so the surface behaves. + * The profile as a page's content — `elevation='flush'`, the way a `Card` chooses its elevation: + * unframed, flush with its host, scrolling with the page. The fixture stands in for the model and + * controller: every page's data, and actions that update it, so the surface behaves. */ export function Default(props: Record) { const { activePage, setActivePage, pages } = useUserProfileFixture(); return ( - - - - - + ); } diff --git a/packages/ui/src/mosaic/components/profile/index.ts b/packages/ui/src/mosaic/components/profile/index.ts index 26cc39990ad..051fa71d8eb 100644 --- a/packages/ui/src/mosaic/components/profile/index.ts +++ b/packages/ui/src/mosaic/components/profile/index.ts @@ -1,6 +1,7 @@ export { Profile } from './profile'; export type { ProfileContentProps, + ProfileElevation, ProfileNavItemProps, ProfileNavProps, ProfileTabPanelProps, diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index 5ab7542f6d3..766ebc54ff9 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -22,6 +22,10 @@ const compact = '@container cl-profile (max-width: 48rem)' as const; /** How far the content's clip edge — and the scrollbar with it — sits inside the frame's corners. */ const SCROLL_INSET = space['1.5']; +const NAV_WIDTH = `calc(${space['40']} + ${space['15']})`; +/** The pages' reading width — see `contentBody`. */ +const CONTENT_MAX_WIDTH = '56rem'; + const NAV_GAP = space['0.5']; const HALF_GAP = `calc(-1 * ${NAV_GAP} / 2)`; @@ -94,7 +98,7 @@ export const styles = stylex.create({ display: 'grid', gridTemplateColumns: { [compact]: 'minmax(0, 1fr)', - default: `calc(${space['40']} + ${space['15']}) minmax(0, 1fr)`, + default: `${NAV_WIDTH} minmax(0, 1fr)`, }, gridTemplateRows: 'minmax(0, 1fr)', minHeight: 0, @@ -107,6 +111,8 @@ export const styles = stylex.create({ layoutInline: { borderRadius: 0, borderWidth: '0px', + // As wide as the navigation, the gap and the pages' reading column, centred in the host. + marginInline: 'auto', overflow: 'visible', backgroundColor: 'transparent', blockSize: 'auto', @@ -114,6 +120,7 @@ export const styles = stylex.create({ // No frame to inset from, so neither column carries padding; a gap holds them apart. columnGap: space['10'], gridTemplateRows: 'auto', + maxWidth: `calc(${NAV_WIDTH} + ${space['10']} + ${CONTENT_MAX_WIDTH})`, }, layoutInDialog: { @@ -272,7 +279,7 @@ export const styles = stylex.create({ /** The pages' column: held to a reading width and centred, however wide the frame runs. */ contentBody: { marginInline: 'auto', - maxInlineSize: '56rem', + maxInlineSize: CONTENT_MAX_WIDTH, }, /** The headline row. */ diff --git a/packages/ui/src/mosaic/components/profile/profile.test.tsx b/packages/ui/src/mosaic/components/profile/profile.test.tsx index 6a6af9edad4..eabdcb08fb3 100644 --- a/packages/ui/src/mosaic/components/profile/profile.test.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.test.tsx @@ -279,6 +279,23 @@ describe('Profile', () => { }); }); + // `flush` is the page-content presentation without a dialog around it: the same look an inline + // dialog implies, chosen the way `Card` chooses its elevation. + it('takes the flush presentation from its elevation prop', () => { + const probe = stylex.create({ frameless: { borderWidth: '0px', backgroundColor: 'transparent' } }); + const flush = renderSurface({ elevation: 'flush' }); + expect(flush.container.querySelector('.cl-profile')).toHaveAttribute('data-elevation', 'flush'); + expect(Array.from(flush.container.querySelector('.cl-profile-layout')!.classList)).toEqual( + expect.arrayContaining(atomsOf(probe.frameless)), + ); + expect(flush.container.querySelector('.cl-profile-content')).toHaveAttribute('data-inline'); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + flush.unmount(); + + const framed = renderSurface(); + expect(framed.container.querySelector('.cl-profile')).toHaveAttribute('data-elevation', 'card'); + }); + describe('inside a dialog', () => { function renderInDialog(inline = false) { return render( diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index 7fad451b5f6..be3ab63636e 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -30,7 +30,7 @@ interface ProfileContextValue { navSheetHeight: DrawerHeight; /** The root element, for parts that have to find something inside the profile. */ root: HTMLElement | null; - /** Presented as the page's content rather than over it — see `Dialog.Root`'s `inline`. */ + /** Flush: the page's own content — `elevation='flush'`, or an `inline` dialog. */ inline: boolean; } @@ -63,6 +63,8 @@ function useProfileContext(part: string): ProfileContextValue { return context; } +export type ProfileElevation = 'card' | 'flush'; + export interface ProfileRootProps extends Omit, 'children'> { /** The selected page, by the `value` of its `Profile.NavItem` and `Profile.TabPanel`. */ value: string; @@ -82,6 +84,15 @@ export interface ProfileRootProps extends Omit, 'chi * @default true */ renderBranding?: boolean; + /** + * How the surface sits in its host, the way `Card`'s does. `card` is framed: border, radius, a + * fixed height with the pages scrolling inside. `flush` is the page's own content: no frame or + * background, the page scrolls, the columns a gap apart. Over the page, in a `profile` dialog, + * the popup decides and this is moot; an `inline` dialog implies `flush`. + * + * @default 'card' + */ + elevation?: ProfileElevation; /** * TEMPORARY, for design review: how tall the compact navigation sheet stands. Removed once a * height is chosen. @@ -106,6 +117,7 @@ const Root = React.forwardRef(function Profile orientation = 'vertical', activationMode, renderBranding = true, + elevation = 'card', navSheetHeight = 'content', children, render, @@ -116,7 +128,7 @@ const Root = React.forwardRef(function Profile ref, ) { const dialog = React.useContext(DialogContext); - const inline = dialog?.inline ?? false; + const inline = elevation === 'flush' || (dialog?.inline ?? false); // Inside a dialog the title takes the id the popup points `aria-labelledby` at, so the surface // names the dialog without knowing it is in one — the way `Card.Title` does. const generatedTitleId = React.useId(); @@ -144,7 +156,7 @@ const Root = React.forwardRef(function Profile ref: [setNode, measure, ref], props: { ...mergeStyleProps( - themeProps('profile'), + themeProps('profile', { elevation: inline ? 'flush' : 'card' }), stylex.props(reset.base, styles.root, dialog !== null && !dialog.inline && styles.rootInDialog), className, style, diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index b38887e3d7e..d01eeac5d74 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -104,6 +104,7 @@ export type { export { Profile } from '../components/profile'; export type { ProfileContentProps, + ProfileElevation, ProfileNavItemProps, ProfileNavProps, ProfileTabPanelProps, From a42faa3a27b305b106003269b7d24bf35c4507cc Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 16:59:27 -0600 Subject: [PATCH 27/31] fix(ui): let the flush Profile fill to its cap before centring Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- packages/ui/src/mosaic/components/profile/profile.styles.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index 766ebc54ff9..1106b228fc9 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -111,7 +111,6 @@ export const styles = stylex.create({ layoutInline: { borderRadius: 0, borderWidth: '0px', - // As wide as the navigation, the gap and the pages' reading column, centred in the host. marginInline: 'auto', overflow: 'visible', backgroundColor: 'transparent', @@ -120,6 +119,9 @@ export const styles = stylex.create({ // No frame to inset from, so neither column carries padding; a gap holds them apart. columnGap: space['10'], gridTemplateRows: 'auto', + // As wide as the navigation, the gap and the pages' reading column, centred in the host. The + // explicit width matters: auto margins on a column-flex item otherwise shrink it to its content. + inlineSize: '100%', maxWidth: `calc(${NAV_WIDTH} + ${space['10']} + ${CONTENT_MAX_WIDTH})`, }, From 07e0397f9f9b2e8529fc1242ce855cf5c58f5d04 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 17:13:31 -0600 Subject: [PATCH 28/31] fix(ui): the navigation sheet opens at two thirds; a press is not a drag Removes the temporary navSheetHeight knob. The headless drawer now marks swiping on the first committed move rather than on pointerdown, so pressing a tab inside the sheet no longer puts the grip in its held state. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../headless/src/primitives/drawer/README.md | 24 +++++++++---------- .../src/primitives/drawer/drawer.test.tsx | 16 +++++++++++++ .../src/primitives/drawer/use-drawer-drag.ts | 6 ++++- .../swingset/src/stories/user-profile.mdx | 4 +--- .../src/stories/user-profile.stories.tsx | 20 ++-------------- .../src/mosaic/components/profile/profile.tsx | 16 ++++--------- 6 files changed, 40 insertions(+), 46 deletions(-) diff --git a/packages/headless/src/primitives/drawer/README.md b/packages/headless/src/primitives/drawer/README.md index a55401f1776..b63b11f214e 100644 --- a/packages/headless/src/primitives/drawer/README.md +++ b/packages/headless/src/primitives/drawer/README.md @@ -176,18 +176,18 @@ properties via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` i ### Data attributes -| Attribute | Applies to | Meaning | -| ------------------------------------------- | ---------------------------------- | ------------------------------- | -| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | -| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase | -| `data-swiping` | Popup, Backdrop | A drag is in progress | -| `data-snap` | Popup | Active snap index | -| `data-expanded` | Popup | Resting at the full-height snap | -| `data-nested` | Popup | This drawer is itself nested | -| `data-nested-drawer-open` | Popup | A nested child is open | -| `data-nested-drawer-swiping` | Popup | A nested child is being dragged | -| `data-drawer-handle` | Handle | Grip / `handleOnly` hit-test | -| `data-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging | +| Attribute | Applies to | Meaning | +| ------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- | +| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | +| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase | +| `data-swiping` | Popup, Backdrop | A drag is in progress (from the first move that commits, not the press) | +| `data-snap` | Popup | Active snap index | +| `data-expanded` | Popup | Resting at the full-height snap | +| `data-nested` | Popup | This drawer is itself nested | +| `data-nested-drawer-open` | Popup | A nested child is open | +| `data-nested-drawer-swiping` | Popup | A nested child is being dragged | +| `data-drawer-handle` | Handle | Grip / `handleOnly` hit-test | +| `data-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging | The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above. diff --git a/packages/headless/src/primitives/drawer/drawer.test.tsx b/packages/headless/src/primitives/drawer/drawer.test.tsx index 759ecc2f346..f146eae2529 100644 --- a/packages/headless/src/primitives/drawer/drawer.test.tsx +++ b/packages/headless/src/primitives/drawer/drawer.test.tsx @@ -635,6 +635,22 @@ describe('Drawer', () => { fireEvent.pointerUp(popup, { pointerId: 1, clientY: 100 }); }); + // A press on a control inside the sheet is not a drag: nothing that keys on `data-swiping` — the + // grip's held colour, the transition freeze — should react until the sheet actually moves. + it('marks swiping only once a move commits to dragging the sheet', () => { + render(); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(popup, { pointerId: 1, clientY: 100, button: 0, pointerType: 'touch' }); + expect(popup).not.toHaveAttribute('data-swiping'); + clock.t += 30; + fireEvent.pointerMove(popup, { pointerId: 1, clientY: 140 }); + expect(popup).toHaveAttribute('data-swiping'); + fireEvent.pointerUp(popup, { pointerId: 1, clientY: 140 }); + expect(popup).not.toHaveAttribute('data-swiping'); + }); + it('updates the swipe-progress var and swiping attribute during a drag', () => { render(); const popup = screen.getByRole('dialog'); diff --git a/packages/headless/src/primitives/drawer/use-drawer-drag.ts b/packages/headless/src/primitives/drawer/use-drawer-drag.ts index cdfcf5f0a0f..dcea2c9d2f0 100644 --- a/packages/headless/src/primitives/drawer/use-drawer-drag.ts +++ b/packages/headless/src/primitives/drawer/use-drawer-drag.ts @@ -233,7 +233,8 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { vel.current = 0; allowed.current = false; draggingRef.current = true; - setIsDragging(true); + // Not `setIsDragging` yet: a press is not a drag. `data-swiping` — and everything the styled + // layer hangs off it — lands on the first move that commits to dragging the sheet. // Capture the actual target (not the popup) so a click on an inner control // still lands on it; the popup handler keeps receiving bubbled moves. (vaul) @@ -285,6 +286,9 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { if (!allowed.current && !shouldDrag(e.target as HTMLElement, down)) { return; } + if (!allowed.current) { + setIsDragging(true); + } allowed.current = true; sample(e.clientY, clock()); diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx index 11ca74f0813..5fd35d80134 100644 --- a/packages/swingset/src/stories/user-profile.mdx +++ b/packages/swingset/src/stories/user-profile.mdx @@ -24,9 +24,7 @@ height. The same view inside a `profile` dialog, opened from a trigger on the page. The dialog positions and animates the popup; the profile paints it, names it, and carries the dismiss. - - - diff --git a/packages/swingset/src/stories/user-profile.stories.tsx b/packages/swingset/src/stories/user-profile.stories.tsx index 94940f4d2b9..658c5b662f5 100644 --- a/packages/swingset/src/stories/user-profile.stories.tsx +++ b/packages/swingset/src/stories/user-profile.stories.tsx @@ -1,6 +1,5 @@ import { Button } from '@clerk/ui/mosaic/components/button'; import { Dialog } from '@clerk/ui/mosaic/components/dialog'; -import type { UserProfileViewProps } from '@clerk/ui/mosaic/user-profile/user-profile.view'; import { UserProfileView } from '@clerk/ui/mosaic/user-profile/user-profile.view'; import type { StoryMeta } from '@/lib/types'; @@ -15,27 +14,14 @@ export const meta: StoryMeta = { label: 'User profile', layout: 'wide', source: 'packages/ui/src/mosaic/user-profile/user-profile.view.tsx', - // TEMPORARY, for design review: the compact navigation sheet's height. - styles: { - _variants: { - navSheetHeight: { content: {}, 'two-thirds': {}, full: {} }, - }, - _defaultVariants: { - navSheetHeight: 'content', - }, - }, }; -function knobsAsProps(props: Record) { - return props as unknown as Pick; -} - /** * The profile as a page's content — `elevation='flush'`, the way a `Card` chooses its elevation: * unframed, flush with its host, scrolling with the page. The fixture stands in for the model and * controller: every page's data, and actions that update it, so the surface behaves. */ -export function Default(props: Record) { +export function Default() { const { activePage, setActivePage, pages } = useUserProfileFixture(); return ( ) { pages={pages} onPageChange={setActivePage} elevation='flush' - {...knobsAsProps(props)} /> ); } @@ -52,7 +37,7 @@ export function Default(props: Record) { * The same profile as an overlay: opened from a trigger on the page into a `profile` dialog, which * positions it while the profile paints itself, names the dialog, and carries its dismiss. */ -export function Overlay(props: Record) { +export function Overlay() { const { activePage, setActivePage, pages } = useUserProfileFixture(); return ( @@ -62,7 +47,6 @@ export function Overlay(props: Record) { activePage={activePage} pages={pages} onPageChange={setActivePage} - {...knobsAsProps(props)} /> diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index be3ab63636e..16d861f7ea0 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -11,7 +11,6 @@ import { focusOutline } from '../../utils/focus-outline.styles'; import { reset } from '../../utils/reset.styles'; import { Branding } from '../branding'; import { Dialog, DialogContext } from '../dialog'; -import type { DrawerHeight } from '../drawer'; import { Drawer } from '../drawer'; import { Heading } from '../heading'; import { Icon } from '../icon'; @@ -27,7 +26,6 @@ interface ProfileContextValue { navOpen: boolean; openNav: () => void; closeNav: () => void; - navSheetHeight: DrawerHeight; /** The root element, for parts that have to find something inside the profile. */ root: HTMLElement | null; /** Flush: the page's own content — `elevation='flush'`, or an `inline` dialog. */ @@ -93,11 +91,6 @@ export interface ProfileRootProps extends Omit, 'chi * @default 'card' */ elevation?: ProfileElevation; - /** - * TEMPORARY, for design review: how tall the compact navigation sheet stands. Removed once a - * height is chosen. - */ - navSheetHeight?: DrawerHeight; children: React.ReactNode; } @@ -118,7 +111,6 @@ const Root = React.forwardRef(function Profile activationMode, renderBranding = true, elevation = 'card', - navSheetHeight = 'content', children, render, className, @@ -147,8 +139,8 @@ const Root = React.forwardRef(function Profile } }, [compact]); const context = React.useMemo( - () => ({ titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, root: node, inline }), - [titleId, renderBranding, compact, navOpen, openNav, closeNav, navSheetHeight, node, inline], + () => ({ titleId, renderBranding, compact, navOpen, openNav, closeNav, root: node, inline }), + [titleId, renderBranding, compact, navOpen, openNav, closeNav, node, inline], ); const element = useRender({ defaultTagName: 'div', @@ -249,7 +241,7 @@ const Nav = React.forwardRef(function ProfileNav( ref, ) { const profile = useProfileContext('Profile.Nav'); - const { titleId, renderBranding, compact, navOpen, closeNav, navSheetHeight, root, inline } = profile; + const { titleId, renderBranding, compact, navOpen, closeNav, root, inline } = profile; // The headline that opened the sheet belongs to the page a choice just left, so the sheet's own // return-focus would land on nothing. The headline of the page now showing is the same control, // on the destination. @@ -300,7 +292,7 @@ const Nav = React.forwardRef(function ProfileNav( > {element} From 4394777b4534649eb013634e60a79a67b9fcf649 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 17:23:05 -0600 Subject: [PATCH 29/31] fix(ui): the Drawer grip deepens on press of the handle, not on drag Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/mosaic/components/drawer/drawer.styles.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/mosaic/components/drawer/drawer.styles.ts b/packages/ui/src/mosaic/components/drawer/drawer.styles.ts index 92cdadf277f..15334efa563 100644 --- a/packages/ui/src/mosaic/components/drawer/drawer.styles.ts +++ b/packages/ui/src/mosaic/components/drawer/drawer.styles.ts @@ -72,13 +72,6 @@ export const styles = stylex.create({ * slow drag past the threshold. */ popup: { - // The grip's colour, set here because the drag state lives on the popup and the grip is a - // descendant: it deepens while the sheet is held. - '--_cl-grip-color': { - default: colorVars['--cl-color-border'], - // A step deeper, not a jump: enough to say "held", not enough to read as a state change. - ':where([data-swiping])': `color-mix(in oklab, ${colorVars['--cl-color-border']}, ${colorVars['--cl-color-neutral-faded']} 35%)`, - }, borderColor: { default: null, '@media (forced-colors: active)': 'CanvasText' }, borderStyle: { default: null, '@media (forced-colors: active)': 'solid' }, borderWidth: { default: null, '@media (forced-colors: active)': '1px' }, @@ -135,6 +128,13 @@ export const styles = stylex.create({ /** The drag affordance: a short pill, centred, with a hit area taller than it looks. */ handle: { + // The grip deepens the instant the handle is pressed, and stays so for as long as it is held — + // the pointer is captured on it, so `:active` survives the drag. A press elsewhere in the sheet + // is not a press on the handle, and leaves it alone. + '--_cl-grip-color': { + default: colorVars['--cl-color-border'], + ':active': `color-mix(in oklab, ${colorVars['--cl-color-border']}, ${colorVars['--cl-color-neutral-faded']} 35%)`, + }, placeItems: 'center', display: 'grid', flexShrink: 0, From db14deaafd989b650747471b7d1d8f2de42b329a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 17:45:01 -0600 Subject: [PATCH 30/31] fix(ui): hide the in-place Profile navigation under the compact query before it is measured Before hydration, and before the ResizeObserver's first callback, the Profile has no width and renders its navigation column in place. On a phone the container query has already collapsed the grid, so the tablist stacked above the page until the sheet took over. The column is now display: none under the compact query, which holds without JS; the sheet's copy is portalled out of the container and is unaffected. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../src/mosaic/components/profile/profile.styles.ts | 8 +++++++- .../ui/src/mosaic/components/profile/profile.test.tsx | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/profile/profile.styles.ts b/packages/ui/src/mosaic/components/profile/profile.styles.ts index 1106b228fc9..8f92ae1d183 100644 --- a/packages/ui/src/mosaic/components/profile/profile.styles.ts +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -142,7 +142,13 @@ export const styles = stylex.create({ borderInlineEndColor: colorVars['--cl-color-border'], borderInlineEndStyle: 'solid', borderInlineEndWidth: '1px', - display: 'flex', + // Until measured — before hydration, or the first observer callback — the column renders in + // place at any width, so compact CSS hides it rather than stack a tablist over the page. The + // sheet's copy is portalled out of the container and never matches. + display: { + [compact]: 'none', + default: 'flex', + }, flexDirection: 'column', minHeight: 0, minWidth: 0, diff --git a/packages/ui/src/mosaic/components/profile/profile.test.tsx b/packages/ui/src/mosaic/components/profile/profile.test.tsx index eabdcb08fb3..056af022662 100644 --- a/packages/ui/src/mosaic/components/profile/profile.test.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.test.tsx @@ -136,6 +136,17 @@ describe('Profile', () => { ); }); + // Unmeasured — before hydration, or the observer's first callback — the column renders in place + // at any width; the compact query must hide it, or a phone shows the tablist over the page. + it('hides the in-place navigation under the compact query', () => { + const probe = stylex.create({ + hidden: { display: { default: 'flex', '@container cl-profile (max-width: 48rem)': 'none' } }, + }); + renderSurface(); + + expect(Array.from(screen.getByRole('navigation').classList)).toEqual(expect.arrayContaining(atomsOf(probe.hidden))); + }); + describe('page title', () => { it('is a level-3 heading, and alone outside a profile', () => { render( From 4d2dae303cf845ad9ad11c581d372e4784b0f421 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 8 Sep 2026 17:52:59 -0600 Subject: [PATCH 31/31] refactor(ui): the Drawer always stands two thirds tall The `height` prop, its `content` / `two-thirds` / `full` cells and the `DrawerHeight` type are gone. The only sheet is the Profile's navigation and it always took `two-thirds`, so the rule moves onto the popup itself and the story knob and docs sentence go with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UNzajPkZEavBH31fpxG7sx --- .../swingset/src/stories/drawer.component.mdx | 6 +++--- .../src/stories/drawer.component.stories.tsx | 17 ++--------------- .../mosaic/components/drawer/drawer.styles.ts | 17 ++--------------- .../mosaic/components/drawer/drawer.test.tsx | 13 ------------- .../ui/src/mosaic/components/drawer/drawer.tsx | 17 +++-------------- .../ui/src/mosaic/components/drawer/index.ts | 1 - .../src/mosaic/components/profile/profile.tsx | 1 - packages/ui/src/mosaic/styles/index.ts | 1 - 8 files changed, 10 insertions(+), 63 deletions(-) diff --git a/packages/swingset/src/stories/drawer.component.mdx b/packages/swingset/src/stories/drawer.component.mdx index a84b83e441d..501a595bfa0 100644 --- a/packages/swingset/src/stories/drawer.component.mdx +++ b/packages/swingset/src/stories/drawer.component.mdx @@ -34,9 +34,9 @@ import { Drawer } from '@clerk/ui/mosaic/components/drawer'; ``` `Drawer.Popup` renders the portal, the scrim, the box the sheet rises in, and the grip; its -children are the sheet's content. `height` picks how tall it stands: `content` (default), at least -`two-thirds` of the screen, or `full`, `3rem` short of the top. `Drawer.Root` takes every headless option — `open` / -`onOpenChange`, `snapPoints`, `dismissible`, `handleOnly`, `autoFocus`. +children are the sheet's content. It stands at least two thirds of the screen tall, and taller +content scrolls inside it. `Drawer.Root` takes every headless option — `open` / `onOpenChange`, +`snapPoints`, `dismissible`, `handleOnly`, `autoFocus`. ## Inside a profile diff --git a/packages/swingset/src/stories/drawer.component.stories.tsx b/packages/swingset/src/stories/drawer.component.stories.tsx index 1cb3e09cd07..978d76abc8f 100644 --- a/packages/swingset/src/stories/drawer.component.stories.tsx +++ b/packages/swingset/src/stories/drawer.component.stories.tsx @@ -1,6 +1,5 @@ import { Button } from '@clerk/ui/mosaic/components/button'; import { Dialog } from '@clerk/ui/mosaic/components/dialog'; -import type { DrawerPopupProps } from '@clerk/ui/mosaic/components/drawer'; import { Drawer } from '@clerk/ui/mosaic/components/drawer'; import { Heading } from '@clerk/ui/mosaic/components/heading'; import { Text } from '@clerk/ui/mosaic/components/text'; @@ -18,20 +17,8 @@ export const meta: StoryMeta = { group: 'Components', title: 'Drawer', source: 'packages/ui/src/mosaic/components/drawer/drawer.tsx', - styles: { - _variants: { - height: { content: {}, 'two-thirds': {}, full: {} }, - }, - _defaultVariants: { - height: 'content', - }, - }, }; -function knobsAsProps(props: Record) { - return props as unknown as Pick; -} - function SheetContent() { return ( <> @@ -46,11 +33,11 @@ function SheetContent() { ); } -export function Default(props: Record) { +export function Default() { return ( }>Sort - + diff --git a/packages/ui/src/mosaic/components/drawer/drawer.styles.ts b/packages/ui/src/mosaic/components/drawer/drawer.styles.ts index 15334efa563..5ad97e9a3b3 100644 --- a/packages/ui/src/mosaic/components/drawer/drawer.styles.ts +++ b/packages/ui/src/mosaic/components/drawer/drawer.styles.ts @@ -91,6 +91,8 @@ export const styles = stylex.create({ // Tall content scrolls inside the sheet; the drag engine yields to inner scroll away from the // top, so the two do not fight over the same gesture. maxBlockSize: `calc(100% - ${space['12']} + ${BLEED})`, + // At least two thirds of the screen; the bleed sits below it and is not part of what shows. + minBlockSize: `calc(66% + ${BLEED})`, overflowWrap: 'anywhere', // The bottom keeps clear of the home indicator, and carries the bleed. Inline padding belongs to // `content`, so the grip can span the sheet's own width. @@ -160,18 +162,3 @@ export const styles = stylex.create({ flexDirection: 'column', }, }); - -/** - * How tall the sheet stands. `content` takes its height from what it holds; `two-thirds` is at - * least that share of the screen; `full` stops `3rem` short of the top. Each includes the - * bleed, which sits below the screen and is not part of what shows. - */ -export const heights = stylex.create({ - content: {}, - 'two-thirds': { - minBlockSize: `calc(66% + ${BLEED})`, - }, - full: { - blockSize: `calc(100% - ${space['12']} + ${BLEED})`, - }, -}); diff --git a/packages/ui/src/mosaic/components/drawer/drawer.test.tsx b/packages/ui/src/mosaic/components/drawer/drawer.test.tsx index e330eb89689..b83bc6f7de0 100644 --- a/packages/ui/src/mosaic/components/drawer/drawer.test.tsx +++ b/packages/ui/src/mosaic/components/drawer/drawer.test.tsx @@ -36,19 +36,6 @@ describe('Drawer', () => { expect(document.querySelector('.cl-drawer-viewport')).toContainElement(sheet); }); - it('reflects its height as a data attribute', () => { - render( - - - - Filters - - - , - ); - expect(screen.getByRole('dialog')).toHaveAttribute('data-height', 'full'); - }); - it('opens from its trigger and closes from inside', async () => { const user = userEvent.setup(); render( diff --git a/packages/ui/src/mosaic/components/drawer/drawer.tsx b/packages/ui/src/mosaic/components/drawer/drawer.tsx index 104ab4544e0..2ce9184abe1 100644 --- a/packages/ui/src/mosaic/components/drawer/drawer.tsx +++ b/packages/ui/src/mosaic/components/drawer/drawer.tsx @@ -7,21 +7,15 @@ import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; import { DialogContext } from '../dialog'; -import { heights, styles } from './drawer.styles'; +import { styles } from './drawer.styles'; export type DrawerRootProps = HeadlessDrawerProps; export type DrawerTriggerProps = React.ComponentPropsWithoutRef; export type DrawerCloseProps = React.ComponentPropsWithoutRef; export type DrawerTitleProps = React.ComponentPropsWithoutRef; export type DrawerDescriptionProps = React.ComponentPropsWithoutRef; -export type DrawerHeight = keyof typeof heights; export interface DrawerPopupProps extends MosaicComponentProps<'div'> { - /** - * How tall the sheet stands: from its content, at least two thirds of the screen, or the - * screen less a hand's width. @default 'content' - */ - height?: DrawerHeight; /** Where focus returns when the sheet closes. Default: the trigger. */ finalFocus?: DrawerFocusTarget; } @@ -82,7 +76,7 @@ const Description = React.forwardRef(function DrawerPopup( - { height = 'content', finalFocus, children, render, className, style, ...rest }, + { finalFocus, children, render, className, style, ...rest }, ref, ) { const host = React.useContext(DialogContext); @@ -102,12 +96,7 @@ const Popup = React.forwardRef(function Drawer ref={ref} render={render} finalFocus={finalFocus} - {...mergeStyleProps( - themeProps('drawer-popup', { height }), - stylex.props(reset.base, styles.popup, heights[height]), - className, - style, - )} + {...mergeStyleProps(themeProps('drawer-popup'), stylex.props(reset.base, styles.popup), className, style)} {...rest} > diff --git a/packages/ui/src/mosaic/components/drawer/index.ts b/packages/ui/src/mosaic/components/drawer/index.ts index 34bf1dbc59a..e4399197265 100644 --- a/packages/ui/src/mosaic/components/drawer/index.ts +++ b/packages/ui/src/mosaic/components/drawer/index.ts @@ -2,7 +2,6 @@ export { Drawer } from './drawer'; export type { DrawerCloseProps, DrawerDescriptionProps, - DrawerHeight, DrawerPopupProps, DrawerRootProps, DrawerTitleProps, diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx index 16d861f7ea0..f0b97b0d8d6 100644 --- a/packages/ui/src/mosaic/components/profile/profile.tsx +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -292,7 +292,6 @@ const Nav = React.forwardRef(function ProfileNav( > {element} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index d01eeac5d74..2f88330e0c0 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -21,7 +21,6 @@ export { Drawer } from '../components/drawer'; export type { DrawerCloseProps, DrawerDescriptionProps, - DrawerHeight, DrawerPopupProps, DrawerRootProps, DrawerTitleProps,