diff --git a/.changeset/calm-aliens-care.md b/.changeset/calm-aliens-care.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/calm-aliens-care.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/tabs/tabs-panel.tsx b/packages/headless/src/primitives/tabs/tabs-panel.tsx index dfe659c10bb..de378c6698a 100644 --- a/packages/headless/src/primitives/tabs/tabs-panel.tsx +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -1,12 +1,22 @@ 'use client'; import { useMergeRefs } from '@floating-ui/react'; -import React, { useRef } from 'react'; +import React, { useRef, version } from 'react'; import { useTransition } from '../../hooks/use-transition'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; import { useTabsContext } from './tabs-context'; +const major = parseInt(version, 10); +const isModernReact = major >= 19 || major === 0; + +export function inertProps(active: boolean): Record { + if (!active) { + return {}; + } + return { inert: isModernReact ? true : '' }; +} + export interface TabsPanelProps extends ComponentProps<'div'> { value: string; /** When true, removes `hidden` so the panel stays in layout flow. */ @@ -51,11 +61,7 @@ export const TabsPanel = React.forwardRef(functi role: 'tabpanel' as const, 'aria-labelledby': tabId, tabIndex: 0, - // `inert` must be a truthy string, not a boolean or empty string, to stay - // correct across React 18 and 19: React 18 drops a boolean `true` and React - // 19 treats `''` as falsy. `'true'` renders the (presence-based) attribute in - // both. Matches the existing pattern in packages/ui PricingTableMatrix. - inert: !isSelected ? 'true' : undefined, + ...inertProps(!isSelected), hidden: !isSelected && !shouldForceMount ? true : undefined, ref: combinedRef, ...(shouldForceMount diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs index f0ec23c47a1..85b091de96a 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -30,6 +30,8 @@ const nextConfig = { webpack(config) { config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic'); // Consume @clerk/headless primitives from source (no dist build needed), mirroring Mosaic. + // `/utils` lives outside `primitives/`, so alias it first (more specific wins). + config.resolve.alias['@clerk/headless/utils'] = resolve(__dirname, '../headless/src/utils'); config.resolve.alias['@clerk/headless'] = resolve(__dirname, '../headless/src/primitives'); return config; }, diff --git a/packages/swingset/src/components/Composition.tsx b/packages/swingset/src/components/Composition.tsx new file mode 100644 index 00000000000..beec856afc9 --- /dev/null +++ b/packages/swingset/src/components/Composition.tsx @@ -0,0 +1,63 @@ +'use client'; + +import Link from 'next/link'; + +export interface CompositionPiece { + /** Display name of the piece (e.g. `Destructive`). */ + name: string; + /** Route to the piece's page in swingset (e.g. `/blocks/destructive`). */ + href: string; + /** Which Mosaic layer the piece lives in (e.g. `Blocks`, `Components`, `Primitives`). */ + layer: string; +} + +// Mosaic layers, high → low. Drives the order the composition groups render in. +// Plural to match the sidebar group names. +const LAYER_ORDER = ['AIO', 'Sections', 'Blocks', 'Components', 'Primitives']; + +function layerRank(layer: string): number { + const i = LAYER_ORDER.indexOf(layer); + return i === -1 ? LAYER_ORDER.length : i; +} + +/** + * The linked pieces shown inside a ``'s attached "Composition" footer, sorted + * and grouped by Mosaic layer. Each piece links to its own page. + */ +export function CompositionPanel({ pieces }: { pieces: CompositionPiece[] }) { + const groups = new Map(); + for (const piece of pieces) { + if (!groups.has(piece.layer)) { + groups.set(piece.layer, []); + } + groups.get(piece.layer)?.push(piece); + } + + const sortedLayers = Array.from(groups.keys()).sort((a, b) => layerRank(a) - layerRank(b) || a.localeCompare(b)); + + return ( +
+ {sortedLayers.map(layer => ( +
+
{layer}
+ {groups + .get(layer) + ?.slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map(piece => ( + + {`<${piece.name} />`} + + ))} +
+ ))} +
+ ); +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 46091da002f..95c83d0de05 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,10 +10,24 @@ 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-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')), + }, + blocks: { + destructive: dynamic(() => import('../stories/destructive.mdx')), + }, components: { button: dynamic(() => import('../stories/button.mdx')), input: dynamic(() => import('../stories/input.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), + tabs: dynamic(() => import('../stories/tabs.component.mdx')), }, primitives: { // Headless primitives — alphabetical. diff --git a/packages/swingset/src/components/StoryEmbed.tsx b/packages/swingset/src/components/StoryEmbed.tsx index 1d0b9311947..1457332727c 100644 --- a/packages/swingset/src/components/StoryEmbed.tsx +++ b/packages/swingset/src/components/StoryEmbed.tsx @@ -1,18 +1,28 @@ 'use client'; import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import { Layers2Icon } from 'lucide-react'; import type React from 'react'; +import { useState } from 'react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { generateKnobs, initKnobValues } from '@/lib/generateKnobs'; import type { StoryModule } from '@/lib/types'; +import type { CompositionPiece } from './Composition'; +import { CompositionPanel } from './Composition'; + interface StoryEmbedProps { name: string; storyModule: StoryModule; + /** When provided, a collapsible "Composition" footer is attached to the example card. */ + composition?: CompositionPiece[]; } -export function StoryEmbed({ name, storyModule }: StoryEmbedProps) { +export function StoryEmbed({ name, storyModule, composition }: StoryEmbedProps) { const StoryComp = storyModule[name] as React.ComponentType>; + const [compositionOpen, setCompositionOpen] = useState(false); + if (!StoryComp) { return
Story "{name}" not found
; } @@ -20,11 +30,36 @@ export function StoryEmbed({ name, storyModule }: StoryEmbedProps) { const knobs = generateKnobs(storyModule.meta); const defaultValues = initKnobValues(knobs); - return ( -
+ const preview = ( +
); + + if (!composition) { + return
{preview}
; + } + + return ( + + {preview} + +
+ + + Composition + +
+ + + + +
+ ); } diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index d6d11062557..ef76475fba8 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { isActive={pathname === href} render={} > - {mod.meta.title} + {mod.meta.label ?? mod.meta.title} {`<${mod.meta.title} />`} diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 8b5bb6787c4..4888815d82e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -3,6 +3,11 @@ import { meta as accordionMeta } from '../stories/accordion.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.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'; import { @@ -12,20 +17,44 @@ 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'; 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'; import { meta as tabsMeta } from '../stories/tabs.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; 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 organizationProfileModule: StoryModule = { meta: organizationProfileMeta, Default: OrganizationProfileDefault }; +const organizationProfileGeneralModule: StoryModule = { + meta: organizationProfileGeneralMeta, + Default: OrganizationProfileGeneralDefault, +}; + const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled }; const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, Disabled: InputDisabled, Invalid }; const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault }; + // Headless primitives carry just `meta` (no story functions). Like every component // they're documented as a single overview page; their live demos come from `` / // `` embeds in the MDX, which import the stories module directly. @@ -40,9 +69,20 @@ const tabsModule: StoryModule = { meta: tabsMeta }; const tooltipModule: StoryModule = { meta: tooltipMeta }; export const registry: StoryModule[] = [ + // AIO + organizationProfileModule, + // Panels + organizationProfileGeneralModule, + // Sections + leaveOrganizationModule, + deleteOrganizationModule, + // Blocks + destructiveModule, + // Components buttonModule, inputModule, dialogComponentModule, + tabsComponentModule, // Primitives — alphabetical within the group. accordionModule, autocompleteModule, diff --git a/packages/swingset/src/lib/slug.ts b/packages/swingset/src/lib/slug.ts index a993c5f7174..44bfb16458d 100644 --- a/packages/swingset/src/lib/slug.ts +++ b/packages/swingset/src/lib/slug.ts @@ -1,6 +1,6 @@ export function toSlug(str: string): string { return str - .replace(/([A-Z])/g, (m, c, i) => (i > 0 ? '-' : '') + c.toLowerCase()) + .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-/, '') diff --git a/packages/swingset/src/lib/types.ts b/packages/swingset/src/lib/types.ts index 5ced9e1bfbb..c9e03b0fccf 100644 --- a/packages/swingset/src/lib/types.ts +++ b/packages/swingset/src/lib/types.ts @@ -37,6 +37,12 @@ export type KnobValues = Record; export interface StoryMeta { group: string; title: string; + /** + * Optional human-friendly label shown in the sidebar. Falls back to `title` when + * omitted. Use this when the desired sidebar text differs from the component name + * (which still drives the slug and the `` tag). + */ + label?: string; /** * Path to the file that exports the documented component, relative to the monorepo * root (e.g. `packages/ui/src/mosaic/components/button.tsx`). Rendered as a "View diff --git a/packages/swingset/src/stories/delete-organization.mdx b/packages/swingset/src/stories/delete-organization.mdx new file mode 100644 index 00000000000..3a82207d49f --- /dev/null +++ b/packages/swingset/src/stories/delete-organization.mdx @@ -0,0 +1,16 @@ +import * as DeleteOrganizationStories from './delete-organization.stories'; + +# Delete Organization + +A section that owns the open/deleting state and wires the `Destructive` block to the delete-organization flow. + +<Story + name='Default' + storyModule={DeleteOrganizationStories} + composition={[ + { name: 'Destructive', href: '/blocks/destructive', layer: 'Blocks' }, + { name: 'Button', href: '/components/button', layer: 'Components' }, + { name: 'Input', href: '/components/input', layer: 'Components' }, + { name: 'Dialog', href: '/components/dialog', layer: 'Components' }, + ]} +/> diff --git a/packages/swingset/src/stories/delete-organization.stories.tsx b/packages/swingset/src/stories/delete-organization.stories.tsx new file mode 100644 index 00000000000..d7b1e435222 --- /dev/null +++ b/packages/swingset/src/stories/delete-organization.stories.tsx @@ -0,0 +1,15 @@ +/** @jsxImportSource @emotion/react */ +import { DeleteOrganization } from '@clerk/ui/mosaic/sections/delete-organization'; + +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() { + return <DeleteOrganization />; +} diff --git a/packages/swingset/src/stories/destructive.mdx b/packages/swingset/src/stories/destructive.mdx new file mode 100644 index 00000000000..89986f39541 --- /dev/null +++ b/packages/swingset/src/stories/destructive.mdx @@ -0,0 +1,10 @@ +import * as DestructiveStories from './destructive.stories'; + +# Destructive + +A controlled block that composes a trigger button, a confirmation dialog, and a guarded input — the user must type the resource name exactly before the action is enabled. + +<Story + name='Default' + storyModule={DestructiveStories} +/> diff --git a/packages/swingset/src/stories/destructive.stories.tsx b/packages/swingset/src/stories/destructive.stories.tsx new file mode 100644 index 00000000000..bceb84fc9cd --- /dev/null +++ b/packages/swingset/src/stories/destructive.stories.tsx @@ -0,0 +1,50 @@ +/** @jsxImportSource @emotion/react */ +import { Destructive } from '@clerk/ui/mosaic/block/destructive'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import type { HTMLAttributes } from 'react'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'Destructive', + source: 'packages/ui/src/mosaic/block/destructive.tsx', +}; + +function DestructiveTrigger(props: Omit<HTMLAttributes<HTMLElement>, 'color'>) { + return ( + <Button + {...props} + color='destructive' + > + Delete organization + </Button> + ); +} + +export function Default() { + const [open, setOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + await new Promise<void>(resolve => setTimeout(resolve, 2000)); + setIsDeleting(false); + setOpen(false); + }; + + return ( + <Destructive + trigger={DestructiveTrigger} + open={open} + onOpenChange={setOpen} + title='Delete organization' + description='Are you sure you want to delete this organization?' + primaryActionLabel='Delete organization' + resourceName='Example organization' + onDelete={handleDelete} + isDeleting={isDeleting} + /> + ); +} diff --git a/packages/swingset/src/stories/leave-organization.mdx b/packages/swingset/src/stories/leave-organization.mdx new file mode 100644 index 00000000000..966737006cb --- /dev/null +++ b/packages/swingset/src/stories/leave-organization.mdx @@ -0,0 +1,16 @@ +import * as LeaveOrganizationStories from './leave-organization.stories'; + +# Leave Organization + +A section that owns the open/deleting state and wires the `Destructive` block to the leave-organization flow. + +<Story + name='Default' + storyModule={LeaveOrganizationStories} + composition={[ + { name: 'Destructive', href: '/blocks/destructive', layer: 'Blocks' }, + { name: 'Button', href: '/components/button', layer: 'Components' }, + { name: 'Input', href: '/components/input', layer: 'Components' }, + { name: 'Dialog', href: '/components/dialog', layer: 'Components' }, + ]} +/> diff --git a/packages/swingset/src/stories/leave-organization.stories.tsx b/packages/swingset/src/stories/leave-organization.stories.tsx new file mode 100644 index 00000000000..3ec1c554292 --- /dev/null +++ b/packages/swingset/src/stories/leave-organization.stories.tsx @@ -0,0 +1,15 @@ +/** @jsxImportSource @emotion/react */ +import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization'; + +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() { + return <LeaveOrganization />; +} diff --git a/packages/swingset/src/stories/organization-profile-general.mdx b/packages/swingset/src/stories/organization-profile-general.mdx new file mode 100644 index 00000000000..3fd513c3662 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general.mdx @@ -0,0 +1,14 @@ +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". + +<Story + name='Default' + storyModule={OrganizationProfileGeneralStories} + composition={[ + { name: 'LeaveOrganization', href: '/sections/leave-organization', layer: 'Sections' }, + { name: 'DeleteOrganization', href: '/sections/delete-organization', layer: 'Sections' }, + ]} +/> diff --git a/packages/swingset/src/stories/organization-profile-general.stories.tsx b/packages/swingset/src/stories/organization-profile-general.stories.tsx new file mode 100644 index 00000000000..b969053afa7 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general.stories.tsx @@ -0,0 +1,15 @@ +/** @jsxImportSource @emotion/react */ +import { OrganizationProfileGeneral } from '@clerk/ui/mosaic/panels/organization-profile-general'; + +import type { StoryMeta } from '@/lib/types'; + +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 <OrganizationProfileGeneral />; +} diff --git a/packages/swingset/src/stories/organization-profile.mdx b/packages/swingset/src/stories/organization-profile.mdx new file mode 100644 index 00000000000..a3c7e8c27df --- /dev/null +++ b/packages/swingset/src/stories/organization-profile.mdx @@ -0,0 +1,10 @@ +import * as OrganizationProfileStories from './organization-profile.stories'; + +# Organization Profile + +The full Organization Profile AIO — assembles all organization-related sections into a single view. + +<Story + name='Default' + storyModule={OrganizationProfileStories} +/> diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx new file mode 100644 index 00000000000..e609e3854f8 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -0,0 +1,15 @@ +/** @jsxImportSource @emotion/react */ +import { OrganizationProfile } from '@clerk/ui/mosaic/aio/organization-profile'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'AIO', + title: 'OrganizationProfile', + label: 'Org Profile', + source: 'packages/ui/src/mosaic/aio/organization-profile.tsx', +}; + +export function Default() { + return <OrganizationProfile />; +} diff --git a/packages/swingset/src/stories/tabs.component.mdx b/packages/swingset/src/stories/tabs.component.mdx new file mode 100644 index 00000000000..39e3cdd61ff --- /dev/null +++ b/packages/swingset/src/stories/tabs.component.mdx @@ -0,0 +1,58 @@ +import * as TabsStories from './tabs.component.stories'; + +# Tabs + +The styled Mosaic `Tabs` — the headless `@clerk/headless` tabs primitives composed with Mosaic +slot recipes. It inherits selection state, roving-tabindex keyboard navigation, and ARIA wiring +(`role="tablist"` / `tab` / `tabpanel`) from the primitive, and adds Mosaic's themed styling for +each part. Slot identity (`data-cl-slot`) is applied by this styled layer, not by the headless +parts. + +## Example + +Click a tab or focus the list and use the arrow keys. The active tab is tracked by a sliding +`Indicator`; the third tab is `disabled`. + +<Story + name='Default' + storyModule={TabsStories} +/> + +## Usage + +```tsx +import { Tabs } from '@clerk/ui/mosaic/components/tabs'; + +<Tabs.Root defaultValue='account'> + <Tabs.List> + <Tabs.Tab value='account'>Account</Tabs.Tab> + <Tabs.Tab value='password'>Password</Tabs.Tab> + <Tabs.Indicator /> + </Tabs.List> + <Tabs.Panel value='account'>Manage your account settings here.</Tabs.Panel> + <Tabs.Panel value='password'>Change your password here.</Tabs.Panel> +</Tabs.Root>; +``` + +`Tab` participates in roving-tabindex keyboard navigation (arrow keys move focus). `Trigger` is a +click-only alternative for cases that don't want keyboard roving. Any part accepts a `render` prop +for polymorphic rendering — pass a function that receives the part's computed props and spreads +them onto your element. + +## Parts + +| Part | Slot | Description | +| ---------------- | ---------------- | ------------------------------------------------------------ | +| `Tabs.Root` | none (context) | Owns the selected value, orientation, and activation mode | +| `Tabs.List` | `tabs-list` | `role="tablist"` container; anchors the indicator | +| `Tabs.Tab` | `tabs-tab` | Selectable tab with roving-tabindex keyboard navigation | +| `Tabs.Trigger` | `tabs-trigger` | Click-only tab (no keyboard roving) | +| `Tabs.Panel` | `tabs-panel` | `role="tabpanel"`; hidden via `data-cl-hidden` when inactive | +| `Tabs.Indicator` | `tabs-indicator` | Sliding underline tracking the active tab (positions inline) | + +## Styling + +Each styled part is themed by the Mosaic tabs recipe and stays targetable through its +`data-cl-slot` plus the state attributes the primitive emits (`data-cl-selected`, +`data-cl-disabled` on tabs; `data-cl-hidden` on panels). Override per slot through +`appearance.elements` — e.g. `{ 'tabs-tab': { fontWeight: 600 } }`. diff --git a/packages/swingset/src/stories/tabs.component.stories.tsx b/packages/swingset/src/stories/tabs.component.stories.tsx new file mode 100644 index 00000000000..b9edfeb468c --- /dev/null +++ b/packages/swingset/src/stories/tabs.component.stories.tsx @@ -0,0 +1,31 @@ +/** @jsxImportSource @emotion/react */ +import { Tabs } from '@clerk/ui/mosaic/components/tabs'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Tabs', + source: 'packages/ui/src/mosaic/components/tabs.tsx', +}; + +export function Default() { + return ( + <Tabs.Root defaultValue='account'> + <Tabs.List> + <Tabs.Tab value='account'>Account</Tabs.Tab> + <Tabs.Tab value='password'>Password</Tabs.Tab> + <Tabs.Tab + value='disabled' + disabled + > + Disabled + </Tabs.Tab> + <Tabs.Indicator /> + </Tabs.List> + <Tabs.Panel value='account'>Manage your account settings here.</Tabs.Panel> + <Tabs.Panel value='password'>Change your password here.</Tabs.Panel> + <Tabs.Panel value='disabled'>This panel is unreachable.</Tabs.Panel> + </Tabs.Root> + ); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index b885f3a8ff2..046ddbbcba4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -122,6 +122,7 @@ "@svgr/rollup": "^8.1.0", "@svgr/webpack": "^6.5.1", "@types/webpack-env": "^1.18.8", + "alien-signals": "2.0.6", "bundlewatch": "^0.4.2", "cross-fetch": "^4.1.0", "minimatch": "^10.2.5", diff --git a/packages/ui/src/mosaic/aio/organization-profile.tsx b/packages/ui/src/mosaic/aio/organization-profile.tsx new file mode 100644 index 00000000000..743da8386b5 --- /dev/null +++ b/packages/ui/src/mosaic/aio/organization-profile.tsx @@ -0,0 +1,45 @@ +import { Box } from '../components/box'; +import { Tabs } from '../components/tabs'; +import { OrganizationProfileGeneral } from '../panels/organization-profile-general'; + +export function OrganizationProfile() { + return ( + <Box + sx={t => ({ + width: '100%', + })} + > + <Box + render={p => <h1 {...p} />} + sx={t => ({ + ...t.text('lg'), + fontWeight: t.font.semibold, + marginBlockEnd: t.spacing(8), + })} + > + Organization Profile + </Box> + <Tabs.Root defaultValue='general'> + <Tabs.List> + <Tabs.Tab value='general'>General</Tabs.Tab> + <Tabs.Tab value='members'>Members</Tabs.Tab> + </Tabs.List> + <Tabs.Panel value='general'> + <OrganizationProfileGeneral /> + </Tabs.Panel> + <Tabs.Panel value='members'> + <Box + render={p => <h1 {...p} />} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.medium, + textAlign: 'center', + })} + > + Members content + </Box> + </Tabs.Panel> + </Tabs.Root> + </Box> + ); +} diff --git a/packages/ui/src/mosaic/block/destructive.tsx b/packages/ui/src/mosaic/block/destructive.tsx new file mode 100644 index 00000000000..2293bab2df7 --- /dev/null +++ b/packages/ui/src/mosaic/block/destructive.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import type { HTMLAttributes } from 'react'; + +import { Box } from '../components/box'; +import { Button } from '../components/button'; +import { Dialog } from '../components/dialog'; +import { Input } from '../components/input'; + +interface DestructiveProps { + trigger: (props: Omit<HTMLAttributes<HTMLElement>, 'color'>) => React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + primaryActionLabel: string; + resourceName: string; + onDelete: () => void | Promise<void>; + isDeleting: boolean; +} + +export function Destructive({ + trigger, + open, + onOpenChange, + title, + description, + primaryActionLabel, + resourceName, + onDelete, + isDeleting, +}: DestructiveProps) { + const [confirmValue, setConfirmValue] = useState(''); + const canSubmit = confirmValue === resourceName && !isDeleting; + + useEffect(() => { + if (!open) setConfirmValue(''); + }, [open]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (canSubmit) onDelete(); + }; + + return ( + <Dialog.Root + open={open} + onOpenChange={onOpenChange} + > + <Dialog.Trigger render={trigger} /> + <Dialog.Portal> + <Dialog.Backdrop /> + <Dialog.Viewport> + <Dialog.Popup> + <Dialog.Title>{title}</Dialog.Title> + <Dialog.Description>{description}</Dialog.Description> + <Box + render={p => ( + <form + {...p} + onSubmit={handleSubmit} + /> + )} + sx={t => ({ + marginBlockStart: t.spacing(3), + })} + > + <Box + render={p => <label {...p} />} + sx={t => ({ + ...t.text('sm'), + fontWeight: t.font.medium, + })} + > + Type "{resourceName}" below to continue. + <Input + value={confirmValue} + onChange={e => setConfirmValue(e.target.value)} + disabled={isDeleting} + sx={t => ({ + marginBlockStart: t.spacing(1), + })} + /> + </Box> + <Box + sx={t => ({ + marginBlockStart: t.spacing(4), + })} + > + <Button + type='submit' + color='destructive' + disabled={!canSubmit} + > + {primaryActionLabel} + </Button> + </Box> + </Box> + </Dialog.Popup> + </Dialog.Viewport> + </Dialog.Portal> + </Dialog.Root> + ); +} diff --git a/packages/ui/src/mosaic/components/box.tsx b/packages/ui/src/mosaic/components/box.tsx new file mode 100644 index 00000000000..6c53461da13 --- /dev/null +++ b/packages/ui/src/mosaic/components/box.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { useMosaicTheme } from '../MosaicProvider'; +import { Box as Primitive, type BoxProps as PrimitiveBoxProps } from '../primitives/box'; +import type { SxProp } from '../slot-recipe'; + +export type BoxProps = PrimitiveBoxProps & { + /** Per-instance styles — a plain object or a function of the resolved Mosaic theme. */ + sx?: SxProp; +}; + +/** Minimal reset applied to every Box; overridable via `sx`. */ +const reset = { boxSizing: 'border-box', margin: 0, padding: 0 } as const; + +/** + * General-purpose mosaic Box. Resolves `sx` against the Mosaic theme and passes it as a `css` + * object to the headless Box primitive — Emotion converts it to a `className` at this JSX boundary + * (the same boundary the `withMosaicSlot` bridge relies on), which the primitive forwards to its + * element. `render` provides polymorphism. + */ +export const Box = React.forwardRef<HTMLDivElement, BoxProps>(function MosaicBox({ sx, ...rest }, ref) { + const theme = useMosaicTheme(); + return ( + <Primitive + ref={ref} + css={[reset, typeof sx === 'function' ? sx(theme) : sx]} + {...rest} + /> + ); +}); diff --git a/packages/ui/src/mosaic/components/button.tsx b/packages/ui/src/mosaic/components/button.tsx index 12dc88e5a2f..2391a94a6a6 100644 --- a/packages/ui/src/mosaic/components/button.tsx +++ b/packages/ui/src/mosaic/components/button.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { defineSlotRecipe, useRecipe } from '../slot-recipe'; import type { RecipeVariantProps } from '../slot-recipe'; +import { defineSlotRecipe, useRecipe } from '../slot-recipe'; export const buttonRecipe = defineSlotRecipe(theme => ({ slot: 'button', @@ -32,6 +32,12 @@ export const buttonRecipe = defineSlotRecipe(theme => ({ _hover: { backgroundColor: theme.mix('primary', 'primaryForeground', 12) }, _active: { backgroundColor: theme.mix('primary', 'primaryForeground', 24) }, }, + destructive: { + backgroundColor: theme.color.destructive, + color: theme.color.destructiveForeground, + _hover: { backgroundColor: theme.mix('destructive', 'destructiveForeground', 12) }, + _active: { backgroundColor: theme.mix('destructive', 'destructiveForeground', 24) }, + }, }, size: { sm: { padding: `${theme.spacing(0.2)} ${theme.spacing(2)}`, ...theme.text('xs') }, diff --git a/packages/ui/src/mosaic/components/section-skeleton.tsx b/packages/ui/src/mosaic/components/section-skeleton.tsx new file mode 100644 index 00000000000..f26d5ce6d4f --- /dev/null +++ b/packages/ui/src/mosaic/components/section-skeleton.tsx @@ -0,0 +1,54 @@ +import { Box } from './box'; +import { Skeleton } from './skeleton'; + +/** + * Loading placeholder mirroring the org-profile section frame: a heading + description + * on the left and an action button on the right. Used by the Leave/Delete sections + * while `useOrganization()` is unloaded, so the layout doesn't shift when content lands. + */ +export function SectionSkeleton() { + return ( + <Box + sx={() => ({ + width: '100%', + containerType: 'inline-size', + })} + > + <Box + sx={t => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + columnGap: t.spacing(10), + rowGap: t.spacing(4), + '@container (min-width: 600px)': { + flexDirection: 'row', + }, + })} + > + <Box + sx={t => ({ + display: 'flex', + flexDirection: 'column', + gap: t.spacing(2), + flexGrow: 1, + })} + > + <Skeleton + width={160} + height='1.25rem' + /> + <Skeleton + width='min(280px, 100%)' + height='1rem' + /> + </Box> + <Skeleton + width={150} + height='2.25rem' + sx={() => ({ flexShrink: 0 })} + /> + </Box> + </Box> + ); +} diff --git a/packages/ui/src/mosaic/components/skeleton.tsx b/packages/ui/src/mosaic/components/skeleton.tsx new file mode 100644 index 00000000000..274c999cd6e --- /dev/null +++ b/packages/ui/src/mosaic/components/skeleton.tsx @@ -0,0 +1,71 @@ +import { keyframes } from '@emotion/react'; +import type { ReactNode } from 'react'; + +import type { SxProp } from '../slot-recipe'; +import { Box, type BoxProps } from './box'; + +const pulse = keyframes({ + '50%': { opacity: 0.5 }, +}); + +export type SkeletonProps = Omit<BoxProps, 'sx' | 'children'> & { + /** + * Wrap real content to size the skeleton to it exactly. The content is rendered + * invisibly (so width, height, and line wrapping match), and a bar is painted over + * each text line. Omit for a plain block sized by `width`/`height`. + */ + children?: ReactNode; + /** Block width when no children — a number is treated as px. Defaults to `100%`. */ + width?: string | number; + /** Block height when no children — a number is treated as px. Defaults to `1rem`. */ + height?: string | number; + sx?: SxProp; +}; + +/** + * Animated placeholder for loading states. Two modes: + * + * - **Block** (`<Skeleton width={150} height='2.25rem' />`) — a single rounded bar. + * - **Content** (`<Skeleton><h2>…</h2></Skeleton>`) — sizes to the wrapped content and + * paints one bar per text line, so multi-line text yields multiple bars that match the + * real wrapping. + * + * Decorative only (`aria-hidden` + `inert`) — pair with an accessible loading + * announcement at the container level if needed. + */ + +export function Skeleton({ children, width = '100%', height = '1rem', sx, ...rest }: SkeletonProps) { + return ( + <Box + aria-hidden + ref={el => el?.setAttribute('inert', '')} + sx={t => ({ + borderRadius: t.rounded.md, + animation: `${pulse} 2s cubic-bezier(0.4, 0, 0.6, 1) infinite`, + '@media (prefers-reduced-motion: reduce)': { animation: 'none' }, + ...(children + ? { + // Content mode: invisible content sets the size; one bar per line via a + // gradient tiled to the element's own line height (`1lh`). + display: 'inline-block', + maxWidth: '100%', + color: 'transparent', + userSelect: 'none', + backgroundImage: `linear-gradient(to bottom, transparent 0 12%, ${t.color.muted} 12% 88%, transparent 88% 100%)`, + backgroundSize: '100% 1lh', + backgroundRepeat: 'repeat-y', + } + : { + // Block mode: explicit dimensions. + width, + height, + backgroundColor: t.color.muted, + }), + ...(typeof sx === 'function' ? sx(t) : sx), + })} + {...rest} + > + {children} + </Box> + ); +} diff --git a/packages/ui/src/mosaic/components/tabs.tsx b/packages/ui/src/mosaic/components/tabs.tsx new file mode 100644 index 00000000000..bf9a76728a5 --- /dev/null +++ b/packages/ui/src/mosaic/components/tabs.tsx @@ -0,0 +1,181 @@ +import React from 'react'; + +import { Tabs as Primitive } from '../primitives/tabs'; +import { defineSlotRecipe, useRecipe } from '../slot-recipe'; + +/** + * One multi-slot recipe owns every tabs part: slot identity (`data-cl-slot`), + * base styles, and the appearance cascade. Each exported part below reads its + * own slot from `useRecipe(tabsRecipe)` and spreads it onto the bridged headless + * primitive. The headless parts no longer emit `data-cl-slot` — slot identity is + * applied here, in the styled layer. + */ +export const tabsRecipe = defineSlotRecipe(theme => ({ + slots: { + list: { slot: 'tabs-list' }, + tab: { slot: 'tabs-tab' }, + trigger: { slot: 'tabs-trigger' }, + panel: { slot: 'tabs-panel' }, + indicator: { slot: 'tabs-indicator' }, + }, + base: { + list: { + position: 'relative', + display: 'flex', + flexDirection: 'row', + gap: theme.spacing(4), + borderBottom: `1px solid ${theme.alpha('primary', 10)}`, + }, + tab: { + appearance: 'none', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + border: 'none', + paddingInline: theme.spacing(1), + paddingBlock: theme.spacing(2), + ...theme.text('sm'), + fontWeight: theme.font.medium, + color: theme.color.mutedForeground, + cursor: 'pointer', + transition: 'color 150ms', + '&[data-cl-selected]': { + color: theme.color.primary, + }, + '&[data-cl-disabled]': { + opacity: 0.5, + cursor: 'not-allowed', + }, + }, + trigger: { + appearance: 'none', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + border: 'none', + paddingInline: theme.spacing(1), + paddingBlock: theme.spacing(2), + ...theme.text('sm'), + fontWeight: theme.font.medium, + color: theme.color.mutedForeground, + cursor: 'pointer', + transition: 'color 150ms', + '&[data-cl-selected]': { + color: theme.color.primary, + }, + '&[data-cl-disabled]': { + opacity: 0.5, + cursor: 'not-allowed', + }, + }, + panel: { + paddingBlock: theme.spacing(4), + '&[data-cl-hidden]': { + display: 'none', + }, + }, + // The headless indicator positions itself (`position`, `left`, `width`) via + // inline style tracking the active tab; the recipe only supplies the visual + // underline at the bottom of the list and the slide transition. + indicator: { + bottom: 0, + height: '2px', + backgroundColor: theme.color.primary, + transition: 'left 150ms ease-out, width 150ms ease-out', + }, + }, +})); + +declare module '../registry' { + interface MosaicSlotRegistry { + 'tabs-list': true; + 'tabs-tab': true; + 'tabs-trigger': true; + 'tabs-panel': true; + 'tabs-indicator': true; + } +} + +const List = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Primitive.List>>( + function TabsList(props, ref) { + const { list } = useRecipe(tabsRecipe); + return ( + <Primitive.List + ref={ref} + {...props} + {...list} + /> + ); + }, +); + +const Tab = React.forwardRef<HTMLButtonElement, React.ComponentPropsWithoutRef<typeof Primitive.Tab>>( + function TabsTab(props, ref) { + const { tab } = useRecipe(tabsRecipe); + return ( + <Primitive.Tab + ref={ref} + {...props} + {...tab} + /> + ); + }, +); + +const Trigger = React.forwardRef<HTMLButtonElement, React.ComponentPropsWithoutRef<typeof Primitive.Trigger>>( + function TabsTrigger(props, ref) { + const { trigger } = useRecipe(tabsRecipe); + return ( + <Primitive.Trigger + ref={ref} + {...props} + {...trigger} + /> + ); + }, +); + +const Panel = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof Primitive.Panel>>( + function TabsPanel(props, ref) { + const { panel } = useRecipe(tabsRecipe); + return ( + <Primitive.Panel + ref={ref} + {...props} + {...panel} + /> + ); + }, +); + +const Indicator = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<typeof Primitive.Indicator>>( + function TabsIndicator(props, ref) { + const { indicator } = useRecipe(tabsRecipe); + return ( + <Primitive.Indicator + ref={ref} + {...props} + {...indicator} + /> + ); + }, +); + +/** Styled mosaic Tabs components built on headless Tabs primitives. */ +export const Tabs: { + Root: typeof Primitive.Root; + List: typeof List; + Tab: typeof Tab; + Trigger: typeof Trigger; + Panel: typeof Panel; + Indicator: typeof Indicator; +} = { + Root: Primitive.Root, + List, + Tab, + Trigger, + Panel, + Indicator, +}; diff --git a/packages/ui/src/mosaic/mock/organization-store.ts b/packages/ui/src/mosaic/mock/organization-store.ts new file mode 100644 index 00000000000..85aa92ae783 --- /dev/null +++ b/packages/ui/src/mosaic/mock/organization-store.ts @@ -0,0 +1,33 @@ +import { signal } from 'alien-signals'; + +/** + * Module-level mock store for the org-profile prototype. + * + * Mirrors the signal-based architecture the real Clerk hooks now use + * (`packages/clerk-js/src/core/signals.ts` + the `useSyncExternalStore` bridge in + * `packages/react/src/hooks/useClerkSignal.ts`). One signal is shared by every + * `useOrganization()` instance, so independently-mounted `<LeaveOrganization />` and + * `<DeleteOrganization />` flip to "loaded" on the same tick instead of racing their + * own timers. + */ + +/** Time until the simulated org load resolves. */ +const LOAD_DELAY_MS = 600; +/** Artificial latency for `destroy()` mutations. */ +export const MUTATION_DELAY_MS = 2000; + +// read: orgLoadedSignal() · write: orgLoadedSignal(next) +export const orgLoadedSignal = signal(false); + +let started = false; + +/** Idempotently kicks the simulated load. Called client-side from the hook's subscribe. */ +export function startOrgLoad(): void { + if (started) { + return; + } + started = true; + setTimeout(() => orgLoadedSignal(true), LOAD_DELAY_MS); +} + +export const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)); diff --git a/packages/ui/src/mosaic/mock/use-organization.tsx b/packages/ui/src/mosaic/mock/use-organization.tsx new file mode 100644 index 00000000000..59bc73eaa22 --- /dev/null +++ b/packages/ui/src/mosaic/mock/use-organization.tsx @@ -0,0 +1,74 @@ +import { effect } from 'alien-signals'; +import { useSyncExternalStore } from 'react'; + +import { delay, MUTATION_DELAY_MS, orgLoadedSignal, startOrgLoad } from './organization-store'; + +/** + * Mock `useOrganization` for prototyping Mosaic organization-profile components. + * + * Mirrors the shape of the real hook in + * `packages/shared/src/react/hooks/useOrganization.tsx`: a + * `{ isLoaded, organization, membership }` discriminated union where + * `organization.destroy()` deletes the org and `membership.destroy()` leaves it. + * + * Loading state is read from a shared module-level signal via `useSyncExternalStore`, + * the same bridge the real signal-based hooks use + * (`packages/react/src/hooks/useClerkSignal.ts`). Simulated async only — no real SDK. + */ + +export interface MockOrganization { + id: string; + name: string; + slug: string | null; + membersCount: number; + /** Delete the entire organization (admin-only in the real API). */ + destroy: () => Promise<void>; +} + +export interface MockMembership { + id: string; + /** e.g. 'org:admin' | 'org:member' */ + role: string; + /** Leave the organization (removes the current member). */ + destroy: () => Promise<void>; +} + +// Mirrors the real discriminated union: while loading, every field is `undefined`. +export type UseOrganizationReturn = + | { isLoaded: false; organization: undefined; membership: undefined } + | { isLoaded: true; organization: MockOrganization | null; membership: MockMembership | null }; + +export function useOrganization(): UseOrganizationReturn { + const isLoaded = useSyncExternalStore( + callback => { + startOrgLoad(); + // effect re-runs whenever the signal changes; returns its dispose fn for cleanup. + return effect(() => { + orgLoadedSignal(); + callback(); + }); + }, + () => orgLoadedSignal(), + () => false, // server snapshot — stays unloaded, so SSR markup matches first client render. + ); + + if (!isLoaded) { + return { isLoaded: false, organization: undefined, membership: undefined }; + } + + return { + isLoaded: true, + organization: { + id: 'org_mock', + name: "Alex's Organization", + slug: 'alex-org', + membersCount: 4, + destroy: () => delay(MUTATION_DELAY_MS), + }, + membership: { + id: 'mem_mock', + role: 'org:admin', + destroy: () => delay(MUTATION_DELAY_MS), + }, + }; +} diff --git a/packages/ui/src/mosaic/panels/organization-profile-general.tsx b/packages/ui/src/mosaic/panels/organization-profile-general.tsx new file mode 100644 index 00000000000..fc346ff9f75 --- /dev/null +++ b/packages/ui/src/mosaic/panels/organization-profile-general.tsx @@ -0,0 +1,25 @@ +import { Box } from '../components/box'; +import { DeleteOrganization } from '../sections/delete-organization'; +import { LeaveOrganization } from '../sections/leave-organization'; +import { alpha } from '../utils'; + +export function OrganizationProfileGeneral() { + return ( + <Box + sx={t => ({ + width: '100%', + containerType: 'inline-size', + })} + > + <LeaveOrganization /> + <Box + sx={t => ({ + height: '1px', + background: `light-dark(${alpha('#000', 10)},${alpha('#fff', 10)})`, + marginBlock: t.spacing(4), + })} + /> + <DeleteOrganization /> + </Box> + ); +} diff --git a/packages/ui/src/mosaic/primitives/box.tsx b/packages/ui/src/mosaic/primitives/box.tsx new file mode 100644 index 00000000000..bad2d945e4f --- /dev/null +++ b/packages/ui/src/mosaic/primitives/box.tsx @@ -0,0 +1,15 @@ +import { type ComponentProps, renderElement } from '@clerk/headless/utils'; +import React from 'react'; + +/** + * The headless Box primitive: a general-purpose element with render-prop polymorphism and no + * styles of its own. The mosaic styled layer (`components/box.tsx`) passes `css` at the JSX + * boundary, where Emotion turns it into a `className` that this part forwards to its rendered + * DOM node — the same boundary the `withMosaicSlot` bridge relies on. + */ +export type BoxProps = ComponentProps<'div'>; + +export const Box = React.forwardRef<HTMLDivElement, BoxProps>(function Box(props, ref) { + const { render, ...rest } = props; + return renderElement({ defaultTagName: 'div', render, props: { ...rest, ref } }); +}); diff --git a/packages/ui/src/mosaic/primitives/tabs.tsx b/packages/ui/src/mosaic/primitives/tabs.tsx new file mode 100644 index 00000000000..69cb2ff21b5 --- /dev/null +++ b/packages/ui/src/mosaic/primitives/tabs.tsx @@ -0,0 +1,25 @@ +import { Tabs as HeadlessTabs } from '@clerk/headless/tabs'; +import type { TabsProps } from '@clerk/headless/tabs'; +import type { FunctionComponent } from 'react'; + +import { withMosaicSlot } from './withMosaicSlot'; + +/** + * The headless tabs parts bridged into mosaic. Each styleable part is wrapped + * with `withMosaicSlot`, which forwards its ref and accepts the per-slot props a + * recipe produces (`css`, `data-cl-slot`, state attrs) — the bridged type is + * inferred, so there is nothing to hand-annotate per part. + * + * `Root` renders no element of its own (it is a context provider) and passes + * through unchanged; it is cast to its public component type so the inferred + * `Tabs` type stays portable (otherwise it references internal `@clerk/headless` + * declaration paths). + */ +export const Tabs = { + Root: HeadlessTabs.Root as FunctionComponent<TabsProps>, + List: withMosaicSlot(HeadlessTabs.List), + Tab: withMosaicSlot(HeadlessTabs.Tab), + Trigger: withMosaicSlot(HeadlessTabs.Trigger), + Panel: withMosaicSlot(HeadlessTabs.Panel), + Indicator: withMosaicSlot(HeadlessTabs.Indicator), +}; diff --git a/packages/ui/src/mosaic/sections/delete-organization.tsx b/packages/ui/src/mosaic/sections/delete-organization.tsx new file mode 100644 index 00000000000..527f1ab3aa0 --- /dev/null +++ b/packages/ui/src/mosaic/sections/delete-organization.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; + +import { Box } from '../components/box'; +import { Button } from '../components/button'; +import { SectionSkeleton } from '../components/section-skeleton'; +import { Destructive } from '../block/destructive'; +import { useOrganization } from '../mock/use-organization'; + +export function DeleteOrganization() { + const { isLoaded, organization } = useOrganization(); + const [open, setOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + if (!isLoaded || !organization) { + return <SectionSkeleton />; + } + + const handleDelete = async () => { + setIsDeleting(true); + await organization.destroy(); + setIsDeleting(false); + setOpen(false); + }; + + return ( + <Box + sx={t => ({ + width: '100%', + containerType: 'inline-size', + })} + > + <Box + sx={t => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + columnGap: t.spacing(10), + rowGap: t.spacing(4), + '@container (min-width: 600px)': { + flexDirection: 'row', + }, + })} + > + <Box> + <Box + render={p => <h2 {...p} />} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.semibold, + })} + > + Delete organization + </Box> + <Box + render={p => <p {...p} />} + sx={t => ({ + ...t.text('sm'), + textWrap: 'balance', + marginBlockStart: t.spacing(1), + color: t.color.mutedForeground, + })} + > + Your organization will be permanently deleted and all members will lose access + </Box> + </Box> + <Destructive + trigger={props => ( + <Button + color='destructive' + {...props} + sx={{ + flexShrink: 0, + }} + > + Delete organization + </Button> + )} + open={open} + onOpenChange={setOpen} + title='Delete organization' + description='Are you sure you want to delete this organization?' + resourceName={organization.name} + primaryActionLabel='Delete organization' + onDelete={handleDelete} + isDeleting={isDeleting} + /> + </Box> + </Box> + ); +} diff --git a/packages/ui/src/mosaic/sections/leave-organization.tsx b/packages/ui/src/mosaic/sections/leave-organization.tsx new file mode 100644 index 00000000000..a32d66a1919 --- /dev/null +++ b/packages/ui/src/mosaic/sections/leave-organization.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; + +import { Box } from '../components/box'; +import { Button } from '../components/button'; +import { SectionSkeleton } from '../components/section-skeleton'; +import { Destructive } from '../block/destructive'; +import { useOrganization } from '../mock/use-organization'; + +export function LeaveOrganization() { + const { isLoaded, organization, membership } = useOrganization(); + const [open, setOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + if (!isLoaded || !organization || !membership) { + return <SectionSkeleton />; + } + + const handleLeave = async () => { + setIsDeleting(true); + await membership.destroy(); + setIsDeleting(false); + setOpen(false); + }; + + return ( + <> + <Box + sx={t => ({ + width: '100%', + containerType: 'inline-size', + })} + > + <Box + sx={t => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + columnGap: t.spacing(10), + rowGap: t.spacing(4), + '@container (min-width: 600px)': { + flexDirection: 'row', + }, + })} + > + <Box> + <Box + render={p => <h2 {...p} />} + sx={t => ({ + ...t.text('base'), + fontWeight: t.font.semibold, + })} + > + Leave organization + </Box> + <Box + render={p => <p {...p} />} + sx={t => ({ + ...t.text('sm'), + textWrap: 'balance', + marginBlockStart: t.spacing(1), + color: t.color.mutedForeground, + })} + > + You will be removed from the organization and need to be invited back + </Box> + </Box> + <Destructive + trigger={props => ( + <Button + color='destructive' + {...props} + sx={{ + flexShrink: 0, + }} + > + Leave organization + </Button> + )} + open={open} + onOpenChange={setOpen} + title='Leave organization' + description='Are you sure you want to leave this organization? You will lose access to this organization and its applications.' + primaryActionLabel='Leave organization' + resourceName={organization.name} + onDelete={handleLeave} + isDeleting={isDeleting} + /> + </Box> + </Box> + </> + ); +} diff --git a/packages/ui/src/mosaic/variables.ts b/packages/ui/src/mosaic/variables.ts index 3b46cd921f3..b00f378a1fb 100644 --- a/packages/ui/src/mosaic/variables.ts +++ b/packages/ui/src/mosaic/variables.ts @@ -7,6 +7,10 @@ export const defaultMosaicVariables = Object.freeze({ color: { primary: 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', primaryForeground: 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', + destructive: 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))', + destructiveForeground: 'oklch(0.985 0 0)', + muted: 'light-dark(oklch(0.97 0 0), oklch(0.269 0 0))', + mutedForeground: 'light-dark(oklch(0.556 0 0), oklch(0.708 0 0))', }, spacing: '0.25rem', rounded: { @@ -24,6 +28,12 @@ export const defaultMosaicVariables = Object.freeze({ xl: { fontSize: '1.25rem', lineHeight: 'calc(1.75 / 1.25)' }, '2xl': { fontSize: '1.5rem', lineHeight: 'calc(2 / 1.5)' }, }, + font: { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, + }, } as const); /** Structural type of the raw token tree, inferred from `defaultMosaicVariables`. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abac24e25af..089c430fe44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1147,6 +1147,9 @@ importers: '@types/webpack-env': specifier: ^1.18.8 version: 1.18.8 + alien-signals: + specifier: 2.0.6 + version: 2.0.6 bundlewatch: specifier: ^0.4.2 version: 0.4.2