Enhancement
Dropdown
A menu that opens beside its button, from markup on the page or from a template filled in per button
- Import
@parallelogram-js/core/components/Dropdown- Selector
[data-dropdown]- Stylesheet
@parallelogram-js/core/styles/dropdown.css
Playground
The menu is in the markup; Dropdown opens it under the button and closes it on a click outside, on Escape, or when a link is followed.
<button type="button" class="btn" data-dropdown data-dropdown-target="#account-menu">Account</button>
<div id="account-menu" class="menu" hidden>
<a href="#profile">Profile</a>
<a href="#bookings">Bookings</a>
<button type="button">Sign out</button>
</div>
Use the example to see the events it sends.
Each item is a row, so an icon before a label sits in line with the others and the label takes the rest. A disabled item is a dimmed row: the arrow keys pass over it and a press leaves the menu open. An <hr> draws a rule between groups.
<button type="button" class="btn" data-dropdown data-dropdown-target="#file-menu">File</button>
<div id="file-menu" class="menu" hidden>
<button type="button"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4" /><path d="M13.5 6.5l4 4" /></svg>Rename</button>
<button type="button"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>Duplicate</button>
<button type="button" disabled><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l14 0" /><path d="M19 12l-6 6" /><path d="M19 12l-6 -6" /></svg>Move to…</button>
<hr>
<button type="button"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7l16 0" /><path d="M10 11l0 6" /><path d="M14 11l0 6" /><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" /><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" /></svg>Delete</button>
</div>
Use the example to see the events it sends.
Each row carries only its params, as separate attributes or as one; the menu is built when the button opens and removed when it closes. Grace’s row disables Cancel and Mary’s leaves Edit out, by the items’ names. Choose an action to see dropdown:select carry the row’s params.
| Ada Lovelace | Tue 24 Sep | |
| Grace Hopper | Wed 25 Sep | |
| Mary Somerville | Thu 26 Sep |
<template id="booking-actions">
<div class="menu">
<a href="/bookings/{id}/edit" data-dropdown-item="edit">Edit {name}</a>
<button type="button" data-action="cancel" data-dropdown-item="cancel">Cancel booking</button>
</div>
</template>
<table class="table">
<tr><td>Ada Lovelace</td><td>Tue 24 Sep</td><td><button type="button" class="btn btn--sm" data-dropdown data-dropdown-template="#booking-actions" data-dropdown-param-id="12" data-dropdown-param-name="Ada">Actions</button></td></tr>
<tr><td>Grace Hopper</td><td>Wed 25 Sep</td><td><button type="button" class="btn btn--sm" data-dropdown data-dropdown-template="#booking-actions" data-dropdown-params="{id: 13, name: Grace}" data-dropdown-disabled="cancel">Actions</button></td></tr>
<tr><td>Mary Somerville</td><td>Thu 26 Sep</td><td><button type="button" class="btn btn--sm" data-dropdown data-dropdown-template="#booking-actions" data-dropdown-param-id="14" data-dropdown-param-name="Mary" data-dropdown-hidden="edit">Actions</button></td></tr>
</table>
Use the example to see the events it sends.
| Ada Lovelace | Tue 24 Sep | |
| Grace Hopper | Wed 25 Sep | |
| Mary Somerville | Thu 26 Sep |
The same menu opened on each side of its button, lined up with each edge.
<div class="button-row">
<button type="button" class="btn" data-dropdown data-dropdown-target="#place-menu" data-dropdown-placement="bottom-start">Bottom start</button>
<button type="button" class="btn" data-dropdown data-dropdown-target="#place-menu" data-dropdown-placement="bottom-end">Bottom end</button>
<button type="button" class="btn" data-dropdown data-dropdown-target="#place-menu" data-dropdown-placement="top-start">Top start</button>
<button type="button" class="btn" data-dropdown data-dropdown-target="#place-menu" data-dropdown-placement="right">Right</button>
</div>
<div id="place-menu" class="menu" hidden>
<a href="#one">First choice</a>
<a href="#two">Second choice</a>
</div>
Use the example to see the events it sends.
Dropdowns share a group unless told otherwise, so opening one closes the others. The last has its own group and no capture, so it stays open while the others come and go; with capture it would still close when focus left it.
<div class="button-row">
<button type="button" class="btn" data-dropdown data-dropdown-target="#file-menu">File</button>
<button type="button" class="btn" data-dropdown data-dropdown-target="#edit-menu">Edit</button>
<button type="button" class="btn" data-dropdown data-dropdown-target="#help-menu" data-dropdown-group="" data-dropdown-capture="false">Help</button>
</div>
<div id="file-menu" class="menu" hidden><button type="button">New</button><button type="button">Open</button></div>
<div id="edit-menu" class="menu" hidden><button type="button">Undo</button><button type="button">Redo</button></div>
<div id="help-menu" class="menu" hidden><a href="#docs">Documentation</a><a href="#about">About</a></div>
Use the example to see the events it sends.
data-dropdown-match-width makes the menu at least the button’s width, like a select; data-dropdown-stay on the menu keeps it open while choosing.
<button type="button" class="btn" data-dropdown data-dropdown-target="#filter-menu" data-dropdown-match-width>Filter bookings by status</button>
<div id="filter-menu" class="menu" data-dropdown-stay hidden>
<button type="button" role="menuitemcheckbox" aria-checked="true">Confirmed</button>
<button type="button" role="menuitemcheckbox" aria-checked="false">Waitlisted</button>
<button type="button" role="menuitemcheckbox" aria-checked="false">Cancelled</button>
</div>
Use the example to see the events it sends.
About
Dropdown is Toggle with a place to sit and a menu to keep: it opens the target beside the trigger on the side data-dropdown-placement asks for, flips to the opposite side when there is no room, stays inside the viewport, and follows the trigger while the page scrolls. Everything Toggle does it does too -- aria-expanded and aria-controls on the trigger, closing on a click or focus outside, on Escape and when a link inside is followed, one group closing each other -- with the defaults a menu wants: outside clicks close it, and every dropdown on the page is in one group unless given its own, so opening one closes the rest.
The menu is either on the page already, named by data-dropdown-target, or built the first time the trigger opens from a <template> named by data-dropdown-template, with every {name} in its text and attribute values filled from the trigger's data-dropdown-param-<name> attributes and put back to nothing when it closes. A page with a menu per row keeps one template and a few attributes per row rather than a menu per row in the DOM. Values are substituted as text, never parsed as markup.
A menu opened from the keyboard puts focus on its first item, as the menu button pattern asks; one opened with a pointer leaves focus on the trigger, so that nothing looks chosen before anything has been, and an arrow key goes into it from there. Which it was is the data-focus-source record the framework keeps on <html>, the same one the focus rings follow.
Inside the menu, the arrow keys move between items, Home and End go to the ends, a letter jumps to the next item starting with it, Escape closes and returns focus to the trigger, and choosing an item dispatches dropdown:select with the item and the trigger's params, then closes -- unless the item or the menu carries data-dropdown-stay. The menu gets role="menu" and its items role="menuitem" when the markup has not said otherwise.
Usage
Register the component and the framework loads it the first time a page contains [data-dropdown].
import { Parallelogram } from '@parallelogram-js/core';
const app = Parallelogram.create();
app.components.add('[data-dropdown]', () => import('@parallelogram-js/core/components/Dropdown'));
app.run();
The framework is optional. Dropdown.enhanceAll() mounts the component on every matching element by itself, for a page that wants one enhancement rather than a framework:
import Dropdown from '@parallelogram-js/core/components/Dropdown';
Dropdown.enhanceAll();
It takes a selector and the component's options, and returns the instance it mounted them with. What it leaves out is what the framework adds: loading a component only on the pages that use it, mounting again after a page swap, and ordering with dependsOn.
Its styles come from the package stylesheet:
@import '@parallelogram-js/core/styles/dropdown.css';
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
data-dropdown-target | string | none | A selector for a menu already on the page |
data-dropdown-template | string | none | A selector for a <template> whose content becomes the menu the first time the trigger opens; its one root element is the menu |
data-dropdown-param-<name> | string | none | Fills every {name} in the template's text and attribute values, as text, so a value cannot inject markup; passed along with dropdown:select. In a boolean attribute such as disabled or hidden, an empty, false or unfilled value takes the attribute away and anything else keeps it |
data-dropdown-params | string | none | All the params in one attribute: JSON, or key:value pairs separated by commas as in {id: 12, name: Ada}, braces and quotes optional; a data-dropdown-param-<name> attribute wins for its name |
data-dropdown-disabled | string | none | The data-dropdown-item names of the items to disable for this trigger, separated by commas or spaces |
data-dropdown-hidden | string | none | The data-dropdown-item names of the items to leave out for this trigger: left out of a menu built from a template, hidden in a menu on the page |
data-dropdown-item | string | none | The name data-dropdown-disabled and data-dropdown-hidden refer to an item by On an item. |
data-dropdown-keep | boolean | false | Keep a menu built from a template in the DOM after it closes, rather than removing it For every instance, Dropdown.defaults.keep. |
data-dropdown-placement | bottom-start | bottom-end | bottom | top-start | top-end | top | left | right | bottom-start | Which side of the trigger the menu opens on and which edge it lines up with; it flips when there is no room For every instance, Dropdown.defaults.placement. |
data-dropdown-offset | number | 4 | Pixels between the trigger and the menu For every instance, Dropdown.defaults.offset. |
data-dropdown-match-width | boolean | false | Make the menu at least as wide as the trigger For every instance, Dropdown.defaults.matchWidth. |
data-dropdown-portal | boolean | false | Move the menu to the end of the body while it is open, for a trigger inside an ancestor that clips or transforms For every instance, Dropdown.defaults.portal. |
data-dropdown-group | string | dropdown | Dropdowns that share a group name close each other; every dropdown is in one group unless given its own, and an empty name makes one independent For every instance, Dropdown.defaults.group. |
data-dropdown-capture | boolean | true | Close on a click or focus outside the trigger and menu For every instance, Dropdown.defaults.capture. |
data-dropdown-close-navigation | boolean | true | Close when a page link inside the menu is followed For every instance, Dropdown.defaults.closeOnNavigation. |
data-dropdown-close-escape | boolean | true | Close with Escape, returning focus to the trigger For every instance, Dropdown.defaults.closeOnEscape. |
data-dropdown-manual | boolean | false | Close only from the trigger or the group For every instance, Dropdown.defaults.manual. |
data-dropdown-animate | boolean | true | Wait for the stylesheet's animations before changing state For every instance, Dropdown.defaults.animateToggle. |
data-dropdown-stay | flag | none | Choosing this item, or any item in this menu, leaves the menu open On an item, or the menu. |
data-dropdown-stateset by the component | closed | opening | open | closing | none | The menu's state; the stylesheet hides a closed menu and animates opening and closing On target. |
data-dropdown-sideset by the component | top | bottom | left | right | none | The side the menu took, which the stylesheet animates it in from On target. |
data-dropdown-enhancedset by the component | flag | none | Present on a trigger while Dropdown is mounted on it |
Events
| Name | Detail | Description |
|---|---|---|
dropdown:show | { target: HTMLElement; trigger: HTMLElement; timestamp: number } | Dispatched on the trigger when its menu opens DOM event and event bus |
dropdown:hide | { target: HTMLElement; trigger: HTMLElement; timestamp: number } | Dispatched on the trigger when its menu closes DOM event and event bus |
dropdown:select | { item: HTMLElement; params: Record<string, string>; target: HTMLElement; trigger: HTMLElement; timestamp: number } | Dispatched on the trigger when an item is chosen, with the item and the trigger's params; the menu then closes unless the item or the menu carries data-dropdown-stay DOM event and event bus |
dropdown:mount | { element: HTMLElement; target: HTMLElement | null; isOpen: boolean; timestamp: number } | A trigger was set up; the target is null until a templated menu is first built Event bus |
CSS custom properties
| Name | Default | Description |
|---|---|---|
--dropdown-z-index | 1000 | Stacking of an open menu |
--dropdown-min-width | 10rem | Narrowest a menu is drawn |
--dropdown-max-height | 60vh | Tallest a menu grows before it scrolls |
--dropdown-padding | 0.25rem | Space between the menu edge and its items |
--dropdown-item-padding | 0.45rem 0.6rem | Padding of an item |
--dropdown-item-radius | 0.2em | Corner radius of an item’s hover |
--dropdown-transition-duration | var(--framework-transition-duration) | How long opening and closing take; none under prefers-reduced-motion |
--dropdown-shift | 4px | How far a menu moves while it opens and closes |