Saltar al contenido
ssntl/ui

Toolbar

Una fila de acciones de ícono de 48px, a 8px entre sí, con foco por flechas.

Instalación

$ pnpm dlx shadcn@latest add @ssntl/toolbar

Uso

tsx
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@/components/ui/toolbar"

<Toolbar aria-label="Formato de texto">
  <ToolbarButton aria-label="Negrita"><Bold /></ToolbarButton>
  <ToolbarButton aria-label="Cursiva"><Italic /></ToolbarButton>
  <ToolbarSeparator />
  <ToolbarButton aria-label="Agregar enlace"><Link /></ToolbarButton>
</Toolbar>
  • Espacio entre objetivos: objetivos de 48px con 8px entre ellos, para que un toque nunca caiga en el vecino.
  • La barra y cada botón necesitan aria-label (los tipos lo exigen); los íconos son decorativos.
  • Las flechas, Inicio y Fin mueven el foco dentro de la barra.
  • Los separadores agrupan acciones relacionadas; no son objetivos.

Código

components/ui/toolbar.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Button } from "@/registry/ssntl/ui/button"

// A row of icon actions. Every target is 48px and they sit 8px apart, so a
// thumb never lands on the neighbour. Concentric: 4px of padding around 48px
// pills makes the bar a pill too. Arrow keys, Home and End move focus.
function Toolbar({
  className,
  onKeyDown,
  ...props
}: React.ComponentProps<"div"> & { "aria-label": string }) {
  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event)
    const keys = ["ArrowLeft", "ArrowRight", "Home", "End"]
    if (event.defaultPrevented || !keys.includes(event.key)) return

    const items = Array.from(
      event.currentTarget.querySelectorAll<HTMLElement>(
        "[data-slot=toolbar-button]:not(:disabled)"
      )
    )
    const current = items.indexOf(document.activeElement as HTMLElement)
    if (current === -1) return

    event.preventDefault()
    const last = items.length - 1
    const next = {
      ArrowLeft: current === 0 ? last : current - 1,
      ArrowRight: current === last ? 0 : current + 1,
      Home: 0,
      End: last,
    }[event.key]
    items[next ?? current]?.focus()
  }

  return (
    <div
      role="toolbar"
      data-slot="toolbar"
      className={cn(
        "inline-flex items-center gap-2 rounded-full bg-card p-1 shadow-float",
        className
      )}
      onKeyDown={handleKeyDown}
      {...props}
    />
  )
}

// Icon only, so the label is required: it is the button's name.
function ToolbarButton({
  className,
  variant = "ghost",
  ...props
}: Omit<React.ComponentProps<typeof Button>, "size"> & {
  "aria-label": string
}) {
  return (
    <Button
      data-slot="toolbar-button"
      variant={variant}
      size="icon"
      className={cn("px-0", className)}
      {...props}
    />
  )
}

// Splits the bar into groups; it adds no target of its own.
function ToolbarSeparator({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      role="separator"
      aria-orientation="vertical"
      data-slot="toolbar-separator"
      className={cn("h-6 w-px bg-border", className)}
      {...props}
    />
  )
}

export { Toolbar, ToolbarButton, ToolbarSeparator }