Capas
Estado: BetaPopover
Para lo que no cabe en un tooltip ni es una lista de acciones: los filtros de reservaciones, la ficha rápida de un cliente, un formulario corto. Se abre con clic, crece desde el botón con resorte, se cierra al hacer clic fuera o con Esc y el foco entra y regresa solo. Flota en la capa superior y se voltea o recorre para no salirse de la pantalla.
- Resorte
- Capa superior
- Foco
Filtros
Mariana Peña
mariana.pena@correo.mx
Paquete · 10 clases
4 de 10 créditos
Vence el 31 oct · en 16 días
Filtros
Mariana Peña
mariana.pena@correo.mx
Paquete · 10 clases
4 de 10 créditos
Vence el 31 oct · en 16 días
app/views/… (la demo de arriba)
<%# Datos de ejemplo (Ui::Demo::Person y un paquete en un hash). En tu app el
formulario apunta a la lista que filtra (method: :get) y "Ver perfil" a su ficha. %>
<% mariana = Ui::Demo::Person.new(first_name: "Mariana", last_name: "Peña", email: "mariana.pena@correo.mx") %>
<% paquete = { name: "10 clases", credits: 10, left: 4, expires_at: 16.days.from_now } %>
<div class="flex w-full flex-wrap items-center justify-center gap-3">
<%= render Ui::PopoverComponent.new(title: "Filtros", align: :start, size: :md) do |p| %>
<% p.with_trigger do %>
<%= render Ui::IconComponent.new(:list_filter, size: :sm, class: "text-muted-foreground") %>
Filtros
<span class="rounded-full bg-primary px-1.5 font-mono text-3xs font-semibold leading-4 text-primary-foreground" aria-label="2 activos">2</span>
<% end %>
<%= form_with url: ui_component_path("popover"), method: :get, class: "grid gap-4" do %>
<%= render Ui::SpringSelectComponent.new(name: "class_type", label: "Clase", selected: "all",
options: [ [ "Todas las clases", "all" ], [ "Vinyasa Flow", "vinyasa" ], [ "Reformer Intermedio", "reformer" ], [ "Mat Pilates", "mat" ] ]) %>
<fieldset class="grid gap-2.5">
<legend class="mb-2.5 text-xs font-medium text-foreground">Estado</legend>
<%= render Ui::CheckboxComponent.new(name: "status[]", value: "confirmed", label: "Confirmada", checked: true) %>
<%= render Ui::CheckboxComponent.new(name: "status[]", value: "waitlisted", label: "En lista de espera", checked: true) %>
<%= render Ui::CheckboxComponent.new(name: "status[]", value: "cancelled", label: "Cancelada") %>
</fieldset>
<div class="-mx-4 -mb-4 flex items-center justify-end gap-2 border-t border-border-subtle bg-raised px-4 py-3">
<%= render Ui::ButtonComponent.new(variant: :ghost, size: :sm, data: { action: "popover#close" }) do %>Cancelar<% end %>
<%= render Ui::ButtonComponent.new(type: "submit", size: :sm) do %>Aplicar filtros<% end %>
</div>
<% end %>
<% end %>
<%# La ficha rápida de un cliente, con flecha. %>
<%= render Ui::PopoverComponent.new(label: "Ficha de #{mariana.full_name}", size: :sm, arrow: true,
trigger_class: "inline-flex h-control-sm items-center gap-2 rounded-full border border-border bg-card pl-1 pr-3 text-sm font-medium text-foreground transition-colors duration-quick hover:border-border-hover hover:bg-surface-hover aria-expanded:border-border-hover eui-press eui-focus") do |p| %>
<% p.with_trigger do %>
<span class="grid size-6 place-items-center rounded-full border border-border-subtle bg-raised text-3xs font-semibold text-muted-foreground" aria-hidden="true">MP</span>
<%= mariana.full_name %>
<% end %>
<div class="flex items-center gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-full border border-border-subtle bg-raised text-xs font-semibold text-muted-foreground" aria-hidden="true">MP</span>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-foreground"><%= mariana.full_name %></p>
<p class="truncate font-mono text-2xs text-muted-foreground"><%= mariana.email %></p>
</div>
</div>
<div class="mt-4 rounded-lg border border-border-subtle bg-raised p-3">
<p class="eui-label">Paquete · <%= paquete[:name] %></p>
<p class="mt-2 flex items-baseline gap-1.5">
<span class="font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground"><%= paquete[:left] %></span>
<span class="text-xs text-muted-foreground">de <%= paquete[:credits] %> créditos</span>
</p>
<div class="mt-2.5 h-1 overflow-hidden rounded-full bg-border" role="presentation">
<div class="h-full rounded-full bg-primary" style="width: <%= 100 * paquete[:left] / paquete[:credits] %>%"></div>
</div>
<p class="mt-2.5 text-xs text-muted-foreground">Vence el <%= I18n.l(paquete[:expires_at].to_date, format: "%-d %b") %> · en <%= ((paquete[:expires_at] - Time.current) / 1.day).round %> días</p>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<%= render Ui::ButtonComponent.new(variant: :secondary, size: :sm, href: "#popover-perfil") do %>Ver perfil<% end %>
<%= render Ui::ButtonComponent.new(size: :sm, data: { action: "popover#close" }) do %>Check-in<% end %>
</div>
<% end %>
</div>Qué hace
- Popover nativo (auto) en la capa superior - clic fuera y Esc lo cierran, abrir otro lo cierra, ningún overflow lo recorta.
- Crece desde el botón (--eui-origin) con el resorte responsive; sale con una transición corta también cuando lo cierra el navegador.
- Sigue al botón al desplazar y la flecha lo sigue apuntando aunque el panel se recorra en la orilla.
- Acepta controles con su propia capa (SpringSelect, Tooltip) sin cerrarse.
- Dispara popover:open y popover:close.
Props
| Nombre | Tipo | Default | Descripción |
|---|---|---|---|
| title | String | — | Encabezado del panel (y su nombre accesible), con botón de cerrar. |
| label | String | — | Nombre accesible del panel cuando no lleva title. |
| side | :bottom | :top | :left | :right | :bottom | Lado preferido; se voltea si no cabe. |
| align | :start | :center | :end | :center | Alineación con el botón. |
| size / width | :sm | :md | :lg / String | :md | size: el ancho preset (w-64, w-80, w-96); width: una clase propia (o el preset, su nombre anterior). Nunca más ancho que la pantalla. |
| arrow | Boolean | false | Flecha que apunta al centro del botón. |
| close_button | Boolean | true con title | Botón ✕ en el encabezado. |
| trigger_class | String | botón secundario sm | Clases del botón (por defecto Ui::ButtonComponent.classes(variant: :secondary, size: :sm)). |
| trigger_label | String | — | aria-label del botón si su contenido es solo un ícono. |
| with_trigger | slot | — | Contenido del botón (texto, ícono, insignia). |
| content | bloque | — | El panel. data-action="popover#close" cierra; [autofocus] recibe el foco. |
Accesibilidad
- El botón lleva aria-haspopup="dialog", aria-expanded y aria-controls; el panel es role=dialog con nombre (title o label).
- Al abrir el foco entra al panel (o al [autofocus]); Esc y popover#close lo devuelven al botón; si el foco sale, se cierra.
- Esc cierra solo el popover, no el diálogo que lo contenga.
- Sin JS el botón lo abre con popovertarget.