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/document-headless-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 9 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ import { ViewSource } from './ViewSource';
const docModules: Record<string, React.ComponentType> = {
button: dynamic(() => import('../stories/button.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
// Headless primitives — alphabetical.
accordion: dynamic(() => import('../stories/accordion.mdx')),
autocomplete: dynamic(() => import('../stories/autocomplete.mdx')),
collapsible: dynamic(() => import('../stories/collapsible.mdx')),
dialog: dynamic(() => import('../stories/dialog.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
select: dynamic(() => import('../stories/select.mdx')),
tabs: dynamic(() => import('../stories/tabs.mdx')),
tooltip: dynamic(() => import('../stories/tooltip.mdx')),
};

interface DocsViewerProps {
Expand Down
31 changes: 30 additions & 1 deletion packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
// Import stories explicitly to control order and avoid type casting through unknown.
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 { meta as dialogMeta } from '../stories/dialog.stories';
import {
Default,
Disabled as InputDisabled,
Invalid,
meta as inputMeta,
Sizes as InputSizes,
} from '../stories/input.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.stories';
import { meta as tabsMeta } from '../stories/tabs.stories';
import { meta as tooltipMeta } from '../stories/tooltip.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';

Expand All @@ -18,9 +26,30 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes,
// Headless primitives carry just `meta` (no story functions). Like every component
// they're documented as a single overview page; their live demos come from `<Story>` /
// `<Preview>` embeds in the MDX, which import the stories module directly.
const accordionModule: StoryModule = { meta: accordionMeta };
const autocompleteModule: StoryModule = { meta: autocompleteMeta };
const collapsibleModule: StoryModule = { meta: collapsibleMeta };
const dialogModule: StoryModule = { meta: dialogMeta };
const menuModule: StoryModule = { meta: menuMeta };
const popoverModule: StoryModule = { meta: popoverMeta };
const selectModule: StoryModule = { meta: selectMeta };
const tabsModule: StoryModule = { meta: tabsMeta };
const tooltipModule: StoryModule = { meta: tooltipMeta };

export const registry: StoryModule[] = [buttonModule, inputModule, collapsibleModule];
export const registry: StoryModule[] = [
buttonModule,
inputModule,
// Primitives — alphabetical within the group.
accordionModule,
autocompleteModule,
collapsibleModule,
dialogModule,
menuModule,
popoverModule,
selectModule,
tabsModule,
tooltipModule,
];

/** Look up a component's story module from its slug (derived from `meta.title`). */
export function getModuleBySlug(slug: string): StoryModule | undefined {
Expand Down
123 changes: 123 additions & 0 deletions packages/swingset/src/stories/accordion.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import * as AccordionStories from './accordion.stories';

# Accordion

A set of stacked sections where each is toggled by its own heading button, from
`@clerk/headless`. It is a **headless** primitive: it supplies behavior, single/multiple
open state, roving-focus keyboard navigation, ARIA wiring, and the expand/collapse
animation lifecycle, but ships **no styles** — you bring your own CSS by targeting the
`data-cl-*` attributes each part emits.

## Example

The demo below is intentionally unstyled — it renders the raw primitive so you can see its
behavior and ARIA wiring. Click a trigger to open its panel; in `single` mode opening one
closes the others, and arrow keys move focus between triggers.

<Story
name='Default'
storyModule={AccordionStories}
/>

## Usage

```tsx
import { Accordion } from '@clerk/headless/accordion';

<Accordion.Root>
<Accordion.Item value='item-1'>
<Accordion.Header>
<Accordion.Trigger>Section 1</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>Content for section 1</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='item-2'>
<Accordion.Header>
<Accordion.Trigger>Section 2</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>Content for section 2</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>;
```

### Single (one item open at a time)

```tsx
<Accordion.Root type='single'>{/* items */}</Accordion.Root>
```

### Controlled

```tsx
const [value, setValue] = useState<string[]>(['item-1']);

<Accordion.Root
value={value}
onValueChange={setValue}
>
{/* items */}
</Accordion.Root>;
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ----------------------------------------------------------- |
| `Accordion.Root` | `<div>` | Root wrapper; owns open-item state and Home/End/arrow nav |
| `Accordion.Item` | `<div>` | Wraps one section; provides item context (value, open, IDs) |
| `Accordion.Header` | `<h3>` | Heading wrapper for the trigger |
| `Accordion.Trigger` | `<button>` | Clickable toggle for its item's panel |
| `Accordion.Panel` | `<div>` | Collapsible content region for its item |

All parts accept a `render` prop for polymorphic rendering and standard HTML
attributes for their default element. Compound parts throw if used outside their
parent (`Accordion.*` outside `Root`; `Trigger`/`Header`/`Panel` outside `Item`).

## Props

### `Accordion.Root`

| Prop | Type | Default | Description |
| -------------------------- | -------------------------------------- | ----------------------- | ---------------------------------------------- |
| <code>type</code> | <code>'single' \| 'multiple'</code> | <code>'multiple'</code> | `single` keeps at most one item open at a time |
| <code>value</code> | <code>string[]</code> | — | Controlled list of open item values |
| <code>defaultValue</code> | <code>string[]</code> | <code>[]</code> | Initially open item values (uncontrolled) |
| <code>onValueChange</code> | <code>(value: string[]) => void</code> | — | Called when the set of open items changes |
| <code>disabled</code> | <code>boolean</code> | <code>false</code> | Disable every item |

### `Accordion.Item`

| Prop | Type | Default | Description |
| --------------------- | -------------------- | --------------- | ---------------------------------- |
| <code>value</code> | <code>string</code> | — (required) | Unique value identifying this item |
| <code>disabled</code> | <code>boolean</code> | inherits `Root` | Disable just this item |

`Accordion.Header`, `Accordion.Trigger`, and `Accordion.Panel` take no additional
props beyond standard HTML attributes for their default element.

## Styling

Each part emits `data-cl-*` attributes you can target with any CSS solution:

| Attribute | Applies To | Description |
| ------------------------ | -------------------- | --------------------------------------------------- |
| `data-cl-slot` | All parts | Part identifier |
| `data-cl-open` | Item, Trigger, Panel | Present when the item is open |
| `data-cl-closed` | Item, Trigger, Panel | Present when the item is closed |
| `data-cl-disabled` | Item, Trigger | Present when the item is disabled |
| `data-cl-starting-style` | Panel | Present on the entering frame of the open animation |
| `data-cl-ending-style` | Panel | Present during the exit animation before unmount |

`Accordion.Panel` exposes `--cl-accordion-panel-height` (its measured content height)
for height-based animations:

```css
[data-cl-slot='accordion-panel'] {
overflow: hidden;
height: var(--cl-accordion-panel-height);
transition: height 200ms ease;
}
[data-cl-slot='accordion-panel'][data-cl-closed] {
height: 0;
}
```
44 changes: 44 additions & 0 deletions packages/swingset/src/stories/accordion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Accordion } from '@clerk/headless/accordion';

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

// Headless primitives ship no styles. This single demo renders the primitive raw —
// unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state,
// and ARIA wiring via the `data-cl-*` attributes each part emits, with zero appearance.
// It is embedded once into the overview via `<Story>` in the MDX (the one thing prose
// can't convey: that items actually expand/collapse). There is no interactive knob canvas
// for headless primitives.

export const meta: StoryMeta = {
group: 'Primitives',
title: 'Accordion',
source: 'packages/headless/src/primitives/accordion/index.ts',
};

export function Default() {
return (
<Accordion.Root
type='single'
defaultValue={['what']}
>
<Accordion.Item value='what'>
<Accordion.Header>
<Accordion.Trigger>What is a headless component?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>
A headless component provides behavior, state management, and accessibility without imposing any styles — you
bring your own CSS via the <code>data-cl-slot</code> attributes it emits.
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='why'>
<Accordion.Header>
<Accordion.Trigger>Why an accordion over a collapsible?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel>
An accordion coordinates a set of items: in <code>single</code> mode opening one closes the others, and arrow
keys move focus between triggers.
</Accordion.Panel>
</Accordion.Item>
</Accordion.Root>
);
}
127 changes: 127 additions & 0 deletions packages/swingset/src/stories/autocomplete.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as AutocompleteStories from './autocomplete.stories';

# Autocomplete

A text input paired with a filtered list of suggestions, from `@clerk/headless`. It is a
**headless** primitive: it supplies input text, selected value, and open state, portalling,
floating-ui positioning, virtual-focus keyboard navigation, and ARIA `combobox` wiring, but
ships **no styles** — you bring your own CSS by targeting the `data-cl-*` attributes each
part emits. It does **no filtering of its own**: you read `inputValue` and render the
surviving options.

## Example

The demo below is intentionally unstyled — it renders the raw primitive so you can see its
behavior and ARIA wiring. Type to filter the consumer-supplied list; arrow keys move the
active option and Enter selects it.

<Story
name='Default'
storyModule={AutocompleteStories}
/>

## Usage

```tsx
import { Autocomplete } from '@clerk/headless/autocomplete';

const [inputValue, setInputValue] = useState('');
const filtered = items.filter(item => item.toLowerCase().includes(inputValue.toLowerCase()));

<Autocomplete.Root
inputValue={inputValue}
onInputValueChange={setInputValue}
>
<Autocomplete.Input placeholder='Search…' />
<Autocomplete.Portal>
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.List>
{filtered.map(item => (
<Autocomplete.Option
key={item}
value={item}
label={item}
>
{item}
</Autocomplete.Option>
))}
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>;
```

## Parts

| Part | Default Element | Description |
| ------------------------- | --------------- | --------------------------------------------------------------- |
| `Autocomplete.Root` | none (context) | Owns input text, selected value, open state, and positioning |
| `Autocomplete.Input` | `<input>` | The combobox text input that drives filtering and navigation |
| `Autocomplete.Portal` | none (portal) | Portals its children; renders nothing until mounted |
| `Autocomplete.Positioner` | `<div>` | Floating-positioned container |
| `Autocomplete.Popup` | `<div>` | Visual wrapper for the option list |
| `Autocomplete.List` | `<div>` | Listbox container (use inline, inside another floating surface) |
| `Autocomplete.Option` | `<div>` | A selectable option (`role="option"`) |
| `Autocomplete.Arrow` | `<svg>` | Optional arrow pointing at the input |

All rendered parts accept a `render` prop for polymorphic rendering and standard HTML
attributes for their default element. `Autocomplete.Arrow` accepts floating-ui
`FloatingArrow` props. Mounting `Autocomplete.List` switches the primitive into inline mode
(Escape / outside press bubble to the parent floating surface instead of dismissing).

## Props

### `Autocomplete.Root`

| Prop | Type | Default | Description |
| ------------------------------- | ------------------------------------ | --------------------------- | ----------------------------------------- |
| <code>inputValue</code> | <code>string</code> | — | Controlled input text |
| <code>defaultInputValue</code> | <code>string</code> | <code>''</code> | Initial input text (uncontrolled) |
| <code>onInputValueChange</code> | <code>(value: string) => void</code> | — | Called when the input text changes |
| <code>value</code> | <code>string</code> | — | Controlled selected value |
| <code>defaultValue</code> | <code>string</code> | — | Initial selected value (uncontrolled) |
| <code>onValueChange</code> | <code>(value: string) => void</code> | — | Called when an option is selected |
| <code>open</code> | <code>boolean</code> | — | Controlled open state |
| <code>defaultOpen</code> | <code>boolean</code> | <code>false</code> | Initial open state (uncontrolled) |
| <code>onOpenChange</code> | <code>(open: boolean) => void</code> | — | Called when the open state changes |
| <code>placement</code> | <code>Placement</code> | <code>'bottom-start'</code> | Placement relative to the input |
| <code>sideOffset</code> | <code>number</code> | <code>4</code> | Gap in px between the input and the popup |

### `Autocomplete.Option`

| Prop | Type | Default | Description |
| --------------------- | -------------------- | --------------------- | ---------------------------------------- |
| <code>value</code> | <code>string</code> | — (required) | The option's value |
| <code>label</code> | <code>string</code> | falls back to `value` | Label written to the input when selected |
| <code>disabled</code> | <code>boolean</code> | — | Prevent selection |

`Autocomplete.Input`, `Autocomplete.Positioner`, `Autocomplete.Popup`, and
`Autocomplete.List` take no additional props beyond standard HTML attributes for their
default element. `Autocomplete.Portal` accepts `root`.

## Styling

Each part emits `data-cl-*` attributes you can target with any CSS solution:

| Attribute | Applies To | Description |
| ------------------ | ----------------- | -------------------------------------------------- |
| `data-cl-slot` | All parts | Part identifier |
| `data-cl-open` | Input | Present when the popup is open |
| `data-cl-closed` | Input | Present when the popup is closed |
| `data-cl-selected` | Option | Present on the option matching the selected value |
| `data-cl-active` | Option | Present on the keyboard-highlighted option |
| `data-cl-disabled` | Option | Present on a disabled option |
| `data-cl-side` | Positioner, Arrow | Resolved side: `top` / `bottom` / `left` / `right` |

The positioner exposes anchor/viewport geometry as CSS variables (and sets an inline
`width` matching the input and a `max-height` of the available viewport height):

| Variable | Description |
| ----------------------- | ----------------------------------------------------- |
| `--cl-anchor-width` | Input width |
| `--cl-anchor-height` | Input height |
| `--cl-available-width` | Available width between the anchor and viewport edge |
| `--cl-available-height` | Available height between the anchor and viewport edge |
| `--cl-transform-origin` | `transform-origin` pointing back toward the anchor |
Loading
Loading