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/mosaic-menu.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 an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
 ---
+
+Add the Mosaic Menu component and menu glyphs.

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

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ 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')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand Down Expand Up @@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand Down Expand Up @@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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 positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

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

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

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

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) 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 {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
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',
};
Comment on lines +11 to +15

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
set -euo pipefail

echo "== candidate story files =="
fd 'menu\.component\.stories\.tsx$' . || true

echo "== target story =="
if [ -f packages/swingset/src/stories/menu.component.stories.tsx ]; then
  cat -n packages/swingset/src/stories/menu.component.stories.tsx
fi

echo "== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx' || true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'

echo "== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts' || true

echo "== inspect menu component =="
if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ]; then
  wc -l packages/ui/src/mosaic/components/menu/menu.tsx
  sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading