Popover
A disclosure-style popover
is a button that opens a floating panel. It is not modal, unlike
Dialog, so the page behind it stays usable. The panel still
traps Tab while open, closes on Escape, and closes when
focus leaves it.
import {
Popover,
PopoverButton,
PopoverPanel,
PopoverOverlay,
} from 'terracotta/popover';
import { DisclosureStateChild, useDisclosureState } from 'terracotta/states';Anatomy
<Popover> {/* owns the open state */}
<PopoverButton/> {/* toggles it, anchors the panel */}
<PopoverOverlay/>{/* optional backdrop; closes on click */}
<PopoverPanel/> {/* the floating content */}
</Popover>Examples
Uncontrolled
Controlled
const [open, setOpen] = createSignal(false);
<Popover class="popover" isOpen={open()} onChange={setOpen}>
<PopoverButton class="popover-button">Account</PopoverButton>
<PopoverPanel class="popover-panel">…</PopoverPanel>
</Popover>Closing from inside the panel
With a backdrop
PopoverOverlay closes the popover when clicked. It gives you click-outside
behaviour with a visible scrim. It is not conditionally mounted, so render it
inside the render prop when you want it only while open:
<Popover class="popover" defaultOpen={false}>
{({ isOpen }) => (
<>
<PopoverButton class="popover-button">Account</PopoverButton>
<Show when={isOpen()}>
<PopoverOverlay class="popover-overlay" />
</Show>
<PopoverPanel class="popover-panel">…</PopoverPanel>
</>
)}
</Popover>.popover-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.1);
}As a dropdown menu
Pair it with Menu when the panel contains actions, and pass
aria-haspopup="menu" to the button. The popover then follows the
menu button pattern:
- Opening it focuses the first menu item.
- ↓ on the button opens it on the first item, and ↑ on the last.
- Tab and activating an item close it, instead of keeping focus in the panel.
<Popover class="popover" defaultOpen={false}>
<PopoverButton class="popover-button" aria-haspopup="menu">Actions</PopoverButton>
<PopoverPanel class="popover-panel popover-panel-flush">
<Menu as="ul" class="menu">
<MenuItem class="menu-item" onClick={rename}>Rename</MenuItem>
<MenuItem class="menu-item" onClick={duplicate}>Duplicate</MenuItem>
<MenuItem class="menu-item" onClick={remove}>Delete</MenuItem>
</Menu>
</PopoverPanel>
</Popover>.popover-panel-flush { padding: 0.25rem; }Positioning
Terracotta gives you behaviour, not geometry. Anchor the panel with CSS as above, or use the CSS anchor positioning API where it is supported:
.popover-button { anchor-name: --popover-anchor; }
.popover-panel {
position: absolute;
position-anchor: --popover-anchor;
inset-block-start: anchor(bottom);
inset-inline-start: anchor(start);
margin-block-start: 0.25rem;
}For a full-featured solution, pass a ref to PopoverButton and
PopoverPanel, then hand both elements to a positioning library. Put the panel
in a Portal so a parent's overflow cannot clip it, and use the fixed
strategy:
import { Portal } from '@solidjs/web';
import { autoUpdate, flip, offset } from '@floating-ui/dom';
import { useFloating } from 'solid-floating-ui';
const [button, setButton] = createSignal<HTMLElement>();
const [panel, setPanel] = createSignal<HTMLElement>();
const position = useFloating(button, panel, {
strategy: 'fixed',
placement: 'bottom-start',
middleware: [offset(4), flip()],
whileElementsMounted: autoUpdate,
});
<Popover>
<PopoverButton ref={setButton}>Options</PopoverButton>
<Portal>
<PopoverPanel
ref={setPanel}
style={{
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
>
…
</PopoverPanel>
</Portal>
</Popover>A panel in a portal still counts as inside the panel it opened from. Focus can move into a nested popover without closing the outer one. See Portals.
Aligning to the end of the button
.popover-panel-end {
inset-inline-start: auto;
inset-inline-end: 0;
}With a transition
<Popover class="popover" defaultOpen={false}>
{({ isOpen }) => (
<>
<PopoverButton class="popover-button">Menu</PopoverButton>
<Transition
show={isOpen()}
enter="pop-enter" enterFrom="pop-from" enterTo="pop-to"
leave="pop-leave" leaveFrom="pop-to" leaveTo="pop-from"
>
<PopoverPanel class="popover-panel" unmount={false}>…</PopoverPanel>
</Transition>
</>
)}
</Popover>.pop-enter { transition: opacity 150ms ease-out, scale 150ms ease-out; }
.pop-leave { transition: opacity 100ms ease-in, scale 100ms ease-in; }
.pop-from { opacity: 0; scale: 0.95; }
.pop-to { opacity: 1; scale: 1; }Disabled
<Popover class="popover" defaultOpen={false} disabled={!hasFilters()}>
<PopoverButton class="popover-button">Filters</PopoverButton>
<PopoverPanel class="popover-panel">…</PopoverPanel>
</Popover>.popover-button[tc-disabled] { opacity: 0.5; cursor: not-allowed; }Reading the state from a nested component
function CloseButton(): JSX.Element {
const state = useDisclosureState();
return <button type="button" onClick={() => state.close()}>Done</button>;
}State attributes
| Element | Attribute | Present when |
|---|---|---|
Popover |
tc-popover |
Always |
Popover |
tc-expanded |
The popover is open |
Popover |
tc-disabled |
The popover is disabled |
PopoverButton |
tc-popover-button, tc-button |
Always |
PopoverButton |
tc-expanded |
The popover is open |
PopoverButton |
tc-disabled |
The button or the popover is disabled |
PopoverPanel |
tc-popover-panel |
Always (whenever rendered) |
PopoverPanel |
tc-expanded, tc-disabled |
Mirrors the popover |
PopoverOverlay |
tc-popover-overlay |
Always |
PopoverOverlay |
tc-expanded, tc-disabled |
Mirrors the popover |
The button also carries aria-expanded, and aria-controls pointing at the
panel while open.
Styling
/* Root-level state cascades to everything inside */
[tc-popover][tc-expanded] .popover-label { color: #1d4ed8; }
/* Rotate a caret on the button */
[tc-popover-button]::after {
content: "▾";
margin-inline-start: 0.375rem;
display: inline-block;
transition: rotate 150ms ease;
}
[tc-popover-button][tc-expanded]::after { rotate: 180deg; }
/* The overlay is always mounted, so hide it while closed */
[tc-popover-overlay]:not([tc-expanded]) { display: none; }
/* With unmount={false} on the panel, you own its visibility */
[tc-popover-panel]:not([tc-expanded]) { display: none; }Reading the state in code
| Member | Type | Description |
|---|---|---|
isOpen() |
boolean |
Whether the popover is open. |
open() / close() |
() => void |
Opens / closes it. No-op while disabled. |
toggle() |
() => void |
Flips the state. |
disabled() |
boolean |
Whether the popover is disabled. |
You can reach this state as the render-prop argument on Popover and each of
its parts, through <DisclosureStateChild>, or with useDisclosureState() in
any descendant. Full reference in
disclosure state.
Behaviour and keyboard
| Key / event | Action |
|---|---|
Click on PopoverButton |
Toggles the popover |
| Enter / Space on the button | Same |
| Escape | Closes it |
| Tab / Shift+Tab | Cycles focus within the panel. Closes it when the panel holds a Menu. |
↓ / ↑ on a button with aria-haspopup="menu" |
Opens it on the first / last menu item |
| Activating a menu item in the panel | Closes it |
| Focus leaves the panel | Closes it, unless the pointer is hovering the button, so a click on the button reads as a toggle rather than a close-then-reopen |
The panel focuses its first menu item when it opens, or else its first focusable element. Focus returns to where it was when the popover closes.
API
<Popover>
Owns the disclosure state and renders a <div>
by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
defaultOpen |
boolean |
none | Initial state, uncontrolled. Mutually exclusive with isOpen. |
isOpen |
boolean |
none | Current state, controlled. Mutually exclusive with defaultOpen. |
disabled |
boolean |
false |
Blocks opening and closing. |
onChange |
(state: boolean) => void |
none | Called with the new state on every change. |
onOpen |
() => void |
none | Called when it opens, before onChange. |
onClose |
() => void |
none | Called when it closes, after onChange. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Contents, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<PopoverButton>
A Button that toggles the popover. Renders a <button> by
default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'button' |
Element or component to render as. |
disabled |
boolean |
false |
Disables this button. It is also disabled when the Popover is. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Label, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<PopoverPanel>
The floating content, with the focus trap. Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
unmount |
boolean | 'offscreen' |
true |
How the panel behaves while closed. See unmount. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Contents, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<PopoverOverlay>
An optional backdrop that closes the popover when clicked. Renders a <div> by
default. It is not conditionally mounted, so hide it with CSS or wrap it in
a <Show>.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Contents, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Every descendant throws if rendered outside a <Popover>.