From 3432ac63c1ce23376b4e9f0295afcaab37164f1a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 3 Sep 2026 13:09:18 -0600 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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; }