diff --git a/.changeset/mosaic-menu.md b/.changeset/mosaic-menu.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-menu.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 82d9e39eb83..4534d12a206 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -36,6 +36,7 @@ const docModules: Record> = { 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')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index bfa22e2ee2b..56360974f3f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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, @@ -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 }; @@ -207,6 +210,7 @@ export const registry: StoryModule[] = [ dialogComponentModule, headingModule, iconModule, + menuComponentModule, tabsComponentModule, textModule, // Primitives — alphabetical within the group. diff --git a/packages/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx new file mode 100644 index 00000000000..a570c8fe966 --- /dev/null +++ b/packages/swingset/src/stories/menu.component.mdx @@ -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. + + + +## Usage + +```tsx +import { Menu } from '@clerk/ui/mosaic/components/menu'; + + + + + } + /> + + + +; +``` + +`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 +Actions + + } /> +``` + +### 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 + +``` + +### 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 + + … +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + … +; +``` + +## 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-` 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. diff --git a/packages/swingset/src/stories/menu.component.stories.tsx b/packages/swingset/src/stories/menu.component.stories.tsx new file mode 100644 index 00000000000..7c4b72aedf0 --- /dev/null +++ b/packages/swingset/src/stories/menu.component.stories.tsx @@ -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 `` 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 ( + + + + } + /> + + } + /> + + + ); +} diff --git a/packages/ui/src/mosaic/components/menu/index.ts b/packages/ui/src/mosaic/components/menu/index.ts new file mode 100644 index 00000000000..f05e07c1ec1 --- /dev/null +++ b/packages/ui/src/mosaic/components/menu/index.ts @@ -0,0 +1,2 @@ +export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu'; +export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu'; diff --git a/packages/ui/src/mosaic/components/menu/menu.styles.ts b/packages/ui/src/mosaic/components/menu/menu.styles.ts new file mode 100644 index 00000000000..aae5314ad84 --- /dev/null +++ b/packages/ui/src/mosaic/components/menu/menu.styles.ts @@ -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'], + }, +}); diff --git a/packages/ui/src/mosaic/components/menu/menu.test.tsx b/packages/ui/src/mosaic/components/menu/menu.test.tsx new file mode 100644 index 00000000000..fc6db7eb30c --- /dev/null +++ b/packages/ui/src/mosaic/components/menu/menu.test.tsx @@ -0,0 +1,148 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Menu } from './menu'; + +function renderMenu(props?: { onSignOut?: () => void }) { + return render( + + + + } + /> + + + + , + ); +} + +describe('Mosaic Menu', () => { + it('renders a ghost sm icon Button as the default trigger', () => { + renderMenu(); + const trigger = screen.getByRole('button'); + expect(trigger).toHaveClass('cl-button', 'cl-menu-trigger'); + expect(trigger).toHaveAttribute('data-variant', 'ghost'); + expect(trigger).toHaveAttribute('data-size', 'sm'); + expect(trigger).toHaveAttribute('data-shape', 'square'); + expect(trigger).toHaveAttribute('aria-haspopup', 'menu'); + }); + + it('renders a consumer trigger passed via render', () => { + render( + + ( +