Ir al contenido
Superposiciones

ContextMenu

El menú del botón derecho: las mismas acciones del DropdownMenu, ancladas al puntero.

import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, … } from "sebs7n-ui/context-menu"

Ejemplos

Archivos de una biblioteca

Como la lista de iCloud Drive: el mismo menú desde el click derecho sobre la fila y desde el botón «…» de la derecha, que es el que se ve. Con foco en la fila, la tecla de menú contextual o Shift+F10 también lo abren. «Eliminar» va al final, en rojo.

import { Button } from "sebs7n-ui/button"
import { ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "sebs7n-ui/context-menu"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "sebs7n-ui/dropdown-menu"
import { Fragment } from "react"
import { MoreHorizontalIcon } from "lucide-react"

function Archivos() {
  return (
    <ul aria-label="Archivos" className="flex w-full max-w-xl flex-col">
      {ARCHIVOS.map((archivo) => (
        <li key={archivo.nombre} className="relative">
          <ContextMenu>
            <ContextMenuTrigger className="flex h-10 w-full items-center gap-3 rounded-item pr-12 pl-3 text-left hover:bg-fill-1">
              <span aria-hidden="true" className="flex size-6 shrink-0 items-center justify-center [&_svg]:size-5">
                {archivo.icon}
              </span>
              <span className="min-w-0 flex-1 truncate text-body text-label">{archivo.nombre}</span>
              <span className="hidden w-32 text-callout text-label-secondary sm:block">{archivo.tipo}</span>
              <span className="w-14 text-right text-callout text-label-secondary">{archivo.peso}</span>
            </ContextMenuTrigger>
            <ContextMenuContent>
              {ACCIONES.map((grupo, i) => (
                <Fragment key={grupo[0]!.label}>
                  {i > 0 && <ContextMenuSeparator />}
                  <ContextMenuGroup>
                    {grupo.map((accion) => (
                      <ContextMenuItem key={accion.label} variant={accion.destructive ? "destructive" : "default"}>
                        {accion.icon}
                        {accion.label}
                      </ContextMenuItem>
                    ))}
                  </ContextMenuGroup>
                </Fragment>
              ))}
            </ContextMenuContent>
          </ContextMenu>

          <DropdownMenu>
            <DropdownMenuTrigger
              render={<Button aria-label={`Acciones de ${archivo.nombre}`} size="icon-sm" variant="plain" />}
              className="absolute top-1.5 right-1.5"
            >
              <MoreHorizontalIcon />
            </DropdownMenuTrigger>
            {/* Como en Drive: el menú se abre al costado del botón, alineado con la fila. */}
            <DropdownMenuContent align="start" side="left">
              {ACCIONES.map((grupo, i) => (
                <Fragment key={grupo[0]!.label}>
                  {i > 0 && <DropdownMenuSeparator />}
                  {grupo.map((accion) => (
                    <DropdownMenuItem key={accion.label} variant={accion.destructive ? "destructive" : "default"}>
                      {accion.icon}
                      {accion.label}
                    </DropdownMenuItem>
                  ))}
                </Fragment>
              ))}
            </DropdownMenuContent>
          </DropdownMenu>
        </li>
      ))}
    </ul>
  )
}

El lienzo de un editor

Checks y radios que no cierran el menú, para probar varias opciones sin volver a abrirlo. Acá el click derecho gana: sobre un lienzo no hay dónde poner un botón que no tape el trabajo.

import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger } from "sebs7n-ui/context-menu"
import { CopyIcon } from "lucide-react"
import { useState } from "react"

function Lienzo() {
  const [zoom, setZoom] = useState("100")
  return (
    <ContextMenu>
      <ContextMenuTrigger className="flex h-40 w-full max-w-md items-center justify-center rounded-control border border-dashed border-separator bg-fill-1 text-callout text-label-secondary">
        Click derecho sobre el lienzo
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuGroup>
          <ContextMenuLabel>Mostrar</ContextMenuLabel>
          <ContextMenuCheckboxItem defaultChecked>Grilla</ContextMenuCheckboxItem>
          <ContextMenuCheckboxItem defaultChecked>Guías</ContextMenuCheckboxItem>
          <ContextMenuCheckboxItem>Reglas</ContextMenuCheckboxItem>
        </ContextMenuGroup>
        <ContextMenuSeparator />
        <ContextMenuGroup>
          <ContextMenuLabel>Zoom</ContextMenuLabel>
          <ContextMenuRadioGroup onValueChange={setZoom} value={zoom}>
            <ContextMenuRadioItem value="50">50 %</ContextMenuRadioItem>
            <ContextMenuRadioItem value="100">100 %</ContextMenuRadioItem>
            <ContextMenuRadioItem value="200">200 %</ContextMenuRadioItem>
          </ContextMenuRadioGroup>
        </ContextMenuGroup>
        <ContextMenuSeparator />
        <ContextMenuItem>
          <CopyIcon />
          Duplicar selección
          <ContextMenuShortcut>⌘D</ContextMenuShortcut>
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  )
}

Props

Generadas del TypeScript del paquete. Las propias del componente, más las heredadas del primitivo que tienen algo que explicar —marcadas «heredada de Base UI»—. El resto está en la línea «hereda de».

ContextMenu

Hereda las props de ContextMenu.Root.

PropTipoPor defectoDescripción
actionsRefReact.RefObject<MenuRoot.Actions | null>—Heredada de Base UI. Ref con las acciones imperativas de Base UI (unmount()), para desmontarlo sin esperar la animación de salida.
defaultOpenboolean—Heredada de Base UI. Si arranca abierto. Es la versión no controlada de open.
disabledboolean—Heredada de Base UI. Apaga la interacción y lo marca con data-disabled, que es el atributo del que cuelgan los estilos de apagado.
loopFocusboolean—Heredada de Base UI. Si al pasar del último elemento el foco vuelve al primero.
onOpenChange((open: boolean, eventDetails: ContextMenuRoot.ChangeEventDetails) => void)—Heredada de Base UI. Se llama con el estado nuevo cada vez que se abre o se cierra.
openboolean—Heredada de Base UI. Si está abierto. Pasarla lo vuelve controlado: sin onOpenChange ya no se cierra solo.
orientation"horizontal" | "vertical"—Heredada de Base UI. horizontal (default) o vertical. Define qué flechas mueven el foco.

ContextMenuCheckboxItem

Hereda las props de ContextMenu.CheckboxItem.

PropTipoPor defectoDescripción
insetboolean—Corre el texto a la canaleta del tilde (pl-7) para que alinee con los CheckboxItem y RadioItem del mismo menú. En un menú sin tildes no hace falta.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuContent

Hereda las props de ContextMenu.Popup y ContextMenu.Positioner.

PropTipoPor defectoDescripción
align"center" | "end" | "start"—Cómo se alinea el panel sobre el eje transversal.
alignOffsetMenuPositionerProps['alignOffset']—Corrimiento en píxeles sobre el eje de alineación.
side"left" | "right" | "top" | "bottom" | "inline-end" | "inline-start"—De qué lado del ancla se abre el panel.
sideOffsetMenuPositionerProps['sideOffset']—Distancia en píxeles entre el ancla y el panel.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuGroup

Hereda las props de ContextMenu.Group.

Sin props propias: pasa todo al primitivo.

ContextMenuItem

Hereda las props de ContextMenu.Item.

PropTipoPor defectoDescripción
externalboolean—Lleva a otro sitio: texto en el acento y ↗ al final.
insetboolean—Alinea el texto con el de los ítems que llevan ícono.
variant"default" | "destructive""default"destructive: texto e ícono en rojo, para la acción que borra.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuLabel

Hereda las props de ContextMenu.GroupLabel.

PropTipoPor defectoDescripción
insetboolean—Corre el texto a la canaleta del tilde (pl-7) para que alinee con los CheckboxItem y RadioItem del mismo menú. En un menú sin tildes no hace falta.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuRadioGroup

Hereda las props de ContextMenu.RadioGroup.

Sin props propias: pasa todo al primitivo.

ContextMenuRadioItem

Hereda las props de ContextMenu.RadioItem.

PropTipoPor defectoDescripción
insetboolean—Corre el texto a la canaleta del tilde (pl-7) para que alinee con los CheckboxItem y RadioItem del mismo menú. En un menú sin tildes no hace falta.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuSeparator

Hereda las props de ContextMenu.Separator.

PropTipoPor defectoDescripción
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuShortcut

Hereda las props de <span>.

PropTipoPor defectoDescripción
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuSub

Hereda las props de ContextMenu.SubmenuRoot.

Sin props propias: pasa todo al primitivo.

ContextMenuSubContent

Hereda las props de ContextMenu.Popup y ContextMenu.Positioner.

PropTipoPor defectoDescripción
align"center" | "end" | "start""start"Cómo se alinea el panel sobre el eje transversal.
alignOffsetMenuPositionerProps['alignOffset']-5Corrimiento en píxeles sobre el eje de alineación.
sideOffsetMenuPositionerProps['sideOffset']-5Distancia en píxeles entre el ancla y el panel.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuSubTrigger

Hereda las props de ContextMenu.SubmenuTrigger.

PropTipoPor defectoDescripción
insetboolean—Corre el texto a la canaleta del tilde (pl-7) para que alinee con los CheckboxItem y RadioItem del mismo menú. En un menú sin tildes no hace falta.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

ContextMenuTrigger

Hereda las props de ContextMenu.Trigger.

PropTipoPor defectoDescripción
focusablebooleantruefalse saca la parada de tabulación y con ella la apertura por teclado.
onKeyDownKeyboardEventHandler<T>—Corre antes que el manejador propio del trigger. Si hacés preventDefault(), la apertura por Shift+F10 no llega a dispararse.
tabIndexnumber—Pisa el 0 que pone focusable. Casi nunca hace falta: es la salida para meter el área en un orden de tabulación armado a mano.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

Teclado

Tab
Llega al área disparadora, que es una sola parada de tabulación.
Menú contextual · Shift+F10
Abre con el foco en el área, anclado a ella y no al puntero.
Click derecho
Abre en el punto exacto del puntero.
Mantener apretado (touch)
Abre a los 500 ms; moverse más de 10 px cancela.
↑ ↓
Recorre los ítems.
→ ←
Entra y sale de un submenú.
Escribir
Salta al ítem que empieza con esas letras.
Enter · Espacio
Ejecuta y cierra.
Escape
Cierra y devuelve el foco al área.

Accesibilidad

  • El trigger es un <div>: sin tabIndex no le llega el foco y la tecla de menú contextual no tiene sobre qué disparar. ContextMenuTrigger lo hace enfocable y sintetiza el evento contextmenu, así que la apertura por teclado funciona aunque Base UI no la traiga.
  • Lleva aria-haspopup="menu" y aria-keyshortcuts="Shift+F10": sobre un área sin botón visible, el atajo es lo único que se puede anunciar.
  • El panel es el mismo role="menu" del DropdownMenu: atrapa el foco, se recorre con flechas y al cerrar lo devuelve al área.
  • focusable={false} saca la parada de tabulación y la apertura por teclado: solo si el área ya tiene adentro un control enfocable que abre el mismo menú.

Reglas de uso

  • Nunca puede ser el único camino a una acción. El click derecho no se ve, no se descubre y en touch depende de un long-press que la mitad de la gente no conoce. Todo lo que esté acá tiene que estar también en un botón visible, en un DropdownMenu o en un atajo anunciado.
  • Es un atajo, no una puerta de entrada. Se pone sobre lo que ya tiene sus acciones a la vista: una fila, una tarjeta, un lienzo.
  • Comparte el panel con DropdownMenu a propósito: son el mismo menú abierto de dos maneras, no dos componentes. En una lista de archivos, como iCloud Drive, el mismo menú sale del click derecho sobre la fila y del botón «…» de la fila, que es el que se ve.
  • ContextMenuShortcut pesa más acá que en un DropdownMenu: es el cartel que enseña el camino alternativo.
  • Sobre un <input> o un <textarea> no: te comés el menú de corregir, copiar y pegar del navegador.

Relacionados