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/hooks/index.ts b/packages/headless/src/hooks/index.ts index f75839cbe8a..7d98b770bf7 100644 --- a/packages/headless/src/hooks/index.ts +++ b/packages/headless/src/hooks/index.ts @@ -19,3 +19,4 @@ export { type UseTransitionReturn, } from './use-transition'; export { type TransitionStatus, useTransitionStatus } from './use-transition-status'; +export { type FocusTarget, useFinalFocus, useInitialFocus } from './use-focus-target'; diff --git a/packages/headless/src/hooks/use-focus-target.ts b/packages/headless/src/hooks/use-focus-target.ts new file mode 100644 index 00000000000..506cab332f6 --- /dev/null +++ b/packages/headless/src/hooks/use-focus-target.ts @@ -0,0 +1,120 @@ +'use client'; + +import type { FloatingContext } from '@floating-ui/react'; +import React from 'react'; + +import { type InteractionType, interactionTypeFromEvent } from '../utils/interaction-modality'; + +/** + * Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`), + * mirroring Base UI: + * + * - `true` or omitted — the default: first tabbable element on open, the trigger (with the + * pointer-close downgrade `useReturnFocus` applies) on close + * - `false` — do not move focus + * - a ref — focus that element + * - a function of the interaction type behind the open/close (`''` when programmatic) — + * returns any of the above, with `void`/`null` meaning the default + */ +export type FocusTarget = + | boolean + | React.RefObject + | ((interactionType: InteractionType) => boolean | void | HTMLElement | null); + +/** + * Resolves `initialFocus` into the `number | ref` form `FloatingFocusManager` takes (a negative + * index disables the focus move). The function form reads the open event floating-ui has + * already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it. + */ +export function useInitialFocus( + initialFocus: FocusTarget | undefined, + open: boolean, + floatingContext: FloatingContext, +): number | React.MutableRefObject { + const elementRef = React.useRef(null); + return React.useMemo(() => { + if (!open || initialFocus === undefined || initialFocus === true) { + return 0; + } + if (initialFocus === false) { + return -1; + } + if (typeof initialFocus !== 'function') { + return initialFocus as React.MutableRefObject; + } + const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent)); + if (result === false) { + return -1; + } + if (result instanceof HTMLElement) { + elementRef.current = result; + return elementRef; + } + return 0; + }, [open, initialFocus, floatingContext]); +} + +/** + * Resolves `finalFocus` into the `boolean | ref` form `FloatingFocusManager`'s `returnFocus` + * takes. + * + * The function form needs the event behind the close, so it runs inside floating-ui's + * synchronous `openchange` emit — the root routes every close through + * `floatingContext.onOpenChange`, and the emit precedes both the state commit and any focus + * restoration. Only the function's decision is stored; the ref handed to the focus manager + * materialises it lazily, at restore time, by which point `useReturnFocus` has applied its + * pointer-close downgrade to the default. + */ +export function useFinalFocus( + finalFocus: FocusTarget | undefined, + returnFocusRef: React.MutableRefObject, + floatingContext: FloatingContext, +): boolean | React.MutableRefObject { + const finalFocusRef = React.useRef(finalFocus); + React.useLayoutEffect(() => { + finalFocusRef.current = finalFocus; + }); + + // The function form is resolved when focus is restored, not when the close is requested: a + // controlled close never passes through floating-ui's `openchange` emit, and a decision taken + // early would be taken against the page as it was. What the emit does carry — the event behind a + // close it drove — is kept for the interaction type, and consumed by the restore that follows. + const closeEventRef = React.useRef(undefined); + const resolvedRef = React.useMemo( + () => ({ + get current() { + const target = finalFocusRef.current; + if (typeof target !== 'function') { + return returnFocusRef.current; + } + const event = closeEventRef.current; + closeEventRef.current = undefined; + const result = target(interactionTypeFromEvent(event)); + if (result instanceof HTMLElement) { + return result; + } + return result === false ? null : returnFocusRef.current; + }, + }), + [returnFocusRef], + ); + + React.useLayoutEffect(() => { + function onOpenChange({ open, event }: { open: boolean; event?: Event }) { + closeEventRef.current = open ? undefined : event; + } + floatingContext.events.on('openchange', onOpenChange); + return () => floatingContext.events.off('openchange', onOpenChange); + }, [floatingContext.events]); + + if (finalFocus === undefined || finalFocus === true) { + return returnFocusRef; + } + if (finalFocus === false) { + return false; + } + if (typeof finalFocus === 'function') { + return resolvedRef; + } + return finalFocus as React.MutableRefObject; +} diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 77fb76d710a..d2e12fbcf11 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -1,122 +1,14 @@ 'use client'; -import { type FloatingContext, FloatingFocusManager } from '@floating-ui/react'; +import { FloatingFocusManager } from '@floating-ui/react'; import React from 'react'; +import { type FocusTarget, useFinalFocus, useInitialFocus } from '../../hooks/use-focus-target'; import { type ComponentProps, type DefaultProps, Freeze, mergeProps, useRender } from '../../utils'; -import { type InteractionType, interactionTypeFromEvent } from '../../utils/interaction-modality'; import { useDialogContext } from './dialog-context'; -/** - * Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`), - * mirroring Base UI: - * - * - `true` or omitted — the default: first tabbable element on open, the trigger (with the - * pointer-close downgrade `useReturnFocus` applies) on close - * - `false` — do not move focus - * - a ref — focus that element - * - a function of the interaction type behind the open/close (`''` when programmatic) — - * returns any of the above, with `void`/`null` meaning the default - */ -export type DialogFocusTarget = - | boolean - | React.RefObject - | ((interactionType: InteractionType) => boolean | void | HTMLElement | null); - -/** - * Resolves `initialFocus` into the `number | ref` form `FloatingFocusManager` takes (a negative - * index disables the focus move). The function form reads the open event floating-ui has - * already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it. - */ -function useInitialFocus( - initialFocus: DialogFocusTarget | undefined, - open: boolean, - floatingContext: FloatingContext, -): number | React.MutableRefObject { - const elementRef = React.useRef(null); - return React.useMemo(() => { - if (!open || initialFocus === undefined || initialFocus === true) { - return 0; - } - if (initialFocus === false) { - return -1; - } - if (typeof initialFocus !== 'function') { - return initialFocus as React.MutableRefObject; - } - const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent)); - if (result === false) { - return -1; - } - if (result instanceof HTMLElement) { - elementRef.current = result; - return elementRef; - } - return 0; - }, [open, initialFocus, floatingContext]); -} - -/** - * Resolves `finalFocus` into the `boolean | ref` form `FloatingFocusManager`'s `returnFocus` - * takes. - * - * The function form needs the event behind the close, so it runs inside floating-ui's - * synchronous `openchange` emit — the root routes every close through - * `floatingContext.onOpenChange`, and the emit precedes both the state commit and any focus - * restoration. Only the function's decision is stored; the ref handed to the focus manager - * materialises it lazily, at restore time, by which point `useReturnFocus` has applied its - * pointer-close downgrade to the default. - */ -function useFinalFocus( - finalFocus: DialogFocusTarget | undefined, - returnFocusRef: React.MutableRefObject, - floatingContext: FloatingContext, -): boolean | React.MutableRefObject { - const finalFocusRef = React.useRef(finalFocus); - React.useLayoutEffect(() => { - finalFocusRef.current = finalFocus; - }); - - // The function's last decision: an element, `false` for "don't move focus", `true` for the - // default (the trigger, via `returnFocusRef`). - const decisionRef = React.useRef(true); - const resolvedRef = React.useMemo( - () => ({ - get current() { - const decision = decisionRef.current; - if (decision instanceof HTMLElement) { - return decision; - } - return decision ? returnFocusRef.current : null; - }, - }), - [returnFocusRef], - ); - - React.useLayoutEffect(() => { - function onOpenChange({ open, event }: { open: boolean; event?: Event }) { - const target = finalFocusRef.current; - if (open || typeof target !== 'function') { - return; - } - const result = target(interactionTypeFromEvent(event)); - decisionRef.current = result instanceof HTMLElement ? result : result !== false; - } - floatingContext.events.on('openchange', onOpenChange); - return () => floatingContext.events.off('openchange', onOpenChange); - }, [floatingContext.events]); - - if (finalFocus === undefined || finalFocus === true) { - return returnFocusRef; - } - if (finalFocus === false) { - return false; - } - if (typeof finalFocus === 'function') { - return resolvedRef; - } - return finalFocus as React.MutableRefObject; -} +/** Where a popup's focus goes on open (`initialFocus`) or close (`finalFocus`); see `useFocusTarget`. */ +export type DialogFocusTarget = FocusTarget; /** Props for {@link DialogPopup}. */ export interface DialogPopupProps extends ComponentProps<'div'> { diff --git a/packages/headless/src/primitives/drawer/README.md b/packages/headless/src/primitives/drawer/README.md index b6d71ac5034..b63b11f214e 100644 --- a/packages/headless/src/primitives/drawer/README.md +++ b/packages/headless/src/primitives/drawer/README.md @@ -136,6 +136,12 @@ sheet resists overshooting. | -------- | -------------- | ---------------------------------------------------------------- | | `handle` | `DrawerHandle` | Drive a detached handle instead of the surrounding `Drawer.Root` | +### `Drawer.Popup` + +| Prop | Type | Default | Description | +| ------------ | ----------------------------------------------------------- | ----------- | --------------------------------------------------------------- | +| `finalFocus` | `boolean \| RefObject \| (interactionType) => Element \| …` | the trigger | Where focus returns on close; same contract as `Dialog.Popup`'s | + ### `Drawer.Viewport` | Prop | Type | Default | Description | @@ -157,12 +163,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 | @@ -170,18 +176,18 @@ properties via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` i ### Data attributes -| Attribute | Applies to | Meaning | -| ------------------------------------------- | ---------------------------------- | ------------------------------- | -| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | -| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase | -| `data-swiping` | Popup, Backdrop | A drag is in progress | -| `data-snap` | Popup | Active snap index | -| `data-expanded` | Popup | Resting at the full-height snap | -| `data-nested` | Popup | This drawer is itself nested | -| `data-nested-drawer-open` | Popup | A nested child is open | -| `data-nested-drawer-swiping` | Popup | A nested child is being dragged | -| `data-drawer-handle` | Handle | Grip / `handleOnly` hit-test | -| `data-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging | +| Attribute | Applies to | Meaning | +| ------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- | +| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | +| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase | +| `data-swiping` | Popup, Backdrop | A drag is in progress (from the first move that commits, not the press) | +| `data-snap` | Popup | Active snap index | +| `data-expanded` | Popup | Resting at the full-height snap | +| `data-nested` | Popup | This drawer is itself nested | +| `data-nested-drawer-open` | Popup | A nested child is open | +| `data-nested-drawer-swiping` | Popup | A nested child is being dragged | +| `data-drawer-handle` | Handle | Grip / `handleOnly` hit-test | +| `data-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging | The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above. diff --git a/packages/headless/src/primitives/drawer/drawer-popup.tsx b/packages/headless/src/primitives/drawer/drawer-popup.tsx index 5388e23153f..eb9249d01c4 100644 --- a/packages/headless/src/primitives/drawer/drawer-popup.tsx +++ b/packages/headless/src/primitives/drawer/drawer-popup.tsx @@ -3,12 +3,19 @@ import { FloatingFocusManager } from '@floating-ui/react'; import React, { useEffect } from 'react'; +import { type FocusTarget, useFinalFocus } from '../../hooks/use-focus-target'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { DrawerAttrs, DrawerCssVars } from './css-vars'; import { useDrawerContext } from './drawer-context'; /** Props for {@link DrawerPopup}. */ -export type DrawerPopupProps = ComponentProps<'div'>; +export interface DrawerPopupProps extends ComponentProps<'div'> { + /** + * Where focus returns when the drawer closes. Default: the trigger, via `useReturnFocus`. The + * function form is called with the close's interaction type and may return an element. + */ + finalFocus?: FocusTarget; +} /** * The drawer sheet (`role="dialog"`). Hosts the drag gesture, focus trapping @@ -17,7 +24,7 @@ export type DrawerPopupProps = ComponentProps<'div'>; * opening on touch does not summon the keyboard. */ export const DrawerPopup = React.forwardRef(function DrawerPopup(props, ref) { - const { render, ...otherProps } = props; + const { render, finalFocus, ...otherProps } = props; const { popupRef, refs, @@ -98,6 +105,8 @@ export const DrawerPopup = React.forwardRef(fu props: mergeProps<'div'>(defaultProps, otherProps), }); + const resolvedReturnFocus = useFinalFocus(finalFocus, returnFocusRef, floatingContext); + if (!element) { return null; } @@ -108,7 +117,7 @@ export const DrawerPopup = React.forwardRef(fu modal={modal} outsideElementsInert={modal} initialFocus={autoFocus ? undefined : popupRef} - returnFocus={returnFocusRef} + returnFocus={resolvedReturnFocus} > {element} diff --git a/packages/headless/src/primitives/drawer/drawer-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..f146eae2529 100644 --- a/packages/headless/src/primitives/drawer/drawer.test.tsx +++ b/packages/headless/src/primitives/drawer/drawer.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import React from 'react'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; @@ -292,6 +293,56 @@ describe('Drawer', () => { }); }); + describe('final focus', () => { + // A close driven from outside — a controlled `open` flipping — never passes through + // floating-ui's own emit, so the function is consulted when focus is restored instead. + it('returns focus where the finalFocus function points, on a controlled close', async () => { + function Controlled() { + const [open, setOpen] = React.useState(true); + const target = React.useRef(null); + return ( + <> + + + + + + target.current}> + Sheet + + + + + + + ); + } + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('inside-close')); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('target')).toHaveFocus()); + }); + }); + describe('dismiss behavior', () => { it('outside press closes a modal drawer', async () => { const onOpenChange = vi.fn(); @@ -568,6 +619,38 @@ 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 }); + }); + + // A press on a control inside the sheet is not a drag: nothing that keys on `data-swiping` — the + // grip's held colour, the transition freeze — should react until the sheet actually moves. + it('marks swiping only once a move commits to dragging the sheet', () => { + render(); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + clock.t += OPEN_GRACE_PERIOD + 50; + fireEvent.pointerDown(popup, { pointerId: 1, clientY: 100, button: 0, pointerType: 'touch' }); + expect(popup).not.toHaveAttribute('data-swiping'); + clock.t += 30; + fireEvent.pointerMove(popup, { pointerId: 1, clientY: 140 }); + expect(popup).toHaveAttribute('data-swiping'); + fireEvent.pointerUp(popup, { pointerId: 1, clientY: 140 }); + expect(popup).not.toHaveAttribute('data-swiping'); + }); + it('updates the swipe-progress var and swiping attribute during a drag', () => { render(); const popup = screen.getByRole('dialog'); @@ -585,6 +668,121 @@ 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'); + }); + + // A dismiss leaves the swipe offset in place for the exit; the next open must not inherit it, + // or `shouldDrag` short-circuits past the inner-scroll check and drags a list that should scroll. + it('starts the next open at rest after a swipe dismiss', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + await user.click(screen.getByTestId('trigger')); + stubHeight(screen.getByRole('dialog'), 400); + drag(screen.getByRole('dialog'), 0, 200, 200); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + await user.click(screen.getByTestId('trigger')); + const popup = screen.getByRole('dialog'); + stubHeight(popup, 400); + const list = screen.getByTestId('scrollable'); + makeScrollable(list, { scrollHeight: 500, clientHeight: 100, scrollTop: 50 }); + + drag(list, 0, 120, 60); + + expect(swipeY(popup)).toBe(''); + expect(popup).toBeInTheDocument(); + }); + + it('does not carry a lost gesture into the next open', async () => { + const user = userEvent.setup(); + render(); + 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 +951,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/index.ts b/packages/headless/src/primitives/drawer/index.ts index 87ad1d67951..f6d128a63f3 100644 --- a/packages/headless/src/primitives/drawer/index.ts +++ b/packages/headless/src/primitives/drawer/index.ts @@ -7,6 +7,7 @@ export { createDrawerHandle } from './drawer-handle'; export type { DrawerHandle } from './drawer-handle'; export { DrawerCssVars, DrawerAttrs, registerDrawerCssVars } from './css-vars'; +export type { FocusTarget as DrawerFocusTarget } from '../../hooks/use-focus-target'; export type { DrawerBackdropProps, diff --git a/packages/headless/src/primitives/drawer/use-drawer-drag.ts b/packages/headless/src/primitives/drawer/use-drawer-drag.ts index cb2386c6a7a..dcea2c9d2f0 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,32 @@ 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; + // A dismiss leaves the swipe where the finger let go, on purpose — the exit slides from there. + // The next open starts at rest, and `shouldDrag` reads this ref before it looks at inner + // scroll, so a stale value would drag the sheet where a list should have scrolled. + cfg.current.setSwipe(0); + cfg.current.setVar(DrawerCssVars.swipeProgress, '0'); + setIsDragging(false); + }, [open]); const shouldDrag = useCallback((target: HTMLElement, down: boolean): boolean => { const { now: clock, curSwipe, snap } = cfg.current; @@ -104,8 +133,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 +169,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(); @@ -178,7 +233,8 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { vel.current = 0; allowed.current = false; draggingRef.current = true; - setIsDragging(true); + // Not `setIsDragging` yet: a press is not a drag. `data-swiping` — and everything the styled + // layer hangs off it — lands on the first move that commits to dragging the sheet. // Capture the actual target (not the popup) so a click on an inner control // still lands on it; the popup handler keeps receiving bubbled moves. (vaul) @@ -186,6 +242,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 @@ -213,6 +286,9 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn { if (!allowed.current && !shouldDrag(e.target as HTMLElement, down)) { return; } + if (!allowed.current) { + setIsDragging(true); + } allowed.current = true; sample(e.clientY, clock()); @@ -234,13 +310,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 +378,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 a8238ca4d90..e8afbb47e73 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')), @@ -50,12 +50,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 8f326162f60..fac4d00ea42 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, @@ -93,6 +98,12 @@ 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, + Transitions as ProfileTransitions, +} from '../stories/profile.component.stories'; import { AuthenticatorOTP as ReverificationAuthenticatorOTP, BackupCode as ReverificationBackupCode, @@ -147,7 +158,11 @@ 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, + Overlay as UserProfileOverlay, +} from '../stories/user-profile.stories'; import { Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, @@ -232,6 +247,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 }; @@ -269,6 +289,12 @@ const popoverComponentModule: StoryModule = { Placement: PopoverComponentPlacement, Alignment: PopoverComponentAlignment, }; +const profileComponentModule: StoryModule = { + meta: profileComponentMeta, + Default: ProfileDefault, + Customized: ProfileCustomized, + Transitions: ProfileTransitions, +}; const itemModule: StoryModule = { meta: itemMeta, @@ -372,9 +398,10 @@ const userProfileApiKeysPanelModule: StoryModule = { Default: UserProfileApiKeysPanelDefault, Empty: UserProfileApiKeysPanelEmpty, }; -const userPageModule: StoryModule = { - meta: userPageMeta, - Default: UserPageDefault, +const userProfileModule: StoryModule = { + meta: userProfileMeta, + Default: UserProfileDefault, + Overlay: UserProfileOverlay, }; const userProfileAccountSectionModule: StoryModule = { @@ -471,7 +498,7 @@ export const registry: StoryModule[] = [ // User Button userButtonModule, // User Profile - userPageModule, + userProfileModule, // User Profile · Panels userProfileProfilePanelModule, userProfileSecurityPanelModule, @@ -502,12 +529,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() { }} > - - + + +## Props + + + +## 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. It stands at least two thirds of the screen tall, and taller +content scrolls inside it. `Drawer.Root` takes every headless option — `open` / `onOpenChange`, +`snapPoints`, `dismissible`, `handleOnly`, `autoFocus`. + +## Inside a profile + +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. + + + +## 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..978d76abc8f --- /dev/null +++ b/packages/swingset/src/stories/drawer.component.stories.tsx @@ -0,0 +1,65 @@ +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 { Text } from '@clerk/ui/mosaic/components/text'; +import { UserProfileView } from '@clerk/ui/mosaic/user-profile/user-profile.view'; + +import type { StoryMeta } from '@/lib/types'; + +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`. +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 + + + + + ); +} + +/** + * 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 { activePage, setActivePage, pages } = useUserProfileFixture(); + return ( + + }>Manage account + + + + + ); +} diff --git a/packages/swingset/src/stories/fixtures/user-page.ts b/packages/swingset/src/stories/fixtures/user-page.ts deleted file mode 100644 index eb1a0c93d4e..00000000000 --- a/packages/swingset/src/stories/fixtures/user-page.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { UserPageViewProps } from '@clerk/ui/mosaic/user-profile/user-page.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 { - /** 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 - * do something. For stories that need a realistic profile surface without being about it. - */ -export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) { - const [activePanel, setActivePanel] = 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 }, - ]); - const [phones, setPhones] = useState([ - { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, - ]); - const [passkeys, setPasskeys] = useState([ - { - id: 'passkey', - name: 'MacBook Pro', - createdAtLabel: 'Created today at 10:12 PM', - lastUsedAtLabel: 'Last used 1h ago', - }, - ]); - const [mfaMethods, setMfaMethods] = useState([ - { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, - { id: 'backup', type: 'backup-codes' }, - ]); - const [devices, setDevices] = useState([ - { - id: 'current', - name: 'Safari on macOS', - description: 'Salt Lake City, UT, United States', - type: 'desktop', - isCurrent: true, - }, - { - id: 'mobile', - name: 'Safari on iOS', - description: 'Last seen 2 weeks ago · Orem, UT, United States', - type: 'mobile', - }, - { - id: 'desktop', - name: 'Clerk App on macOS', - description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', - type: 'desktop', - }, - ]); - - const addEmail = (value: string) => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); - - const panels: UserPageViewProps['panels'] = { - account: { - allowMultipleAccounts: true, - imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', - name: 'Preston Booth', - username: 'prestonxyz', - emails, - phones, - onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), - onAddPhone: () => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]), - onDeleteAccount: () => Promise.resolve(), - onEditProfilePicture: () => undefined, - onManageEmail: () => undefined, - onManagePhone: () => undefined, - onNameChange: () => undefined, - onRemoveEmail: id => setEmails(current => current.filter(email => email.id !== id)), - onRemovePhone: id => setPhones(current => current.filter(phone => phone.id !== id)), - onSetPrimaryEmail: id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))), - onSetPrimaryPhone: id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))), - onUsernameChange: () => undefined, - onVerifyEmail: id => - setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), - onVerifyPhone: id => - setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), - }, - security: { - hasPassword: true, - passkeys, - mfaMethods, - devices, - onAddMfaMethod: type => - setMfaMethods(current => [ - ...current, - { id: `${type}-${Date.now()}`, type, description: type === 'sms' ? '+1 801-555-0100' : undefined }, - ]), - onAddPasskey: () => - setPasskeys(current => [ - ...current, - { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, - ]), - onChangePassword: () => undefined, - onDeleteAccount: () => Promise.resolve(), - onManageDevice: () => undefined, - onManagePasskey: () => undefined, - onRegenerateBackupCodes: () => undefined, - onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), - onRemovePasskey: id => setPasskeys(current => current.filter(passkey => passkey.id !== id)), - onSignOutAllOtherDevices: () => setDevices(current => current.filter(device => device.isCurrent)), - onSignOutDevice: id => setDevices(current => current.filter(device => device.id !== id)), - }, - }; - - return { activePanel, setActivePanel, panels, addEmail, devices }; -} diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/fixtures/user-profile.ts similarity index 80% rename from packages/swingset/src/stories/user-page.stories.tsx rename to packages/swingset/src/stories/fixtures/user-profile.ts index 42da0e3aa7e..47bff61cd50 100644 --- a/packages/swingset/src/stories/user-page.stories.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -1,5 +1,4 @@ -import type { UserPageViewProps } from '@clerk/ui/mosaic/user-profile/user-page.view'; -import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import type { UserProfileViewProps } from '@clerk/ui/mosaic/user-profile/user-profile.view'; import type { UserProfileAPIKey } from '@clerk/ui/mosaic/user-profile/user-profile-api-keys-panel.view'; import type { UserProfilePaymentMethod, @@ -11,20 +10,12 @@ import type { 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 { useMemo, useState } from 'react'; -import type { StoryMeta } from '@/lib/types'; - -export { default as __source } from './user-page.stories?raw'; - -export const meta: StoryMeta = { - group: 'User Profile', - title: 'UserPage', - label: 'User page', - layout: 'wide', - source: 'packages/ui/src/mosaic/user-profile/user-page.view.tsx', -}; +export interface UserProfileFixtureOptions { + /** Replaces the default "append an address" behaviour, e.g. to open a real prompt. */ + onAddEmail?: () => void; +} const initialAPIKeys: UserProfileAPIKey[] = [ { @@ -44,11 +35,15 @@ const initialAPIKeys: UserProfileAPIKey[] = [ }, ]; -export function Default() { - const [activePanel, setActivePanel] = useState('account'); +/** + * Every page of the user profile, 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 useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) { + 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 }, + { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'preston.booth@gmail.com', isVerified: true }, ]); const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, @@ -56,7 +51,7 @@ export function Default() { const [passkeys, setPasskeys] = useState([ { id: 'passkey', - name: 'Passkey', + name: 'MacBook Pro', createdAtLabel: 'Created today at 10:12 PM', lastUsedAtLabel: 'Last used 1h ago', }, @@ -79,7 +74,14 @@ export function Default() { description: 'Last seen 2 weeks ago · Orem, UT, United States', type: 'mobile', }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, ]); + const [subscription, setSubscription] = useState({ planName: 'Basic Plan', priceLabel: '$12 / Month', @@ -99,7 +101,10 @@ export function Default() { [apiKeys, searchValue], ); - const panels: UserPageViewProps['panels'] = { + const addEmail = (value: string) => + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); + + const pages: UserProfileViewProps['pages'] = { account: { allowMultipleAccounts: true, imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', @@ -107,11 +112,7 @@ export function Default() { username: 'prestonxyz', emails, phones, - onAddEmail: () => - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]), + onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), onAddPhone: () => setPhones(current => [ ...current, @@ -142,20 +143,10 @@ export function Default() { mfaMethods, devices, onAddMfaMethod: type => - setMfaMethods(current => { - const timestamp = Date.now(); - return [ - ...current, - { - id: `${type}-${timestamp}`, - type, - description: type === 'sms' ? '+1 801-555-0100' : undefined, - }, - ...(current.some(method => method.type === 'backup-codes') - ? [] - : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), - ]; - }), + setMfaMethods(current => [ + ...current, + { id: `${type}-${Date.now()}`, type, description: type === 'sms' ? '+1 801-555-0100' : undefined }, + ]), onAddPasskey: () => setPasskeys(current => [ ...current, @@ -165,10 +156,7 @@ export function Default() { onDeleteAccount: () => Promise.resolve(), onManageDevice: () => undefined, onManagePasskey: () => undefined, - onRegenerateBackupCodes: () => - setMfaMethods(current => - current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), - ), + onRegenerateBackupCodes: () => undefined, onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), onRemovePasskey: id => setPasskeys(current => current.filter(passkey => passkey.id !== id)), onSignOutAllOtherDevices: () => setDevices(current => current.filter(device => device.isCurrent)), @@ -241,11 +229,5 @@ export function Default() { }, }; - return ( - - ); + 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..2617eb9e59f --- /dev/null +++ b/packages/swingset/src/stories/profile.component.mdx @@ -0,0 +1,112 @@ +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'; + + + User profile + + } + > + Account + + } + > + Security + + + + + + +; +``` + +`Root` is controlled: `value` names the open page and `onValueChange` reports a selection. Its +`elevation` picks how it sits, the way `Card`'s does: `card` (default) is framed, with a fixed +height and the pages scrolling inside; `flush` is the page's own content — no frame, the page +scrolls, the columns a gap apart, held to a reading width and centred. A +`NavItem` and a `Page` pair by `value`. `icon` takes any node, so a page of the consumer's own can +bring its own mark. `Title` is a visually hidden heading: it names the navigation, the compact +sheet, and — inside a dialog — the dialog itself, the way `Card.Title` does. + +## 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.TabPanel` | `div[role=tabpanel]` | `cl-profile-tab-panel` | + +`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 + +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`). + + 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..1e505c62403 --- /dev/null +++ b/packages/swingset/src/stories/profile.component.stories.tsx @@ -0,0 +1,350 @@ +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'; + +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: { + elevation: { card: {}, flush: {} }, + renderBranding: { true: {}, false: {} }, + }, + _defaultVariants: { + elevation: 'card', + 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. +
+ ); +} + +/** 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 ( + + Settings + + {pages.map(item => ( + + } + > + {item.label} + + ))} + + + {pages.map(item => ( + + {stub ? ( + + ) : ( + + )} + + ))} + + + ); +} + +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 ( + <> + + + + ); +} + +/** + * A page transition, in CSS alone — the CSS a customer can add against a `` 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 ( + <> + + + + ); +} diff --git a/packages/swingset/src/stories/user-page.mdx b/packages/swingset/src/stories/user-page.mdx deleted file mode 100644 index 8592ea9f463..00000000000 --- a/packages/swingset/src/stories/user-page.mdx +++ /dev/null @@ -1,17 +0,0 @@ -import * as Stories from './user-page.stories'; - -# UserPage - -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. - - diff --git a/packages/swingset/src/stories/user-profile.mdx b/packages/swingset/src/stories/user-profile.mdx new file mode 100644 index 00000000000..5fd35d80134 --- /dev/null +++ b/packages/swingset/src/stories/user-profile.mdx @@ -0,0 +1,30 @@ +import * as Stories from './user-profile.stories'; + +# UserProfile + +The complete user profile: a `Profile` whose navigation lists the Account, Security, Billing, and +API Keys pages it was given content for, then any pages of the consumer's own, in the order asked +for. Below it is a page's content — `elevation='flush'` — unframed, flush with the page, scrolling +with it. Rendered over the page instead, it names the dialog, carries its dismiss, and fills its +height. + + + +## As an overlay + +The same view inside a `profile` dialog, opened from a trigger on the page. The dialog positions +and animates the popup; the profile paints it, names it, and carries the dismiss. + + diff --git a/packages/swingset/src/stories/user-profile.stories.tsx b/packages/swingset/src/stories/user-profile.stories.tsx new file mode 100644 index 00000000000..658c5b662f5 --- /dev/null +++ b/packages/swingset/src/stories/user-profile.stories.tsx @@ -0,0 +1,54 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Dialog } from '@clerk/ui/mosaic/components/dialog'; +import { UserProfileView } from '@clerk/ui/mosaic/user-profile/user-profile.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { useUserProfileFixture } from './fixtures/user-profile'; + +export { default as __source } from './user-profile.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfile', + label: 'User profile', + layout: 'wide', + source: 'packages/ui/src/mosaic/user-profile/user-profile.view.tsx', +}; + +/** + * The profile as a page's content — `elevation='flush'`, the way a `Card` chooses its elevation: + * unframed, flush with its host, scrolling with the page. The fixture stands in for the model and + * controller: every page's data, and actions that update it, so the surface behaves. + */ +export function Default() { + const { activePage, setActivePage, pages } = useUserProfileFixture(); + return ( + + ); +} + +/** + * The same profile as an overlay: opened from a trigger on the page into a `profile` dialog, which + * positions it while the profile paints itself, names the dialog, and carries its dismiss. + */ +export function Overlay() { + const { activePage, setActivePage, pages } = useUserProfileFixture(); + return ( + + }>Manage account + + + + + ); +} 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 f81e53e5b67..446abee637b 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'; const compactCard = '@container card (max-width: 20rem)' as const; @@ -19,9 +19,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'], @@ -33,9 +31,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'], }, }); @@ -102,6 +98,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'], @@ -110,19 +107,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 772759021c5..2e1d6eec3f0 100644 --- a/packages/ui/src/mosaic/components/card/card.test.tsx +++ b/packages/ui/src/mosaic/components/card/card.test.tsx @@ -146,8 +146,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'); @@ -318,7 +317,7 @@ describe('Mosaic Card', () => { it('carries no dismiss button in an inline dialog, which nothing closes', () => { render( - + Account diff --git a/packages/ui/src/mosaic/components/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..5ad97e9a3b3 --- /dev/null +++ b/packages/ui/src/mosaic/components/drawer/drawer.styles.ts @@ -0,0 +1,164 @@ +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})`, + // At least two thirds of the screen; the bleed sits below it and is not part of what shows. + minBlockSize: `calc(66% + ${BLEED})`, + overflowWrap: 'anywhere', + // The bottom keeps clear of the home indicator, and carries the bleed. Inline padding belongs to + // `content`, so the grip can span the sheet's own width. + 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: { + // The grip deepens the instant the handle is pressed, and stays so for as long as it is held — + // the pointer is captured on it, so `:active` survives the drag. A press elsewhere in the sheet + // is not a press on the handle, and leaves it alone. + '--_cl-grip-color': { + default: colorVars['--cl-color-border'], + ':active': `color-mix(in oklab, ${colorVars['--cl-color-border']}, ${colorVars['--cl-color-neutral-faded']} 35%)`, + }, + placeItems: 'center', + display: 'grid', + flexShrink: 0, + paddingBlockEnd: space['2.5'], + paddingBlockStart: space['3'], + userSelect: 'none', + }, + grip: { + borderRadius: radiusVars['--cl-radius-full'], + backgroundColor: 'var(--_cl-grip-color)', + blockSize: space['1.5'], + inlineSize: space['11.5'], + transitionDuration: durationVars['--cl-duration-fast'], + transitionProperty: 'background-color', + transitionTimingFunction: 'linear', + }, + + /** 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..2ce9184abe1 --- /dev/null +++ b/packages/ui/src/mosaic/components/drawer/drawer.tsx @@ -0,0 +1,120 @@ +import type { DrawerFocusTarget, DrawerProps as HeadlessDrawerProps } from '@clerk/headless/drawer'; +import { Drawer as Primitive, registerDrawerCssVars } from '@clerk/headless/drawer'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +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 interface DrawerPopupProps extends MosaicComponentProps<'div'> { + /** Where focus returns when the sheet closes. Default: the trigger. */ + finalFocus?: DrawerFocusTarget; +} + +/** + * 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( + { finalFocus, 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..e4399197265 --- /dev/null +++ b/packages/ui/src/mosaic/components/drawer/index.ts @@ -0,0 +1,10 @@ +export { Drawer } from './drawer'; +export type { + DrawerCloseProps, + DrawerDescriptionProps, + DrawerPopupProps, + DrawerRootProps, + DrawerTitleProps, + DrawerTriggerProps, +} from './drawer'; +export type { DrawerFocusTarget } from '@clerk/headless/drawer'; diff --git a/packages/ui/src/mosaic/components/icon/icon.styles.ts b/packages/ui/src/mosaic/components/icon/icon.styles.ts index 1ed40b76011..6915abbe612 100644 --- a/packages/ui/src/mosaic/components/icon/icon.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon.styles.ts @@ -23,4 +23,6 @@ export const sizes = stylex.create({ sm: { height: space['3.5'], width: space['3.5'] }, md: { height: space['4'], width: space['4'] }, lg: { height: space['5'], width: space['5'] }, + // Sized by the font around it, for an icon that sits in running text or a heading. + inherit: { height: '1em', width: '1em' }, }); diff --git a/packages/ui/src/mosaic/components/icon/icon.tsx b/packages/ui/src/mosaic/components/icon/icon.tsx index 0477fdb17a2..67b5a3e3fca 100644 --- a/packages/ui/src/mosaic/components/icon/icon.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.tsx @@ -11,7 +11,7 @@ import { sizes, styles } from './icon.styles'; export interface IconProps extends React.ComponentPropsWithRef<'svg'> { name: IconName; - size?: 'sm' | 'md' | 'lg'; + size?: 'sm' | 'md' | 'lg' | 'inherit'; placement?: 'inline-start' | 'inline-end'; } diff --git a/packages/ui/src/mosaic/components/profile/index.ts b/packages/ui/src/mosaic/components/profile/index.ts new file mode 100644 index 00000000000..051fa71d8eb --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/index.ts @@ -0,0 +1,11 @@ +export { Profile } from './profile'; +export type { + ProfileContentProps, + ProfileElevation, + ProfileNavItemProps, + ProfileNavProps, + ProfileTabPanelProps, + ProfilePageTitleProps, + ProfileRootProps, + ProfileTitleProps, +} 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..8f92ae1d183 --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/profile.styles.ts @@ -0,0 +1,329 @@ +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 — and the scrollbar with it — sits inside the frame's corners. */ +const SCROLL_INSET = space['1.5']; + +const NAV_WIDTH = `calc(${space['40']} + ${space['15']})`; +/** The pages' reading width — see `contentBody`. */ +const CONTENT_MAX_WIDTH = '56rem'; + +const NAV_GAP = space['0.5']; +const HALF_GAP = `calc(-1 * ${NAV_GAP} / 2)`; + +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. The frame + // runs wide; the content inside is held to a reading width of its own, see `contentBody`. + marginInline: 'auto', + containerName: 'cl-profile', + containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', + position: 'relative', + maxWidth: '94.625rem', + 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', + }, + // In a page — standalone or inline — the frame is its border alone, the way a card sits flat in + // content. The card's elevation belongs to the overlay; see `layoutInDialog`. + boxShadow: 'none', + color: colorVars['--cl-color-card-foreground'], + display: 'grid', + gridTemplateColumns: { + [compact]: 'minmax(0, 1fr)', + default: `${NAV_WIDTH} minmax(0, 1fr)`, + }, + gridTemplateRows: 'minmax(0, 1fr)', + minHeight: 0, + }, + + /** + * Inline, the profile is the page's own content: no frame, no background, no fixed height, and + * no scroll region of its own — the page scrolls. Flush with whatever holds it. + */ + layoutInline: { + borderRadius: 0, + borderWidth: '0px', + marginInline: 'auto', + overflow: 'visible', + backgroundColor: 'transparent', + blockSize: 'auto', + boxShadow: 'none', + // No frame to inset from, so neither column carries padding; a gap holds them apart. + columnGap: space['10'], + gridTemplateRows: 'auto', + // As wide as the navigation, the gap and the pages' reading column, centred in the host. The + // explicit width matters: auto margins on a column-flex item otherwise shrink it to its content. + inlineSize: '100%', + maxWidth: `calc(${NAV_WIDTH} + ${space['10']} + ${CONTENT_MAX_WIDTH})`, + }, + + layoutInDialog: { + blockSize: 'auto', + // Lifted off the page like a card in a dialog: the card's elevation, none compact, where the + // popup is the screen and there is nothing to lift off. + boxShadow: { + [compact]: 'none', + default: shadowVars['--cl-shadow-card'], + }, + flexGrow: 1, + minHeight: 0, + }, + + nav: { + padding: space['4'], + borderInlineEndColor: colorVars['--cl-color-border'], + borderInlineEndStyle: 'solid', + borderInlineEndWidth: '1px', + // Until measured — before hydration, or the first observer callback — the column renders in + // place at any width, so compact CSS hides it rather than stack a tablist over the page. The + // sheet's copy is portalled out of the container and never matches. + display: { + [compact]: 'none', + default: 'flex', + }, + flexDirection: 'column', + minHeight: 0, + minWidth: 0, + }, + + /** Inline there is no frame, so no edge between the column and the pages either. */ + navInline: { + padding: 0, + borderInlineEndWidth: '0px', + }, + + /** 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, + }, + /** Inline the column is not a scroll region, so it carries no inset for a clip edge. */ + contentInline: { + paddingBlock: 0, + }, + + /** Inline the pages carry no padding of their own: the headline starts level with the first destination. */ + contentViewportInline: { + paddingBlock: 0, + paddingInline: 0, + }, + + /** Inline, the branding closes out the pages' column instead of the navigation's. */ + contentBranding: { + marginBlockStart: space['8'], + }, + + contentViewport: { + paddingBlock: { + [compact]: space['6'], + default: `calc(${space['16']} - ${SCROLL_INSET})`, + }, + paddingInline: { + [compact]: space['6'], + default: space['16'], + }, + }, + + /** The pages' column: held to a reading width and centred, however wide the frame runs. */ + contentBody: { + marginInline: 'auto', + maxInlineSize: CONTENT_MAX_WIDTH, + }, + + /** 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', + }, + /** + * Beside the title, `vertical-align: middle`: the caret's midpoint on the baseline plus half the + * x-height, which centres it on the lowercase letters rather than the line box. Sized in `em` + * through the icon's `inherit` size, so it scales with the heading; coloured through the icon's + * own variable rather than `color`, which the icon sets itself. + */ + caret: { + '--_cl-icon-color': colorVars['--cl-color-neutral-faded'], + fontSize: '0.6em', + marginInlineStart: '0.25em', + verticalAlign: 'middle', + }, +}); + +export const contentScroll = scrollAreaRoot; +// A held gutter: switching to a page that does not scroll must not reflow the one that did. +export const contentViewportScroll = scrollAreaViewport('stable'); 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..056af022662 --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/profile.test.tsx @@ -0,0 +1,384 @@ +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 ( + + User profile + + } + > + 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(); + + // The title is a heading for the page outline and the name of the navigation, but not a + // visible headline: those belong to the pages. + const title = screen.getByRole('heading', { level: 2, name: 'User profile' }); + expect(title).toHaveClass('cl-profile-title', 'cl-visually-hidden'); + expect(screen.getByRole('navigation', { name: 'User profile' })).toHaveAttribute('aria-labelledby', title.id); + 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-tab-panel'); + 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)), + ); + }); + + // Unmeasured — before hydration, or the observer's first callback — the column renders in place + // at any width; the compact query must hide it, or a phone shows the tablist over the page. + it('hides the in-place navigation under the compact query', () => { + const probe = stylex.create({ + hidden: { display: { default: 'flex', '@container cl-profile (max-width: 48rem)': 'none' } }, + }); + renderSurface(); + + expect(Array.from(screen.getByRole('navigation').classList)).toEqual(expect.arrayContaining(atomsOf(probe.hidden))); + }); + + describe('page title', () => { + it('is a level-3 heading, and alone outside a profile', () => { + render( + + 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 => + this.callback( + [{ target, contentRect: { width, height: 600 } } as unknown as ResizeObserverEntry], + 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 ( + + User profile + + 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()); + }); + + // The tab a panel is normally named by exists only while the sheet is open, so compact the + // panel is named by its own title. + it('names the visible panel by its title while the tablist is away', () => { + renderSurface(); + act(() => observe?.(400)); + const panel = screen.getByRole('tabpanel'); + const title = screen.getByRole('heading', { level: 3, name: 'Account' }); + expect(panel).toHaveAttribute('aria-labelledby', title.id); + expect(panel).toHaveAccessibleName('Account'); + }); + + it('closes the sheet when the layout widens, and does not bring it back on narrowing', async () => { + const user = userEvent.setup(); + renderSurface(); + act(() => observe?.(400)); + await user.click(screen.getByRole('button', { name: 'Account' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + act(() => observe?.(1000)); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + act(() => observe?.(400)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('returns the tablist to the column when the width comes back', () => { + renderSurface(); + act(() => observe?.(400)); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + act(() => observe?.(1000)); + expect(screen.getByRole('navigation', { name: 'User profile' })).toContainElement(screen.getByRole('tablist')); + }); + }); + + // `flush` is the page-content presentation without a dialog around it: the same look an inline + // dialog implies, chosen the way `Card` chooses its elevation. + it('takes the flush presentation from its elevation prop', () => { + const probe = stylex.create({ frameless: { borderWidth: '0px', backgroundColor: 'transparent' } }); + const flush = renderSurface({ elevation: 'flush' }); + expect(flush.container.querySelector('.cl-profile')).toHaveAttribute('data-elevation', 'flush'); + expect(Array.from(flush.container.querySelector('.cl-profile-layout')!.classList)).toEqual( + expect.arrayContaining(atomsOf(probe.frameless)), + ); + expect(flush.container.querySelector('.cl-profile-content')).toHaveAttribute('data-inline'); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + flush.unmount(); + + const framed = renderSurface(); + expect(framed.container.querySelector('.cl-profile')).toHaveAttribute('data-elevation', 'card'); + }); + + describe('inside a dialog', () => { + function renderInDialog(inline = false) { + return render( + + + + + + + , + ); + } + + // 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(); + 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 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(); + + renderInDialog(); + expect(frame()).toEqual(expect.arrayContaining(handed)); + }); + + // Inline the profile is the page's content: no frame, no scroll region of its own, and the + // branding closes the pages' column out rather than the navigation's. + it('is flush and unframed inline, and scrolls with the page', () => { + const probe = stylex.create({ + frameless: { borderWidth: '0px', overflow: 'visible', backgroundColor: 'transparent', blockSize: 'auto' }, + scroller: { overflowY: 'auto' }, + }); + renderInDialog(true); + + const frame = document.querySelector('.cl-profile-layout')!; + expect(Array.from(frame.classList)).toEqual(expect.arrayContaining(atomsOf(probe.frameless))); + const viewport = document.querySelector('.cl-profile-content-viewport')!; + expect(Array.from(viewport.classList)).not.toEqual(expect.arrayContaining(atomsOf(probe.scroller))); + expect(document.querySelector('.cl-profile-content')).toHaveAttribute('data-inline'); + + const branding = screen.getByText(/Secured by/); + expect(document.querySelector('.cl-profile-content-body')).toContainElement(branding); + expect(screen.getByRole('navigation')).not.toContainElement(branding); + }); + }); +}); diff --git a/packages/ui/src/mosaic/components/profile/profile.tsx b/packages/ui/src/mosaic/components/profile/profile.tsx new file mode 100644 index 00000000000..f0b97b0d8d6 --- /dev/null +++ b/packages/ui/src/mosaic/components/profile/profile.tsx @@ -0,0 +1,526 @@ +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 { useMeasure } from '../../hooks/useMeasure'; +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 { + /** The id `Profile.Title` renders under; the navigation and the sheet point their names at it. */ + titleId: string; + 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; + /** The root element, for parts that have to find something inside the profile. */ + root: HTMLElement | null; + /** Flush: the page's own content — `elevation='flush'`, or an `inline` dialog. */ + inline: boolean; +} + +const ProfileContext = React.createContext(null); + +/** The id a `Profile.PageTitle` renders under, so the panel around it can be named by it. */ +const TabPanelContext = React.createContext(undefined); + +/** + * The width below which the layout is compact — the same `48rem` the container query in + * `profile.styles.ts` reads, measured here because WHERE the navigation renders is a DOM decision + * CSS cannot make: one tablist, in the column or in the sheet, never both. + */ +const COMPACT_MAX_WIDTH_REM = 48; + +/** `width <= 48rem`, the way the container query reads it. Unmeasured, or not laid out, is wide. */ +function isCompact(width: number | null): boolean { + if (width === null || width === 0) { + return false; + } + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + return width <= COMPACT_MAX_WIDTH_REM * rem; +} + +function useProfileContext(part: string): ProfileContextValue { + const context = React.useContext(ProfileContext); + if (!context) { + throw new Error(`${part} must be rendered inside Profile.Root`); + } + return context; +} + +export type ProfileElevation = 'card' | 'flush'; + +export interface ProfileRootProps extends Omit, 'children'> { + /** The selected page, by the `value` of its `Profile.NavItem` and `Profile.TabPanel`. */ + value: string; + 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; + /** + * How the surface sits in its host, the way `Card`'s does. `card` is framed: border, radius, a + * fixed height with the pages scrolling inside. `flush` is the page's own content: no frame or + * background, the page scrolls, the columns a gap apart. Over the page, in a `profile` dialog, + * the popup decides and this is moot; an `inline` dialog implies `flush`. + * + * @default 'card' + */ + elevation?: ProfileElevation; + 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 (through `Profile.Title`) and carry its dismiss, so the + * composition needs nothing passed in; standalone it carries no dismiss. + */ +const Root = React.forwardRef(function ProfileRoot( + { + value, + onValueChange, + orientation = 'vertical', + activationMode, + renderBranding = true, + elevation = 'card', + children, + render, + className, + style, + ...rest + }, + ref, +) { + const dialog = React.useContext(DialogContext); + const inline = elevation === 'flush' || (dialog?.inline ?? false); + // Inside a dialog the title takes the id the popup points `aria-labelledby` at, so the surface + // names the dialog without knowing it is in one — the way `Card.Title` does. + const generatedTitleId = React.useId(); + const titleId = dialog?.labelId ?? generatedTitleId; + const [node, setNode] = React.useState(null); + const [measure, { width }] = useMeasure(); + const compact = isCompact(width); + const [navOpen, setNavOpen] = React.useState(false); + const openNav = React.useCallback(() => setNavOpen(true), []); + const closeNav = React.useCallback(() => setNavOpen(false), []); + // Widening past the threshold unmounts the sheet; the state must go with it, or the sheet would + // be back the moment the layout narrowed again, unasked. + React.useEffect(() => { + if (!compact) { + setNavOpen(false); + } + }, [compact]); + const context = React.useMemo( + () => ({ titleId, renderBranding, compact, navOpen, openNav, closeNav, root: node, inline }), + [titleId, renderBranding, compact, navOpen, openNav, closeNav, node, inline], + ); + const element = useRender({ + defaultTagName: 'div', + render, + ref: [setNode, measure, ref], + props: { + ...mergeStyleProps( + themeProps('profile', { elevation: inline ? 'flush' : 'card' }), + 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} +
+ {children} +
+ + ), + }, + }); + + return ( + + + {element} + + + ); +}); + +export type ProfileTitleProps = MosaicComponentProps<'h2'>; + +/** + * What the surface is called — "User profile", "Organization" — as a visually hidden heading. The + * navigation and the compact sheet take their accessible names from it, and inside a dialog it + * names the dialog too, through the popup's `labelId`: the counterpart of `Card.Title`, for a + * surface whose visible headings belong to its pages. + */ +const Title = React.forwardRef(function ProfileTitle( + { render, className, style, ...rest }, + ref, +) { + const { titleId } = useProfileContext('Profile.Title'); + return ( + } + render={render ??

} + {...mergeStyleProps(themeProps('profile-title'), className, style)} + {...rest} + // The ids the navigation and the dialog point at, so the caller's cannot displace it. + id={titleId} + /> + ); +}); + +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 { titleId, renderBranding, compact, navOpen, closeNav, root, inline } = profile; + // The headline that opened the sheet belongs to the page a choice just left, so the sheet's own + // return-focus would land on nothing. The headline of the page now showing is the same control, + // on the destination. + const finalFocus = React.useCallback( + () => + root?.querySelector('.cl-profile-tab-panel:not([hidden]):not([inert]) .cl-profile-nav-trigger') ?? + null, + [root], + ); + const list = ( + + {children} + + ); + const element = useRender({ + defaultTagName: 'nav', + render, + ref, + props: { + 'aria-labelledby': titleId, + ...mergeStyleProps( + themeProps('profile-nav', { compact }), + stylex.props(reset.base, styles.nav, inline && styles.navInline, compact && styles.navInSheet), + className, + style, + ), + ...rest, + children: ( + <> + {list} + {renderBranding && !compact && !inline ? : null} + + ), + }, + }); + + if (!compact) { + return element; + } + return ( + { + if (!open) { + closeNav(); + } + }} + > + + {element} + + + ); +}); + +export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { + /** 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.TabPanel` 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); + const panelTitleId = React.useContext(TabPanelContext); + 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. Standalone and over the page it is the surface's scroll region + * — the navigation stays put while a long page scrolls; inline the page itself scrolls and the + * branding closes the column out. A plain `div`: the profile is often the content of the host's + * own `main`, or of a dialog, so it claims no landmark. + */ +const Content = React.forwardRef(function ProfileContent( + { children, render, className, style, ...rest }, + ref, +) { + const { inline, compact, renderBranding } = useProfileContext('Profile.Content'); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('profile-content', { inline }), + stylex.props(reset.base, styles.content, inline ? styles.contentInline : contentScroll), + className, + style, + ), + ...rest, + children: ( +
+
+ {children} + {inline && renderBranding && !compact ? ( +
+ +
+ ) : null} +
+
+ ), + }, + }); +}); + +export interface ProfileTabPanelProps extends MosaicComponentProps<'div'> { + /** Matches the `value` of the `Profile.NavItem` that opens this page. */ + value: string; + /** + * Keeps the page in the document while another is selected — `inert`, and carrying the tabs + * primitive's transition attributes — so a page transition can be styled. Off, an unselected + * page is `hidden`. Stack the pages yourself when on: they are all in flow. + */ + shouldForceMount?: boolean; +} + +/** + * One destination's content, shown while its `value` is selected and `hidden` otherwise. With + * `shouldForceMount` it stays in the document and carries the tabs primitive's transition contract + * — `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 TabPanel = React.forwardRef(function ProfileTabPanel( + { value, shouldForceMount, className, style, ...rest }, + ref, +) { + const { compact } = useProfileContext('Profile.TabPanel'); + const titleId = React.useId(); + return ( + + + + ); +}); + +/** + * A surface you navigate, composed through `Profile.Root`, `Profile.Title`, `Profile.Nav`, + * `Profile.NavItem`, `Profile.Content`, `Profile.TabPanel`, and `Profile.PageTitle`. Every part + * accepts the Mosaic `render` prop and forwards its ref. + * + * ```tsx + * + * User profile + * + * }>Account + * + * + * + * + * + * ``` + */ +export const Profile = { Root, Title, Nav, NavItem, PageTitle, Content, TabPanel }; diff --git a/packages/ui/src/mosaic/hooks/__tests__/useMeasure.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useMeasure.test.tsx new file mode 100644 index 00000000000..38547fbe487 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useMeasure.test.tsx @@ -0,0 +1,59 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { useMeasure } from '../useMeasure'; + +function Probe() { + const [ref, { width, height }] = useMeasure(); + return ( +
+ ); +} + +describe('useMeasure', () => { + let report: ((width: number, height: number) => void) | null = null; + const original = globalThis.ResizeObserver; + + beforeEach(() => { + report = null; + class FakeResizeObserver { + private readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + observe(target: Element) { + report = (width, height) => + this.callback( + [{ target, contentRect: { width, height } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + disconnect() {} + unobserve() {} + } + globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + globalThis.ResizeObserver = original; + }); + + it('is unmeasured until the observer reports, then tracks the content box', () => { + render(); + const probe = screen.getByTestId('probe'); + expect(probe).toHaveAttribute('data-width', 'null'); + expect(probe).toHaveAttribute('data-height', 'null'); + + act(() => report?.(400, 300)); + expect(probe).toHaveAttribute('data-width', '400'); + expect(probe).toHaveAttribute('data-height', '300'); + + act(() => report?.(1024, 300)); + expect(probe).toHaveAttribute('data-width', '1024'); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useMeasure.ts b/packages/ui/src/mosaic/hooks/useMeasure.ts new file mode 100644 index 00000000000..b3034163677 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useMeasure.ts @@ -0,0 +1,43 @@ +import React from 'react'; + +export interface Measurement { + /** Content-box width, or `null` before the first measurement. */ + width: number | null; + /** Content-box height, or `null` before the first measurement. */ + height: number | null; +} + +const UNMEASURED: Measurement = { width: null, height: null }; + +/** + * An element's size, kept current by a `ResizeObserver`. Attach the returned ref to the element to + * measure; the measurement is `null` until it has been laid out once. Content box, the box a + * container query measures, so a `width` compared against a breakpoint answers the way + * `@container (max-width: …)` would. + * + * ```tsx + * const [ref, { width }] = useMeasure(); + * const compact = width !== null && width <= 768; + * ``` + */ +export function useMeasure(): [(node: T | null) => void, Measurement] { + const [node, setNode] = React.useState(null); + const [measurement, setMeasurement] = React.useState(UNMEASURED); + + React.useLayoutEffect(() => { + if (!node || typeof ResizeObserver === 'undefined') { + return; + } + // The observer reports once on `observe`, so there is no separate first read. + const observer = new ResizeObserver(entries => { + const rect = entries[0]?.contentRect; + if (rect) { + setMeasurement({ width: rect.width, height: rect.height }); + } + }); + observer.observe(node); + return () => observer.disconnect(); + }, [node]); + + return [setNode, measurement]; +} 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 bd0cfde4645..2f88330e0c0 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, @@ -98,6 +100,17 @@ export type { PopoverTitleProps, PopoverTriggerProps, } from '../components/popover'; +export { Profile } from '../components/profile'; +export type { + ProfileContentProps, + ProfileElevation, + ProfileNavItemProps, + ProfileNavProps, + ProfileTabPanelProps, + ProfilePageTitleProps, + ProfileRootProps, + ProfileTitleProps, +} from '../components/profile'; import { colorVars, @@ -109,6 +122,7 @@ import { radiusVars, scrollbarVars, scrollFadeVars, + shadowVars, space, spacingVars, targetVars, @@ -125,6 +139,7 @@ export { radiusVars, scrollbarVars, scrollFadeVars, + shadowVars, space, spacingVars, targetVars, @@ -138,6 +153,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..80b64582025 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile.layout.test.ts @@ -0,0 +1,44 @@ +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 replace a built-in it shares an id with, in its place', () => { + const shadow: CustomProfilePage = { label: 'Mine', path: 'security', content: null }; + expect(resolveUserProfilePages(['account', 'security', 'billing'], [shadow])).toEqual([ + { id: 'account' }, + { id: 'security', custom: shadow }, + { id: 'billing' }, + ]); + }); +}); 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..f819adb83ac --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.layout.ts @@ -0,0 +1,46 @@ +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`). A custom page given a + * built-in's id REPLACES it, in its place — `applyOrder`'s rule, applied before the order is. + */ +export function resolveUserProfilePages( + builtIn: readonly UserProfilePageId[], + customPages: readonly CustomProfilePage[] = [], + order?: readonly string[], +): UserProfileNavEntry[] { + const customById = new Map(customPages.map(page => [page.path, page])); + const entries: UserProfileNavEntry[] = [ + ...builtIn.map((id): UserProfileNavEntry => { + const custom = customById.get(id); + return custom ? { id, custom } : { id }; + }), + ...customPages + .filter(page => !builtIn.includes(page.path as UserProfilePageId)) + .map(page => ({ id: page.path, custom: page })), + ]; + return applyOrder(order, entries, entry => entry.id); +} 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..4fb4b5cf2c0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.view.tsx @@ -0,0 +1,103 @@ +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 { + /** Names the surface, and the dialog it opens in. Defaults to English; pass a localized string once one is available. */ + label?: string; + /** 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 ( + + {label} + + {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))]; +}