Skip to content
Open
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

1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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 @@ -46,6 +46,7 @@ import {
meta as inputMeta,
Sizes as InputSizes,
} from '../stories/input.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 @@ -156,6 +157,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 @@ -207,6 +210,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
124 changes: 124 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item
label='Add workspace'
icon={<PlusIcon />}
/>
Comment on lines +20 to +29

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

Declare the icon used by the usage example.

Line 28 references PlusIcon, but the snippet neither imports nor defines it, so it cannot be copied into a TypeScript consumer.

Proposed fix
 import { Menu } from '`@clerk/ui/mosaic/components/menu`';
+import { iconRegistry } from '`@clerk/ui/mosaic/icons/registry`';
+
+const PlusIcon = iconRegistry.plus;

As per coding guidelines, update documentation for API 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
```tsx
import { Menu } from '@clerk/ui/mosaic/components/menu';
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item
label='Add workspace'
icon={<PlusIcon />}
/>
import { Menu } from '`@clerk/ui/mosaic/components/menu`';
import { iconRegistry } from '`@clerk/ui/mosaic/icons/registry`';
const PlusIcon = iconRegistry.plus;
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item
label='Add workspace'
icon={<PlusIcon />}
/>
🤖 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.mdx` around lines 20 - 29,
Update the Menu usage example around Menu.Item to import or define the PlusIcon
symbol before it is passed to the icon prop, ensuring the snippet is
self-contained and valid for TypeScript consumers.

Source: Coding guidelines

<Menu.Separator />
<Menu.Item
label='Sign out'
onClick={signOut}
/>
</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, unless `children` is given, the visible text. `icon` renders a leading
glyph sized and tinted by the item. `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'
disabled
/>
```

### 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. Also emits `menu-item-icon` and `menu-item-label`. |
| `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.
37 changes: 37 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** @jsxImportSource @emotion/react */
import { Menu } from '@clerk/ui/mosaic/components/menu';
import { iconRegistry } from '@clerk/ui/mosaic/icons/registry';

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.


const PlusIcon = iconRegistry.plus;
const LogOutIcon = iconRegistry['log-out'];

export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item
label='Add workspace'
icon={<PlusIcon />}
/>
<Menu.Separator />
<Menu.Item
label='Sign out'
icon={<LogOutIcon />}
/>
</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';
114 changes: 114 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,114 @@
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 on lines +6 to +10

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 | 🟠 Major | ⚡ Quick win

Restore a default stacking layer for the positioner.

position: fixed alone does not outrank app headers, dialogs, or overlays with explicit stacking contexts. Since consumers cannot pass a className or style to MenuContent’s positioner, the default menu can be obscured. Restore the prior z-index or use the shared overlay stacking token.

🤖 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/ui/src/mosaic/components/menu/menu.styles.ts` around lines 6 - 10,
Update the positioner style in the menu styles configuration to restore the
default stacking layer, using the prior z-index value or shared overlay stacking
token. Keep the existing outline reset and positioning behavior unchanged so the
default MenuContent positioner renders above page content and common overlays.

},

popup: {
borderColor: colorVars['--cl-color-border'],
borderRadius: radiusVars['--cl-radius-container'],
borderStyle: 'solid',
borderWidth: '1px',
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['1'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 10px 24px color-mix(in oklab, oklch(0 0 0) 8%, transparent),
0 2px 6px color-mix(in oklab, oklch(0 0 0) 4%, transparent)`,
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: '11rem',
overflowY: 'auto',
},

item: {
borderRadius: radiusVars['--cl-radius-inner'],
borderStyle: 'none',
gap: space['2'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': colorVars['--cl-color-neutral'],
'@media (hover: hover)': {
':hover': colorVars['--cl-color-neutral'],
},
},
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 },
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
minHeight: space['8'],
width: '100%',
},

itemIcon: {
alignItems: 'center',
color: `color-mix(in oklab, ${colorVars['--cl-color-card-foreground']} 60%, transparent)`,
display: 'inline-flex',
flexShrink: 0,
justifyContent: 'center',
height: space['4'],
width: space['4'],
},

itemLabel: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},

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

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