Modal

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.

Cargando…

Edita la clase o cancela la reserva

Vinyasa Flow

con Mariana Ortiz · Sala 1

mié 14 oct · 07:00

Faltan menos de 12 horas: la cancelación es tardía y el crédito no se devuelve.

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