diff --git a/.changeset/mosaic-experimental-export.md b/.changeset/mosaic-experimental-export.md new file mode 100644 index 00000000000..35b2a1dc9be --- /dev/null +++ b/.changeset/mosaic-experimental-export.md @@ -0,0 +1,21 @@ +--- +'@clerk/nextjs': minor +'@clerk/react': minor +'@clerk/ui': minor +--- + +Add an experimental subpath for Mosaic components that mount directly in your app's tree rather than being rendered by clerk-js. `UserButton` is the first one. It reads Clerk through hooks, so a `ClerkProvider` above it is all it needs: + +```tsx +import { UserButton } from '@clerk/nextjs/experimental/mosaic'; +``` + +Pair it with the stylesheet, which carries the design tokens and every component rule: + +```css +@import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk); +``` + +The surface and the components behind it will change without a major version while they are experimental. + +In `@clerk/ui`, the Mosaic stylesheet moves from `@clerk/ui/styles.css` to `@clerk/ui/experimental/mosaic/styles.css` to sit alongside the components it styles. Update the import if you were using it. diff --git a/.changeset/mosaic-user-button-custom-pages.md b/.changeset/mosaic-user-button-custom-pages.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-custom-pages.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md index 38f86642269..916beecd46f 100644 --- a/.claude/skills/mosaic/references/stylex.md +++ b/.claude/skills/mosaic/references/stylex.md @@ -621,8 +621,8 @@ export interface PopoverPopupProps extends MosaicComponentProps<'div'> { … } - **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the `styles/index.ts` barrel into `dist-mosaic/styles.css`, exported as - `@clerk/ui/styles.css`. Consumers choose the cascade layer at import: - `@import '@clerk/ui/styles.css' layer(components)`. + `@clerk/ui/experimental/mosaic/styles.css`. Consumers choose the cascade layer at import: + `@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`. - **Swingset** (source-consumed): `@stylexjs/unplugin/webpack` in `next.config` transforms StyleX **JS only** (calls → static atoms; SWC/Emotion untouched); `@stylexjs/postcss-plugin` extracts the **CSS** by replacing `@stylex;` in diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 700a78d22b0..20690715cf1 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -55,6 +55,12 @@ "import": "./dist/esm/experimental.js", "require": "./dist/cjs/experimental.js" }, + "./experimental/mosaic": { + "types": "./dist/types/experimental/mosaic.d.ts", + "import": "./dist/esm/experimental/mosaic.js", + "require": "./dist/cjs/experimental/mosaic.js" + }, + "./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css", "./legacy": { "types": "./dist/types/legacy.d.ts", "import": "./dist/esm/legacy.js", @@ -70,7 +76,7 @@ "webhooks" ], "scripts": { - "build": "pnpm clean && tsup", + "build": "pnpm clean && tsup && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css", "build:declarations": "tsc -p tsconfig.declarations.json", "clean": "rimraf ./dist", "dev": "tsup --watch", @@ -78,7 +84,7 @@ "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", "lint": "eslint src", - "lint:attw": "attw --pack . --profile node16 --ignore-rules unexpected-module-syntax", + "lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16 --ignore-rules unexpected-module-syntax", "lint:publint": "publint", "test": "vitest run", "test:watch": "vitest watch" @@ -91,6 +97,7 @@ "tslib": "catalog:repo" }, "devDependencies": { + "@clerk/ui": "workspace:*", "crypto-es": "^2.1.0", "next": "15.5.19" }, diff --git a/packages/nextjs/src/experimental/mosaic.ts b/packages/nextjs/src/experimental/mosaic.ts new file mode 100644 index 00000000000..27da5d32e99 --- /dev/null +++ b/packages/nextjs/src/experimental/mosaic.ts @@ -0,0 +1,16 @@ +'use client'; + +/** + * Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They + * read Clerk via hooks, so a `ClerkProvider` above them is all they need. + * + * Pair with the stylesheet, which carries the design tokens and every component rule: + * + * ```css + * @import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk); + * ``` + * + * @experimental The surface and the components behind it are subject to change. + */ +export { UserButton } from '@clerk/react/experimental/mosaic'; +export type { UserButtonProps } from '@clerk/react/experimental/mosaic'; diff --git a/packages/react/package.json b/packages/react/package.json index 210204c6b49..4885138c602 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -63,6 +63,17 @@ "default": "./dist/experimental.cjs" } }, + "./experimental/mosaic": { + "import": { + "types": "./dist/experimental/mosaic.d.mts", + "default": "./dist/experimental/mosaic.mjs" + }, + "require": { + "types": "./dist/experimental/mosaic.d.cts", + "default": "./dist/experimental/mosaic.cjs" + } + }, + "./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css", "./legacy": { "import": { "types": "./dist/legacy.d.mts", @@ -88,14 +99,14 @@ "dist" ], "scripts": { - "build": "tsdown", + "build": "tsdown && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css", "clean": "rimraf ./dist", "dev": "tsdown --watch", "dev:pub": "pnpm dev --env.publish", "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", "lint": "eslint src", - "lint:attw": "attw --pack . --profile node16", + "lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16", "lint:publint": "publint", "test": "vitest run", "test:watch": "vitest watch" diff --git a/packages/react/src/experimental/mosaic.ts b/packages/react/src/experimental/mosaic.ts new file mode 100644 index 00000000000..1c841f4012f --- /dev/null +++ b/packages/react/src/experimental/mosaic.ts @@ -0,0 +1,16 @@ +'use client'; + +/** + * Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They + * read Clerk via `@clerk/shared/react` hooks, so a `ClerkProvider` above them is all they need. + * + * Pair with the stylesheet, which carries the design tokens and every component rule: + * + * ```css + * @import '@clerk/react/experimental/mosaic/styles.css' layer(clerk); + * ``` + * + * @experimental The surface and the components behind it are subject to change. + */ +export { UserButton } from '@clerk/ui/experimental/mosaic'; +export type { UserButtonProps } from '@clerk/ui/experimental/mosaic'; diff --git a/packages/react/tsdown.config.mts b/packages/react/tsdown.config.mts index 3b2d3521d6c..de1302b7242 100644 --- a/packages/react/tsdown.config.mts +++ b/packages/react/tsdown.config.mts @@ -63,6 +63,7 @@ export default defineConfig((overrideOptions: Options) => { internal: 'src/internal.ts', errors: 'src/errors.ts', experimental: 'src/experimental.ts', + 'experimental/mosaic': 'src/experimental/mosaic.ts', legacy: 'src/legacy.ts', types: 'src/types/index.ts', }, @@ -76,7 +77,12 @@ export default defineConfig((overrideOptions: Options) => { // Bundle @clerk/ui/register inline at build time so consumers don't need // @clerk/ui as a dependency. The registration code sets up globalThis.__clerkSharedModules // to enable @clerk/ui's shared variant to use the host app's React. - noExternal: ['@clerk/ui/register'], + // + // The Mosaic entry is inlined for the same reason: left external, the re-export resolves from + // the consumer's tree at runtime, which makes @clerk/ui a dependency and installs its whole + // graph (Emotion, the Solana wallet adapters, ...) for every consumer, Mosaic or not. Its build + // already bundles everything except React and @clerk/shared, both of which we ship anyway. + noExternal: ['@clerk/ui/register', '@clerk/ui/experimental/mosaic'], define: { PACKAGE_NAME: `"${pkgJson.name}"`, PACKAGE_VERSION: `"${pkgJson.version}"`, diff --git a/packages/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx index b1a749eaeea..33047d7ef42 100644 --- a/packages/swingset/src/stories/menu.component.mdx +++ b/packages/swingset/src/stories/menu.component.mdx @@ -120,10 +120,10 @@ const [open, setOpen] = useState(false); Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part carries a stable `.cl-` class (the slots above) alongside the StyleX atoms. Consumers never target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins -over `@clerk/ui/styles.css`: +over `@clerk/ui/experimental/mosaic/styles.css`: ```css -@import '@clerk/ui/styles.css' layer(components); +@import '@clerk/ui/experimental/mosaic/styles.css' layer(components); @layer overrides { .cl-menu-popup { diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx index e1b8e2aecad..a2145f370e0 100644 --- a/packages/swingset/src/stories/popover.component.mdx +++ b/packages/swingset/src/stories/popover.component.mdx @@ -189,10 +189,10 @@ them through your own typography (`Heading`, `Text`) inside the surface. Unlike the slot-recipe components, the Mosaic popover is themed with **StyleX**. Each styled part carries a stable `.cl-` class (the slots in the table above) alongside the StyleX atoms. Consumers never target the hashed atomic classes — override by targeting the `.cl-*` slot from a -CSS layer that wins over `@clerk/ui/styles.css`: +CSS layer that wins over `@clerk/ui/experimental/mosaic/styles.css`: ```css -@import '@clerk/ui/styles.css' layer(components); +@import '@clerk/ui/experimental/mosaic/styles.css' layer(components); @layer overrides { .cl-popover-popup[data-size='lg'] { diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 3b818b7738c..8f1bf28bec5 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -33,6 +33,8 @@ { "path": "./dist/op-plans-page*.js", "maxSize": "3KB" }, { "path": "./dist/statement-page*.js", "maxSize": "5KB" }, { "path": "./dist/payment-attempt-page*.js", "maxSize": "4KB" }, - { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" } + { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" }, + { "path": "./dist-mosaic/styles.css", "maxSize": "8KB" }, + { "path": "./dist-mosaic/index.js", "maxSize": "88KB" } ] } diff --git a/packages/ui/package.json b/packages/ui/package.json index cd7d5172288..18a328b8e3d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -57,7 +57,12 @@ "default": "./dist/experimental/index.js" }, "./themes/shadcn.css": "./dist/themes/shadcn.css", - "./styles.css": { + "./experimental/mosaic": { + "types": "./dist-mosaic/index.d.ts", + "import": "./dist-mosaic/index.js", + "default": "./dist-mosaic/index.js" + }, + "./experimental/mosaic/styles.css": { "types": "./styles.css.d.ts", "default": "./dist-mosaic/styles.css" }, @@ -81,7 +86,7 @@ "register" ], "scripts": { - "build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm type-check", + "build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm check:no-emotion && pnpm type-check", "build:analyze": "rspack build --config rspack.config.js --env production --env analyze", "build:esm": "tsdown", "build:mosaic": "tsdown --config tsdown.mosaic.config.mts", @@ -89,6 +94,7 @@ "build:umd": "rspack build --config rspack.config.js --env production", "bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json", "bundlewatch:fix": "node bundlewatch-fix.mjs", + "check:no-emotion": "node scripts/check-mosaic-emotion-free.mjs", "check:no-rhc": "node ../../scripts/search-for-rhc.mjs directory dist/no-rhc", "clean": "rimraf ./dist", "dev": "rspack serve --config rspack.config.js", @@ -116,7 +122,6 @@ "@solana/wallet-adapter-base": "catalog:module-manager", "@solana/wallet-adapter-react": "catalog:module-manager", "@solana/wallet-standard": "catalog:module-manager", - "@stylexjs/stylex": "0.19.0", "@swc/helpers": "catalog:repo", "copy-to-clipboard": "3.3.3", "core-js": "catalog:repo", @@ -135,6 +140,7 @@ "@rspack/plugin-react-refresh": "catalog:rspack", "@stylexjs/eslint-plugin": "0.19.0", "@stylexjs/rollup-plugin": "0.19.0", + "@stylexjs/stylex": "0.19.0", "@stylexjs/unplugin": "0.19.0", "@svgr/rollup": "^8.1.0", "@svgr/webpack": "^6.5.1", diff --git a/packages/ui/scripts/check-mosaic-emotion-free.mjs b/packages/ui/scripts/check-mosaic-emotion-free.mjs new file mode 100644 index 00000000000..8d4e07f4020 --- /dev/null +++ b/packages/ui/scripts/check-mosaic-emotion-free.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +/** + * The `build:mosaic` entry is published as `@clerk/ui/experimental/mosaic` and mounted directly in + * host apps, so it must stay Emotion-free: pulling `@emotion/react` in ships a second styling + * runtime to every consumer. Nothing about the barrel enforces that — one legacy component reached + * from the graph (an `sx` prop, a `Box`, a `keyframes`) drags it back in silently. This fails the + * build instead. + */ + +import { readFileSync } from 'node:fs'; + +const BUNDLE = new URL('../dist-mosaic/index.js', import.meta.url); + +const source = readFileSync(BUNDLE, 'utf8'); +const offenders = source.split('\n').filter(line => line.includes('@emotion')); + +if (offenders.length > 0) { + console.error(`Found Emotion in the Mosaic build output (dist-mosaic/index.js):\n${offenders.join('\n')}`); + process.exit(1); +} + +console.log('✅ No Emotion found in the Mosaic build output'); diff --git a/packages/ui/src/mosaic/components/menu/index.ts b/packages/ui/src/mosaic/components/menu/index.ts index f05e07c1ec1..e1c23cc0be0 100644 --- a/packages/ui/src/mosaic/components/menu/index.ts +++ b/packages/ui/src/mosaic/components/menu/index.ts @@ -1,2 +1,2 @@ export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'; -export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu'; +export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from './menu'; diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx index ecff3266ff5..96ba671c2ca 100644 --- a/packages/ui/src/mosaic/components/menu/menu.tsx +++ b/packages/ui/src/mosaic/components/menu/menu.tsx @@ -2,7 +2,6 @@ import type { MenuItemProps as PrimitiveMenuItemProps, MenuPopupProps, MenuPortalProps, - MenuProps, MenuSeparatorProps, } from '@clerk/headless/menu'; import { Menu as Primitive } from '@clerk/headless/menu'; @@ -16,8 +15,6 @@ import { Icon } from '../icon'; import { reset } from '../reset.styles'; import { styles } from './menu.styles'; -export type { MenuProps, MenuSeparatorProps }; - export type MenuTriggerProps = MosaicComponentProps<'button'>; /** diff --git a/packages/ui/src/mosaic/components/popover/index.ts b/packages/ui/src/mosaic/components/popover/index.ts index 8ac0cb8c76b..7c72cb1ccbe 100644 --- a/packages/ui/src/mosaic/components/popover/index.ts +++ b/packages/ui/src/mosaic/components/popover/index.ts @@ -3,7 +3,6 @@ export type { PopoverCloseProps, PopoverDescriptionProps, PopoverPopupProps, - PopoverRootProps, PopoverSize, PopoverTitleProps, PopoverTriggerProps, diff --git a/packages/ui/src/mosaic/components/popover/popover.tsx b/packages/ui/src/mosaic/components/popover/popover.tsx index 20139f5d0ce..5f4bb1b2bb1 100644 --- a/packages/ui/src/mosaic/components/popover/popover.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -1,4 +1,3 @@ -import type { PopoverProps as HeadlessPopoverProps } from '@clerk/headless/popover'; import { Popover as Primitive } from '@clerk/headless/popover'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -10,8 +9,6 @@ import { sizes, styles } from './popover.styles'; export type PopoverSize = 'sm' | 'md' | 'lg'; -export type PopoverRootProps = HeadlessPopoverProps; - /** * The headless parts type their props (and the `render` callback's argument) against * the raw tag props, which carry the non-standard HTML `color` attribute typed diff --git a/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx new file mode 100644 index 00000000000..e209673b17a --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx @@ -0,0 +1,212 @@ +import type { CustomPage } from '@clerk/shared/types'; +import { act, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { CustomPagesOptions, CustomProfileItem } from '../useCustomPages'; +import { useCustomPages } from '../useCustomPages'; + +// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to +// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the +// tests exercise the same handshake the real modal performs. +function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement { + const el = document.createElement('div'); + document.body.appendChild(el); + act(() => callback?.(el)); + return el; +} + +function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) { + act(() => callback?.(el)); + el.remove(); +} + +let emitted: CustomPage[] | undefined; + +function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) { + const { customPages, portals } = useCustomPages({ items, order, builtInPages }); + emitted = customPages; + return
{portals}
; +} + +const terms: CustomProfileItem = { + label: 'Terms', + path: 'terms', + icon: terms icon, + content:

Terms body

, +}; + +const docs: CustomProfileItem = { + label: 'Docs', + path: 'docs', + href: 'https://clerk.com/docs', + icon: docs icon, +}; + +beforeEach(() => { + emitted = undefined; +}); + +describe('useCustomPages', () => { + it('sends nothing when there are no custom pages', () => { + render(); + + expect(emitted).toBeUndefined(); + expect(screen.getByTestId('host')).toBeEmptyDOMElement(); + }); + + it('sends a page as its path and a link as its href', () => { + render(); + + expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']); + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what + // make an item a page. A link carrying them would be routed to instead of followed. + it('sends content callbacks for a page and none for a link', () => { + render(); + + const [page, link] = emitted ?? []; + expect(page.mount).toBeTypeOf('function'); + expect(page.unmount).toBeTypeOf('function'); + expect(link.mount).toBeUndefined(); + expect(link.unmount).toBeUndefined(); + }); + + // The same presence check rejects any item missing an icon pair outright, so the callbacks go out + // whether or not there is an icon to put through them. Without this, `icon` could not be optional: + // leaving it off would drop the page from the profile with no explanation. + it('sends the icon callbacks even for an item with no icon', () => { + render(Terms body

}]} />); + + const [page] = emitted ?? []; + expect(page.mountIcon).toBeTypeOf('function'); + expect(page.unmountIcon).toBeTypeOf('function'); + + const el = mountInto(page.mountIcon); + expect(el).toBeEmptyDOMElement(); + }); + + it('renders page content into the element clerk-js hands back', () => { + render(); + + const el = mountInto(emitted?.[0].mount); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + + it('renders an icon into its own element, apart from the content', () => { + render(); + + const content = mountInto(emitted?.[0].mount); + const icon = mountInto(emitted?.[0].mountIcon); + + expect(within(icon).getByText('terms icon')).toBeInTheDocument(); + expect(within(content).queryByText('terms icon')).toBeNull(); + }); + + it('keeps each page in the element that asked for it', () => { + const help: CustomProfileItem = { label: 'Help', path: 'help', content:

Help body

}; + render(); + + const first = mountInto(emitted?.[0].mount); + const second = mountInto(emitted?.[1].mount); + + expect(within(first).getByText('Terms body')).toBeInTheDocument(); + expect(within(second).getByText('Help body')).toBeInTheDocument(); + }); + + it('stops rendering content once clerk-js gives the element back', () => { + render(); + + const el = mountInto(emitted?.[0].mount); + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + + unmountFrom(emitted?.[0].unmount, el); + + expect(screen.queryByText('Terms body')).toBeNull(); + }); + + // The profile is opened once with the callbacks from that render, and never handed a later set. + // They have to keep working against the current content, or a page re-rendered while the profile + // is open goes stale. + it('renders updated content through the callbacks the profile was opened with', () => { + const { rerender } = render(); + const el = mountInto(emitted?.[0].mount); + + rerender(Revised terms

}]} />); + + expect(within(el).getByText('Revised terms')).toBeInTheDocument(); + }); + + describe('order', () => { + it('leaves the built-in pages alone when no order is given', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + it('sends the pages in the order it was given', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['security', 'Terms', 'account', 'Docs']); + }); + + // clerk-js takes a request to move a built-in page as the page's id and nothing else; anything + // more and it reads as a custom page instead. + it('sends a built-in page as its id alone', () => { + render(); + + expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]); + }); + + // clerk-js puts a built-in page it was not sent *before* every page it was, so leaving one out + // of the order would jump it to the front rather than leave it where it was. + it('sends the pages left out of the order after the ones in it', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']); + }); + + it('drops an id that belongs to no page', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']); + }); + + it('sends a page once even when the order names it twice', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['security', 'account']); + }); + + it('renders a reordered page into the element clerk-js hands back', () => { + render( + , + ); + + const el = mountInto(emitted?.[1].mount); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useCustomPages.tsx b/packages/ui/src/mosaic/hooks/useCustomPages.tsx new file mode 100644 index 00000000000..f406a0d3dad --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useCustomPages.tsx @@ -0,0 +1,142 @@ +import type { CustomPage } from '@clerk/shared/types'; +import type { ReactNode } from 'react'; +import { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; + +/** 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; + /** The order the profile's navigation should run in, by id. */ + order: readonly string[] | undefined; + /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */ + builtInPages: readonly string[]; +} + +export interface CustomPagesBridge { + /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */ + customPages: CustomPage[] | undefined; + /** Render these for as long as the profile can be open, or its pages come up blank. */ + portals: ReactNode[]; +} + +const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined; + +/** + * The ids to send, in the order the profile should show them. + * + * clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a + * built-in left out of the order has to be sent anyway to keep it behind the pages that were named. + * Ids that match no page are dropped rather than sent: clerk-js would reject them, and does so by + * logging them as invalid page data, which is not what a typo in this list deserves. + */ +function arrange( + order: readonly string[], + items: ReadonlyMap, + builtInPages: readonly string[], +): string[] { + const exists = (id: string) => items.has(id) || builtInPages.includes(id); + const named = [...new Set(order)].filter(exists); + const rest = [...builtInPages, ...items.keys()].filter(id => !named.includes(id)); + return [...named, ...rest]; +} + +function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode { + const container = containers.get(id); + return container ? createPortal(node, container, id) : null; +} + +/** + * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes. + * + * The profile opens in clerk-js's own React root, which cannot render a node from the host app's + * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the + * page belongs and hands it over, and the host tree portals the content into it from here. The + * portals therefore have to stay mounted in the host tree the whole time the profile is open, which + * is why they come back out rather than being rendered here. + * + * This is the shape of the bridge only for as long as the profile renders outside the host tree. A + * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the + * props a consumer writes. + */ +export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge { + const [containers, setContainers] = useState>(new Map()); + + // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep + // working: the portal re-reads its container from state on every render of the host tree. + const bind = useCallback( + (id: string) => ({ + mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)), + unmount: () => + setContainers(prev => { + const next = new Map(prev); + next.delete(id); + return next; + }), + }), + [], + ); + + const byId = new Map((items ?? []).map(item => [item.path, item])); + const ids = order?.length ? arrange(order, byId, builtInPages) : [...byId.keys()]; + + if (!ids.length) { + return { customPages: undefined, portals: [] }; + } + + const customPages = ids.map(id => { + const item = byId.get(id); + // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and + // it reads as a custom page instead. + if (!item) { + return { label: id }; + } + + // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an + // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put + // through them; without them, leaving `icon` off would silently cost you the page. + const icon = bind(`icon:${id}`); + const content = isLink(item) ? undefined : bind(`content:${id}`); + + return { + label: item.label, + // A page is routed to by its path; a link is followed to wherever it points. + url: isLink(item) ? item.href : item.path, + mountIcon: icon.mount, + unmountIcon: icon.unmount, + ...(content && { mount: content.mount, unmount: content.unmount }), + }; + }); + + const portals = (items ?? []).flatMap(item => [ + portalInto(containers, `icon:${item.path}`, item.icon), + ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]), + ]); + + return { customPages, portals }; +} diff --git a/packages/ui/src/mosaic/hooks/useUserProfilePages.ts b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts new file mode 100644 index 00000000000..88c731d6d9c --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts @@ -0,0 +1,33 @@ +import { + disabledUserAPIKeysFeature, + disabledUserBillingFeature, +} from '@clerk/shared/internal/clerk-js/componentGuards'; +import { useClerk } from '@clerk/shared/react'; + +import { useMosaicEnvironment } from './useMosaicEnvironment'; + +/** A page the UserProfile brings itself, named by the id its navigation knows it as. */ +export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys'; + +/** + * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned + * off. + * + * Ordering a custom page after a built-in one means naming every built-in that follows it, so the + * list has to match what the profile will actually show. It mirrors clerk-js rather than being read + * from it: the profile is not mounted yet at the point this is needed, and it decides its own pages + * from the same environment behind the same guards. + */ +export function useUserProfilePages(): UserProfilePageId[] { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + + const pages: UserProfilePageId[] = ['account', 'security']; + if (!disabledUserBillingFeature(clerk, environment)) { + pages.push('billing'); + } + if (!disabledUserAPIKeysFeature(clerk, environment)) { + pages.push('apiKeys'); + } + return pages; +} diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts new file mode 100644 index 00000000000..f029a76256f --- /dev/null +++ b/packages/ui/src/mosaic/index.ts @@ -0,0 +1,7 @@ +// Public entry for `@clerk/ui/experimental/mosaic`. The side-effect import keeps every migrated +// component in the StyleX graph so the emitted `styles.css` stays complete, without making them API: +// `./styles` is the build barrel, and re-exporting it would publish the headless primitive types too. +import './styles'; + +export { UserButton } from './user-button/user-button'; +export type { UserButtonProps } from './user-button/user-button'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 9f5d0813832..727930ec431 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -6,6 +6,9 @@ export type { MosaicComponentProps, MosaicElementProps } from '../props'; +export { UserButton } from '../user-button/user-button'; +export type { UserButtonProps } from '../user-button/user-button'; + export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar'; export { Badge } from '../components/badge'; @@ -23,13 +26,7 @@ export type { InputProps } from '../components/input'; export { Item } from '../components/item'; export type { ItemProps } from '../components/item'; export { Menu } from '../components/menu'; -export type { - MenuContentProps, - MenuItemProps, - MenuProps, - MenuSeparatorProps, - MenuTriggerProps, -} from '../components/menu'; +export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from '../components/menu'; export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; export type { ScrollAreaGutter } from '../components/scroll-area'; export { Spinner } from '../components/spinner'; @@ -42,7 +39,6 @@ export type { PopoverCloseProps, PopoverDescriptionProps, PopoverPopupProps, - PopoverRootProps, PopoverSize, PopoverTitleProps, PopoverTriggerProps, diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx index 98b0ba9dc00..5057f2e8dfb 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -1,4 +1,5 @@ import type * as SharedReact from '@clerk/shared/react'; +import type { CustomPage } from '@clerk/shared/types'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -169,8 +170,8 @@ afterEach(() => { vi.clearAllMocks(); }); -function Harness(options: UserButtonControllerOptions = {}) { - const c = useUserButtonController(options); +function Harness({ customPages, ...options }: UserButtonControllerOptions & { customPages?: CustomPage[] } = {}) { + const c = useUserButtonController(options, customPages); if (c.status !== 'ready') { return {c.status}; } @@ -588,6 +589,26 @@ describe('useUserButtonController', () => { expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer }); }); + // Custom pages are bridged into this DOM-callback form by the container, since it is the layer + // that can render their portals. All the controller owes them is a ride to the modal. + it('hands the profile modal the custom pages it was given', () => { + const customPages = [ + { + label: 'Terms', + url: 'terms', + mount: vi.fn(), + unmount: vi.fn(), + mountIcon: vi.fn(), + unmountIcon: vi.fn(), + }, + ]; + render(); + + fireEvent.click(screen.getByText('manage-account')); + + expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages }); + }); + // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass // alongside it. The two are resolved apart, so routing one profile leaves the other a modal. it('navigates to a profile URL when one is given, and only for that profile', () => { diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index af3499a53d2..38b98d2260e 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -1,5 +1,6 @@ import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor, within } from '@testing-library/react'; +import type { CustomPage } from '@clerk/shared/types'; +import { act as reactAct, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -84,6 +85,8 @@ vi.mock('@clerk/shared/react', async importOriginal => { displayConfig: { afterSwitchSessionUrl: '/after-switch' }, authConfig: { singleSessionMode }, organizationSettings: { enabled: organizationsEnabled }, + commerceSettings: { billing: { user: { enabled: false } } }, + apiKeysSettings: { user_api_keys_enabled: false }, }, }), }; @@ -390,6 +393,48 @@ describe('UserButton (connected)', () => { await waitFor(() => expect(popup()).toBeNull()); }); + // The whole round trip for a custom page: the prop a consumer writes, through the bridge, out to + // the callbacks clerk-js is handed, and back into the element clerk-js renders for the page. The + // popover has closed by then, so this also covers the portals outliving what opened them. + it('renders a custom page into the element the opened profile hands back', async () => { + renderUserButton({ + userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, + }); + const act = await open(); + + await accountAction(act, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const { customPages } = openUserProfile.mock.calls[0][0]; + expect(customPages).toHaveLength(1); + expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + + // Stands in for clerk-js's `ExternalElementMounter`, which renders this `div` where the page goes. + const el = document.createElement('div'); + document.body.appendChild(el); + reactAct(() => { + customPages[0].mount(el); + }); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + + it('opens the profile with its pages in the order it was given', async () => { + renderUserButton({ + userProfileProps: { + customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], + pageOrder: ['account', 'terms'], + }, + }); + const act = await open(); + + await accountAction(act, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const { customPages } = openUserProfile.mock.calls[0][0]; + expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); + }); + it('inviting members opens the InviteMembers modal and closes the popover', async () => { renderUserButton(); const act = await open(); diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx index ac802fc4806..bd5e36a6687 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -1,6 +1,6 @@ import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; -import type { OrganizationResource, UserResource } from '@clerk/shared/types'; +import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types'; import { populateParamFromObject } from '../../contexts/utils'; import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; @@ -124,7 +124,15 @@ function toSession(sessionId: string, user: UserResource): UserButtonSession { }; } -export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController { +/** + * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's + * DOM-callback form. The container owns that conversion because it is the layer that can render + * the portals behind it, so they arrive here ready to forward and stay out of the public options. + */ +export function useUserButtonController( + options?: UserButtonControllerOptions, + userProfileCustomPages?: CustomPage[], +): UserButtonController { const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); const { isLoaded: isOrgLoaded, organization } = useOrganization(); @@ -145,7 +153,7 @@ export function useUserButtonController(options?: UserButtonControllerOptions): const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer }), + openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); diff --git a/packages/ui/src/mosaic/user-button/user-button.test-d.ts b/packages/ui/src/mosaic/user-button/user-button.test-d.ts new file mode 100644 index 00000000000..e1fe0cb19e6 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.test-d.ts @@ -0,0 +1,177 @@ +import type { OrganizationResource, UserResource } from '@clerk/shared/types'; +import { describe, expectTypeOf, test } from 'vitest'; + +import type { UserButtonProps } from '../index'; + +// The published surface of `@clerk/nextjs/experimental/mosaic`, imported the way a consumer gets it. +// Object literals reach `accept` the same way JSX attributes reach the component, excess-property +// checking and all, so a rejection here is a rejection a consumer would see. +// +// Rejections stay on one line: `@ts-expect-error` only covers the line that follows it, and a +// literal spread over several lines can report its error on any of them. +const accept = (props: UserButtonProps): UserButtonProps => props; + +// ─── The surface as a whole ────────────────────────────────────────────────── + +describe('UserButtonProps — nothing is required', () => { + test('the button takes no props at all', () => { + accept({}); + }); + + test('a misspelled prop is caught rather than silently ignored', () => { + // @ts-expect-error — `userProfileUrls` is not a prop + accept({ userProfileUrls: '/account' }); + }); +}); + +// ─── mode / modePriority ───────────────────────────────────────────────────── + +describe('mode and modePriority — the two vocabularies stay apart', () => { + test('mode is the three switcher shapes', () => { + expectTypeOf().toEqualTypeOf<'combined' | 'orgs' | 'user' | undefined>(); + }); + + test('modePriority names the organization in full, unlike mode', () => { + expectTypeOf().toEqualTypeOf<'organizations' | 'user' | undefined>(); + }); + + test('every mode is accepted', () => { + accept({ mode: 'combined' }); + accept({ mode: 'orgs' }); + accept({ mode: 'user' }); + }); + + test('a mode outside the union is rejected', () => { + // @ts-expect-error — 'organizations' is modePriority's word, not mode's + accept({ mode: 'organizations' }); + }); + + test("modePriority does not take mode's abbreviation", () => { + // @ts-expect-error — 'orgs' is mode's word, not modePriority's + accept({ modePriority: 'orgs' }); + }); +}); + +// ─── Routing: url and mode cannot contradict each other ────────────────────── + +describe('profile routing — a URL is the whole opt-in to navigation', () => { + test('a URL alone routes; naming navigation alongside it is allowed', () => { + accept({ userProfileUrl: '/account' }); + accept({ userProfileUrl: '/account', userProfileMode: 'navigation' }); + accept({ organizationProfileUrl: '/org', organizationProfileMode: 'navigation' }); + accept({ createOrganizationUrl: '/org/new', createOrganizationMode: 'navigation' }); + }); + + test('modal is the default, and stands on its own', () => { + accept({ userProfileMode: 'modal' }); + accept({ organizationProfileMode: 'modal' }); + accept({ createOrganizationMode: 'modal' }); + }); + + test('a URL cannot ask for a modal', () => { + // @ts-expect-error — a URL means navigation; 'modal' contradicts it + accept({ userProfileUrl: '/account', userProfileMode: 'modal' }); + // @ts-expect-error — same contradiction on the organization profile + accept({ organizationProfileUrl: '/org', organizationProfileMode: 'modal' }); + // @ts-expect-error — same contradiction on create-organization + accept({ createOrganizationUrl: '/org/new', createOrganizationMode: 'modal' }); + }); + + test('the three surfaces are configured apart — routing one leaves the others modal', () => { + accept({ userProfileUrl: '/account', organizationProfileMode: 'modal' }); + }); +}); + +describe('after-select URLs — each builder gets the entity it resolves against', () => { + test('a path template is accepted', () => { + accept({ afterSelectOrganizationUrl: '/orgs/:slug', afterSelectPersonalUrl: '/me' }); + }); + + test('the organization builder receives an organization', () => { + accept({ + afterSelectOrganizationUrl: organization => { + expectTypeOf(organization).toEqualTypeOf(); + return `/orgs/${organization.id}`; + }, + }); + }); + + test('the personal builder receives the user, not an organization', () => { + accept({ + afterSelectPersonalUrl: user => { + expectTypeOf(user).toEqualTypeOf(); + return `/users/${user.id}`; + }, + }); + }); + + test('a builder must return a string', () => { + // @ts-expect-error — the URL is what gets navigated to; there is nothing to do with a number + accept({ afterSelectOrganizationUrl: () => 42 }); + }); +}); + +// ─── Custom menu items ─────────────────────────────────────────────────────── + +describe('customMenuItems — a row either acts or leaves', () => { + test('an action row', () => { + accept({ customMenuItems: [{ id: 'support', label: 'Contact support', onClick: () => {} }] }); + }); + + test('a link row', () => { + accept({ customMenuItems: [{ id: 'docs', label: 'Documentation', href: 'https://example.com' }] }); + }); + + test('a row cannot do both', () => { + // @ts-expect-error — `href` and `onClick` are mutually exclusive + accept({ customMenuItems: [{ id: 'x', label: 'X', href: '/x', onClick: () => {} }] }); + }); + + test('a row must do one', () => { + // @ts-expect-error — a row with neither `href` nor `onClick` does nothing + accept({ customMenuItems: [{ id: 'x', label: 'X' }] }); + }); + + test('menuItemOrder takes built-in ids and the app’s own, side by side', () => { + accept({ menuItemOrder: ['docs', 'createOrganization', 'addAccount', 'signOutAll'] }); + }); +}); + +// ─── The profile the button opens ──────────────────────────────────────────── + +describe('userProfileProps — a navigation entry either has content or goes somewhere', () => { + test('a page brings its own content', () => { + accept({ userProfileProps: { customPages: [{ label: 'Usage', path: 'usage', content: null }] } }); + }); + + test('a link goes somewhere else', () => { + accept({ userProfileProps: { customPages: [{ label: 'Docs', path: 'docs', href: 'https://example.com' }] } }); + }); + + test('an entry cannot be both', () => { + // @ts-expect-error — `content` and `href` are mutually exclusive + accept({ userProfileProps: { customPages: [{ label: 'X', path: 'x', content: null, href: '/x' }] } }); + }); + + test('an entry needs a path to be ordered by', () => { + // @ts-expect-error — `path` identifies the entry, so it is required either way + accept({ userProfileProps: { customPages: [{ label: 'X', content: null }] } }); + }); + + test('pageOrder takes built-in page ids and custom paths, side by side', () => { + accept({ userProfileProps: { pageOrder: ['account', 'usage', 'security', 'billing', 'apiKeys'] } }); + }); +}); + +// ─── Trigger ───────────────────────────────────────────────────────────────── + +describe('trigger flags', () => { + test('both are booleans', () => { + accept({ renderTriggerLabel: false, renderPlanBadge: false }); + }); + + test('a truthy value of another type is rejected', () => { + // @ts-expect-error — `renderTriggerLabel` is a boolean, not a label + accept({ renderTriggerLabel: 'Acme' }); + }); +}); diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 77ba5d4d96f..749f34190b1 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -3,23 +3,41 @@ import type { ReactElement } from 'react'; import { useState } from 'react'; +import type { CustomProfileItem } from '../hooks/useCustomPages'; +import { useCustomPages } from '../hooks/useCustomPages'; import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; import { useSpinDelay } from '../hooks/useSpinDelay'; +import type { UserProfilePageId } from '../hooks/useUserProfilePages'; +import { useUserProfilePages } from '../hooks/useUserProfilePages'; import type { UserButtonController, UserButtonControllerOptions } from './user-button.controller'; import { useUserButtonController } from './user-button.controller'; import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; import type { UserButtonTriggerProps } from './user-button.view'; import { userButtonBusyKeys, UserButtonView } from './user-button.view'; +/** Configures the UserProfile this button opens. */ +export interface UserButtonUserProfileProps { + /** Pages and links of your own, added to the profile's navigation. */ + customPages?: CustomProfileItem[]; + /** + * The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's + * `path`. Anything left out follows the pages named here. The first page is the one the profile + * opens on, so it cannot be a link. + */ + pageOrder?: (UserProfilePageId | (string & {}))[]; +} + /** * Everything `` takes: where its profile surfaces open (`UserButtonControllerOptions`), - * what the trigger shows (`UserButtonTriggerProps`), and the app's own rows at the foot of the menu - * (`UserButtonMenuProps`). + * what the trigger shows (`UserButtonTriggerProps`), the app's own rows at the foot of the menu + * (`UserButtonMenuProps`), and the profile it opens (`UserButtonUserProfileProps`). */ export type UserButtonProps = UserButtonControllerOptions & UserButtonTriggerProps & UserButtonMenuProps & - UserButtonModeProps; + UserButtonModeProps & { + userProfileProps?: UserButtonUserProfileProps; + }; /** The one action in flight: which affordance owns it, and what the surface froze on to run it. */ interface PendingAction { @@ -38,7 +56,7 @@ interface PendingAction { * * @example * ```tsx - * import { UserButton } from '@clerk/ui/mosaic'; + * import { UserButton } from '@clerk/nextjs/experimental/mosaic'; * * * ``` @@ -65,10 +83,14 @@ interface PendingAction { * ``` * * @example - * `customMenuItems` adds your own rows to the foot of the menu, each one either an `onClick` action - * or an `href` link, and `menuItemOrder` names the order the foot's rows run in. + * `customPages` adds your own pages to the profile this button opens; `customMenuItems` adds your + * own rows to the foot of the menu, each one either an `onClick` action or an `href` link. * ```tsx * , content: }], + * pageOrder: ['account', 'usage', 'security'], + * }} * customMenuItems={[ * { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }, * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, @@ -83,11 +105,21 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { renderPlanBadge, mode: requestedMode, modePriority, + userProfileProps, customMenuItems, menuItemOrder, ...options } = props; - const controller = useUserButtonController(options); + // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered + // from here. They have to outlive the popover that opened it, and the button's own data with it, + // which is why they hang off the container rather than anything the popover renders. + const builtInPages = useUserProfilePages(); + const { customPages, portals } = useCustomPages({ + items: userProfileProps?.customPages, + order: userProfileProps?.pageOrder, + builtInPages, + }); + const controller = useUserButtonController(options, customPages); const [open, setOpen] = useState(false); const [action, setAction] = useState(null); @@ -106,7 +138,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { // promised to people who are never going to get one. `` is where an app that knows // its own nav puts a placeholder. if (controller.status !== 'ready') { - return null; + return <>{portals}; } const close = () => setOpen(false); @@ -177,28 +209,31 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { } = action?.snapshot ?? controller; return ( - + <> + + {portals} + ); } diff --git a/packages/ui/styles.css.d.ts b/packages/ui/styles.css.d.ts index 2f6203bca8b..2f6934a024b 100644 --- a/packages/ui/styles.css.d.ts +++ b/packages/ui/styles.css.d.ts @@ -1,3 +1,3 @@ -// Type stub so `import '@clerk/ui/styles.css'` type-checks. The StyleX build emits +// Type stub so `import '@clerk/ui/experimental/mosaic/styles.css'` type-checks. The StyleX build emits // the real stylesheet to dist-mosaic/styles.css; this side-effect import has no value. export {}; diff --git a/packages/ui/tsconfig.mosaic.json b/packages/ui/tsconfig.mosaic.json index 05fde7579f6..516fc111be7 100644 --- a/packages/ui/tsconfig.mosaic.json +++ b/packages/ui/tsconfig.mosaic.json @@ -7,8 +7,10 @@ // declaration bundle. Its published `dist/*.d.ts` are re-export barrels that // rolldown-plugin-dts can't follow when inlining, so building types against source // (the monorepo default) lets Mosaic components import headless types directly. + // `utils` and `hooks` sit at the source root; every other subpath is a primitive. "@clerk/headless/utils": ["../headless/src/utils/index.ts"], - "@clerk/headless/*": ["../headless/src/*"], + "@clerk/headless/hooks": ["../headless/src/hooks/index.ts"], + "@clerk/headless/*": ["../headless/src/primitives/*"], // Preserve the base config's test-only aliases (extends replaces `paths` wholesale). "@/core/*": ["../clerk-js/src/core/*"], "@/*": ["./src/*"], diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts index fccff71ba8b..f6c29047e83 100644 --- a/packages/ui/tsdown.mosaic.config.mts +++ b/packages/ui/tsdown.mosaic.config.mts @@ -2,17 +2,20 @@ import stylexPlugin from '@stylexjs/rollup-plugin'; import { defineConfig } from 'tsdown'; import { mosaicLightningCssTargets } from './stylex-lightningcss.config.mjs'; -// Isolated Mosaic build: compiles ONLY the StyleX barrel (`src/mosaic/styles`) -// with the StyleX rollup plugin, emitting transformed ESM + a single static -// `styles.css` that consumers import. Kept separate from the main tsdown build so -// the Emotion-based code is untouched and this entry stays Emotion-free. +// Isolated Mosaic build: compiles `src/mosaic` with the StyleX rollup plugin, emitting transformed +// ESM + a single static `styles.css` that consumers import. Kept separate from the main tsdown build +// so the Emotion-based code is untouched and this entry stays Emotion-free. +// +// The entry is the narrow public surface, not the `src/mosaic/styles` barrel: the barrel exists to +// pull every migrated component into the StyleX graph, and pointing the published export at it would +// make all of them (and the headless primitive types behind them) API. // // `useCSSLayers` wraps StyleX's atomic rules in `@layer priorityN` for correct // intra-StyleX precedence; consumers import the sheet into a layer they control -// (`@import '@clerk/ui/styles.css' layer(components)`), under which those nest +// (`@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`), under which those nest // cleanly, and override from a later layer. export default defineConfig({ - entry: ['./src/mosaic/styles/index.ts'], + entry: ['./src/mosaic/index.ts'], outDir: './dist-mosaic', format: ['esm'], dts: true, @@ -22,10 +25,30 @@ export default defineConfig({ minify: false, // Use the standard React JSX runtime, not Emotion's — the Mosaic build must be Emotion-free. tsconfig: './tsconfig.mosaic.json', - // `@clerk/headless` stays external here (the main build inlines it): this entry exists to - // extract `styles.css`, and only that file is exported from the package — so there is nothing - // to gain from pulling the primitives and their deps into a bundle nobody imports. - external: ['react', 'react-dom', '@stylexjs/stylex', /^@clerk\/headless/], + // tsdown externalizes everything in `dependencies` by default, which is what we want for + // `@clerk/shared`: it carries the Clerk context, so the host's copy has to be the one we read. + // The two below have to override that default. + // + // `@clerk/headless` is a private workspace package. Left external, `@clerk/ui` publishes with a + // dependency that does not exist on npm, and installing it 404s. `tsconfig.mosaic.json` already + // resolves it to source, so this is the backstop: if a subpath ever escapes those `paths`, the + // build fails loudly here instead of silently externalizing an unpublishable package. + // + // StyleX is compiled away at build time; only the tiny `props` merger survives. Bundling it keeps + // it out of consumer trees entirely, so nobody inherits our StyleX version or has to have it. + // + // Floating UI arrives through the bundled `@clerk/headless` primitives, so leaving it external + // would make it the one install this entry still demands, defeating the point: SDKs inline this + // bundle so consumers need nothing beyond React and `@clerk/shared`. Its contexts are per-tree, + // not global, so a second copy alongside the Emotion UI's is inert. + deps: { + neverBundle: ['react', 'react-dom'], + alwaysBundle: [/^@clerk\/headless/, '@stylexjs/stylex', /^@floating-ui\//], + }, + // The bundle collapses every module into one, so the per-file `'use client'` directives are lost. + // Everything here is interactive and hook-driven, so the entry is a client boundary in whole — + // without this, importing it from a React Server Component fails. + outputOptions: { banner: "'use client';" }, plugins: [ stylexPlugin({ fileName: 'styles.css', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d071c614880..acca6e4d917 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: specifier: catalog:repo version: 2.8.1 devDependencies: + '@clerk/ui': + specifier: workspace:* + version: link:../ui crypto-es: specifier: ^2.1.0 version: 2.1.0 @@ -1225,9 +1228,6 @@ importers: '@solana/wallet-standard': specifier: catalog:module-manager version: 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10))(bs58@6.0.0)(react@18.3.1) - '@stylexjs/stylex': - specifier: 0.19.0 - version: 0.19.0 '@swc/helpers': specifier: catalog:repo version: 0.5.21 @@ -1283,6 +1283,9 @@ importers: '@stylexjs/rollup-plugin': specifier: 0.19.0 version: 0.19.0 + '@stylexjs/stylex': + specifier: 0.19.0 + version: 0.19.0 '@stylexjs/unplugin': specifier: 0.19.0 version: 0.19.0(unplugin@2.3.11) @@ -8340,6 +8343,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-mediaquery@0.1.2: resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} diff --git a/scripts/copy-mosaic-styles.mjs b/scripts/copy-mosaic-styles.mjs new file mode 100644 index 00000000000..ca1fc61d330 --- /dev/null +++ b/scripts/copy-mosaic-styles.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +/** + * Copies `@clerk/ui`'s built Mosaic stylesheet into the calling package's dist, so an SDK can + * export it under its own name (`@clerk/nextjs/experimental/mosaic/styles.css`). + * + * Copied rather than re-exported through a path into `node_modules`: pnpm's layout gives no stable + * relative path from one package to another's files, so an export pointing there resolves only by + * luck of hoisting. + * + * Usage: node ../../scripts/copy-mosaic-styles.mjs + */ + +import { copyFileSync, mkdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; + +const dest = resolve(process.cwd(), process.argv[2]); +const source = createRequire(`${process.cwd()}/`).resolve('@clerk/ui/experimental/mosaic/styles.css'); + +mkdirSync(dirname(dest), { recursive: true }); +copyFileSync(source, dest); + +console.log(`✅ Copied the Mosaic stylesheet to ${process.argv[2]}`);