Capas
Estado: BetaModal
El modal de Eclipse UI sobre <dialog> y showModal(): el navegador pone la capa superior, la página inerte y Esc; el componente agrega la entrada que se asienta (escala, posición y opacidad con física), el velo con desenfoque, la salida que termina antes de cerrar, el scroll bloqueado y el foco que regresa a quien lo abrió. Encabezado y pie fijos, cuerpo con scroll. Con src: el cuerpo es un turbo-frame que se pide al abrir: el formulario de "Editar" llega del servidor.
- dialog nativo
- Resorte
- Turbo Frame
- Formularios
app/views/… (la demo de arriba)
<%# Una clase de ejemplo (Ui::Demo::SessionForm); en tu app viene de la base. %>
<% clase = Ui::Demo::SessionForm.new(name: "Vinyasa Flow", host_id: 1, date: Date.new(2026, 10, 14),
start_time: "07:00", end_time: "08:00", minutes: 60, capacity: 18) %>
<% host = "Mariana Ortiz" %>
<% when_label = "#{I18n.l(clase.date, format: "%a %-d %b")} · #{clase.start_time.strftime("%H:%M")}" %>
<%# El "servidor" de la demo: el frame del modal pide esta misma ficha y aquí
se responde con el formulario. En tu app es la vista edit envuelta en
turbo_frame_tag con el mismo id (modal.frame_id). %>
<% if turbo_frame_request_id == "modal-edit-class-frame" %>
<%= turbo_frame_tag "modal-edit-class-frame" do %>
<%= form_with model: clase, url: ui_component_path("modal"), method: :get, id: "modal-edit-class-form",
data: { turbo_frame: "modal-result" }, class: "grid gap-4 pt-1" do |f| %>
<%= render Ui::FieldComponent.new(label: "Quién la da", form: f, attribute: :host_id) do |field| %>
<%= f.select :host_id, [ [ "Mariana Ortiz", 1 ], [ "Ana López", 2 ], [ "Iván Reyes", 3 ] ], { selected: 1 }, class: Ui::FieldComponent.control_classes(invalid: field.invalid?), **field.control_attributes %>
<% end %>
<div class="grid gap-3 min-[420px]:grid-cols-2">
<%= render Ui::FieldComponent.new(label: "Fecha", form: f, attribute: :date) do |field| %>
<%= f.date_field :date, class: Ui::FieldComponent.control_classes(extra: "font-mono"), **field.control_attributes %>
<% end %>
<%= render Ui::FieldComponent.new(label: "Inicio", form: f, attribute: :start_time) do |field| %>
<%= f.time_field :start_time, value: "07:00", class: Ui::FieldComponent.control_classes(extra: "font-mono"), **field.control_attributes %>
<% end %>
</div>
<%= render Ui::FieldComponent.new(label: "Capacidad", form: f, attribute: :capacity, hint: "Lugares en sala; las reservas actuales se conservan.") do |field| %>
<%= f.number_field :capacity, min: 1, class: Ui::FieldComponent.control_classes(extra: "font-mono"), **field.control_attributes %>
<% end %>
<%= render Ui::FieldComponent.new(label: "Suplente", form: f, attribute: :substitute_name, optional: true) do |field| %>
<%= f.text_field :substitute_name, placeholder: "Aparece en el horario público", class: Ui::FieldComponent.control_classes, **field.control_attributes %>
<% end %>
<% end %>
<% end %>
<% else %>
<div class="flex w-full max-w-sm flex-col items-center gap-5">
<div class="flex flex-wrap items-center justify-center gap-2">
<%# 1. Formulario remoto: se pide al abrir y se cierra cuando Turbo
confirma el envío. %>
<%= render Ui::ModalComponent.new(id: "modal-edit-class", title: "Editar clase", description: "#{clase.name} · #{when_label}",
src: ui_component_path("modal"), close_on_submit: true) do |modal| %>
<% modal.with_trigger do %>
<%= render Ui::ButtonComponent.new do %>Editar clase<% end %>
<% end %>
<% modal.with_footer do %>
<%= render Ui::ButtonComponent.new(variant: :ghost, data: { action: "modal#close" }) do %>Cancelar<% end %>
<%= render Ui::ButtonComponent.new(type: "submit", form: "modal-edit-class-form") do %>Guardar cambios<% end %>
<% end %>
<% end %>
<%# 2. Sin slot de disparador: cualquier botón o enlace de la página lo
abre con data-eui-open. %>
<%= render Ui::ButtonComponent.new(variant: :secondary, data: { eui_open: "modal-cancel" }) do %>Cancelar reserva<% end %>
</div>
<%= turbo_frame_tag "modal-result", class: "block min-h-5 text-center text-xs text-muted-foreground" do %>
<% if (saved = params[:clase]) %>
<span class="text-foreground">Guardado</span> · <span class="font-mono"><%= saved[:capacity] %> lugares</span>
<% elsif params[:reservation] == "cancelled" %>
<span class="text-foreground">Reservación cancelada</span> · el lugar volvió a la lista
<% else %>
Edita la clase o cancela la reserva
<% end %>
<% end %>
</div>
<%# dismissible: false — un clic en el fondo o Esc no la pierden; la X y
los botones sí cierran. %>
<%= render Ui::ModalComponent.new(id: "modal-cancel", title: "¿Cancelar tu reservación?", size: :sm, dismissible: false, close_on_submit: true) do |modal| %>
<div class="grid gap-3">
<div class="flex items-center justify-between gap-3 rounded-xl border border-border bg-raised px-4 py-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-foreground"><%= clase.name %></p>
<p class="truncate text-xs text-muted-foreground">con <%= host %> · Sala 1</p>
</div>
<p class="shrink-0 font-mono text-xs text-muted-foreground"><%= when_label %></p>
</div>
<p class="flex gap-2 text-xs leading-relaxed text-muted-foreground">
<%= render Ui::IconComponent.new(:triangle_alert, size: :sm, class: "mt-px text-warning") %>
<span>Faltan menos de 12 horas: la cancelación es tardía y <span class="text-foreground">el crédito no se devuelve</span>.</span>
</p>
</div>
<% modal.with_footer do %>
<%= render Ui::ButtonComponent.new(variant: :secondary, data: { action: "modal#close" }) do %>Conservar mi lugar<% end %>
<%= button_to "Cancelar reserva", ui_component_path("modal"), method: :get, params: { reservation: "cancelled" },
form: { class: "contents", data: { turbo_frame: "modal-result" } }, class: Ui::ButtonComponent.classes(tone: :danger, extra: "max-sm:flex-1") %>
<% end %>
<% end %>
<% end %>Qué hace
- Abre con la acción modal#open, con data-eui-open="<id>" en cualquier elemento o con dialog.dispatchEvent(new Event("eui:open")).
- Emite modal:open y modal:close (detail.returnValue) para encadenar otras reacciones.
- Un <form method="dialog"> adentro cierra animado y deja su returnValue.
- Cuando llega el contenido remoto (o el formulario vuelve con errores) el panel cambia de alto con resorte.
- Si el form remoto guarda y redirige a una página sin el frame (el show, el index), se visita esa página: nada de "Content missing".
- Escala de 0.92 a 1 con un rebote mínimo; el contenido llega 50ms después, una capa más adentro.
- Bloquea el scroll de la página sin que brinque la barra; varias capas abiertas cuentan una vez.
- Las líneas del encabezado y el pie aparecen solo cuando hay contenido escondido detrás.
Props
| Nombre | Tipo | Default | Descripción |
|---|---|---|---|
| id | String | aleatorio | Id del <dialog>; lo usan data-eui-open y el frame (id-frame). |
| title / with_title | String | slot | — | Título (h2); nombra al diálogo para el lector de pantalla. |
| description / with_description | String | slot | nil | Texto bajo el título; describe al diálogo (aria-describedby). |
| with_trigger | slot | — | El botón que lo abre (opcional). Fuera del componente: data-eui-open="<id>". |
| contenido | bloque | — | El cuerpo; hace scroll si es largo. Con src: es lo que se ve mientras carga. |
| with_footer | slot | nil | Acciones fijas abajo; en móvil se reparten el ancho. |
| size | :sm | :md | :lg | :md | Ancho máximo: 24, 30 o 42rem. En móvil, la ventana menos 16px por lado. |
| dismissible | Boolean | true | false: el fondo y Esc no lo cierran (da un empujón); la X y los botones sí. |
| src | String (URL) | nil | El cuerpo es un turbo-frame con ese src, loading=lazy; se pide al abrir con esqueleto de carga. |
| frame_id | String | "<id>-frame" | Id del frame; la vista remota lo envuelve con turbo_frame_tag. |
| reload_on_open | Boolean | true | Con src, vuelve a pedir el frame cada vez que se abre (formularios al día). |
| close_on_submit | Boolean | false | Se cierra tras un turbo:submit-end exitoso de un form adentro. |
| open | Boolean | false | Se abre al conectarse: respuesta de Turbo Stream o de un frame. |
| remove_on_close | Boolean | false | Se quita del DOM al cerrar (los que llegan por Turbo Stream). |
| close_label | String | "Cerrar" | Nombre accesible de la X. |
Accesibilidad
- Es un <dialog> abierto con showModal(); lo de atrás queda inerte y Tab da la vuelta dentro del panel.
- El título lo nombra (aria-labelledby) y la descripción lo describe (aria-describedby).
- Al abrir, el foco va a [autofocus] o al panel; al cerrar, regresa al disparador.
- La X es un botón con nombre accesible; Esc cierra salvo que haya un menú abierto adentro.
- Los disparadores reciben aria-haspopup="dialog" y aria-controls.
- Con movimiento reducido abre y cierra sin animar.