-
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
Changes from all commits
c84c62c
8e0ce8d
5f341d8
fda4382
891efd3
0312177
d2488ca
e7ee73a
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,2 @@ | ||
| --- | ||
| --- |
| 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} | ||
|
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. | ||
| 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
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 |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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> | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| 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,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', | ||
|
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'], | ||
| }, | ||
| }); | ||
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