Skip to content

Popover ​

A panel anchored to the control that opened it, which the rest of the page stays usable around. Reach for it for a filter panel, a short form beside the thing it edits, or a note that is too long and too interactive for a tooltip.

It is a dialog in ARIA's sense, but not a modal one. It takes focus when it opens; it does not trap it, it puts nothing over the page, and a press anywhere outside it closes it and still lands on whatever was pressed. When the next thing the user does has to be answering it, that is a dialog. When it is a list of commands, it is a menu.

tsx
/**
 * Two popovers: a small form the application owns, and a note that
 * owns itself.
 *
 * **Filters** is controlled. The application holds `open`, hears every
 * request through `onOpenChange`, and closes it itself when Apply is
 * pressed. Its trigger says two things about the popover: `expanded`,
 * from the same cell, which it announces as expanded or collapsed, and
 * `controls`, the panel's node, which `panelRef` writes into a cell of
 * its own. `initialFocus` puts the caret in the field as it opens.
 *
 * **About filters** opens a note given `defaultOpen`: nothing but
 * the popover knows whether it is open. It opens on the end side of
 * its trigger, which is the right in this page and the left in a
 * right-to-left one.
 *
 * Neither trigger has an `onClick` for the popover. A popover hears
 * its anchor's presses itself, so one written here as well would open
 * it and close it again in the same click.
 */
export function Filters(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  const anchor = internalState<UiNode | null>(null);
  const panel = internalState<UiNode | null>(null);
  const field = internalState<UiNode | null>(null);
  const open = internalState(false);
  const query = internalState('');
  const openOnly = internalState(true);
  const applied = internalState('Showing every issue.');
  const about = internalState<UiNode | null>(null);

  const apply = (): void => {
    const words = query.value.trim();
    const which = openOnly.value ? 'open issues' : 'all issues';
    applied.value = words.length === 0 ? `Showing ${which}.` : `Showing ${which} with "${words}" in the title.`;
    open.value = false;
  };

  return (
    <column gap={12} padding={20} width={percent(100)} height={percent(100)}>
      <row gap={8} y="center">
        <Button
          ref={(node: UiNode | null) => (anchor.value = node)}
          label="Filters"
          variant="outlined"
          expanded={open}
          controls={panel}
        />
        <Button ref={(node: UiNode | null) => (about.value = node)} label="About filters" variant="outlined">
          <text text="?" textStyle="body" fontWeight={600} color="controlForeground" selectable={false} />
        </Button>
      </row>
      <text text={applied} fontSize={12} color="textMuted" />

      <Popover
        open={open}
        onOpenChange={(next: boolean) => (open.value = next)}
        anchor={anchor}
        panelRef={(node: UiNode | null) => (panel.value = node)}
        initialFocus={field}
        label="Filter issues"
        width={260}
        content={
          <column gap={10} width={percent(100)}>
            <TextInput
              ref={(node: UiNode | null) => (field.value = node)}
              label="Title contains"
              value={query}
              onChange={(next: string) => (query.value = next)}
            />
            <Checkbox
              label="Open issues only"
              checked={openOnly}
              onChange={(next: boolean) => (openOnly.value = next)}
            />
            <Button label="Apply" onClick={apply} />
          </column>
        }
      />

      <Popover
        defaultOpen={false}
        anchor={about}
        side="end"
        align="center"
        label="What filters do"
        width={220}
        content={
          <text
            text="Filters narrow the list without changing it. Press outside, or Escape, to put this away."
            fontSize={13}
            color="text"
          />
        }
      />
    </column>
  );
}

Press Filters: the panel opens under it with the caret in the field. Type, press Tab to walk the checkbox and Apply, and Apply closes it with the line underneath updated. Press ? for a note on its end side, and press anywhere else to put it away: the press is not swallowed, so pressing ? while the filters are open closes them and opens the note in one press.

Props ​

PropTypeDefaultWhat it does
openbooleannoneWhether it is open, when the application owns that. Supplying it makes the popover controlled.
defaultOpenbooleannoneWhether it starts open, for a popover that owns its own state. Supplying both throws.
onOpenChange(open: boolean) => voidnoneCalled with what the popover asks to become: true on an anchor press, false on every close.
anchorUiNode | nullnullThe trigger it opens beside, and whose presses toggle it. Pass a cell, as for Menu.
contentUiChildempty rowWhat the panel holds. Read when it opens.
labelstring'Popover'The panel's accessible name. Not drawn.
descriptionstring''Read after the name. Not drawn.
sidePopoverSide'bottom'bottom, top, start or end. Read when it opens.
alignPopoverAlign'start'start, center or end: which edge of the anchor it lines up with. Read when it opens.
offsetnumber6The gap between the anchor and the panel, in pixels.
initialFocusUiNode | nullnoneThe node inside the content to focus as it opens, as a cell its ref writes.
widthnumbernoneIts width. Without one it is as wide as its content, up to 360 pixels, and wraps past that.
disabledbooleanfalseNothing opens it, and an open one closes. Disable the trigger as well, so it says so.
panelRef(node: UiNode | null) => voidnoneReceives the panel's node while it is open, and null when it closes: the trigger's controls.

Popover does not take the shared layout props, and has no rootModifiers. Nothing is drawn where it is declared: it leaves an invisible placeholder there, for the theme the panel inherits, and draws the panel in the overlay layer.

Controlled and uncontrolled ​

Given open, the application owns the state. Every way the popover closes, Escape, a press outside, Tab out of it, focus moved away from it, its anchor pressed again, is a request through onOpenChange(false), and an application that does not write the value back keeps it open: that is how a form with unsaved changes refuses to be put away. A press on the anchor of a closed popover is onOpenChange(true), which is likewise only a request.

Given defaultOpen, it keeps its own state in a cell, and onOpenChange is still called on every change for an application that wants to hear about them.

The anchor is the trigger ​

The anchor is a node, handed over through a ref written into a cell, the same as Menu's and for the same reason: a component body runs before the ref fires, so a plain field would hand the popover the null it held then, and a popover with no anchor opens in the window's corner.

A popover listens to its anchor's clicks itself, which is where it parts company with Menu. A click is what the pointer, Enter and Space on a focused button, and an assistive technology's press all send, so one listener answers all three. That is what lets defaultOpen mean anything, since a popover that owned its state and could not be opened would be no use, and it is why the trigger in the example has no onClick. A trigger that also wrote open from its own onClick would toggle the popover twice in one press, which is to say not at all.

The node and not a wrapper around a child, as Tooltip takes, because the trigger's half of the semantics belongs on the button a screen reader is on. A popover cannot write properties onto a node it did not render, so the trigger says them itself, from cells it already has. The library's Button takes both: expanded, which it announces as expanded or collapsed, and controls, the panel's node as panelRef writes it.

tsx
<Button
  ref={node => (anchor.value = node)}
  label="Filters"
  expanded={open}
  controls={panel}
/>
<Popover open={open} anchor={anchor} panelRef={node => (panel.value = node)} … />

Placement ​

The panel is placed by the layout engine, as every anchored overlay is. It takes the side asked for, flips to the opposite side when it would overflow the window there and the other side has more room, and shifts along the anchor to stay on screen. A panel taller than the room on its side is cut to that room and its content scrolls inside the border. It follows its anchor when the page scrolls, and the wheel does not close it: a form half filled in should not vanish because the page moved.

start and end are logical. Under textDirection="rtl" the start side is the anchor's right and the end side its left, and align="start" on the top or bottom lines the panel up with the anchor's right edge. The direction is the anchor's: the popover hands its anchor to the overlay layer as the tree the panel belongs to, and the layer resolves start and end against it, as it does for every overlay, and lays the panel's own content out in it too. A direction that changes while the panel is open moves it to the other side.

Keyboard ​

KeyWhat it does
EscapeAsks to close, and once closed puts focus back on the anchor
TabThe next control in the panel; after the last, leaves to what follows the anchor and asks to close
Shift+TabThe previous control in the panel; before the first, back to the anchor, and asks to close

Opening moves focus into the panel: to initialFocus when it names a node inside that can take focus, and to the panel itself otherwise, which holds focus without becoming a tab stop, as a dialog's body does. Escape is heard there.

Leaving with Tab closes it. The APG's guidance for a non-modal dialog allows either keeping it open or closing it as focus leaves. This closes, because the panel is drawn in the overlay layer and not beside its anchor in the page's order, so a popover left open behind the focus could only be reached again through the anchor, whose press would close it. Tab and Shift+Tab are handled by the panel rather than by the focus manager for the same reason: the manager walks the tree in order, and from the overlay layer Tab off the last control would wrap to the top of the page.

A control that takes Tab keeps it. The panel acts only on a Tab nothing inside it used: a field of the application's own that indents on Tab cancels the key in its onKeyDown, and the panel then leaves focus where it is. A TextArea is not such a field. Tab leaves a TextArea everywhere in the library, so that the keyboard is never caught in one, and inside a popover it goes on to the panel's next control like any other.

Focus leaving any other way closes it too. Focus moved by code, by a shortcut that focuses another control, or by an assistive technology moving its own focus, is focus that left, and the popover asks to close through onOpenChange(false), leaving focus where it went. Focus on the anchor, or in an overlay opened from inside the panel, does not count as leaving, by the same rule a press outside is judged by (see below). Focus that goes nowhere, as when the window loses focus, does not close it, and nor does focus that was elsewhere before the panel took it on opening.

The page behind stays usable from the keyboard too, which is the point of a non-modal popover. Inside a modal dialog it is the one exception: the dialog confines focus to itself, and the panel, drawn in the overlay layer, is outside it, so there the popover takes a focus scope of its own, as a menu opened in a dialog does. Escape then closes the popover and not the dialog.

Light dismiss ​

There is no backdrop. A press outside the panel is heard on its way to whatever was pressed and closes the popover as it goes, so the button pressed is pressed, and focus goes where that press puts it rather than back to the anchor. Nothing dims the page.

Two things outside the panel's own subtree count as inside it. The anchor, because its press toggles; and every overlay opened after the popover, because those are drawn as entries of their own in the overlay layer: a select's list, a nested popover, a dialog the panel opened. Choosing an option in a select inside a popover leaves the popover open. A press in an outer popover closes a nested one.

Semantics ​

WhatValue
Paneldialog, named by label, with description when given, and no modal state
TriggerWhatever the caller gives it: a Button's expanded and controls, set from the popover's state and its panel
ContentIts own records, under the panel's

What this page has checked ​

The behaviour above is asserted by two specs, both driving the real runtime with a fake canvas and pressing through hit testing as a pointer does: the component's own, and the one beside this example. They cover opening from the anchor by pointer and by Enter, the controlled form refusing a close, the uncontrolled form toggling itself, both forms together throwing, each placement side, the flip above an anchor at the window's bottom, the clamp beside one at its right edge, long content wrapping at 360 pixels, focus on open with and without initialFocus, Escape back to the anchor, Tab walking the panel and leaving it in both directions, Tab leaving a TextArea inside it and left alone for a field that cancels it, focus moved outside by code, by a shortcut and by an assistive technology's focus action closing it once, and focus in a nested popover or a select's list not closing it, a press outside closing it while reaching what was pressed, the wheel not closing it, a select and a nested popover inside it, the trigger's expanded and controls, disabled, right-to-left placement and content, and a popover inside a modal dialog.

What they cannot check is anything drawn. The panel's look, its border and radius in each theme, and the ring on the anchor when focus comes back have not been opened in a browser at all yet, on either renderer. No screen reader has been sat in front of the semantics above, so whether a reader announces the panel as a dialog as focus arrives, and reads the trigger's state, is unverified.

Next ​

Menu is the same overlay machinery for a list of commands, with the keyboard trapped, and Dialog is the modal one.