Skip to content

feat(docs): add the Popover, with a tested reference implementation - #55

Open
alexgrozav wants to merge 7 commits into
mainfrom
popover-uxf-408
Open

alexgrozav wants to merge 7 commits into
mainfrom
popover-uxf-408

Conversation

@alexgrozav

@alexgrozav alexgrozav commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What changed

This adds the Popover page, across all four layers (UI, UX, DX and AX), with 38 rules that each have a basis, a contract that follows the schema, live examples in every framework, an anatomy figure, a playground and a tested Vue reference implementation in app/reference/popover/. The Popover is the non-modal sibling of the Dialog and the interactive sibling of the Tooltip, so it keeps the promises those pages already make about it: it opens in the top layer, takes focus, gives it back, and Escape closes only the layer on top. The page also settles what the Dropdown Menu and the Combobox will reuse from it (placement, light dismiss, Escape and focus return) without designing them.

The decisions a reviewer is most likely to question, with what settled each:

  • The popover attribute, with auto rather than manual. The Button page's popovertarget="filters" opens it with no script, and auto gives the top layer, light dismiss, Escape, one open at a time and focus return, all from the platform (HTML Standard, Baseline since 2024). manual, which the Tooltip uses, would mean rebuilding light dismiss and Escape in script, but it would let the Popover ask before closing, as the Dialog does. Flagged: auto is our pick, and the price is that it can't ask first (next point).
  • It closes itself, and close reports it after the fact. With auto, the browser closes it on a press outside and on Escape before any script hears about it, so unlike the Dialog it can't ask first. close carries a reason (escape, outside, close-button, trigger or closed), after the Dialog's. A form whose answers people could lose belongs in a Dialog, and the page says so.
  • A non-modal role="dialog", named by a required title. HTML-AAM maps a popover without a role to group, which isn't announced as something that opened. title is the Dialog's name for the same job. The title is a <p>, not a heading, since the popover sits in the page's own outline right after its trigger. Flagged: the sources don't settle the element, and a reviewer may prefer a heading.
  • The trigger is the developer's Button, with popovertarget and aria-haspopup="dialog". popovertarget gives the button an expanded state in the accessibility tree only, and no popup type (HTML-AAM). So the Button page's Filters example now sets aria-haspopup="dialog" in all seven frameworks, and the Popover sets aria-expanded on the trigger that opened it (and "false" on every trigger while it's closed), which the Button's Expanded style and Playwright read, as button/popup-expanded asks. A new rule, popover/trigger-expanded, covers it. There's no trigger slot, as on the Dialog. The Popover finds its trigger from ToggleEvent.source (Baseline 2026), or else the first [popovertarget] that points at it, and only that trigger carries the anchor's name, so a popover with two triggers opens beside the one people pressed.
  • open is two-way. The trigger opens it with no state at all, so open follows every open and close: v-model:open in Vue, bind:open in Svelte, [(open)] in Angular, and the native toggle event in React and Solid, the way they follow a Checkbox's change. Whichever acts last wins, and setting open fires no close. Flagged: this differs from the Dialog's one-way open, since the browser, not the page, opens and closes a popover.
  • Focus. It moves in to data-autofocus, or else the first control in the body, then the close button (the Dialog's order), since the platform only honours autofocus. It goes back to the trigger when the popover closes from inside, which the Popover does itself, since Safari doesn't focus a button on click. After a press outside, it stays where people pressed (HTML Standard). Tab out of the last control leaves the popover open, as the platform does, which WCAG 2.4.11 allows because Escape closes it from anywhere without moving focus. Flagged: closing on Tab out is a defensible alternative.
  • Escape. The Popover listens on itself and on its trigger, skips a key something inside already cancelled, then cancels and stops it. Inside a Dialog, which cancels Escape before the browser could close the popover, it also listens on the Dialog's parts, which hear an Escape after the control that has focus and before the Dialog, so a control that handles Escape itself still goes first (dialog.md's rule). With a popover open inside it, Escape is the inner one's. It also listens on the Dialog itself, in the capture phase, for a key while no control has focus, like after a click on text in the popover. When the Dialog closes, from Escape or its own buttons, the popover closes with it. A Tooltip inside closes first and alone, and a Dialog around it stays open, with focus in the popover or on its trigger. It doesn't copy the Tooltip's document listener, which would run before the Tooltip's own. Tests mount the real Dialog and Tooltip for all three cases.
  • placement="bottom", with an arrow. bottom differs from the Tooltip's top: a popover is something people asked for and act in, so it opens where their eyes go next, below the button, as Radix and Base UI do. The arrow is on by default, as on the Tooltip, since it shows at a glance which button a popover belongs to, and arrow={false} turns it off. The placement names, CSS anchor positioning and the scripted fallback are the Tooltip's, in a copy of its own in Popover.vue, since extracting them would change the Tooltip's reference code.
  • One size and look for every popover. Every popover matches the anatomy figure, so readers see the same panel everywhere on the page: 15rem wide (--popover--width, never wider than the screen less 32px), with its padding, corners, border, shadow and type, a title at the text's own line height, and an xs close Button, 24 × 24px, the minimum WCAG 2.5.8 allows. The anatomy draws its panel with the same values.
  • A close button. Touch screen readers can't press Escape or press outside without leaving the popover first, so every popover has one, an xs Button (the Dialog's is sm) named by closeLabel, as on the Dialog.
  • Small screens and short ones. At most the screen less 16px on each side. When neither side of the trigger has room, start and end open below or above it, and @position-try fallbacks shrink it on a side with about half the screen or more, with the body scrolling, so it stays inside 320 × 256 (400% zoom) and never covers its trigger in browsers with CSS anchor positioning. In older ones (Chrome and Edge before 129, Firefox before 147, Safari before 26), the scripted fallback keeps it inside the viewport, but a tall popover can cover its trigger, and the page says so, with those versions. position-try-order: most-height was tried and dropped, since Chrome sorts the base position with it and overrides placement. No bottom sheet, which would move the content away from its trigger and is a Dialog in all but name.
  • Angular names. popoverTitle and closeRequest, for the same reasons as the Dialog's dialogTitle and closeRequest.

The Dialog, Tooltip and Button pages now link to the Popover where they called it upcoming, and in those sentences the Dialog and Tooltip link to each other too. The Roadmap ticks it, and the changelog has an entry.

At the owner's request, the Radio Group page is renamed from 5.radio-group.md to radio-group.md, content unchanged, so CI's Test passes. The same rename reached main first, with the Button page's split (#54), so this branch takes it from main rather than making it again. The Button's Filters example and its "With other components" bullet now live in the Button's DX part, button/3.dx.md.

Checklist

  • The title follows Conventional Commits, as in feat(docs): add the focus guideline.
  • pnpm check-types passes.
  • Docs changes: the pages read well on pnpm dev. (Not run. Read on the built site instead, served from dist/, in Chrome at 320 × 256, 390 and 1280px wide, in light and dark, with the keyboard, under dir="rtl" and in Chrome's accessibility tree.)
  • Homepage changes: pnpm build, then pnpm lighthouse, still scores 100 in every category. (No homepage changes.)

alexgrozav and others added 2 commits October 21, 2026 00:13
The Popover page covers UI, UX, DX and AX, with 37 rules, a contract,
live examples in every framework, an anatomy figure, a playground and a
tested Vue reference implementation. It's built on popover="auto", opened
by a Button with popovertarget, named by its title as a non-modal dialog,
and Escape closes only the layer on top, inside a Dialog and around a
Tooltip.

The Roadmap ticks it, the changelog has an entry, and the Dialog, Tooltip
and Button pages link to it. The Button's Filters example now sets
aria-haspopup="dialog", which popovertarget doesn't give.

Co-authored-by: multica-agent <github@multica.ai>
… its trigger expanded

Escape on another control in a Dialog now closes the Popover and leaves the
Dialog open. The Popover sets aria-expanded on the trigger that opened it,
anchors to that trigger alone, and shrinks to fit when neither side has room.
Your code can close and reopen it in one go, and the Vanilla script draws the
arrow. New tests cover each, and the stand-in moves focus as browsers do.

Co-authored-by: multica-agent <github@multica.ai>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://popover-uxf-408-opencomponents.blue-water-0397.workers.dev (commit 0a68535)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://370f193c-opencomponents.blue-water-0397.workers.dev 0a68535 2026-10-22T14:41:45.209Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://5a166dc8-opencomponents.blue-water-0397.workers.dev 0a4436f 2026-10-21T07:36:26.179Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://8a073097-opencomponents.blue-water-0397.workers.dev 51d9892 2026-10-21T06:47:56.603Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://fc3ba50a-opencomponents.blue-water-0397.workers.dev 12e116a 2026-10-21T06:14:17.925Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://8fa6745f-opencomponents.blue-water-0397.workers.dev 9321ace 2026-10-20T22:23:37.872Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://f9f31e74-opencomponents.blue-water-0397.workers.dev 253e54d 2026-10-20T21:50:04.606Z Visit the dashboard ↗

alexgrozav and others added 2 commits October 21, 2026 01:21
… layer on top

The Popover closes when a Dialog around it closes, and hears Escape on the
Dialog's parts, after the control that has focus, rather than before it. With
a popover open inside it, Escape is the inner one's. Reopening it from another
trigger resets the last one, and start and end open below or above a trigger
with no room beside it. New tests cover each.

Co-authored-by: multica-agent <github@multica.ai>
With focus on the Dialog itself, like after a click on text in the popover,
Escape now closes the popover first and the Dialog on the next press. The
page says which browsers keep the popover off its trigger, splits the
Placement paragraph, and says the popover closes with its Dialog from the
keyboard, and with outside from a pointer.

Co-authored-by: multica-agent <github@multica.ai>
@alexgrozav
alexgrozav marked this pull request as ready for review October 21, 2026 06:29
@alexgrozav
alexgrozav marked this pull request as draft October 21, 2026 06:38
alexgrozav and others added 2 commits October 21, 2026 09:44
…y popover

The arrow is on by default, as on the Tooltip, and turns off with
arrow={false}. Every popover is 15rem wide, with the anatomy's title and an
xs close button, so the anatomy, the playground and the examples show the
same panel. --popover--width replaces --popover--max-width.

Co-authored-by: multica-agent <github@multica.ai>
A start or end popover the screen's edge pushes off its trigger's centre now
draws its arrow on the edge facing the trigger, in the reference and the
Vanilla script. The anatomy follows dark mode, and the Tokens paragraph
says the title and close button differ from the Dialog's.

Co-authored-by: multica-agent <github@multica.ai>
@alexgrozav
alexgrozav marked this pull request as ready for review October 21, 2026 07:59
@alexgrozav
alexgrozav marked this pull request as draft October 22, 2026 14:30
# Conflicts:
#	content/docs/3.components/button.md
@alexgrozav
alexgrozav marked this pull request as ready for review October 22, 2026 14:55
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