Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 470
feat(ui): split the Mosaic popover from its surface#9295
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
636dd7efccd08ac4c2ca6b31dae75bce752664bf1e634c3daFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
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,137 @@ | ||
| 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 roving focus, typeahead, nested submenus, and | ||
| `role="menu"` / `role="menuitem"` wiring, and adds the surface and row styling. | ||
| Reach for `Menu` when the content is a list of actions. Reach for [`Popover`](/components/popover) | ||
| when it is arbitrary content — a popover is a `role="dialog"` and has none of the list semantics. | ||
| ## Example | ||
| <Story | ||
| name='Default' | ||
| storyModule={MenuStories} | ||
| /> | ||
| ## Usage | ||
| `label` is required on every item: it drives typeahead matching. When an item has no children it | ||
| renders its `label`, so the common case stays a single prop. | ||
| ```tsx | ||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| import { Menu } from '@clerk/ui/mosaic/components/menu'; | ||
| <Menu trigger={props => <Button {...props}>Actions</Button>}> | ||
| <Menu.Item | ||
| label='Edit' | ||
| onClick={handleEdit} | ||
| /> | ||
| <Menu.Item | ||
| label='Duplicate' | ||
| onClick={handleDuplicate} | ||
| /> | ||
| <Menu.Separator /> | ||
| <Menu.Item | ||
| label='Delete' | ||
| onClick={handleDelete} | ||
| /> | ||
| </Menu>; | ||
| ``` | ||
| ### Disabled items | ||
| Disabled items keep `role="menuitem"` and stay focusable — they use `aria-disabled`, not the | ||
| `disabled` attribute, so keyboard users can still reach and read them. They are excluded from | ||
| typeahead and their `onClick` never fires. | ||
| <Story | ||
| name='Disabled' | ||
| storyModule={MenuStories} | ||
| /> | ||
| ### Submenus | ||
| Nest a `Menu.Root` inside a popup and open it with `Menu.SubTrigger`. Submenus open on hover with a | ||
| safe-polygon pointer zone, and `ArrowRight` / `ArrowLeft` move in and out of them. | ||
| Use `Menu.SubTrigger` rather than wrapping a `Menu.Item` in a nested `Menu.Trigger` — the trigger | ||
| already registers itself as a `menuitem` in the parent list, so nesting an `Item` inside it would | ||
| register a second entry and desync arrow-key navigation from what is on screen. | ||
| <Story | ||
| name='Submenu' | ||
| storyModule={MenuStories} | ||
| /> | ||
| ### Controlled | ||
| ```tsx | ||
| const [open, setOpen] = useState(false); | ||
| <Menu | ||
| open={open} | ||
| onOpenChange={setOpen} | ||
| trigger={props => <Button {...props}>Actions</Button>} | ||
| > | ||
| <Menu.Item label='Edit' /> | ||
| </Menu>; | ||
| ``` | ||
| ## Parts | ||
| The convenience `Menu` composes the trigger and a portalled, positioned popup. For submenus, compose | ||
| the compound parts directly. | ||
| | Part | Slot | Description | | ||
| | ----------------- | ------------------ | ---------------------------------------------------------- | | ||
| | `Menu.Root` | — | State provider; owns open/close and placement. | | ||
| | `Menu.Trigger` | — | Anchor element; accepts a `render` prop. | | ||
| | `Menu.Portal` | — | Portals the popup out to the document body. | | ||
| | `Menu.Positioner` | `menu-positioner` | Floating wrapper; owns positioning, stacking, `data-side`. | | ||
| | `Menu.Popup` | `menu-popup` | The menu surface; `role="menu"`. | | ||
| | `Menu.Item` | `menu-item` | A single action; `role="menuitem"`. `label` is required. | | ||
| | `Menu.SubTrigger` | `menu-sub-trigger` | Row that opens a nested submenu. | | ||
| | `Menu.Separator` | `menu-separator` | Divider between groups of items; `role="separator"`. | | ||
| ## Styling | ||
| The Mosaic menu is themed with **StyleX**. Each styled part carries a stable `.cl-<slot>` class | ||
| 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 { | ||
| min-width: 16rem; | ||
| } | ||
| } | ||
| ``` | ||
| Unlike the popover — whose popup is transparent so the content inside supplies the surface — a | ||
| menu's popup **is** its surface, so it owns the background, border, radius, shadow, and inset | ||
| padding. The two components share only the floating box: stacking, viewport clamps, and the | ||
| enter/exit transition (`mosaic/components/floating.styles.ts`). | ||
| Rows reuse the `Item` geometry, so a menu row and a list row are the same shape. | ||
| State attributes from the headless layer are available for CSS targeting: | ||
| | Attribute | Applies To | Description | | ||
| | --------------------- | ------------------- | --------------------------------------------------- | | ||
| | `data-open` | Trigger, SubTrigger | Present when the menu it controls is open | | ||
| | `data-closed` | Trigger, SubTrigger | Present when closed | | ||
| | `data-active` | Item | The roving-focus position, moved by the arrow keys | | ||
| | `data-disabled` | Item | Present on disabled items | | ||
| | `data-starting-style` | Popup | Present on the entering frame | | ||
| | `data-ending-style` | Popup | Present during the exit animation | | ||
| | `data-side` | Positioner | Resolved side (`top` / `bottom` / `left` / `right`) | | ||
| Rows highlight on `data-active` as well as on hover, so keyboard and pointer navigation look the | ||
| same. The popup's enter/exit transition (opacity + scale) is driven off `data-starting-style` / | ||
| `data-ending-style` and is disabled under `prefers-reduced-motion: reduce`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| /** @jsxImportSource @emotion/react */ | ||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| 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', | ||
| styleEngine: 'stylex', | ||
| }; | ||
| const menuTrigger = (props: Omit<React.HTMLAttributes<HTMLElement>, 'color'>) => ( | ||
| <Button | ||
| variant='outline' | ||
| {...props} | ||
| > | ||
| Actions | ||
| </Button> | ||
| ); | ||
Comment on lines
+18
to
+25
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
fd -H -t f 'tsconfig*.json'. -x sh -c ' echo "--- $1" rg -n "\"allowUmdGlobalAccess\"|\"jsx\"|\"types\"" "$1"' sh {}
rg -n --glob '*.tsx''React\.HTMLAttributes' packages/swingset/src/storiesRepository: clerk/javascript Length of output: 4125 🏁 Script executed: #!/bin/bashset -eu
echo"--- swingset tsconfig"
cat -n packages/swingset/tsconfig.json
echoecho"--- root tsconfig relevant settings"
python3 - <<'PY'import jsonp='tsconfig.json'; data=json.load(open(p))print(json.dumps(data.get('compilerOptions', {}), indent=2, sort_keys=True))PYechoecho"--- imports in story files"
sed -n '1,40p' packages/swingset/src/stories/menu.component.stories.tsx
sed -n '1,80p' packages/swingset/src/stories/popover.component.stories.tsx
sed -n '1,80p' packages/swingset/src/stories/dialog.component.stories.tsxRepository: clerk/javascript Length of output: 6449 Import These module-scoped stories are not importing React, so
Replace 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines | ||
| export function Default() { | ||
| return ( | ||
| <div style={{ paddingBlockEnd: '10rem' }}> | ||
| <Menu trigger={menuTrigger}> | ||
| <Menu.Item label='Edit' /> | ||
| <Menu.Item label='Duplicate' /> | ||
| <Menu.Separator /> | ||
| <Menu.Item label='Delete' /> | ||
| </Menu> | ||
| </div> | ||
| ); | ||
| } | ||
| export function Disabled() { | ||
| return ( | ||
| <div style={{ paddingBlockEnd: '10rem' }}> | ||
| <Menu trigger={menuTrigger}> | ||
| <Menu.Item label='Edit' /> | ||
| <Menu.Item | ||
| label='Duplicate' | ||
| disabled | ||
| /> | ||
| <Menu.Item label='Delete' /> | ||
| </Menu> | ||
| </div> | ||
| ); | ||
| } | ||
| export function Submenu() { | ||
| return ( | ||
| <div style={{ paddingBlockEnd: '12rem' }}> | ||
| <Menu.Root> | ||
| <Menu.Trigger render={menuTrigger} /> | ||
| <Menu.Portal> | ||
| <Menu.Positioner> | ||
| <Menu.Popup> | ||
| <Menu.Item label='Edit' /> | ||
| <Menu.Root> | ||
| <Menu.SubTrigger>Share</Menu.SubTrigger> | ||
| <Menu.Portal> | ||
| <Menu.Positioner> | ||
| <Menu.Popup> | ||
| <Menu.Item label='Copy link' /> | ||
| <Menu.Item label='Email' /> | ||
| </Menu.Popup> | ||
| </Menu.Positioner> | ||
| </Menu.Portal> | ||
| </Menu.Root> | ||
| <Menu.Separator /> | ||
| <Menu.Item label='Delete' /> | ||
| </Menu.Popup> | ||
| </Menu.Positioner> | ||
| </Menu.Portal> | ||
| </Menu.Root> | ||
| </div> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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 a release entry for the new public UI APIs.
An empty changeset will omit the exported Menu and Popover additions from package versioning and release notes. Add the appropriate
packages/uibump and summary.As per coding guidelines, “Use Changesets for version management and changelogs.” Based on learnings, empty changesets are only acceptable for documentation-only or non-published changes.
🤖 Prompt for AI Agents
Sources: Coding guidelines, Learnings