Popover
Rich panels anchored to a trigger - a title, content, even a form - that open by click, hover or focus, flip to stay on screen, and take the keyboard with them

Popover

E.popover() anchors a floating panel to a trigger. Where a tooltip holds a line of text and vanishes as the pointer leaves, a popover holds whatever you give it - a title, paragraphs, links, a small form - and stays open until it is dismissed. It is what help icons, "quick edit" panels and profile cards are made of.

The panel is moved to the end of <body> and positioned against the viewport, so a card with overflow: hidden cannot clip it. It prefers the side you ask for, flips when that side has no room, slides along the edge to stay on screen and keeps its arrow on the trigger. Nothing wraps or moves the trigger itself.

Key Features

  • Four triggers - click, hover, focus or manual, and combinations such as 'hover focus'
  • Placement - top, bottom, left, right or auto, each with -start / -end
  • Collision handling - flips, then shifts, and follows scrolling and resizing
  • Safe content - text by default, DOM nodes as they are, HTML only when asked and then sanitised
  • Accessible - a real dialog: focus moves in, Tab and Esc behave, focus comes back
  • One at a time - opening one closes the others in its group
  • Declarative - data-popover markup and E.popover.scan()

Basic Usage


E.popover('#billing-help', {
    title: 'Billing date',
    content: 'Invoices are raised on the first working day of each month.',
    dismissible: true
});

Getting Started

A help icon next to a form label, in three steps

Step 1 - Add a trigger

Any element can be a trigger, but a <button> is keyboard-operable already. Give it an aria-label, because an icon alone says nothing to a screen reader.


<label for="slug">
    URL slug
    <button id="slug-help" type="button" class="sx-pop-help" aria-label="About the URL slug">
        <span data-icon="help-circle" data-icon-size="16"></span>
    </button>
</label>

Step 2 - Attach the popover


E.popover('#slug-help', {
    title: 'URL slug',
    content: 'The last part of the page address. Lower case, words joined by hyphens.',
    placement: 'top'
});

That is a working popover. A click opens it and moves focus into it; Esc, a click outside or a second click on the trigger closes it and puts focus back on the button. The button gets aria-haspopup="dialog", aria-expanded and aria-controls.

Step 3 - Or do it all in markup

For many help icons, write the text into the markup and let E.popover.scan() find them - the same way Domma.icons.scan() finds data-icon. It is safe to call again after rendering more content: triggers that already have a popover are skipped.


<button type="button" aria-label="About the URL slug"
        data-popover="The last part of the page address."
        data-popover-title="URL slug"
        data-popover-placement="top">?</button>

<script>
    E.popover.scan();          // or E.popover.scan('#settings-form')
</script>

data-popover is always treated as text, never HTML. For rich declarative content, point data-popover-content at a <template>; it is cloned each time the popover opens.

Title, content and a close button

Click the button. The panel takes focus; press Esc or use the close button and focus returns to the trigger.


E.popover('#demo-basic', {
    title: 'Delivery options',
    content: 'Standard delivery takes 3-5 working days. Next-day delivery is available '
           + 'on orders placed before 2pm.',
    dismissible: true
});

E.popover('#demo-plain', {content: 'A popover needs nothing more than content.'});

Placement

placement is a side - top, bottom (the default), left, right or auto - optionally followed by -start or -end to line the panel up with one edge of the trigger instead of its middle. Every popover in this grid shares a group, so only one is open at a time.


$('[data-place]').each((i, el) => {
    E.popover(el, {
        placement: el.dataset.place,
        group: 'placement',
        content: 'Placed ' + el.dataset.place + '.'
    });
});

Flipping and shifting

These two ask for left and right from the very edges of the card. On a narrow screen there is no room, so the popover flips to the other side, and failing that to below or above. Open one and scroll the page: it follows the trigger, and when the trigger scrolls out of view the panel waits for it rather than sticking to the edge of the screen.


// onShown reports where the panel actually went - and shows off setContent().
const whereDidItGo = (asked) => ({
    placement: asked,
    content: 'Asked for ' + asked + '.',
    onShown: (p) => p.setContent('Asked for ' + asked + ', placed ' + p.panel.dataset.side + '.')
});
E.popover('#demo-edge-left',  whereDidItGo('left'));
E.popover('#demo-edge-right', whereDidItGo('right'));

Triggers

click is the default and the right choice for anything with controls in it. A hover popover stays open while the pointer moves from the trigger into the panel, and also opens on keyboard focus so it is not mouse-only. focus suits hints on form fields. manual binds nothing and leaves show() and hide() to you.

Hover (or Tab to it)

Written by Ada Lovelace.

Focus

Manual

Target

E.popover('#demo-hover', {trigger: 'hover', title: 'Ada Lovelace', content: profileCard});
E.popover('#demo-focus', {trigger: 'focus', placement: 'top',
                          content: 'UK numbers only. Spaces are fine.'});

const manual = E.popover('#demo-manual', {
    trigger: 'manual', closeOnOutside: false, group: null,
    content: 'Opened from code. Only hide() - or Esc - closes me.'
});
$('#demo-manual-show').on('click', () => manual.show());
$('#demo-manual-hide').on('click', () => manual.hide());

Rich content and forms

content takes a string (set as text), a DOM node, or a function that returns either - called every time the popover opens, so it can show current data. Here the function builds a small form; focus lands on its input, Tab moves through the buttons, and Tab past the last one leaves the popover for whatever follows the trigger on the page.

Project: Website refresh

const rename = E.popover('#demo-rename', {
    title: 'Rename project',
    placement: 'bottom-start',
    content: () => {
        const form = $('<form class="sx-pop-form">' +
            '<input class="form-input" name="name" aria-label="Project name">' +
            '<div class="sx-pop-actions">' +
            '<button type="button" class="btn btn-sm btn-ghost" data-cancel>Cancel</button>' +
            '<button class="btn btn-sm btn-primary">Save</button>' +
            '</div></form>');
        form.find('input').val($('#demo-name').text());
        form.find('[data-cancel]').on('click', () => rename.hide({returnFocus: true}));
        form.on('submit', (e) => {
            e.preventDefault();
            $('#demo-name').text(form.find('input').val());
            rename.hide({returnFocus: true});
        });
        return form.get(0);
    }
});

With html: true a string is treated as markup, and goes through Domma's sanitiser (DOMPurify when the page loads it) first. The button above passes a javascript: link and an onmouseover handler along with the formatting; only the formatting survives.


E.popover('#demo-html', {
    html: true,
    title: 'Sanitised',
    content: '<p><strong>Bold</strong>, <em>italic</em> and a <a href="#">link</a> survive.</p>'
           + '<p><a href="javascript:alert(1)">This link</a> loses its href, '
           + '<span onmouseover="alert(1)">this span</span> its handler.</p>'
});

Declarative help icons

This form's help icons are written entirely in markup. One E.popover.scan('#demo-scan') call turns them into popovers. The last one takes its content from a <template>, so it can hold a list and a link.


<button type="button" aria-label="About visibility"
        data-popover-content="#visibility-help"
        data-popover-title="Who can see this page">?</button>

<template id="visibility-help">
    <ul><li><strong>Public</strong> - anyone with the link</li> …</ul>
</template>
AttributeOption
data-popovercontent (always text)
data-popover-contentSelector of a <template> or element to clone as content
data-popover-titletitle
data-popover-triggertrigger
data-popover-placementplacement
data-popover-dismissibledismissible (present = true)
data-popover-arrow="false"arrow: false
data-popover-groupgroup ("none" = independent)
data-popover-width / -max-widthwidth / maxWidth
data-popover-classclassName

Inside a modal

Popovers sit above modals and slideovers, so a help icon inside a dialog works. Esc closes the popover first and stops there - the modal stays open until you press it again.


E.popover('#demo-modal-help', {
    title: 'Roles',
    content: 'Editors can change pages. Viewers can only read them.',
    placement: 'right'
});

Events

Each phase calls its callback and then fires a bubbling popover:* event on the trigger, with {popover} as event.detail. Return false from onShow / onHide, or call preventDefault() on popover:show / popover:hide, to cancel. This log listens to the whole page.

  • Nothing yet - open a popover above.

$('main').on('popover:shown', (e) => console.log('Opened', e.detail.popover.panel.id));
CallbackEventWhen
onShowpopover:showBefore opening; cancelable
onShownpopover:shownAfter the open transition
onHidepopover:hideBefore closing; cancelable
onHiddenpopover:hiddenAfter the panel has been removed

Keyboard and screen readers

InputDoes
Enter / Space on the triggerOpens a click popover and moves focus to its first control (or the panel)
Tab in the panelMoves through its controls; past the last one, focus goes to what follows the trigger and the popover closes
Shift + Tab from the first controlBack to the trigger; Tab from there goes back in
EscCloses the most recent popover (only) and returns focus to its trigger

A click popover is role="dialog", labelled by its title, and its trigger carries aria-haspopup="dialog", aria-expanded and (while open) aria-controls. A hover or focus popover is role="tooltip" and is joined to its trigger through aria-describedby. A <span> or <div> used as a trigger is given tabindex="0" and, for click, role="button" with Enter/Space; destroy() takes them away again. Under prefers-reduced-motion the fade and scale are skipped.

Options

OptionTypeDefaultDescription
contentstring | Node | Function''Text, a DOM node, or (popover) => either, called on every open
titlestring | Node | Function''Heading; empty for none
htmlbooleanfalseTreat string content and title as HTML, sanitised
triggerstring'click'click, hover, focus, manual, or several separated by spaces
placementstring'bottom'top / bottom / left / right / auto, with optional -start / -end
flipbooleantrueTry other sides when the preferred one has no room
offsetnumber | [x, y]10Gap in px, or [crossAxis, mainAxis]
arrowbooleantrueShow the arrow
dismissiblebooleanfalseShow a close button
closeOnOutsidebooleantrueClose on a press or focus outside
closeOnEscapebooleantrueClose on Esc
groupstring | null'default'One open at a time per group; null for independent
width / maxWidthstring | numbernullAny CSS width (numbers are px); the stylesheet caps it at 20rem
classNamestring''Extra classes on the panel
idstringgeneratedPanel id
rolestringautodialog, or tooltip for hover/focus-only
ariaLabelstringnullAccessible name when there is no title
autoFocusboolean | nullnullMove focus in on open; null means "for click popovers"
trapFocusbooleanfalseTab cycles inside the panel instead of leaving it
delaynumber | {show, hide}{show: 80, hide: 120}Hover delays in ms
animationbooleantrueFade and scale
animationDurationnumber150ms
containerElement | stringdocument.bodyWhere the panel is placed
zIndexnumbernullOverride the stylesheet's 10045

Methods

MethodDescription
show({focus})Open; focus overrides autoFocus once
hide({returnFocus})Close; focus returns to the trigger by default only when it was inside the panel
toggle()Open or close
isOpen()Whether it is open
setContent(content)Replace the content (repositions when open)
setTitle(title)Replace the title; empty removes the header
update()Reposition, after the trigger moved or the content changed size
setOptions(opts)Change options, including the trigger type
destroy()Close, remove the panel and listeners, restore the trigger's attributes
panel / triggerGetters for the two elements
E.popover.scan(root)Create popovers from data-popover markup; returns the new instances
E.popover.closeAll(group)Close every open popover, or one group's
E.popover.getInstance(el)The popover bound to a trigger

Theming

The panel uses theme tokens only - --dm-surface, --dm-text, --dm-border, --dm-shadow-lg and --dm-radius-md - so it follows every theme, light and dark. Width goes through custom properties you can set in CSS too.


.dm-popover.help-pop {
    --dm-popover-max-width: 24rem;
    border-color: var(--dm-primary);
}
.dm-popover.help-pop .dm-popover-arrow {
    border-color: var(--dm-primary);
}

The panel carries data-side and data-placement with where it actually ended up, .has-title, .is-dismissible and .is-open. The trigger carries .is-popover-open while its popover is open. See docs/Popover.md for the full reference.