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/mosaic-popover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add a release entry for the new public UI APIs.

An empty changeset will omit the exported Menu and Popover additions from package versioning and release notes. Add the appropriate packages/ui bump and summary.

As per coding guidelines, “Use Changesets for version management and changelogs.” Based on learnings, empty changesets are only acceptable for documentation-only or non-published changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/mosaic-popover.md around lines 1 - 2, Add a non-empty Changesets
release entry in the frontmatter of mosaic-popover.md for the packages/ui
package, using the appropriate version bump and a concise summary describing the
new public Menu and Popover APIs.

Sources: Coding guidelines, Learnings

47 changes: 46 additions & 1 deletion .claude/skills/mosaic/references/stylex.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,50 @@ value, sub-pattern A collapses it to a single `--var` atom; reach for a raw inli
> values, and even then the first move is usually to write a single `--cl`/`--_cl`
> var rather than a raw inline style.

**Every condition is a value key, never a top-level object.** A pseudo/at-rule
goes _inside_ the property it modifies (`transitionProperty: { default: …, '@media …': … }`),
not as a bare key on the style object. A top-level `'@media …': { … }` block is
legacy syntax and the `@stylexjs/no-legacy-contextual-styles` +
`@stylexjs/valid-styles` rules reject it (only `::before`/`::after` may sit at the
top level). Reduced-motion is the common case:

```ts
transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'none' },
```

### Reacting to `data-*` state (the headless-transition case)

Headless primitives drive animation off `data-*` attributes — e.g. the popover
popup carries its own `data-starting-style` (entering frame) and
`data-ending-style` (exiting). You can style off these in StyleX; it depends on
_whose_ attribute you're reading:

- **The element's own attribute → wrap in `:where(...)`.** Conditional keys must
start with `:` or `@`, so a bare `[data-*]` is rejected — but `:where([data-*])`
is a valid pseudo-class string that matches the same element (zero specificity;
StyleX self-doubles the atom class so the conditional still wins):

```ts
popup: {
opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 },
transform: { default: 'scale(1)', ':where([data-starting-style], [data-ending-style])': 'scale(0.98)' },
transitionProperty: { default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'none' },
transitionDuration: '150ms',
},
```

- **Another element's attribute → `stylex.when.*`.** For relational state use
`stylex.when.ancestor(sel)` / `.descendant(sel)` / `.siblingBefore(sel)` /
`.siblingAfter(sel)` / `.anySibling(sel)`, each taking a `:${string}` or
`[${string}]` selector and returning a valid conditional key
(`:where-ancestor(...)` etc.). Use this when a parent/sibling owns the state
(e.g. a `[data-open]` container theming its children); use `:where([data-*])`
when the element owns it.

So: `:where(...)` for self-state, `stylex.when.*` for relational state. Both
compile to real attribute selectors in `styles.css`, so animation stays
CSS-native — no JS state plumbing through the component.

## Public contract & composition (`props.ts`)

The element carries three things, and nothing else is a contract:
Expand Down Expand Up @@ -526,7 +570,8 @@ token colors aren't down-leveled into an invalid polyfill.
`::before`/`::after`/`::backdrop`, `@starting-style` (enter animations),
`stylex.keyframes(...)`, `anchor-size(width|height)` (popover/menu matching its
trigger), CSS counters, `@media (hover: hover)` / `(prefers-reduced-motion)` /
`(pointer: coarse)`.
`(pointer: coarse)`, `data-*` state via `:where([data-*])` (self) or
`stylex.when.*` (relational) — see "Reacting to `data-*` state" above.
- Prefer CSS-native solutions over JS workarounds for anything StyleX supports.
- Avoid manual `@layer` / `@property` inside `create` (StyleX owns layering;
`@property` compiles but emits invalid output).
Expand Down
2 changes: 2 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
popover: dynamic(() => import('../stories/popover.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
28 changes: 28 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import {
Default as MenuComponentDefault,
Disabled as MenuComponentDisabled,
meta as menuComponentMeta,
Submenu as MenuComponentSubmenu,
} from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand Down Expand Up @@ -86,6 +92,12 @@ import {
meta as organizationProfileProfileSectionMeta,
} from '../stories/organization-profile-profile-section.stories';
import { meta as otpMeta } from '../stories/otp.stories';
import {
Alignment as PopoverComponentAlignment,
Default as PopoverComponentDefault,
meta as popoverComponentMeta,
Placement as PopoverComponentPlacement,
} from '../stories/popover.component.stories';
import { meta as popoverMeta } from '../stories/popover.stories';
import { meta as selectMeta } from '../stories/select.stories';
import { Default as TabsComponentDefault, meta as tabsComponentMeta } from '../stories/tabs.component.stories';
Expand Down Expand Up @@ -155,6 +167,20 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes,

const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };

const menuComponentModule: StoryModule = {
meta: menuComponentMeta,
Default: MenuComponentDefault,
Disabled: MenuComponentDisabled,
Submenu: MenuComponentSubmenu,
};

const popoverComponentModule: StoryModule = {
meta: popoverComponentMeta,
Default: PopoverComponentDefault,
Placement: PopoverComponentPlacement,
Alignment: PopoverComponentAlignment,
};

const itemModule: StoryModule = {
meta: itemMeta,
Default: ItemDefault,
Expand Down Expand Up @@ -221,6 +247,8 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
popoverComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
137 changes: 137 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's roving focus, typeahead, nested submenus, and
`role="menu"` / `role="menuitem"` wiring, and adds the surface and row styling.

Reach for `Menu` when the content is a list of actions. Reach for [`Popover`](/components/popover)
when it is arbitrary content — a popover is a `role="dialog"` and has none of the list semantics.

## Example

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

## Usage

`label` is required on every item: it drives typeahead matching. When an item has no children it
renders its `label`, so the common case stays a single prop.

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

<Menu trigger={props => <Button {...props}>Actions</Button>}>
<Menu.Item
label='Edit'
onClick={handleEdit}
/>
<Menu.Item
label='Duplicate'
onClick={handleDuplicate}
/>
<Menu.Separator />
<Menu.Item
label='Delete'
onClick={handleDelete}
/>
</Menu>;
```

### Disabled items

Disabled items keep `role="menuitem"` and stay focusable — they use `aria-disabled`, not the
`disabled` attribute, so keyboard users can still reach and read them. They are excluded from
typeahead and their `onClick` never fires.

<Story
name='Disabled'
storyModule={MenuStories}
/>

### Submenus

Nest a `Menu.Root` inside a popup and open it with `Menu.SubTrigger`. Submenus open on hover with a
safe-polygon pointer zone, and `ArrowRight` / `ArrowLeft` move in and out of them.

Use `Menu.SubTrigger` rather than wrapping a `Menu.Item` in a nested `Menu.Trigger` — the trigger
already registers itself as a `menuitem` in the parent list, so nesting an `Item` inside it would
register a second entry and desync arrow-key navigation from what is on screen.

<Story
name='Submenu'
storyModule={MenuStories}
/>

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu
open={open}
onOpenChange={setOpen}
trigger={props => <Button {...props}>Actions</Button>}
>
<Menu.Item label='Edit' />
</Menu>;
```

## Parts

The convenience `Menu` composes the trigger and a portalled, positioned popup. For submenus, compose
the compound parts directly.

| Part | Slot | Description |
| ----------------- | ------------------ | ---------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close and placement. |
| `Menu.Trigger` | — | Anchor element; accepts a `render` prop. |
| `Menu.Portal` | — | Portals the popup out to the document body. |
| `Menu.Positioner` | `menu-positioner` | Floating wrapper; owns positioning, stacking, `data-side`. |
| `Menu.Popup` | `menu-popup` | The menu surface; `role="menu"`. |
| `Menu.Item` | `menu-item` | A single action; `role="menuitem"`. `label` is required. |
| `Menu.SubTrigger` | `menu-sub-trigger` | Row that opens a nested submenu. |
| `Menu.Separator` | `menu-separator` | Divider between groups of items; `role="separator"`. |

## Styling

The Mosaic menu is themed with **StyleX**. Each styled part carries a stable `.cl-<slot>` class
alongside the StyleX atoms. Consumers never target the hashed atomic classes — override by targeting
the `.cl-*` slot from a CSS layer that wins over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
min-width: 16rem;
}
}
```

Unlike the popover — whose popup is transparent so the content inside supplies the surface — a
menu's popup **is** its surface, so it owns the background, border, radius, shadow, and inset
padding. The two components share only the floating box: stacking, viewport clamps, and the
enter/exit transition (`mosaic/components/floating.styles.ts`).

Rows reuse the `Item` geometry, so a menu row and a list row are the same shape.

State attributes from the headless layer are available for CSS targeting:

| Attribute | Applies To | Description |
| --------------------- | ------------------- | --------------------------------------------------- |
| `data-open` | Trigger, SubTrigger | Present when the menu it controls is open |
| `data-closed` | Trigger, SubTrigger | Present when closed |
| `data-active` | Item | The roving-focus position, moved by the arrow keys |
| `data-disabled` | Item | Present on disabled items |
| `data-starting-style` | Popup | Present on the entering frame |
| `data-ending-style` | Popup | Present during the exit animation |
| `data-side` | Positioner | Resolved side (`top` / `bottom` / `left` / `right`) |

Rows highlight on `data-active` as well as on hover, so keyboard and pointer navigation look the
same. The popup's enter/exit transition (opacity + scale) is driven off `data-starting-style` /
`data-ending-style` and is disabled under `prefers-reduced-motion: reduce`.
83 changes: 83 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/** @jsxImportSource @emotion/react */
import { Button } from '@clerk/ui/mosaic/components/button';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};

const menuTrigger = (props: Omit<React.HTMLAttributes<HTMLElement>, 'color'>) => (
<Button
variant='outline'
{...props}
>
Actions
</Button>
);
Comment on lines +18 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -H -t f 'tsconfig*.json' . -x sh -c '
  echo "--- $1"
  rg -n "\"allowUmdGlobalAccess\"|\"jsx\"|\"types\"" "$1"
' sh {}
rg -n --glob '*.tsx' 'React\.HTMLAttributes' packages/swingset/src/stories

Repository: clerk/javascript

Length of output: 4125


🏁 Script executed:

#!/bin/bash
set -eu
echo "--- swingset tsconfig"
cat -n packages/swingset/tsconfig.json
echo
echo "--- root tsconfig relevant settings"
python3 - <<'PY'
import json
p='tsconfig.json'; data=json.load(open(p))
print(json.dumps(data.get('compilerOptions', {}), indent=2, sort_keys=True))
PY
echo
echo "--- imports in story files"
sed -n '1,40p' packages/swingset/src/stories/menu.component.stories.tsx
sed -n '1,80p' packages/swingset/src/stories/popover.component.stories.tsx
sed -n '1,80p' packages/swingset/src/stories/dialog.component.stories.tsx

Repository: clerk/javascript

Length of output: 6449


Import HTMLAttributes directly.

These module-scoped stories are not importing React, so React.HTMLAttributes still relies on a UMD global unless the project is explicitly configured to allow it. Use a type-only import instead:

  • packages/swingset/src/stories/menu.component.stories.tsx
  • packages/swingset/src/stories/popover.component.stories.tsx
  • packages/swingset/src/stories/dialog.component.stories.tsx

Replace React.HTMLAttributes<HTMLElement> with HTMLAttributes<HTMLElement> and import it via import type { HTMLAttributes } from 'react';.

📍 Affects 2 files
  • packages/swingset/src/stories/menu.component.stories.tsx#L18-L25 (this comment)
  • packages/swingset/src/stories/popover.component.stories.tsx#L21-L23
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/menu.component.stories.tsx` around lines 18 -
25, Update the menuTrigger story in
packages/swingset/src/stories/menu.component.stories.tsx (lines 18-25) and the
corresponding story in
packages/swingset/src/stories/popover.component.stories.tsx (lines 21-23) to
replace React.HTMLAttributes<HTMLElement> with HTMLAttributes<HTMLElement>,
adding a type-only import for HTMLAttributes from react in each file.

Source: Coding guidelines


export function Default() {
return (
<div style={{ paddingBlockEnd: '10rem' }}>
<Menu trigger={menuTrigger}>
<Menu.Item label='Edit' />
<Menu.Item label='Duplicate' />
<Menu.Separator />
<Menu.Item label='Delete' />
</Menu>
</div>
);
}

export function Disabled() {
return (
<div style={{ paddingBlockEnd: '10rem' }}>
<Menu trigger={menuTrigger}>
<Menu.Item label='Edit' />
<Menu.Item
label='Duplicate'
disabled
/>
<Menu.Item label='Delete' />
</Menu>
</div>
);
}

export function Submenu() {
return (
<div style={{ paddingBlockEnd: '12rem' }}>
<Menu.Root>
<Menu.Trigger render={menuTrigger} />
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Edit' />
<Menu.Root>
<Menu.SubTrigger>Share</Menu.SubTrigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Copy link' />
<Menu.Item label='Email' />
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Menu.Separator />
<Menu.Item label='Delete' />
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>
);
}
Loading
Loading