Skip to content
Closed
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/swingset-tooltip-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
16 changes: 9 additions & 7 deletions packages/swingset/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
suppressHydrationWarning
className={cn('font-sans', geist.variable)}
>
{process.env.NODE_ENV === 'development' && (
<Script
src='//unpkg.com/react-grab/dist/index.global.js'
crossOrigin='anonymous'
strategy='beforeInteractive'
/>
)}
<head>
{process.env.NODE_ENV === 'development' && (
<Script
src='//unpkg.com/react-grab/dist/index.global.js'
crossOrigin='anonymous'
strategy='beforeInteractive'
/>
)}
</head>
<body className='antialiased'>
<ThemeProvider>
<ClientRoot>{children}</ClientRoot>
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';

const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
};

interface DocsViewerProps {
Expand Down
20 changes: 11 additions & 9 deletions packages/swingset/src/components/StoryCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
import type { MosaicVariables } from '@clerk/ui/mosaic/variables';
import type React from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';

import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { generateKnobs, initKnobValues } from '@/lib/generateKnobs';
Expand All @@ -21,18 +21,20 @@ interface StoryCanvasProps {
export function StoryCanvas({ componentSlug, storySlug }: StoryCanvasProps) {
const found = findStory(componentSlug, storySlug);

const [knobs, setKnobs] = useState<KnobRecord>(() => (found ? generateKnobs(found.mod.meta) : {}));
// Derive knobs from the slug primitives — `found` is a fresh object every render, so depending
// on it (in the effect below) would reset state on every render and loop until React bails out.
const knobs = useMemo<KnobRecord>(() => {
const entry = findStory(componentSlug, storySlug);
return entry ? generateKnobs(entry.mod.meta) : {};
}, [componentSlug, storySlug]);

const [knobValues, setKnobValues] = useState<KnobValues>(() => initKnobValues(knobs));
const [variables, setVariables] = useState<MosaicVariables>({});

// Reset knob values when the active story (and therefore its knobs) changes.
useEffect(() => {
if (!found) {
return;
}
const nextKnobs = generateKnobs(found.mod.meta);
setKnobs(nextKnobs);
setKnobValues(initKnobValues(nextKnobs));
}, [found, componentSlug, storySlug]);
setKnobValues(initKnobValues(knobs));
}, [knobs]);

if (!found) {
return <div className='text-muted-foreground p-8 text-sm'>Story not found.</div>;
Expand Down
125 changes: 75 additions & 50 deletions packages/swingset/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,65 +23,90 @@ import { toSlug } from '@/lib/slug';

const groups = getSidebarGroups();

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
type ComponentGroupProps = {
title: string;
componentSlug: string;
names: string[];
};

// One controlled Collapsible per component. The group opens when its route becomes
// active, but stays user-toggleable otherwise. It must be controlled (not `defaultOpen`)
// because the instance persists across navigation: `defaultOpen` is only read on init,
// so a route-derived value mutating later trips Base UI's uncontrolled-state warning.
function ComponentGroup({ title, componentSlug, names }: ComponentGroupProps) {
const pathname = usePathname();
const docsHref = `/components/${componentSlug}`;
const isActive = pathname.startsWith(docsHref);
const [open, setOpen] = React.useState(isActive);

React.useEffect(() => {
if (isActive) {
setOpen(true);
}
}, [isActive]);

return (
<Collapsible
open={open}
onOpenChange={setOpen}
className='group/collapsible'
>
<SidebarGroup>
<SidebarGroupLabel
className='group/label text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground text-sm'
render={<CollapsibleTrigger />}
>
{title}
<ChevronRightIcon className='group-data-open/collapsible:rotate-90 ml-auto transition-transform' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === docsHref}
render={<Link href={docsHref} />}
>
Overview
</SidebarMenuButton>
</SidebarMenuItem>
{names.map(name => {
const href = `/components/${componentSlug}/${toSlug(name)}`;
return (
<SidebarMenuItem key={name}>
<SidebarMenuButton
isActive={pathname === href}
render={<Link href={href} />}
>
{name}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
}

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar {...props}>
<SidebarHeader className='flex h-12 justify-center border-b px-4'>
<span className='text-sm font-semibold'>Swingset</span>
</SidebarHeader>
<SidebarContent className='gap-0'>
{groups.flatMap(({ stories }) =>
stories.map(({ mod, componentSlug, names }) => {
const docsHref = `/components/${componentSlug}`;
const isOpen = pathname.startsWith(docsHref);

return (
<Collapsible
key={mod.meta.title}
defaultOpen={isOpen}
className='group/collapsible'
>
<SidebarGroup>
<SidebarGroupLabel
className='group/label text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground text-sm'
render={<CollapsibleTrigger />}
>
{mod.meta.title}
<ChevronRightIcon className='group-data-open/collapsible:rotate-90 ml-auto transition-transform' />
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === docsHref}
render={<Link href={docsHref} />}
>
Overview
</SidebarMenuButton>
</SidebarMenuItem>
{names.map(name => {
const href = `/components/${componentSlug}/${toSlug(name)}`;
return (
<SidebarMenuItem key={name}>
<SidebarMenuButton
isActive={pathname === href}
render={<Link href={href} />}
>
{name}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
}),
stories.map(({ mod, componentSlug, names }) => (
<ComponentGroup
key={mod.meta.title}
title={mod.meta.title}
componentSlug={componentSlug}
names={names}
/>
)),
)}
</SidebarContent>
<SidebarRail />
Expand Down
4 changes: 3 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Import stories explicitly to control order and avoid type casting through unknown.
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
import { meta as tooltipMeta, Options as TooltipOptions } from '../stories/tooltip.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';

const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
const tooltipModule: StoryModule = { meta: tooltipMeta, Options: TooltipOptions };

export const registry: StoryModule[] = [buttonModule];
export const registry: StoryModule[] = [buttonModule, tooltipModule];

export interface RegistryEntry {
mod: StoryModule;
Expand Down
33 changes: 33 additions & 0 deletions packages/swingset/src/stories/tooltip.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as TooltipStories from './tooltip.stories';

# Tooltip

The `Tooltip` component reveals a small label when its trigger is hovered or focused. It wraps any trigger element and positions the bubble with the `side` variant. Focus and blur bubble up from the trigger, so the tooltip is keyboard-accessible without extra wiring.

## Options

<Story
name='Options'
storyModule={TooltipStories}
/>

## Usage

```tsx
import { Tooltip } from '@clerk/ui/mosaic/components/tooltip';
import { Button } from '@clerk/ui/mosaic/components/button';

<Tooltip
content='Tooltip content'
side='top'
>
<Button>Hover me</Button>
</Tooltip>;
```

## Props

<PropTable
meta={TooltipStories.meta}
extra={[{ name: 'content', type: 'React.ReactNode' }]}
/>
53 changes: 53 additions & 0 deletions packages/swingset/src/stories/tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** @jsxImportSource @emotion/react */
import { Button } from '@clerk/ui/mosaic/components/button';
import type { TooltipProps } from '@clerk/ui/mosaic/components/tooltip';
import { Tooltip, tooltipStyles } from '@clerk/ui/mosaic/components/tooltip';

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

export const meta: StoryMeta = {
group: 'Components',
title: 'Tooltip',
styles: tooltipStyles,
};

// Story functions accept Record<string,unknown> (knob values) and cast to TooltipProps.
// The cast is unavoidable: knobs are dynamically typed; Tooltip has a strict prop interface.
function knobsAsProps(props: Record<string, unknown>) {
return props as unknown as TooltipProps;
}

export function Options(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 32, alignItems: 'center' }}>
<Tooltip
{...knobsAsProps(props)}
content='On top'
side='top'
>
<Button>Top</Button>
</Tooltip>
<Tooltip
{...knobsAsProps(props)}
content='On the right'
side='right'
>
<Button>Right</Button>
</Tooltip>
<Tooltip
{...knobsAsProps(props)}
content='On the bottom'
side='bottom'
>
<Button>Bottom</Button>
</Tooltip>
<Tooltip
{...knobsAsProps(props)}
content='On the left'
side='left'
>
<Button>Left</Button>
</Tooltip>
</div>
);
}
Loading
Loading