-
Notifications
You must be signed in to change notification settings - Fork 460
feat(ui): add Mosaic Popover component #9241
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,115 @@ | ||
| import * as PopoverStories from './popover.component.stories'; | ||
|
|
||
| # Popover | ||
|
|
||
| The Mosaic `Popover` — the styled Mosaic component composed from the `@clerk/headless` popover | ||
| primitive and themed with StyleX. It is a reusable floating card anchored to a trigger: drop any | ||
| inner content into `Popover.Content` and an optional `Popover.Footer`. It inherits the primitive's | ||
| positioning, focus management, and ARIA wiring. | ||
|
|
||
| ## Example | ||
|
|
||
| <Story | ||
| name='Default' | ||
| storyModule={PopoverStories} | ||
| /> | ||
|
|
||
| ## Usage | ||
|
|
||
| ```tsx | ||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| import { Popover } from '@clerk/ui/mosaic/components/popover'; | ||
|
|
||
| <Popover trigger={props => <Button {...props}>Open popover</Button>}> | ||
| <Popover.Content>Flexible inner content.</Popover.Content> | ||
| <Popover.Footer> | ||
| <Button | ||
| intent='destructive' | ||
| fullWidth | ||
| > | ||
| Sign out of all accounts | ||
| </Button> | ||
| </Popover.Footer> | ||
| </Popover>; | ||
| ``` | ||
|
|
||
| The `trigger` render prop receives the interaction props (ARIA attributes, click handler) from the | ||
| headless layer and should spread them onto whatever element opens the popover. | ||
|
|
||
| ### Controlled | ||
|
|
||
| ```tsx | ||
| const [open, setOpen] = useState(false); | ||
|
Comment on lines
+41
to
+42
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
file="packages/swingset/src/stories/popover.component.mdx"
echo "== File outline =="
wc -l "$file"
echo
nl -ba "$file" | sed -n '1,220p'
echo
echo "== Search for composition usage in similar stories =="
rg -n "composition\s*:" packages/swingset/src/stories -g '*.mdx' || true
echo
echo "== Search for useState import in this file =="
rg -n "useState|from 'react'|from \"react\"" "$file" || trueRepository: clerk/javascript Length of output: 270 🏁 Script executed: #!/bin/bash
set -euo pipefail
file="packages/swingset/src/stories/popover.component.mdx"
echo "== File contents =="
cat -n "$file"
echo
echo "== MDX stories with composition arrays =="
rg -n "composition\s*:" packages/swingset/src/stories -g '*.mdx' || true
echo
echo "== Popover references elsewhere =="
rg -n "popover" packages/swingset/src/stories -g '*.mdx' -g '*.ts' -g '*.tsx' || trueRepository: clerk/javascript Length of output: 9929 🏁 Script executed: #!/bin/bash
set -euo pipefail
file="packages/swingset/src/stories/popover.component.mdx"
echo "== File contents =="
cat -n "$file"Repository: clerk/javascript Length of output: 5749 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '1,140p' packages/swingset/src/stories/popover.component.mdxRepository: clerk/javascript Length of output: 4924 Import
🤖 Prompt for AI Agents |
||
|
|
||
| <Popover | ||
| open={open} | ||
| onOpenChange={setOpen} | ||
| trigger={props => <Button {...props}>Open</Button>} | ||
| > | ||
| <Popover.Content>Flexible inner content.</Popover.Content> | ||
| </Popover>; | ||
| ``` | ||
|
|
||
| ### Placement | ||
|
|
||
| `placement` sets the preferred side and alignment; `sideOffset` sets the gap from the trigger. The | ||
| popup flips and shifts automatically to stay in view. | ||
|
|
||
| ```tsx | ||
| <Popover | ||
| placement='bottom-end' | ||
| sideOffset={8} | ||
| trigger={props => <Button {...props}>Open</Button>} | ||
| > | ||
| <Popover.Content>Aligned to the trigger's end edge.</Popover.Content> | ||
| </Popover>; | ||
| ``` | ||
|
|
||
| ## Parts | ||
|
|
||
| The convenience `Popover` composes the trigger and a portalled, positioned popup. For custom | ||
| layouts, compose the compound parts directly. | ||
|
|
||
| | Part | Slot | Description | | ||
| | --------------------- | -------------------- | -------------------------------------------------------------- | | ||
| | `Popover.Root` | — | State provider; owns open/close and placement. | | ||
| | `Popover.Trigger` | — | Anchor element; accepts a `render` prop. | | ||
| | `Popover.Portal` | — | Portals the popup out to the document body. | | ||
| | `Popover.Positioner` | `popover-positioner` | Floating wrapper; owns positioning, stacking, `data-side`. | | ||
| | `Popover.Popup` | `popover-popup` | The card surface; holds content + footer, runs the enter/exit. | | ||
| | `Popover.Content` | `popover-content` | Flexible inner content region; scrolls on overflow. | | ||
| | `Popover.Footer` | `popover-footer` | Footer region, separated from content by a top border. | | ||
| | `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. | | ||
| | `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. | | ||
| | `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. | | ||
| | `Popover.Arrow` | — | Optional arrow pointing at the trigger. | | ||
|
|
||
| ## Styling | ||
|
|
||
| Unlike the slot-recipe components, the Mosaic popover is themed with **StyleX**. Each styled part | ||
| carries a stable `.cl-<slot>` class (the slots in the table 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-popover-popup { | ||
| border-radius: 20px; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| State attributes from the headless layer are available for CSS targeting: | ||
|
|
||
| | Attribute | Applies To | Description | | ||
| | --------------------- | -------------- | --------------------------------------------------- | | ||
| | `data-open` | Trigger, Popup | Present when the popover is open | | ||
| | `data-closed` | Trigger, Popup | Present when closed (during exit) | | ||
| | `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`) | | ||
|
|
||
| The popup's default 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,38 @@ | ||
| /** @jsxImportSource @emotion/react */ | ||
| import { Button } from '@clerk/ui/mosaic/components/button'; | ||
| import { Popover } from '@clerk/ui/mosaic/components/popover'; | ||
|
|
||
| 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 './popover.component.stories?raw'; | ||
|
|
||
| export const meta: StoryMeta = { | ||
| group: 'Components', | ||
| title: 'Popover', | ||
| source: 'packages/ui/src/mosaic/components/popover/popover.tsx', | ||
| }; | ||
|
|
||
| const popoverTrigger = (props: React.HTMLAttributes<HTMLElement>) => <Button {...props}>Open popover</Button>; | ||
|
|
||
| export function Default() { | ||
| return ( | ||
| <Popover trigger={popoverTrigger}> | ||
| <Popover.Content> | ||
| <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> | ||
| <strong>Ada Lovelace</strong> | ||
| <span style={{ color: 'var(--cl-color-muted-foreground)' }}>ada@example.com</span> | ||
| </div> | ||
| </Popover.Content> | ||
| <Popover.Footer> | ||
| <Button | ||
| intent='destructive' | ||
| fullWidth | ||
| > | ||
| Sign out of all accounts | ||
| </Button> | ||
| </Popover.Footer> | ||
| </Popover> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { Popover } from './popover'; | ||
| export type { PopoverProps } from './popover'; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,68 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import * as stylex from '@stylexjs/stylex'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { colorVars, radiusVars, space } from '../../tokens.stylex'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const styles = stylex.create({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Floating wrapper. Positioning styles are applied inline by the headless | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // positioner; this only owns stacking and clears the focus outline the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // FloatingFocusManager places here. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| positioner: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outline: 'none', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| zIndex: 50, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The popup card: the flexible container that holds content + footer. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| popup: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| borderColor: colorVars['--cl-color-border'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| borderRadius: radiusVars['--cl-radius-container'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| borderStyle: 'solid', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| borderWidth: '1px', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outline: 'none', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| overflow: 'hidden', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| backgroundColor: colorVars['--cl-color-card'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| boxShadow: '0 10px 30px rgba(0, 0, 0, 0.12)', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| color: colorVars['--cl-color-card-foreground'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display: 'flex', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| flexDirection: 'column', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| opacity: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default: 1, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ':where([data-starting-style], [data-ending-style])': 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| transform: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default: 'scale(1)', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ':where([data-starting-style], [data-ending-style])': 'scale(0.98)', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| transitionDuration: '150ms', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Enter/exit transition. The headless popup sets `data-starting-style` on the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // entering frame and `data-ending-style` while exiting — both are the element's | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // OWN attributes. A bare `[data-*]` key is rejected by StyleX (conditional keys | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // must start with `:` or `@`), so wrap it in `:where(...)`, a valid pseudo-class | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // string that targets the same element. `stylex.when.*` covers ancestor/sibling | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // state; this covers self-state. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| transitionProperty: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default: 'opacity, transform', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '@media (prefers-reduced-motion: reduce)': 'none', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| transitionTimingFunction: 'ease-out', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| maxWidth: 'calc(100vw - 2rem)', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| minWidth: '18rem', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Flexible inner content region. Scrolls on overflow so tall content never | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // pushes the footer out of view. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| padding: space['4'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| display: 'flex', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| flexDirection: 'column', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| minHeight: 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| overflowY: 'auto', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+47
to
+58
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 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the target file and surrounding popover styles.
FILE="packages/ui/src/mosaic/components/popover/popover.styles.ts"
echo "== file outline =="
ast-grep outline "$FILE" --view expanded || true
echo
echo "== relevant lines =="
nl -ba "$FILE" | sed -n '1,220p'
echo
echo "== search for related popover sizing/positioning styles =="
rg -n "maxHeight|maxWidth|minWidth|overflowY|popover|content:" packages/ui/src/mosaic/components/popover -S || trueRepository: clerk/javascript Length of output: 337 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="packages/ui/src/mosaic/components/popover/popover.styles.ts"
echo "== file with line numbers =="
awk '{printf "%4d: %s\n", NR, $0}' "$FILE" | sed -n '1,220p'
echo
echo "== surrounding popover files =="
fd -t f . packages/ui/src/mosaic/components/popover
echo
echo "== related references =="
rg -n "maxHeight|maxWidth|minWidth|overflowY|overflow|popover" packages/ui/src/mosaic/components/popover -S || trueRepository: clerk/javascript Length of output: 6065 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="packages/ui/src/mosaic/components/popover/popover.tsx"
echo "== file with line numbers =="
awk '{printf "%4d: %s\n", NR, $0}' "$FILE" | sed -n '1,220p'
echo
echo "== popover tests =="
awk '{printf "%4d: %s\n", NR, $0}' "packages/ui/src/mosaic/components/popover/popover.test.tsx" | sed -n '1,220p'Repository: clerk/javascript Length of output: 8904 Constrain the popup to the viewport. Proposed fix transitionTimingFunction: 'ease-out',
+ maxHeight: 'calc(100dvh - 2rem)',
maxWidth: 'calc(100vw - 2rem)',
- minWidth: '18rem',
+ minWidth: 'min(18rem, calc(100vw - 2rem))',📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Footer region, visually separated from content by a top border. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| footer: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| padding: space['4'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| borderTopColor: colorVars['--cl-color-border'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| borderTopStyle: 'solid', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| borderTopWidth: '1px', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 | 🟡 Minor | ⚡ Quick win
Add the composed-layer documentation metadata.
Document which layer owns open/placement state and which lower-layer pieces are wired together, then pass a
compositionarray to this sole<Story>.As per coding guidelines, “For Archetype C (composed layer) MDX files, lead with a single paragraph on what state is owned and which lower-layer pieces are wired together, then render a single
<Story>with acompositionarray naming each direct dependency.”🤖 Prompt for AI Agents
Source: Coding guidelines