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
pnpm add @headless-primitives/toolbarnpm install @headless-primitives/toolbaryarn add @headless-primitives/toolbarbun add @headless-primitives/toolbarAnatomía
<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 atributosaria-orientationpara tus selectores CSS. Por ejemplo:hp-toolbar { border: 1px solid #e2e8f0; border-radius: 6px; padding: 4px; }. O importa@headless-primitives/stylespara obtener un tema completo con tokens CSS.
<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">⇤</button>
<button type="button" class="toolbar-btn">↔</button>
<button type="button" class="toolbar-btn">⇥</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>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.
<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">
⇤
</button>
<button type="button" class="toolbar-btn" aria-pressed="false" id="btn-align-center">
↔
</button>
<button type="button" class="toolbar-btn" aria-pressed="false" id="btn-align-right">
⇥
</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>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.
- Buscar y reemplazar
- Insertar imagen
- Eliminar
<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>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.
<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>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.
usa ↑ / ↓ para navegar
entre los items del toolbar.
<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>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.
<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>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
| Propiedad | Tipo | Por defecto | Descripción |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Controla qué teclas de flecha navegan entre items y el aria-orientation. |
label | string | "Toolbar" | Texto accesible para aria-label. Usar cuando no hay aria-labelledby. |
Eventos
| Evento | Cuándo se emite | detail |
|---|---|---|
hp-focus-change | Cada vez que la navegación por teclado mueve el foco a un item. | { index: number, item: HTMLElement } |
Atributos ARIA
| Atributo | Valores | Descripción |
|---|---|---|
role | "toolbar" | Asignado automáticamente en connectedCallback. |
aria-orientation | "horizontal" / "vertical" | Sincronizado con la propiedad orientation. |
aria-label | string | Sincronizado con la propiedad label. |
aria-labelledby | string | Alternativa a aria-label; si está presente, no se sobreescribe. |
data-hp-component | "toolbar" | Identificador de componente para selectores CSS. |
Teclado
| Tecla | Acción (horizontal) | Acción (vertical) |
|---|---|---|
ArrowRight | Mueve el foco al item siguiente | — |
ArrowLeft | Mueve el foco al item anterior | — |
ArrowDown | — | Mueve el foco al item siguiente |
ArrowUp | — | Mueve el foco al item anterior |
Home | Mueve el foco al primer item | Mueve el foco al primer item |
End | Mueve el foco al último item | Mueve el foco al último item |
Tab | Sale del toolbar | Sale del toolbar |
Accesibilidad
hp-toolbar implementa el patrón WAI-ARIA Toolbar con las siguientes garantías:
role="toolbar"conaria-labeloaria-labelledbypara identificar el propósito del toolbar.- Roving tabindex: solo un item tiene
tabindex="0"en cada momento. El resto tienentabindex="-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"conaria-orientationapropiada para comunicar la división a tecnologías asistivas.
