# Dropdown menu / Menú desplegable

> List of actions opened from a button.

Page: https://componentes-lovat.vercel.app/c/menu/  
License: MIT · Design: coss ui (MIT) · menu.tsx  
Requires: tokens.css, button  
Context: herramienta · Density: media · Tone: sobria

## Files

- Tokens (once): https://componentes-lovat.vercel.app/tokens.css
- Dependency `button`: https://componentes-lovat.vercel.app/r/button.json
- HTML: https://componentes-lovat.vercel.app/raw/menu/menu.html
- CSS: https://componentes-lovat.vercel.app/raw/menu/menu.css
- JS: https://componentes-lovat.vercel.app/raw/menu/menu.js
- Everything in one file: https://componentes-lovat.vercel.app/r/menu.html

Dark theme: add class `dark` to `<html>`.

## When to use

- For secondary actions on an item.

## When not to use

- To navigate between pages: use links.
- To pick a value: use a select.

## Accessibility

Roles: button, menu, menuitem

| Keys | Action |
|---|---|
| Enter / ↓ | Opens and focuses the first action |
| ↑ / ↓ | Moves through actions |
| Esc | Closes and returns to the button |
| Letra / Letter | Jumps to the action |

- W3C “menu button” pattern over a native popover (top layer and outside click).
- Disabled actions are skipped with the arrows.

## Code

```html
<button class="c-btn c-btn--outline" type="button" id="menu-trigger" aria-haspopup="menu" aria-expanded="false" aria-controls="menu-actions" data-c-menu>
  Acciones
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="c-menu__popup" id="menu-actions" popover role="menu" aria-labelledby="menu-trigger">
  <div class="c-menu__list">
    <div class="c-menu__label" role="presentation">Proyecto</div>
    <button class="c-menu__item" role="menuitem" type="button" tabindex="-1">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>
      Editar <span class="c-menu__shortcut" aria-hidden="true">⌘E</span>
    </button>
    <button class="c-menu__item" role="menuitem" type="button" tabindex="-1">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
      Duplicar <span class="c-menu__shortcut" aria-hidden="true">⌘D</span>
    </button>
    <button class="c-menu__item" role="menuitem" type="button" tabindex="-1" aria-disabled="true">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="m16 6-4-4-4 4"/><path d="M12 2v13"/></svg>
      Compartir
    </button>
    <div class="c-menu__separator" role="separator"></div>
    <button class="c-menu__item" role="menuitem" type="button" tabindex="-1" data-variant="destructive">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
      Eliminar
    </button>
  </div>
</div>
<p class="c-sr-only" role="status" data-c-menu-status></p>
```

```css
/* menu · Componentes (MIRÓ Labs). Diseño de coss ui (MIT, © coss.com, antes Origin UI).
   Generado por tools/build_css.py desde menu.tw.json. No editar a mano. Necesita tokens.css. */
.c-menu__popup{position:relative}
.c-menu__separator{margin-inline:calc(var(--spacing)*2);margin-block:calc(var(--spacing)*1)}
.c-menu__shortcut{margin-inline-start:auto}
.c-menu__popup{display:flex}
.c-menu__item{display:flex}
.c-menu__separator{height:1px}
.c-menu__list{max-height:var(--available-height)}
.c-menu__item{min-height:calc(var(--spacing)*8)}
.c-menu__list{width:100%}
.c-menu__item{cursor:default;align-items:center;gap:calc(var(--spacing)*2)}
.c-menu__list{overflow-y:auto}
.c-menu__popup{border-radius:var(--radius)}
.c-menu__item{border-radius:calc(var(--radius) - 4px)}
.c-menu__popup{border-style:var(--tw-border-style);border-width:1px}
.c-menu__separator{background-color:var(--border)}
.c-menu__popup{background-color:var(--popover)}
.c-menu__list{padding:calc(var(--spacing)*1)}
.c-menu__item{padding-inline:calc(var(--spacing)*2)}
.c-menu__label{padding-inline:calc(var(--spacing)*2)}
.c-menu__item{padding-block:calc(var(--spacing)*1)}
.c-menu__label{padding-block:calc(var(--spacing)*1.5)}
.c-menu__shortcut{font-family:var(--font-sans)}
.c-menu__item{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}
.c-menu__label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}
.c-menu__shortcut{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}
.c-menu__label{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}
.c-menu__shortcut{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}
.c-menu__item{color:var(--foreground)}
.c-menu__label{color:var(--muted-foreground)}
.c-menu__shortcut{color:var(--muted-foreground)}
@supports (color:color-mix(in lab, red, red)){
.c-menu__shortcut{color:color-mix(in oklab,var(--muted-foreground)72%,transparent)}
}
.c-menu__popup{--tw-shadow-alpha:5%;--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000000d),0 4px 6px -4px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}
@supports (color:lab(0% 0 0)){
.c-menu__popup{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,lab(0% 0 0/.05)),0 4px 6px -4px var(--tw-shadow-color,lab(0% 0 0/.05))}
}
.c-menu__popup{--tw-outline-style:none;outline-style:none}
.c-menu__item{--tw-outline-style:none;outline-style:none;-webkit-user-select:none;user-select:none}
.c-menu__popup:not(:is(.dark *)){background-clip:padding-box}
.c-menu__popup:before{content:var(--tw-content);pointer-events:none;content:var(--tw-content);position:absolute;content:var(--tw-content);inset:calc(var(--spacing)*0);content:var(--tw-content);border-radius:calc(var(--radius-lg) - 1px);content:var(--tw-content);--tw-shadow:0 1px var(--tw-shadow-color,#0000000a)}
@supports (color:color-mix(in lab, red, red)){
.c-menu__popup:before{--tw-shadow:0 1px var(--tw-shadow-color,color-mix(in oklab,var(--color-black)4%,transparent))}
}
.c-menu__popup:before{box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}
.c-menu__popup:focus{--tw-outline-style:none;outline-style:none}
.c-menu__item[aria-disabled=true]{pointer-events:none;opacity:.64}
.c-menu__item:is(:hover,:focus-visible,:focus){background-color:var(--accent);color:var(--accent-foreground)}
.c-menu__item[data-variant=destructive]{color:var(--destructive-foreground)}
@media (min-width:40rem){
.c-menu__item{min-height:calc(var(--spacing)*7);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}
}
.c-menu__popup:is(.dark *):before{content:var(--tw-content);--tw-shadow:0 -1px var(--tw-shadow-color,#ffffff0f)}
@supports (color:color-mix(in lab, red, red)){
.c-menu__popup:is(.dark *):before{--tw-shadow:0 -1px var(--tw-shadow-color,color-mix(in oklab,var(--color-white)6%,transparent))}
}
.c-menu__popup:is(.dark *):before{box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}
.c-menu__item>svg{pointer-events:none;margin-inline:calc(var(--spacing)*-.5);flex-shrink:0}
.c-menu__item>svg:not([class*=opacity-]){opacity:.8}
.c-menu__item>svg:not([class*=size-]){width:calc(var(--spacing)*4.5);height:calc(var(--spacing)*4.5)}
@media (min-width:40rem){
.c-menu__item>svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}
}
/* propio */
.c-menu__popup{position:fixed;margin:0;padding:0;color:var(--popover-foreground);inset:auto;opacity:0;scale:.98;transform-origin:top left;transition:opacity .1s,scale .1s,overlay .1s allow-discrete,display .1s allow-discrete}
.c-menu__popup:popover-open{opacity:1;scale:1}
@starting-style{.c-menu__popup:popover-open{opacity:0;scale:.98}}
.c-menu__item{width:100%;background:transparent;border:0;text-align:start;font:inherit;cursor:default}
.c-menu__item[data-variant=destructive] svg{color:currentColor}
.c-menu__list{display:grid}
.c-menu__popup{min-width:calc(var(--spacing)*32)}
.c-sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
```

```js
/* Menú desplegable (patrón «menu button» de la W3C) sobre popover nativo.
   El popover da la capa superior y el cierre al hacer clic fuera; aquí va el teclado y la posición. */
(function () {
  const C = (window.Componentes = window.Componentes || {});

  function position(trigger, popup) {
    const r = trigger.getBoundingClientRect();
    const h = popup.offsetHeight, w = popup.offsetWidth;
    const below = r.bottom + 4 + h <= innerHeight || r.top - 4 - h < 0;
    popup.style.top = (below ? r.bottom + 4 : r.top - 4 - h) + 'px';
    popup.style.left = Math.max(8, Math.min(r.left, innerWidth - w - 8)) + 'px';
    popup.style.transformOrigin = below ? 'top left' : 'bottom left';
  }

  function setup(trigger) {
    const popup = document.getElementById(trigger.getAttribute('aria-controls'));
    if (!popup || !popup.showPopover) return;
    const items = () => [...popup.querySelectorAll('[role^="menuitem"]')];
    const enabled = () => items().filter((i) => i.getAttribute('aria-disabled') !== 'true');
    const focusAt = (i) => { const list = enabled(); if (list.length) list[(i + list.length) % list.length].focus(); };

    const open = (where) => {
      if (!popup.matches(':popover-open')) popup.showPopover();
      position(trigger, popup);
      requestAnimationFrame(() => focusAt(where === 'last' ? -1 : 0));
    };
    const close = (refocus) => { if (popup.matches(':popover-open')) popup.hidePopover(); if (refocus) trigger.focus(); };

    popup.addEventListener('toggle', (e) => {
      const isOpen = e.newState === 'open';
      trigger.setAttribute('aria-expanded', String(isOpen));
      if (!isOpen && popup.contains(document.activeElement)) trigger.focus();
    });
    trigger.addEventListener('click', () => (popup.matches(':popover-open') ? close(false) : open('first')));
    trigger.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowDown') { e.preventDefault(); open('first'); }
      if (e.key === 'ArrowUp') { e.preventDefault(); open('last'); }
    });
    popup.addEventListener('keydown', (e) => {
      const list = enabled(), i = list.indexOf(document.activeElement);
      if (e.key === 'ArrowDown') { e.preventDefault(); focusAt(i + 1); }
      else if (e.key === 'ArrowUp') { e.preventDefault(); focusAt(i - 1); }
      else if (e.key === 'Home') { e.preventDefault(); focusAt(0); }
      else if (e.key === 'End') { e.preventDefault(); focusAt(-1); }
      else if (e.key === 'Escape') { e.preventDefault(); close(true); }
      else if (e.key === 'Tab') { close(false); }
      else if (e.key.length === 1 && /\S/.test(e.key)) {
        const k = e.key.toLowerCase();
        const from = list.slice(i + 1).concat(list.slice(0, i + 1));
        const hit = from.find((el) => el.textContent.trim().toLowerCase().startsWith(k));
        if (hit) hit.focus();
      }
    });
    popup.addEventListener('pointermove', (e) => {
      const item = e.target.closest('[role^="menuitem"]');
      if (item && item.getAttribute('aria-disabled') !== 'true' && document.activeElement !== item) item.focus({ preventScroll: true });
    });
    popup.addEventListener('click', (e) => {
      const item = e.target.closest('[role^="menuitem"]');
      if (!item || item.getAttribute('aria-disabled') === 'true') return;
      close(true);
      const st = document.querySelector('[data-c-menu-status]');
      if (st) st.textContent = item.textContent.replace(/\s+/g, ' ').trim().replace(/⌘\w$/, '').trim();
      trigger.dispatchEvent(new CustomEvent('c-menu-select', { detail: { item }, bubbles: true }));
    });
    addEventListener('resize', () => { if (popup.matches(':popover-open')) position(trigger, popup); });
    addEventListener('scroll', () => { if (popup.matches(':popover-open')) position(trigger, popup); }, true);
  }

  function init(root) {
    (root || document).querySelectorAll('[data-c-menu]:not([data-c-ready])').forEach((t) => {
      t.dataset.cReady = '';
      try { setup(t); } catch (e) { console.warn('[menu]', e); }
    });
  }

  C.menu = { init };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init());
  else init();
})();
```

## Credits

- coss ui (MIT) · menu.tsx: https://coss.com/ui
- W3C WAI-ARIA APG, Menu Button: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
