Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): add Mosaic Delete Org section and destructive button variant by kylemac · Pull Request #8839 · clerk/javascript · GitHub
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic Delete Org section and destructive button variant by kylemac · Pull Request #8839 · clerk/javascript · GitHub
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic Delete Org section and destructive button variant by kylemac · Pull Request #8839 · clerk/javascript · GitHub
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): add Mosaic Delete Org section and destructive button variant by kylemac · Pull Request #8839 · clerk/javascript · GitHub
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic Delete Org section and destructive button variant by kylemac · Pull Request #8839 · clerk/javascript · GitHub
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic Delete Org section and destructive button variant by kylemac · Pull Request #8839 · clerk/javascript · GitHub
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): add Mosaic Delete Org section and destructive button variant by kylemac · Pull Request #8839 · clerk/javascript · GitHub
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/delete-org-block.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
63 changes: 63 additions & 0 deletions packages/swingset/src/components/Composition.tsx
Original file line numberDiff line numberDiff line change
@@ -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 `<Story>`'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<string, CompositionPiece[]>();
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 (
<div className='flex flex-col gap-4 p-3'>
{sortedLayers.map(layer => (
<section
key={layer}
className='flex flex-col gap-2'
>
<div className='text-brand text-[10px] font-semibold uppercase tracking-widest'>{layer}</div>
{groups
.get(layer)
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(piece => (
<Link
key={piece.href}
href={piece.href}
className='text-muted-foreground hover:text-foreground font-mono text-xs'
>
{`<${piece.name} />`}
</Link>
))}
</section>
))}
</div>
);
}
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
},
sections: {
'leave-organization': dynamic(() => import('../stories/leave-organization.mdx')),
'delete-organization': dynamic(() => import('../stories/delete-organization.mdx')),
},
blocks: {
destructive: dynamic(() => import('../stories/destructive.mdx')),
Expand Down
41 changes: 38 additions & 3 deletions packages/swingset/src/components/StoryEmbed.tsx
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
'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<Record<string, unknown>>;
const [compositionOpen, setCompositionOpen] = useState(false);

if (!StoryComp) {
return <div className='rounded bg-red-50 p-3 text-sm text-red-500'>Story &quot;{name}&quot; not found</div>;
}

const knobs = generateKnobs(storyModule.meta);
const defaultValues = initKnobValues(knobs);

return (
<div className='not-prose border-border bg-background my-4 flex min-h-20 items-center justify-center rounded-lg border p-6'>
const preview = (
<div className='flex min-h-20 items-center justify-center p-6'>
<MosaicProvider>
<StoryComp {...defaultValues} />
</MosaicProvider>
</div>
);

if (!composition) {
return <div className='not-prose border-border bg-background my-4 rounded-lg border'>{preview}</div>;
}

return (
<Collapsible
open={compositionOpen}
onOpenChange={setCompositionOpen}
className='not-prose border-border bg-background my-4 overflow-hidden rounded-lg border'
>
{preview}

<div className='flex items-center justify-start gap-1 border-t px-2 py-1.5'>
<CollapsibleTrigger className='text-muted-foreground hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs'>
<Layers2Icon className='size-3' />
Composition
</CollapsibleTrigger>
</div>

<CollapsibleContent className='border-t'>
<CompositionPanel pieces={composition} />
</CollapsibleContent>
</Collapsible>
);
}
2 changes: 1 addition & 1 deletion packages/swingset/src/components/app-sidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,7 +78,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
isActive={pathname === href}
render={<Link href={href} />}
>
<span className='truncate'>{mod.meta.title}</span>
<span className='truncate'>{mod.meta.label ?? mod.meta.title}</span>
<span className='text-sidebar-foreground/50 shrink-0 font-mono text-[10px] leading-none'>
{`<${mod.meta.title} />`}
</span>
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,10 @@ 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';
Expand DownExpand Up@@ -31,6 +35,7 @@ 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 buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
Expand All@@ -57,6 +62,7 @@ export const registry: StoryModule[] = [
organizationProfileModule,
// Sections
leaveOrganizationModule,
deleteOrganizationModule,
// Blocks
destructiveModule,
// Components
Expand Down
6 changes: 6 additions & 0 deletions packages/swingset/src/lib/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,12 @@ export type KnobValues = Record<string, string | boolean | number>;
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 `<Title />` 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
Expand Down
16 changes: 16 additions & 0 deletions packages/swingset/src/stories/delete-organization.mdx
Original file line numberDiff line numberDiff line change
@@ -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' },
]}
/>
15 changes: 15 additions & 0 deletions packages/swingset/src/stories/delete-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -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 organizationName="Alex's Organization" />;
}
6 changes: 6 additions & 0 deletions packages/swingset/src/stories/leave-organization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,4 +7,10 @@ A section that owns the open/deleting state and wires the `Destructive` block to
<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' },
]}
/>
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/leave-organization.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
/** @jsxImportSource @emotion/react */
import { LeaveOrganization } from '@clerk/ui/mosaic/section/leave-organization';
import { LeaveOrganization } from '@clerk/ui/mosaic/sections/leave-organization';

import type { StoryMeta } from '@/lib/types';

export const meta: StoryMeta = {
group: 'Sections',
title: 'LeaveOrganization',
source: 'packages/ui/src/mosaic/section/leave-organization.tsx',
label: 'Leave Org',
source: 'packages/ui/src/mosaic/sections/leave-organization.tsx',
};

export function Default() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ 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',
};

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/aio/organization-profile.tsx
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { LeaveOrganization } from '../section/leave-organization';
import { LeaveOrganization } from '../sections/leave-organization';

interface OrganizationProfileProps {
organizationName: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/mosaic/block/destructive.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,10 @@ export function Destructive({
Are you sure you want to leave this {resourceType}? You will lose access to this {resourceType} and its
applications.
</Dialog.Description>
<form onSubmit={handleSubmit}>
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<label>
Type &quot;{resourceName}&quot; below to continue.
<Input
Expand All@@ -62,7 +65,9 @@ export function Destructive({
</label>
<Button
type='submit'
color='destructive'
disabled={!canSubmit}
sx={{ alignSelf: 'flex-start' }}
>
{isDeleting ? 'Leaving…' : `Leave ${resourceType}`}
</Button>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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') },
Expand Down
43 changes: 43 additions & 0 deletions packages/ui/src/mosaic/sections/delete-organization.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';

import { Button } from '../components/button';
import { Destructive } from '../block/destructive';

interface DeleteOrganizationProps {
organizationName: string;
}

export function DeleteOrganization({ organizationName }: DeleteOrganizationProps) {
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Delete organization</h2>
<p>Your organization will be permanently deleted and all members will lose access</p>
<Destructive
trigger={props => (
<Button
color='destructive'
{...props}
>
Delete organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,17 +19,25 @@ export function LeaveOrganization({ organizationName }: LeaveOrganizationProps)
};

return (
<>
<h2>Leave organization</h2>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<h2 style={{ fontWeight: 700 }}>Leave organization</h2>
<p>You will be removed from the organization and need to be invited back</p>
<Destructive
trigger={props => <Button {...props}>Leave organization</Button>}
trigger={props => (
<Button
color='destructive'
{...props}
>
Leave organization
</Button>
)}
open={open}
onOpenChange={setOpen}
resourceType='organization'
resourceName={organizationName}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,8 @@ 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)',
},
spacing: '0.25rem',
rounded: {
Expand Down
Loading