Dropdown menu
Action menu anchored to a button. It flips sides when it doesn't fit, closes on an outside click, on
Escape and when focus leaves, and moves with the arrow keys. The items can be written
in the template, which is the way to go when the server is the one that knows what the person is allowed to do.
Examples
The same component, with different positions and content.
Right-aligned
data-placement="bottom-end": for a button pushed against the right edge.
To the side
data-placement="right-start" opens alongside, like a submenu.
With icons
The icon goes in .tuc-dropdown__icon and is muted; on the danger item, it follows the red.
Icon button
The "more actions" of a table row. With no visible text, aria-label is required.
The positions are the same as the tooltip's: side top, bottom, left or
right, and alignment start, center or end. The default is
bottom-start. When the requested side doesn't fit and the opposite one fits better, the menu flips; and it
never spills off the screen, it slides back inside.
How to use
A button with data-tuc-dropdown pointing to the panel, and the panel right below it, with
hidden. It initializes on its own, including whatever arrives later through HTMX.
<button type="button" class="tuc-btn is-outline" data-tuc-dropdown="#actions">Actions</button>
<div class="tuc-dropdown" id="actions" hidden>
<div class="tuc-dropdown__label">Contract</div>
<button type="button" class="tuc-dropdown__item">
<span class="tuc-dropdown__text">Edit</span>
<span class="tuc-dropdown__shortcut">⌘E</span>
</button>
<a class="tuc-dropdown__item" href="/contracts/12/">
<span class="tuc-dropdown__text">Open</span>
</a>
<hr class="tuc-dropdown__separator">
<button type="button" class="tuc-dropdown__item is-danger">
<span class="tuc-dropdown__text">Delete</span>
</button>
</div>hidden is required
The script is loaded with defer and only runs after the page has been drawn. Without hidden, the menu shows up open in the middle of the page until then. On initialization the panel is taken out of the flow and positioned by the component, which removes hidden by itself.
Panel parts
| Class or attribute | What it's for |
|---|---|
.tuc-dropdown | The panel. Needs an id, for the trigger to point to, and hidden |
.tuc-dropdown__item | An item. <a href> to navigate, <button type="button"> to act |
.tuc-dropdown__text | The label; truncated with an ellipsis when it doesn't fit |
.tuc-dropdown__icon | Icon on the left, 16px, in the muted color |
.tuc-dropdown__shortcut | Shortcut on the right, with fixed-width digits so the keys line up |
.tuc-dropdown__label | Group title, not clickable |
.tuc-dropdown__separator | Line between groups, on an <hr> |
is-danger | On the item: destructive action, in red |
aria-disabled="true" | On the item: disabled. The arrows skip it, and clicking doesn't close the menu |
An item with href is a real <a>. Middle-click still opens it in another tab, and
the menu doesn't steal the browser's context menu. Clicking any item closes the menu.
In a Django template
Actions that depend on permissions are left out on the server, not hidden in the browser. In a list, the panel's
id carries the row's key so each menu points to its own.
{% for contract in contracts %}
<button type="button" class="tuc-btn is-outline is-icon is-sm" aria-label="Actions"
data-tuc-dropdown="#actions-{{ contract.pk }}" data-placement="bottom-end">...</button>
<div class="tuc-dropdown" id="actions-{{ contract.pk }}" hidden>
<a class="tuc-dropdown__item" href="{% url 'contract-edit' contract.pk %}">
<span class="tuc-dropdown__text">Edit</span>
</a>
{% if perms.contracts.delete_contract %}
<hr class="tuc-dropdown__separator">
<a class="tuc-dropdown__item is-danger" href="{% url 'contract-delete' contract.pk %}">
<span class="tuc-dropdown__text">Delete</span>
</a>
{% endif %}
</div>
{% endfor %}In JavaScript
For when the items are computed on the fly. The component builds the panel, and each item gets what to do when it's picked.
const menu = new Tucano.Dropdown('#actions', {
placement: 'bottom-end',
items: [
{ label: 'Contract' },
{ text: 'Edit', icon: 'M12 20h9M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z', shortcut: '⌘E', onClick: () => editContract() },
{ text: 'Duplicate', icon: Tucano.ICON_COPY, onClick: (dropdown) => duplicateContract() },
{ text: 'Open', href: '/contracts/12/' },
{ text: 'Archive', disabled: true },
{ separator: true },
{ text: 'Delete', variant: 'danger', onClick: () => deleteContract() },
],
});
menu.open();
menu.close();
menu.toggle();
menu.destroy(); // closes and removes the listeners| Item key | What it's for |
|---|---|
text | The label |
icon | The d of a 24×24 SVG path, like the Tucano.ICON_* ones |
shortcut | Shortcut text, on the right. Display only: binding the key is up to you |
onClick | Called when picked, with the menu instance |
href | Makes the item an <a> |
variant | 'danger' becomes is-danger |
disabled | Disables it: aria-disabled="true" and no onClick |
separator | On its own, { separator: true } becomes the line |
label | On its own, { label: 'Section' } becomes a group title |
closeOnPick: false keeps the menu open after a pick, for items that toggle something
on and off.
Keyboard and accessibility
The trigger gets aria-haspopup="menu" and aria-expanded, the panel is a
menu and each item a menuitem. Opening moves focus inside, and closing returns it to the trigger.
| Key | Action |
|---|---|
Enter Space | On the trigger: opens with the first item already highlighted |
| Click | On the trigger: opens with nothing highlighted; the first arrow key highlights the first item |
↓ | On the trigger: opens straight on the first item |
↑ | On the trigger: opens straight on the last item |
↑ ↓ | In the menu: moves between items, wrapping around and skipping disabled ones |
Home End | First and last item |
Esc | Closes and returns focus to the trigger |
Tab | Closes, and focus follows the page order from the trigger |
Items are kept out of Tab on purpose
Each item gets tabindex="-1". Inside a menu, the arrows do the moving; with tabbable items, Tab would leave the menu one item at a time, which is exactly what the menu pattern avoids. And focus returns to the trigger because, without that, keyboard users would close the menu and land at the top of the page.
Besides Escape, the menu closes on an outside click, when focus moves elsewhere and when the trigger scrolls
off screen. The item highlight follows focus, not just the pointer: the arrows are what move focus, and without that
keyboard users wouldn't see where they are.
API
Generated from the code on every build — if something is not here, it does not exist.
[data-tuc-dropdown]new Tucano.Dropdown(alvo, opcoes)data-placement data-tuc-dropdownopenAt open close toggle destroyOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
placement | 'bottom-start' | |
items | null | [{ text, icon, shortcut, onClick, href, variant, disabled, separator, label }] |
closeOnPick | true |