Context Menu
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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
actionsRef | React.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. |
defaultOpen | boolean | — | Heredada de Base UI. Si arranca abierto. Es la versión no controlada de open. |
disabled | boolean | — | 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. |
loopFocus | boolean | — | 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. |
open | boolean | — | 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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
inset | boolean | — | 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. |
className | string | — | 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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
align | "center" | "end" | "start" | — | Cómo se alinea el panel sobre el eje transversal. |
alignOffset | MenuPositionerProps['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. |
sideOffset | MenuPositionerProps['sideOffset'] | — | Distancia en píxeles entre el ancla y el panel. |
className | string | — | 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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
external | boolean | — | Lleva a otro sitio: texto en el acento y ↗ al final. |
inset | boolean | — | 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. |
className | string | — | Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana. |
ContextMenuLabel
Hereda las props de ContextMenu.GroupLabel.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
inset | boolean | — | 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. |
className | string | — | 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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
inset | boolean | — | 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. |
className | string | — | Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana. |
ContextMenuSeparator
Hereda las props de ContextMenu.Separator.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
className | string | — | Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana. |
ContextMenuShortcut
Hereda las props de <span>.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
className | string | — | 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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
align | "center" | "end" | "start" | "start" | Cómo se alinea el panel sobre el eje transversal. |
alignOffset | MenuPositionerProps['alignOffset'] | -5 | Corrimiento en píxeles sobre el eje de alineación. |
sideOffset | MenuPositionerProps['sideOffset'] | -5 | Distancia en píxeles entre el ancla y el panel. |
className | string | — | Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana. |
ContextMenuSubTrigger
Hereda las props de ContextMenu.SubmenuTrigger.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
inset | boolean | — | 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. |
className | string | — | Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana. |
ContextMenuTrigger
Hereda las props de ContextMenu.Trigger.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
focusable | boolean | true | false saca la parada de tabulación y con ella la apertura por teclado. |
onKeyDown | KeyboardEventHandler<T> | — | Corre antes que el manejador propio del trigger. Si hacés preventDefault(), la apertura por Shift+F10 no llega a dispararse. |
tabIndex | number | — | 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. |
className | string | — | 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>: sintabIndexno le llega el foco y la tecla de menú contextual no tiene sobre qué disparar.ContextMenuTriggerlo hace enfocable y sintetiza el eventocontextmenu, así que la apertura por teclado funciona aunque Base UI no la traiga. - Lleva
aria-haspopup="menu"yaria-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"delDropdownMenu: 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
DropdownMenuo 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
DropdownMenua 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. ContextMenuShortcutpesa más acá que en unDropdownMenu: 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.