Tucano v0.37.2

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 attributeWhat it's for
.tuc-dropdownThe panel. Needs an id, for the trigger to point to, and hidden
.tuc-dropdown__itemAn item. <a href> to navigate, <button type="button"> to act
.tuc-dropdown__textThe label; truncated with an ellipsis when it doesn't fit
.tuc-dropdown__iconIcon on the left, 16px, in the muted color
.tuc-dropdown__shortcutShortcut on the right, with fixed-width digits so the keys line up
.tuc-dropdown__labelGroup title, not clickable
.tuc-dropdown__separatorLine between groups, on an <hr>
is-dangerOn 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 keyWhat it's for
textThe label
iconThe d of a 24×24 SVG path, like the Tucano.ICON_* ones
shortcutShortcut text, on the right. Display only: binding the key is up to you
onClickCalled when picked, with the menu instance
hrefMakes the item an <a>
variant'danger' becomes is-danger
disabledDisables it: aria-disabled="true" and no onClick
separatorOn its own, { separator: true } becomes the line
labelOn 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.

KeyAction
Enter SpaceOn the trigger: opens with the first item already highlighted
ClickOn 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 EndFirst and last item
EscCloses and returns focus to the trigger
TabCloses, 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.

Markup
[data-tuc-dropdown]
In JS
new Tucano.Dropdown(alvo, opcoes)
Attributes
data-placement data-tuc-dropdown
Methods
openAt open close toggle destroy

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
placement'bottom-start'
itemsnull[{ text, icon, shortcut, onClick, href, variant, disabled, separator, label }]
closeOnPicktrue