Popover

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.

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