diff --git a/.changeset/open-heads-brush.md b/.changeset/open-heads-brush.md new file mode 100644 index 00000000000..96f7dd95ba7 --- /dev/null +++ b/.changeset/open-heads-brush.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Export experimental mosaic components. diff --git a/packages/swingset/src/components/Composition.tsx b/packages/swingset/src/components/Composition.tsx index 9edacfe3ef5..3724b72d61b 100644 --- a/packages/swingset/src/components/Composition.tsx +++ b/packages/swingset/src/components/Composition.tsx @@ -12,8 +12,8 @@ export interface CompositionPiece { } // Mosaic layers, high → low. Drives the order the composition groups render in. -// Plural to match the sidebar group names. -const LAYER_ORDER = ['AIO', 'Panels', 'Sections', 'Blocks', 'Components', 'Primitives']; +// Matches the sidebar group names. +const LAYER_ORDER = ['Organization', 'Blocks', 'Components', 'Primitives']; function layerRank(layer: string): number { const i = LAYER_ORDER.indexOf(layer); diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 66c8423ae2a..ddf1d779db0 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,15 +10,11 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { - aio: { + organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), - }, - panels: { - 'organization-profile-general': dynamic(() => import('../stories/organization-profile-general.mdx')), - }, - sections: { - 'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')), - 'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')), + 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), + 'organization-profile-leave-section': dynamic(() => import('../stories/organization-profile-leave-section.mdx')), + 'organization-profile-delete-section': dynamic(() => import('../stories/organization-profile-delete-section.mdx')), }, blocks: { destructive: dynamic(() => import('../stories/destructive.mdx')), diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index ef76475fba8..35cd5ef3ad8 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -71,16 +71,19 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {components.map(({ mod, componentSlug }) => { const href = `/${groupSlug}/${componentSlug}`; + // Hooks (e.g. `useDataTable`) are called, not rendered — show `useX()` rather + // than JSX ``. Everything else is a component. + const isHook = /^use[A-Z]/.test(mod.meta.title); + const usage = isHook ? `${mod.meta.title}()` : `<${mod.meta.title} />`; return ( } > - {mod.meta.label ?? mod.meta.title} - - {`<${mod.meta.title} />`} + + {usage} diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ff0ee8b6f6a..0c97e0d7ec8 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -8,10 +8,6 @@ import { meta as cardComponentMeta, } from '../stories/card.component.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; -import { - Default as DeleteOrganizationDefault, - meta as deleteOrganizationMeta, -} from '../stories/delete-organization.stories'; import { Default as DestructiveDefault, meta as destructiveMeta } from '../stories/destructive.stories'; import { Default as DialogDefault, meta as dialogComponentMeta } from '../stories/dialog.component.stories'; import { meta as dialogMeta } from '../stories/dialog.stories'; @@ -35,19 +31,23 @@ import { meta as inputMeta, Sizes as InputSizes, } from '../stories/input.stories'; -import { - Default as LeaveOrganizationDefault, - meta as leaveOrganizationMeta, -} from '../stories/leave-organization.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationProfileDefault, meta as organizationProfileMeta, } from '../stories/organization-profile.stories'; import { - Default as OrganizationProfileGeneralDefault, - meta as organizationProfileGeneralMeta, -} from '../stories/organization-profile-general.stories'; + Default as OrganizationProfileDeleteSectionDefault, + meta as organizationProfileDeleteSectionMeta, +} from '../stories/organization-profile-delete-section.stories'; +import { + Default as OrganizationProfileGeneralPanelDefault, + meta as organizationProfileGeneralPanelMeta, +} from '../stories/organization-profile-general-panel.stories'; +import { + Default as OrganizationProfileLeaveSectionDefault, + meta as organizationProfileLeaveSectionMeta, +} from '../stories/organization-profile-leave-section.stories'; import { meta as popoverMeta } from '../stories/popover.stories'; import { meta as selectMeta } from '../stories/select.stories'; import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories'; @@ -64,12 +64,18 @@ import { toSlug } from './slug'; import type { StoryModule } from './types'; const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault }; -const leaveOrganizationModule: StoryModule = { meta: leaveOrganizationMeta, Default: LeaveOrganizationDefault }; -const deleteOrganizationModule: StoryModule = { meta: deleteOrganizationMeta, Default: DeleteOrganizationDefault }; +const organizationProfileLeaveSectionModule: StoryModule = { + meta: organizationProfileLeaveSectionMeta, + Default: OrganizationProfileLeaveSectionDefault, +}; +const organizationProfileDeleteSectionModule: StoryModule = { + meta: organizationProfileDeleteSectionMeta, + Default: OrganizationProfileDeleteSectionDefault, +}; const organizationProfileModule: StoryModule = { meta: organizationProfileMeta, Default: OrganizationProfileDefault }; -const organizationProfileGeneralModule: StoryModule = { - meta: organizationProfileGeneralMeta, - Default: OrganizationProfileGeneralDefault, +const organizationProfileGeneralPanelModule: StoryModule = { + meta: organizationProfileGeneralPanelMeta, + Default: OrganizationProfileGeneralPanelDefault, }; const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; @@ -115,13 +121,11 @@ const tooltipModule: StoryModule = { meta: tooltipMeta }; const useDataTableModule: StoryModule = { meta: useDataTableMeta }; export const registry: StoryModule[] = [ - // AIO + // Organization organizationProfileModule, - // Panels - organizationProfileGeneralModule, - // Sections - leaveOrganizationModule, - deleteOrganizationModule, + organizationProfileGeneralPanelModule, + organizationProfileLeaveSectionModule, + organizationProfileDeleteSectionModule, // Blocks destructiveModule, // Components diff --git a/packages/swingset/src/stories/delete-organization.stories.tsx b/packages/swingset/src/stories/delete-organization.stories.tsx deleted file mode 100644 index da11b24e639..00000000000 --- a/packages/swingset/src/stories/delete-organization.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; -import { deleteOrgMachine } from '@clerk/ui/mosaic/sections/delete-organization.machine'; -import { DeleteOrganizationView } from '@clerk/ui/mosaic/sections/delete-organization.view'; - -import type { StoryMeta } from '@/lib/types'; - -export const meta: StoryMeta = { - group: 'Sections', - title: 'DeleteOrganization', - label: 'Delete Org', - source: 'packages/ui/src/mosaic/sections/delete-organization.tsx', -}; - -export function Default() { - const [snapshot, send, actor] = useMachine(deleteOrgMachine, { - context: { - organizationName: 'Acme Inc', - destroyOrganization: () => new Promise(resolve => setTimeout(resolve, 800)), - }, - }); - - return ( - - ); -} diff --git a/packages/swingset/src/stories/leave-organization.stories.tsx b/packages/swingset/src/stories/leave-organization.stories.tsx deleted file mode 100644 index dfec833c4a0..00000000000 --- a/packages/swingset/src/stories/leave-organization.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; -import { leaveOrgMachine } from '@clerk/ui/mosaic/sections/leave-organization.machine'; -import { LeaveOrganizationView } from '@clerk/ui/mosaic/sections/leave-organization.view'; - -import type { StoryMeta } from '@/lib/types'; - -export const meta: StoryMeta = { - group: 'Sections', - title: 'LeaveOrganization', - label: 'Leave Org', - source: 'packages/ui/src/mosaic/sections/leave-organization.tsx', -}; - -export function Default() { - const [snapshot, send, actor] = useMachine(leaveOrgMachine, { - context: { - organizationName: 'Acme Inc', - leaveOrganization: () => new Promise(resolve => setTimeout(resolve, 800)), - }, - }); - - return ( - - ); -} diff --git a/packages/swingset/src/stories/delete-organization.mdx b/packages/swingset/src/stories/organization-profile-delete-section.mdx similarity index 69% rename from packages/swingset/src/stories/delete-organization.mdx rename to packages/swingset/src/stories/organization-profile-delete-section.mdx index 3a82207d49f..0d885514caa 100644 --- a/packages/swingset/src/stories/delete-organization.mdx +++ b/packages/swingset/src/stories/organization-profile-delete-section.mdx @@ -1,12 +1,12 @@ -import * as DeleteOrganizationStories from './delete-organization.stories'; +import * as OrganizationProfileDeleteSectionStories from './organization-profile-delete-section.stories'; -# Delete Organization +# Organization Profile Delete Section A section that owns the open/deleting state and wires the `Destructive` block to the delete-organization flow. new Promise(resolve => setTimeout(resolve, 800)), + }, + }); + + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile-general-panel.mdx b/packages/swingset/src/stories/organization-profile-general-panel.mdx new file mode 100644 index 00000000000..6cb03d83629 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general-panel.mdx @@ -0,0 +1,22 @@ +import * as OrganizationProfileGeneralPanelStories from './organization-profile-general-panel.stories'; + +# Organization Profile General Panel + +The General tab panel of the Organization Profile — composes the organization-level sections shown under "General". + + diff --git a/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx new file mode 100644 index 00000000000..0d592a68f75 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx @@ -0,0 +1,22 @@ +/** @jsxImportSource @emotion/react */ +import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization/organization-profile-general-panel-view'; + +import type { StoryMeta } from '@/lib/types'; + +import { Default as OrganizationProfileDeleteSectionDemo } from './organization-profile-delete-section.stories'; +import { Default as OrganizationProfileLeaveSectionDemo } from './organization-profile-leave-section.stories'; + +export const meta: StoryMeta = { + group: 'Organization', + title: 'OrganizationProfileGeneralPanel', + source: 'packages/ui/src/mosaic/organization/organization-profile-general-panel.tsx', +}; + +export function Default() { + return ( + } + deleteOrganization={} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-general.mdx b/packages/swingset/src/stories/organization-profile-general.mdx deleted file mode 100644 index 3fd513c3662..00000000000 --- a/packages/swingset/src/stories/organization-profile-general.mdx +++ /dev/null @@ -1,14 +0,0 @@ -import * as OrganizationProfileGeneralStories from './organization-profile-general.stories'; - -# Organization Profile General - -The General tab panel of the Organization Profile — composes the organization-level sections shown under "General". - - diff --git a/packages/swingset/src/stories/organization-profile-general.stories.tsx b/packages/swingset/src/stories/organization-profile-general.stories.tsx deleted file mode 100644 index 3682934c571..00000000000 --- a/packages/swingset/src/stories/organization-profile-general.stories.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { OrganizationProfileGeneralView } from '@clerk/ui/mosaic/panels/organization-profile-general-view'; - -import type { StoryMeta } from '@/lib/types'; - -import { Default as DeleteOrganizationDemo } from './delete-organization.stories'; -import { Default as LeaveOrganizationDemo } from './leave-organization.stories'; - -export const meta: StoryMeta = { - group: 'Panels', - title: 'OrganizationProfileGeneral', - label: 'Org Profile General', - source: 'packages/ui/src/mosaic/panels/organization-profile-general.tsx', -}; - -export function Default() { - return ( - } - deleteOrganization={} - /> - ); -} diff --git a/packages/swingset/src/stories/leave-organization.mdx b/packages/swingset/src/stories/organization-profile-leave-section.mdx similarity index 53% rename from packages/swingset/src/stories/leave-organization.mdx rename to packages/swingset/src/stories/organization-profile-leave-section.mdx index 966737006cb..ce0be64a871 100644 --- a/packages/swingset/src/stories/leave-organization.mdx +++ b/packages/swingset/src/stories/organization-profile-leave-section.mdx @@ -1,12 +1,12 @@ -import * as LeaveOrganizationStories from './leave-organization.stories'; +import * as OrganizationProfileLeaveSectionStories from './organization-profile-leave-section.stories'; -# Leave Organization +# Organization Profile Leave Section -A section that owns the open/deleting state and wires the `Destructive` block to the leave-organization flow. +A section that owns the open/leaving state and wires the `Destructive` block to the leave-organization flow. new Promise(resolve => setTimeout(resolve, 800)), + }, + }); + + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx index bf473a8c921..261e90e9fbd 100644 --- a/packages/swingset/src/stories/organization-profile.mdx +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -8,7 +8,11 @@ The full Organization Profile AIO — lays out the organization panels under a t name='Default' storyModule={OrganizationProfileStories} composition={[ - { name: 'OrganizationProfileGeneral', href: '/panels/organization-profile-general', layer: 'Panels' }, + { + name: 'OrganizationProfileGeneralPanel', + href: '/organization/organization-profile-general-panel', + layer: 'Organization', + }, { name: 'Tabs', href: '/components/tabs', layer: 'Components' }, ]} /> diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index d6bc644fd36..09cf2d1b20e 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -1,17 +1,16 @@ /** @jsxImportSource @emotion/react */ -import { OrganizationProfileView } from '@clerk/ui/mosaic/aio/organization-profile-view'; +import { OrganizationProfileView } from '@clerk/ui/mosaic/organization/organization-profile-view'; import type { StoryMeta } from '@/lib/types'; -import { Default as OrganizationProfileGeneralDemo } from './organization-profile-general.stories'; +import { Default as OrganizationProfileGeneralPanelDemo } from './organization-profile-general-panel.stories'; export const meta: StoryMeta = { - group: 'AIO', + group: 'Organization', title: 'OrganizationProfile', - label: 'Org Profile', - source: 'packages/ui/src/mosaic/aio/organization-profile.tsx', + source: 'packages/ui/src/mosaic/organization/organization-profile.tsx', }; export function Default() { - return } />; + return } />; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 610cf198c6f..6c6a534f524 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -35,6 +35,11 @@ "import": "./dist/entry.js", "default": "./dist/entry.js" }, + "./experimental/mosaic": { + "types": "./dist/experimental/mosaic.d.ts", + "import": "./dist/experimental/mosaic.js", + "default": "./dist/experimental/mosaic.js" + }, "./internal": { "types": "./dist/internal/index.d.ts", "import": "./dist/internal/index.js", diff --git a/packages/ui/src/experimental/__tests__/mosaic.test.tsx b/packages/ui/src/experimental/__tests__/mosaic.test.tsx new file mode 100644 index 00000000000..f6da7d93580 --- /dev/null +++ b/packages/ui/src/experimental/__tests__/mosaic.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { OrganizationProfileDeleteSection as DeleteSectionPart } from '../../mosaic/organization/organization-profile-delete-section'; +import { OrganizationProfileGeneralPanel as GeneralPanelPart } from '../../mosaic/organization/organization-profile-general-panel'; +import { OrganizationProfileLeaveSection as LeaveSectionPart } from '../../mosaic/organization/organization-profile-leave-section'; +import { + OrganizationProfile, + OrganizationProfileDeleteSection, + OrganizationProfileGeneralPanel, + OrganizationProfileLeaveSection, +} from '../mosaic'; + +// The parts are exposed two ways: as a compound namespace (`OrganizationProfile.GeneralPanel`, +// ergonomic inside a client component) and as flat top-level exports (RSC-safe — each is its own +// client reference, so a React Server Component can render it without a `'use client'` boundary). +// Property access on a client reference is impossible across the RSC boundary, so the flat exports +// are the only form reachable from a server component. Both forms must resolve to the same +// component object. +describe('experimental/mosaic flat part exports', () => { + it('exports the general panel as a top-level export equal to the compound part', () => { + expect(OrganizationProfileGeneralPanel).toBe(GeneralPanelPart); + expect(OrganizationProfileGeneralPanel).toBe(OrganizationProfile.GeneralPanel); + }); + + it('exports the leave section as a top-level export equal to the compound part', () => { + expect(OrganizationProfileLeaveSection).toBe(LeaveSectionPart); + expect(OrganizationProfileLeaveSection).toBe(OrganizationProfile.LeaveSection); + }); + + it('exports the delete section as a top-level export equal to the compound part', () => { + expect(OrganizationProfileDeleteSection).toBe(DeleteSectionPart); + expect(OrganizationProfileDeleteSection).toBe(OrganizationProfile.DeleteSection); + }); +}); diff --git a/packages/ui/src/experimental/mosaic.ts b/packages/ui/src/experimental/mosaic.ts new file mode 100644 index 00000000000..6635a8242ba --- /dev/null +++ b/packages/ui/src/experimental/mosaic.ts @@ -0,0 +1,40 @@ +'use client'; + +/** + * Experimental entrypoint for the Mosaic design system. + * + * No semver guarantees: anything exported here may change or be removed in a minor + * release. Render `OrganizationProfile` under a `ClerkProvider` (for data) and a + * `MosaicProvider` (for styling/appearance). + * + * This entry carries a `'use client'` boundary because every Mosaic component needs one + * (React context, hooks, emotion can't run in a Server Component). The directive marks a + * client boundary — it does not opt out of SSR; React still invokes these components during + * the server render. In practice they emit no server markup anyway, because their controllers + * gate on Clerk being loaded (`!isLoaded` → render `null`) and Clerk only loads client-side, + * so the UI appears after hydration. What the directive actually buys is letting the flat + * exports below drop straight into a Server Component with no consumer setup. It lives under + * `experimental/mosaic` rather than the generic `experimental` so that boundary stays scoped + * to Mosaic and does not force a client directive onto unrelated experimental exports. + * + * The profile's parts are exposed two ways: + * + * - As flat top-level exports (`OrganizationProfileGeneralPanel`, + * `OrganizationProfileLeaveSection`, `OrganizationProfileDeleteSection`). Each is its + * own named export, so React treats it as an individual client reference and a React + * Server Component can render it directly — no `'use client'` needed in the consumer. + * - As a compound namespace (`OrganizationProfile.GeneralPanel`, `.LeaveSection`, + * `.DeleteSection`), which is more ergonomic but only works inside a client component: + * property access on a client reference is not possible across the RSC boundary, so + * `OrganizationProfile.GeneralPanel` reads as `undefined` in a server component. Server + * components must use the flat exports. + * + * Both forms resolve to the same component object. + */ +export { MosaicProvider } from '../mosaic/MosaicProvider'; +export type { MosaicProviderProps } from '../mosaic/MosaicProvider'; +export { OrganizationProfile } from '../mosaic/organization/organization-profile'; +export { OrganizationProfileGeneralPanel } from '../mosaic/organization/organization-profile-general-panel'; +export { OrganizationProfileDeleteSection } from '../mosaic/organization/organization-profile-delete-section'; +export { OrganizationProfileLeaveSection } from '../mosaic/organization/organization-profile-leave-section'; +export type { MosaicAppearance } from '../mosaic/appearance'; diff --git a/packages/ui/src/mosaic/MosaicProvider.tsx b/packages/ui/src/mosaic/MosaicProvider.tsx index 5ed16f7ef4b..ae2db8060ad 100644 --- a/packages/ui/src/mosaic/MosaicProvider.tsx +++ b/packages/ui/src/mosaic/MosaicProvider.tsx @@ -9,13 +9,41 @@ import { MosaicAppearanceProvider, MosaicIconsProvider, parseMosaicAppearance } import type { MosaicTheme } from './variables'; import { defaultMosaicVariables, resolveVariables } from './variables'; -const getInsertionPoint = (): HTMLElement | null => { +const INSERTION_POINT_ID = 'cl-mosaic-style-insertion-point'; + +// Anchor Emotion's