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',
};

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',
},

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