Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-rsc-compound-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

Compound components such as `Avatar`, `Banner`, `DataList`, `EmptyState`, `Entity` and `Item` can now be rendered from React Server Components, e.g. `<Avatar.Root>` in a Next.js App Router page. Each compound export is now a module namespace object instead of a plain object, so `X.Part` usage is unchanged, but its parts are read-only: assigning to `X.Part` no longer replaces it.
2 changes: 1 addition & 1 deletion packages/mosaic/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/index.js", "maxSize": "95KB" },
{ "path": "./dist/.bundlewatch/index.js", "maxSize": "95KB" },
{ "path": "./dist/styles.css", "maxSize": "18KB" }
]
}
3 changes: 2 additions & 1 deletion packages/mosaic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,15 @@
"dist",
"!dist/**/*.d.ts.map",
"!dist/**/*.d.mts.map",
"!dist/.bundlewatch",
"styles.css.d.ts",
"CHANGELOG.md"
],
"scripts": {
"build": "pnpm build:js && pnpm build:css && pnpm check:bundle && pnpm typecheck",
"build:css": "tsdown --config tsdown.css.config.mts",
"build:js": "tsdown --config tsdown.config.mts",
"bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json",
"bundlewatch": "node ./scripts/bundle-for-size.mjs && FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json",
"check:bundle": "node ./scripts/check-bundle.mjs",
"clean": "rimraf ./dist ./dist-css",
"dev": "tsdown --config tsdown.config.mts --watch",
Expand Down
16 changes: 16 additions & 0 deletions packages/mosaic/scripts/bundle-for-size.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import { build } from 'rolldown';

const packageDir = resolve(import.meta.dirname, '..');
const packageJson = JSON.parse(await readFile(resolve(packageDir, 'package.json'), 'utf8'));
const externals = Object.keys({ ...packageJson.dependencies, ...packageJson.peerDependencies });

await build({
input: resolve(packageDir, 'dist/index.js'),
platform: 'browser',
external: id => externals.some(name => id === name || id.startsWith(`${name}/`)),
output: { dir: resolve(packageDir, 'dist/.bundlewatch'), entryFileNames: '[name].js', format: 'esm' },
logLevel: 'warn',
});
65 changes: 56 additions & 9 deletions packages/mosaic/scripts/check-bundle.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,64 @@
import { readdir, readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { dirname, relative, resolve } from 'node:path';

const distDir = resolve(import.meta.dirname, '../dist');
const jsFiles = (await readdir(distDir, { recursive: true })).filter(file => file.endsWith('.js'));
const sources = await Promise.all(jsFiles.map(file => readFile(resolve(distDir, file), 'utf8')));
const componentEntries = (await readdir(resolve(distDir, 'components')))
.filter(file => file.endsWith('.js'))
.map(file => `components/${file}`);

for (const entryFile of ['index.js', ...componentEntries]) {
const entry = await readFile(resolve(distDir, entryFile), 'utf8');
if (!entry.startsWith("'use client';") && !entry.startsWith('"use client";')) {
throw new Error(`Published Mosaic JS entry ${entryFile} is missing a top-level 'use client' boundary.`);
const packageJson = JSON.parse(await readFile(resolve(import.meta.dirname, '../package.json'), 'utf8'));
const entryFiles = Object.entries(packageJson.exports)
.filter(([subpath]) => subpath === '.' || /^\.\/(components|primitives)\//.test(subpath))
.map(([, target]) => target)
.map(target => (typeof target === 'string' ? target : target.import))
.filter(file => file?.endsWith('.js'))
.map(file => resolve(import.meta.dirname, '..', file));

const isClientModule = source => source.startsWith("'use client';") || source.startsWith('"use client";');
const importsOf = source =>
[...source.matchAll(/^import\s+(?:[^'"]*?\s+from\s+)?["']([^"']+)["'];?$/gm)].map(m => m[1]);
const importsReact = source =>
importsOf(source).some(specifier => specifier === 'react' || specifier.startsWith('react/'));
const code = source =>
source
.replace(/\/\/.*$/gm, '')
.split('\n')
.map(line => line.trim())
.filter(Boolean);

const checkNamespaceEntry = async (entryFile, source) => {
const display = relative(distDir, entryFile);
const lines = code(source);
const namespaceImport = /^import \{ (\w+) \} from "(\.[^"]+)";$/;
const namespaceExport = /^export \{ (\w+ as \w+(?:, \w+ as \w+)*) \};$/;
if (!lines.every(line => namespaceImport.test(line) || namespaceExport.test(line))) {
throw new Error(
`Published Mosaic JS entry ${display} must start with 'use client' or contain only \`export * as\` namespace re-exports.`,
);
}
for (const line of lines.filter(line => namespaceImport.test(line))) {
const [, binding, specifier] = line.match(namespaceImport);
const partsFile = resolve(dirname(entryFile), specifier);
const parts = await readFile(partsFile, 'utf8');
if (isClientModule(parts) || !new RegExp(`^var ${binding} = /\\* @__PURE__ \\*/ __exportAll\\(`, 'm').test(parts)) {
throw new Error(
`${display} imports ${binding} from ${relative(distDir, partsFile)}, which must be a namespace object with no directive.`,
);
}
for (const leafSpecifier of importsOf(parts).filter(specifier => specifier.startsWith('.'))) {
const leafFile = resolve(dirname(partsFile), leafSpecifier);
const leaf = await readFile(leafFile, 'utf8');
if (importsReact(leaf) && !isClientModule(leaf)) {
throw new Error(
`${relative(distDir, leafFile)}, re-exported by the namespace ${relative(distDir, partsFile)}, imports React but is missing a top-level 'use client' boundary.`,
);
}
}
}
};

for (const entryFile of entryFiles) {
const source = await readFile(entryFile, 'utf8');
if (!isClientModule(source)) {
await checkNamespaceEntry(entryFile, source);
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/mosaic/src/components/action-bar/action-bar.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
ActionBarRoot as Root,
ActionBarAction as Action,
ActionBarCount as Count,
ActionBarSeparator as Separator,
ActionBarDismiss as Dismiss,
} from './action-bar';
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PortalProvider } from '../../primitives/portal';
import { Dialog } from '../dialog';
import { Menu } from '../menu';
import { ActionBar } from './action-bar';
import { ActionBar } from '.';

const testStyles = stylex.create({
positioner: {
Expand Down
10 changes: 9 additions & 1 deletion packages/mosaic/src/components/action-bar/action-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import {
autoUpdate,
FloatingFocusManager,
Expand Down Expand Up @@ -343,4 +345,10 @@ const Dismiss = React.forwardRef<HTMLButtonElement, ActionBarDismissProps>(funct
);
});

export const ActionBar = { Root, Action, Count, Separator, Dismiss };
export {
Root as ActionBarRoot,
Action as ActionBarAction,
Count as ActionBarCount,
Separator as ActionBarSeparator,
Dismiss as ActionBarDismiss,
};
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/action-bar/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { ActionBar } from './action-bar';
export * as ActionBar from './action-bar.parts';
export type {
ActionBarActionProps,
ActionBarCountProps,
Expand Down
1 change: 1 addition & 0 deletions packages/mosaic/src/components/avatar/avatar.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AvatarRoot as Root, AvatarImage as Image, AvatarFallback as Fallback, AvatarIcon as Icon } from './avatar';
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/avatar/avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { Pen } from '../../icons/glyphs/pen';
import { Icon } from '../icon';
import { Avatar } from './avatar';
import { Avatar } from '.';
import { styles } from './avatar.styles';

// React reads this off the global object and ships no typing for it.
Expand Down
15 changes: 3 additions & 12 deletions packages/mosaic/src/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import { useSafeLayoutEffect } from '@clerk/shared/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';
Expand Down Expand Up @@ -193,15 +195,4 @@ const AvatarIcon = React.forwardRef<HTMLSpanElement, AvatarIconProps>(function M
);
});

/**
* Compound avatar. `Avatar.Root` positions and sizes the box; `Avatar.Image` renders
* once its source loads; `Avatar.Fallback` holds the space until then, as a blank
* placeholder that shimmers only while an image is actually on its way; `Avatar.Icon`
* adds an optional corner affordance.
*/
export const Avatar = {
Root: AvatarRoot,
Image: AvatarImage,
Fallback: AvatarFallback,
Icon: AvatarIcon,
};
export { AvatarRoot, AvatarImage, AvatarFallback, AvatarIcon };
10 changes: 7 additions & 3 deletions packages/mosaic/src/components/avatar/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
'use client';

export { Avatar } from './avatar';
/**
* Compound avatar. `Avatar.Root` positions and sizes the box; `Avatar.Image` renders
* once its source loads; `Avatar.Fallback` holds the space until then, as a blank
* placeholder that shimmers only while an image is actually on its way; `Avatar.Icon`
* adds an optional corner affordance.
*/
export * as Avatar from './avatar.parts';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from './avatar';
1 change: 1 addition & 0 deletions packages/mosaic/src/components/banner/banner.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BannerRoot as Root, BannerLabel as Label, BannerDescription as Description } from './banner';
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/banner/banner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { Banner } from './banner';
import { Banner } from '.';

const overrides = stylex.create({
root: { marginTop: '8px' },
Expand Down
18 changes: 3 additions & 15 deletions packages/mosaic/src/components/banner/banner.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import * as stylex from '@stylexjs/stylex';
import React from 'react';

Expand Down Expand Up @@ -104,18 +106,4 @@ const Description = React.forwardRef<HTMLParagraphElement, BannerDescriptionProp
});
});

/**
* A tinted strip that annotates the surface it sits on with a status message. Composed through
* `Banner.Root`, `Banner.Label`, and `Banner.Description`; every part accepts the Mosaic `render`
* prop and forwards its ref. `Banner.Root` renders the icon for its `color` itself.
*
* `Banner.Root` sets no ARIA role. A banner that appears in response to something the user did
* should be given `role='status'` (or `role='alert'` for an error) so it is announced.
*
* @example
* <Banner.Root color='negative'>
* <Banner.Label>Error banner</Banner.Label>
* <Banner.Description>Renew now to avoid service interruption.</Banner.Description>
* </Banner.Root>
*/
export const Banner = { Root, Label, Description };
export { Root as BannerRoot, Label as BannerLabel, Description as BannerDescription };
18 changes: 15 additions & 3 deletions packages/mosaic/src/components/banner/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
'use client';

export { Banner } from './banner';
/**
* A tinted strip that annotates the surface it sits on with a status message. Composed through
* `Banner.Root`, `Banner.Label`, and `Banner.Description`; every part accepts the Mosaic `render`
* prop and forwards its ref. `Banner.Root` renders the icon for its `color` itself.
*
* `Banner.Root` sets no ARIA role. A banner that appears in response to something the user did
* should be given `role='status'` (or `role='alert'` for an error) so it is announced.
*
* @example
* <Banner.Root color='negative'>
* <Banner.Label>Error banner</Banner.Label>
* <Banner.Description>Renew now to avoid service interruption.</Banner.Description>
* </Banner.Root>
*/
export * as Banner from './banner.parts';
export type { BannerDescriptionProps, BannerLabelProps, BannerRootProps } from './banner';
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/card/card.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest';

import { Dialog } from '../dialog';
import { Flow } from '../flow';
import { Card } from './card';
import { Card } from '.';

afterEach(() => cleanup());

Expand Down
10 changes: 10 additions & 0 deletions packages/mosaic/src/components/card/card.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export {
CardRoot as Root,
CardHeader as Header,
CardMedia as Media,
CardTitle as Title,
CardDescription as Description,
CardBanner as Banner,
CardContent as Content,
CardFooter as Footer,
} from './card';
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/card/card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Button, SubmitButton } from '../button';
import { ButtonContext } from '../button/button.context';
import { Dialog } from '../dialog';
import { Flow } from '../flow';
import { Card } from './card';
import { Card } from '.';

const compactCard = '@container card (max-width: 20rem)' as const;

Expand Down
25 changes: 12 additions & 13 deletions packages/mosaic/src/components/card/card.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import * as stylex from '@stylexjs/stylex';
import React from 'react';

Expand Down Expand Up @@ -318,16 +320,13 @@ const Footer = React.forwardRef<HTMLDivElement, MosaicComponentProps<'div'>>(fun
return <ButtonContext.Provider value={buttonDefaults}>{element}</ButtonContext.Provider>;
});

/**
* A styled surface composed through `Card.Root`, `Card.Header`, `Card.Media`, `Card.Title`,
* `Card.Description`, `Card.Banner`, `Card.Content`, and `Card.Footer`. Every part accepts the Mosaic
* `render` prop and forwards its ref.
*
* `Card.Banner` is a message slot between the header and the content. It is always rendered, and
* a `Banner` expands into it while its children hold a message, collapsing again once they are
* empty, so the slot takes `role='alert'` and stays a live region across both.
*
* Rendered as the content of a `Dialog.Popup`, the card reads that surface from `DialogContext`:
* the title and description take the popup's ARIA ids, and the header carries the dismiss button.
*/
export const Card = { Root, Header, Media, Title, Description, Banner: CardBanner, Content, Footer };
export {
Root as CardRoot,
Header as CardHeader,
Media as CardMedia,
Title as CardTitle,
Description as CardDescription,
CardBanner,
Content as CardContent,
Footer as CardFooter,
};
14 changes: 13 additions & 1 deletion packages/mosaic/src/components/card/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,14 @@
export { Card } from './card';
/**
* A styled surface composed through `Card.Root`, `Card.Header`, `Card.Media`, `Card.Title`,
* `Card.Description`, `Card.Banner`, `Card.Content`, and `Card.Footer`. Every part accepts the Mosaic
* `render` prop and forwards its ref.
*
* `Card.Banner` is a message slot between the header and the content. It is always rendered, and
* a `Banner` expands into it while its children hold a message, collapsing again once they are
* empty, so the slot takes `role='alert'` and stays a live region across both.
*
* Rendered as the content of a `Dialog.Popup`, the card reads that surface from `DialogContext`:
* the title and description take the popup's ARIA ids, and the header carries the dismiss button.
*/
export * as Card from './card.parts';
export type { CardAlign, CardBannerProps, CardProps, CardSize } from './card';
12 changes: 12 additions & 0 deletions packages/mosaic/src/components/combobox/combobox.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export {
ComboboxRoot as Root,
ComboboxCollection as Collection,
ComboboxInput as Input,
ComboboxTrigger as Trigger,
ComboboxClear as Clear,
ComboboxPopup as Popup,
ComboboxList as List,
ComboboxOption as Option,
ComboboxOptionIndicator as OptionIndicator,
ComboboxEmpty as Empty,
} from './combobox';
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/combobox/combobox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Button } from '../button';
import { Field } from '../field';
import { Icon } from '../icon';
import { InputGroup } from '../input-group';
import { Combobox } from './combobox';
import { Combobox } from '.';

function FloatingCombobox(props?: { onValueChange?: (value: string | null) => void; anchor?: HTMLElement }) {
return (
Expand Down
13 changes: 0 additions & 13 deletions packages/mosaic/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,16 +248,3 @@ export const ComboboxEmpty = React.forwardRef<HTMLParagraphElement, ComboboxEmpt
},
});
});

export const Combobox = {
Root: ComboboxRoot,
Collection: ComboboxCollection,
Input: ComboboxInput,
Trigger: ComboboxTrigger,
Clear: ComboboxClear,
Popup: ComboboxPopup,
List: ComboboxList,
Option: ComboboxOption,
OptionIndicator: ComboboxOptionIndicator,
Empty: ComboboxEmpty,
};
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/combobox/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * as Combobox from './combobox.parts';
export {
Combobox,
ComboboxClear,
ComboboxCollection,
ComboboxEmpty,
Expand Down
6 changes: 6 additions & 0 deletions packages/mosaic/src/components/data-list/data-list.parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
DataListRoot as Root,
DataListItem as Item,
DataListLabel as Label,
DataListValue as Value,
} from './data-list';
Loading
Loading