Skip to content

Toolbar

Nuevo

El componente hp-toolbar implementa el patrón WAI-ARIA toolbar para agrupar controles relacionados (botones, toggles, separadores, inputs) con navegación por teclado eficiente. Usa roving tabindex: solo un elemento está en la secuencia de Tab en cada momento; las flechas de cursor mueven el foco entre los items.

Instalación

bash
pnpm add @headless-primitives/toolbar
bash
npm install @headless-primitives/toolbar
bash
yarn add @headless-primitives/toolbar
bash
bun add @headless-primitives/toolbar

Anatomía

html
<hp-toolbar label="Text formatting">
  <button type="button">Bold</button>
  <button type="button">Italic</button>
  <div role="separator" aria-orientation="vertical"></div>
  <button type="button">Align left</button>
  <button type="button">Align center</button>
</hp-toolbar>

Demostración

Sin estilos (solo base.css)

Así se ve hp-toolbar usando únicamente @headless-primitives/utils/base.css. La navegación por teclado con roving tabindex funciona completamente.

¿Quieres agregar colores? Usa role="toolbar" y los atributos aria-orientation para tus selectores CSS. Por ejemplo: hp-toolbar { border: 1px solid #e2e8f0; border-radius: 6px; padding: 4px; }. O importa @headless-primitives/styles para obtener un tema completo con tokens CSS.

html
<hp-toolbar class="toolbar" label="Text formatting">
  <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-bold">B</button>
  <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-italic">I</button>
  <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-underline">U</button>
  <div role="separator" aria-orientation="vertical" class="toolbar-sep"></div>
  <button type="button" class="toolbar-btn">&#8676;</button>
  <button type="button" class="toolbar-btn">&#8596;</button>
  <button type="button" class="toolbar-btn">&#8677;</button>
</hp-toolbar>

<script>
  ["btn-bold", "btn-italic", "btn-underline"].forEach((id) => {
    const btn = document.getElementById(id);
    btn.addEventListener("click", () => {
      const pressed = btn.getAttribute("aria-pressed") === "true";
      btn.setAttribute("aria-pressed", String(!pressed));
    });
  });
</script>
css
hp-toolbar.toolbar {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 4px 6px;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  background: #fff;
}

.toolbar-btn {
  padding: 4px 10px;
  border: 1px solid transparent;
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
  font-family: inherit;
  font-size: 0.875rem;
  transition: background 0.15s;
}

.toolbar-btn:hover:not([disabled]) {
  background: #f1f5f9;
}

.toolbar-btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 1px;
}

.toolbar-btn[aria-pressed="true"] {
  background: #eff6ff;
  border-color: #2563eb;
  color: #2563eb;
}

.toolbar-btn[disabled] {
  opacity: 0.4;
  cursor: not-allowed;
  pointer-events: none;
}

.toolbar-sep {
  width: 1px;
  height: 18px;
  background: #e2e8f0;
  margin: 0 4px;
}

Con estilos personalizados

Editor de texto con grupos

Toolbar de formato con dos grupos lógicos separados: estilo de texto (toggle) y alineación (selección exclusiva). Cada grupo comunica su propósito con aria-label.

html
<hp-toolbar class="toolbar" label="Formato de texto">
  <div role="group" aria-label="Estilo">
    <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-bold">B</button>
    <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-italic">I</button>
    <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-underline">U</button>
    <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-strike">S</button>
  </div>
  <div role="separator" aria-orientation="vertical" class="toolbar-sep"></div>
  <div role="group" aria-label="Alineación">
    <button type="button" class="toolbar-btn" aria-pressed="true" id="btn-align-left">
      &#8676;
    </button>
    <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-align-center">
      &#8596;
    </button>
    <button type="button" class="toolbar-btn" aria-pressed="false" id="btn-align-right">
      &#8677;
    </button>
  </div>
  <div role="separator" aria-orientation="vertical" class="toolbar-sep"></div>
  <button type="button" class="toolbar-btn" disabled aria-disabled="true">↩</button>
  <button type="button" class="toolbar-btn">↪</button>
</hp-toolbar>

<script>
  const toggleBtns = ["btn-bold", "btn-italic", "btn-underline", "btn-strike"];
  toggleBtns.forEach((id) => {
    const btn = document.getElementById(id);
    btn.addEventListener("click", () => {
      const active = btn.getAttribute("aria-pressed") === "true";
      btn.setAttribute("aria-pressed", String(!active));
    });
  });

  const alignBtns = ["btn-align-left", "btn-align-center", "btn-align-right"];
  alignBtns.forEach((id) => {
    const btn = document.getElementById(id);
    btn.addEventListener("click", () => {
      alignBtns.forEach((otherId) => {
        document.getElementById(otherId).setAttribute("aria-pressed", "false");
      });
      btn.setAttribute("aria-pressed", "true");
    });
  });
</script>
css
hp-toolbar.toolbar {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 4px;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
}

[role="group"] {
  display: contents;
}

.toolbar-btn {
  width: 32px;
  height: 32px;
  border: 1px solid transparent;
  border-radius: 5px;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  font-family: inherit;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.1s;
}

.toolbar-btn:hover:not([disabled]) {
  background: #f1f5f9;
}

.toolbar-btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 1px;
}

.toolbar-btn[aria-pressed="true"] {
  background: #eff6ff;
  border-color: #2563eb;
  color: #2563eb;
}

.toolbar-btn[disabled],
.toolbar-btn[aria-disabled="true"] {
  opacity: 0.35;
  cursor: not-allowed;
  pointer-events: none;
}

.toolbar-sep {
  width: 1px;
  height: 20px;
  background: #e2e8f0;
  margin: 0 4px;
  flex-shrink: 0;
}

Con menú desplegable

Un botón del toolbar abre un menú de opciones adicionales. El menú es controlado manualmente: se abre con clic, se cierra con Escape o clic exterior, y sus items son accesibles con flechas.

html
<hp-toolbar class="toolbar" label="Acciones">
  <button type="button" class="toolbar-btn">✂ Cortar</button>
  <button type="button" class="toolbar-btn">⎘ Copiar</button>
  <button type="button" class="toolbar-btn">⎗ Pegar</button>
  <div role="separator" aria-orientation="vertical" class="toolbar-sep"></div>

  <!-- Menú desplegable -->
  <div class="toolbar-menu-wrap">
    <button
      type="button"
      id="more-btn"
      class="toolbar-btn"
      aria-haspopup="menu"
      aria-expanded="false"
      aria-controls="more-menu"
    >
      Más ▾
    </button>
    <ul id="more-menu" role="menu" aria-label="Más opciones" hidden class="toolbar-menu">
      <li role="menuitem" tabindex="-1" class="toolbar-menu-item">Buscar y reemplazar</li>
      <li role="menuitem" tabindex="-1" class="toolbar-menu-item">Insertar imagen</li>
      <li role="separator" class="toolbar-menu-sep"></li>
      <li role="menuitem" tabindex="-1" class="toolbar-menu-item toolbar-menu-item--danger">
        Eliminar
      </li>
    </ul>
  </div>
</hp-toolbar>

<script>
  const btn = document.getElementById("more-btn");
  const menu = document.getElementById("more-menu");
  const items = [...menu.querySelectorAll('[role="menuitem"]')];

  const open = () => {
    menu.hidden = false;
    btn.setAttribute("aria-expanded", "true");
    items[0]?.focus();
  };
  const close = () => {
    menu.hidden = true;
    btn.setAttribute("aria-expanded", "false");
    btn.focus();
  };

  btn.addEventListener("click", () => (menu.hidden ? open() : close()));
  items.forEach((item, i) => {
    item.addEventListener("click", close);
    item.addEventListener("keydown", (e) => {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        (items[i + 1] ?? items[0]).focus();
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        (items[i - 1] ?? items.at(-1)).focus();
      }
      if (e.key === "Escape" || e.key === "Tab") close();
    });
  });
  document.addEventListener("click", (e) => {
    if (!menu.hidden && !btn.contains(e.target) && !menu.contains(e.target)) close();
  });
</script>
css
hp-toolbar.toolbar {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 4px;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
}

.toolbar-btn {
  height: 32px;
  padding: 0 12px;
  border: 1px solid transparent;
  border-radius: 5px;
  background: transparent;
  cursor: pointer;
  font-size: 13px;
  font-family: inherit;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.toolbar-btn:hover {
  background: #f1f5f9;
}

.toolbar-btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 1px;
}

.toolbar-sep {
  width: 1px;
  height: 20px;
  background: #e2e8f0;
  margin: 0 4px;
  flex-shrink: 0;
}

.toolbar-menu-wrap {
  position: relative;
  display: inline-block;
}

.toolbar-menu {
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  margin: 0;
  padding: 4px;
  list-style: none;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  min-width: 160px;
  z-index: 10;
}

.toolbar-menu-item {
  padding: 7px 12px;
  border-radius: 5px;
  cursor: pointer;
  font-size: 13px;
}

.toolbar-menu-item:hover,
.toolbar-menu-item:focus {
  background: #f1f5f9;
  outline: none;
}

.toolbar-menu-item--danger {
  color: #dc2626;
}

.toolbar-menu-sep {
  height: 1px;
  background: #e2e8f0;
  margin: 4px 0;
}

Con tooltip en los botones

Patrón accesible para mostrar tooltips en los items del toolbar. Cada botón usa aria-describedby apuntando a su tooltip, que se muestra con :focus-visible y :hover vía CSS puro — sin JavaScript.

Lápiz
Pincel
Borrador
Relleno
Cuentagotas
html
<hp-toolbar class="toolbar" label="Herramientas de dibujo">
  <div class="tip-wrap">
    <button type="button" class="toolbar-btn" aria-pressed="false" aria-describedby="tip-pencil">
      ✏️
    </button>
    <span id="tip-pencil" role="tooltip" class="tooltip">Lápiz</span>
  </div>
  <div class="tip-wrap">
    <button type="button" class="toolbar-btn" aria-pressed="false" aria-describedby="tip-brush">
      🖌️
    </button>
    <span id="tip-brush" role="tooltip" class="tooltip">Pincel</span>
  </div>
  <div class="tip-wrap">
    <button type="button" class="toolbar-btn" aria-pressed="false" aria-describedby="tip-eraser">
      🧹
    </button>
    <span id="tip-eraser" role="tooltip" class="tooltip">Borrador</span>
  </div>
  <div role="separator" aria-orientation="vertical" class="toolbar-sep"></div>
  <div class="tip-wrap">
    <button type="button" class="toolbar-btn" aria-pressed="false" aria-describedby="tip-fill">
      🪣
    </button>
    <span id="tip-fill" role="tooltip" class="tooltip">Relleno</span>
  </div>
</hp-toolbar>

<script>
  document.querySelectorAll(".tip-wrap button").forEach((btn) => {
    btn.addEventListener("click", () => {
      const active = btn.getAttribute("aria-pressed") === "true";
      btn.setAttribute("aria-pressed", String(!active));
    });
  });
</script>
css
hp-toolbar.toolbar {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 6px;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
}

.tip-wrap {
  position: relative;
  display: inline-block;
}

/* Mostrar tooltip en hover y focus-visible (CSS puro) */
.tip-wrap:hover .tooltip,
.tip-wrap:focus-within .tooltip {
  opacity: 1;
}

.toolbar-btn {
  width: 36px;
  height: 36px;
  border: 1px solid transparent;
  border-radius: 6px;
  background: transparent;
  cursor: pointer;
  font-size: 17px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.toolbar-btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 1px;
}

.toolbar-btn[aria-pressed="true"] {
  background: #eff6ff;
  border-color: #2563eb;
}

.tooltip {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  padding: 4px 8px;
  background: #1e293b;
  color: #fff;
  font-size: 11px;
  border-radius: 4px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s;
}

.toolbar-sep {
  width: 1px;
  height: 22px;
  background: #e2e8f0;
  margin: 0 2px;
  flex-shrink: 0;
}

Orientación vertical

Cambia a orientation="vertical" para toolbars laterales. La navegación se invierte: ArrowUp/ArrowDown mueven el foco, ArrowLeft/ArrowRight no hacen nada dentro del toolbar.

Tip: Con orientación vertical,
usa / para navegar
entre los items del toolbar.
html
<hp-toolbar class="toolbar toolbar--vertical" orientation="vertical" label="Capas">
  <button type="button" class="toolbar-btn" aria-pressed="true" title="Mover">⬆</button>
  <button type="button" class="toolbar-btn" aria-pressed="false" title="Escalar">⤡</button>
  <button type="button" class="toolbar-btn" aria-pressed="false" title="Rotar">↻</button>
  <div role="separator" aria-orientation="horizontal" class="toolbar-sep--h"></div>
  <button type="button" class="toolbar-btn" aria-pressed="false" title="Bloquear">🔒</button>
  <button type="button" class="toolbar-btn" aria-pressed="false" title="Ocultar">👁</button>
</hp-toolbar>
css
hp-toolbar.toolbar--vertical {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 4px;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  width: 48px;
}

.toolbar-btn {
  width: 36px;
  height: 36px;
  border: 1px solid transparent;
  border-radius: 5px;
  background: transparent;
  cursor: pointer;
  font-size: 15px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.1s;
}

.toolbar-btn:hover {
  background: #f1f5f9;
}

.toolbar-btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 1px;
}

.toolbar-btn[aria-pressed="true"] {
  background: #eff6ff;
  border-color: #2563eb;
  color: #2563eb;
}

/* Separador horizontal para toolbar vertical */
.toolbar-sep--h {
  width: 80%;
  height: 1px;
  background: #e2e8f0;
  margin: 2px 0;
  align-self: center;
}

Con input integrado

Un input dentro del toolbar participa en el roving tabindex como cualquier otro item. Útil para toolbars de búsqueda o filtrado con acciones adjuntas.

html
<hp-toolbar class="toolbar" label="Búsqueda y filtros">
  <input type="search" class="toolbar-search" placeholder="Buscar…" aria-label="Buscar" />
  <div role="separator" aria-orientation="vertical" class="toolbar-sep"></div>
  <button type="button" class="toolbar-btn" aria-pressed="false">Nombre ↕</button>
  <button type="button" class="toolbar-btn" aria-pressed="false">Fecha ↕</button>
  <div role="separator" aria-orientation="vertical" class="toolbar-sep"></div>
  <button type="button" class="toolbar-btn" id="btn-filters" aria-pressed="false">⚙ Filtros</button>
</hp-toolbar>

<script>
  const btn = document.getElementById("btn-filters");
  btn.addEventListener("click", () => {
    btn.setAttribute("aria-pressed", String(btn.getAttribute("aria-pressed") !== "true"));
  });
</script>
css
hp-toolbar.toolbar {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px 6px;
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
}

.toolbar-search {
  height: 30px;
  padding: 0 10px;
  border: 1px solid #e2e8f0;
  border-radius: 5px;
  font-size: 13px;
  font-family: inherit;
  background: transparent;
  color: inherit;
  width: 180px;
}

.toolbar-search:focus-visible {
  outline: none;
  border-color: #2563eb;
  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}

.toolbar-btn {
  height: 30px;
  padding: 0 10px;
  border: 1px solid transparent;
  border-radius: 5px;
  background: transparent;
  cursor: pointer;
  font-size: 12px;
  font-family: inherit;
  white-space: nowrap;
}

.toolbar-btn:hover {
  background: #f1f5f9;
}

.toolbar-btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 1px;
}

.toolbar-btn[aria-pressed="true"] {
  background: #eff6ff;
  border-color: #2563eb;
  color: #2563eb;
}

.toolbar-sep {
  width: 1px;
  height: 20px;
  background: #e2e8f0;
  margin: 0 2px;
  flex-shrink: 0;
}

API Reference

hp-toolbar

Contenedor raíz con role="toolbar". Gestiona roving tabindex en todos sus items focusables e intercepta las teclas de navegación.

Propiedades

PropiedadTipoPor defectoDescripción
orientation"horizontal" | "vertical""horizontal"Controla qué teclas de flecha navegan entre items y el aria-orientation.
labelstring"Toolbar"Texto accesible para aria-label. Usar cuando no hay aria-labelledby.

Eventos

EventoCuándo se emitedetail
hp-focus-changeCada vez que la navegación por teclado mueve el foco a un item.{ index: number, item: HTMLElement }

Atributos ARIA

AtributoValoresDescripción
role"toolbar"Asignado automáticamente en connectedCallback.
aria-orientation"horizontal" / "vertical"Sincronizado con la propiedad orientation.
aria-labelstringSincronizado con la propiedad label.
aria-labelledbystringAlternativa a aria-label; si está presente, no se sobreescribe.
data-hp-component"toolbar"Identificador de componente para selectores CSS.

Teclado

TeclaAcción (horizontal)Acción (vertical)
ArrowRightMueve el foco al item siguiente
ArrowLeftMueve el foco al item anterior
ArrowDownMueve el foco al item siguiente
ArrowUpMueve el foco al item anterior
HomeMueve el foco al primer itemMueve el foco al primer item
EndMueve el foco al último itemMueve el foco al último item
TabSale del toolbarSale del toolbar

Accesibilidad

hp-toolbar implementa el patrón WAI-ARIA Toolbar con las siguientes garantías:

  • role="toolbar" con aria-label o aria-labelledby para identificar el propósito del toolbar.
  • Roving tabindex: solo un item tiene tabindex="0" en cada momento. El resto tienen tabindex="-1". Esto evita que el usuario tenga que hacer Tab por cada control del toolbar.
  • Las teclas de flecha correctas para cada orientación navegarán secuencialmente; las flechas en la dirección opuesta no hacen nada, preservando el comportamiento esperado.
  • El foco de teclado actualiza roving tabindex de forma síncrona, sin necesidad de async.
  • Los separadores deben tener role="separator" con aria-orientation apropiada para comunicar la división a tecnologías asistivas.

Lanzado bajo la MIT License.