-
Notifications
You must be signed in to change notification settings - Fork 460
feat(ui): add Mosaic Menu component #9252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Suggested change
🤖 Prompt for AI AgentsSource: 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. | ||||||||||||||||||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' || trueRepository: 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
fiRepository: clerk/javascript Length of output: 8206 Mark the Menu story as StyleX-based.
Proposed fix export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| 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'; |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
🤖 Prompt for AI Agents |
||
| }, | ||
|
|
||
| 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'], | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
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/uirelease entry.This empty changeset will omit the new public Menu API from the package release and changelog. Add an
@clerk/uiminor entry with a concise feature summary.Proposed fix
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
🤖 Prompt for AI Agents
Sources: Coding guidelines, Learnings