Skip to content

feat(react): add the Popover primitive#21

Open
ivanbanov wants to merge 2 commits into
feat/floatingfrom
feat/popover
Open

feat(react): add the Popover primitive#21
ivanbanov wants to merge 2 commits into
feat/floatingfrom
feat/popover

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

Summary

Adds the Popover primitive across both scopes:

  • @dunky.dev/popover (packages/core/popover) — the framework-free state machine and connect layer.
  • @dunky.dev/react-popover (packages/react/popover) — the thin React compound-component binding, plus stories and a changeset.

A popover is a non-modal floating panel opened from a trigger that coexists with the page: Escape, an outside press, or focus leaving the panel dismisses it, and the modal option opts into a focus trap with the outside hidden from assistive tech instead. Open state is exposed controlled and uncontrolled; with the open prop set, every open/close intent is reported through onOpenChange and the popover only moves when the prop does, so ignoring a report is a working veto. No positioning engine in v0 — the consumer anchors the panel, and data-state on the trigger and panel is the styling hook.

Stacked PR: this branch is based on the feat/floating shared-overlay-utilities extraction and targets that branch; it will retarget to main once the extraction PR merges.

Anatomy

<Popover>              — root; owns open/close state, renders nothing
  |_ <Popover.Trigger>     — toggles the popover; focus returns here on close
  |_ <Popover.Portal>      — teleports the panel out of the tree while open
     |_ <Popover.Content>      — the floating panel (role="dialog")
        |_ <Popover.Title>       — the popover's accessible name
        |_ <Popover.Description> — the popover's accessible description
        |_ <Popover.Close>       — the visible in-panel close affordance

Behavior

  • Trigger carries aria-haspopup="dialog", aria-expanded, and — while open — aria-controls; a press while open is a toggle, never an outside interaction (no dismiss-then-reopen flicker).
  • Content is role="dialog", aria-modal only when modal; labelled/described by the rendered Title/Description with no dangling references when a part is omitted.
  • Focus on open goes to the consumer-designated initial-focus element, else the first focusable in the panel, else the panel itself; on close it returns to the previously focused element — except after a focus-out dismissal, where it stays where the user sent it.
  • Non-modal by default: the page stays interactive, Tab flows out of the panel, and focus leaving counts as an outside interaction. modal traps focus and hides everything outside from assistive tech.
  • Escape and outside-interaction dismissal are each gateable at the root and vetoable per occurrence from their handlers.
  • Participates in the shared overlay layer stack: nested overlays stack, Escape unwinds one layer per press, and interactions inside a nested layer never count as outside the one beneath.

Test plan

  • pnpm test:ci — 158 tests, 13 files, all passing
  • pnpm typecheck
  • pnpm lint
  • pnpm format:check
  • pnpm build

🤖 Generated with Claude Code

ivanbanov and others added 2 commits July 18, 2026 23:45
Anatomy: Popover root with Trigger, Portal, Content, Title,
Description, and Close compound parts, shipped as an agnostic core
(@dunky.dev/popover) plus a React binding (@dunky.dev/react-popover).

A non-modal floating panel toggled from its trigger that coexists with
the page: Escape, an outside press, or focus leaving the panel
dismisses it, and the modal option opts into a focus trap instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov ivanbanov changed the title feat: add the Popover primitive feat(react): add the Popover primitive Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant