Packages

Headless-first, token-driven UI components for Phoenix LiveView — rich selects, date pickers, dialogs, menus, tables, sliders and toasts that live in the browser top layer. Themeable through CSS tokens, viewport-aware, server-authoritative, with zero third-party JS.

Current section

Files

Jump to
skua lib skua components overlay.ex
Raw

lib/skua/components/overlay.ex

defmodule Skua.Components.Overlay do
@moduledoc """
Top-layer overlay components — popover, dialog and drawer.
Both use browser-native top-layer primitives (the Popover API and native
`<dialog>`), so an ancestor's `overflow` can never clip them. They are
morphdom-safe: the dialog ships `JS.ignore_attributes("open")` so a
re-render never strips the browser-set `open` attribute (LiveView #4152),
and every panel carries a stable id so morphdom never replaces the node
(which would force-close top-layer state).
"""
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc """
A popover: a trigger that toggles a top-layer panel positioned next to it.
<.popover id="invite" pad width="280px">
<:trigger><.button>Invite</.button></:trigger>
<p>Panel content…</p>
</.popover>
The `id` must be stable. The trigger **is** the button (give it a label via
the `trigger` slot and a look via `trigger_variant`) — do not nest a
`<.button>` inside the slot, which would produce invalid nested buttons. It
owns a DOM id and a measurable box and is wired with `aria-haspopup`/
`aria-expanded`/`aria-controls`.
"""
attr :id, :string, required: true
attr :pad, :boolean, default: false
attr :width, :string, default: nil
attr :placement, :string, default: "bottom", values: ~w(bottom right)
attr :trigger_variant, :string, default: "secondary", values: ~w(primary secondary ghost danger)
attr :class, :any, default: nil
slot :trigger, required: true
slot :inner_block, required: true
def popover(assigns) do
~H"""
<span class="sk-anchor" style="position:relative;display:inline-flex">
<button
type="button"
id={"#{@id}-trigger"}
class={"sk-btn sk-btn--#{@trigger_variant} sk-focusable"}
phx-hook="SkuaPopover"
data-sk-panel={@id}
aria-haspopup="dialog"
aria-expanded="false"
aria-controls={@id}
>
{render_slot(@trigger)}
</button>
<div
id={@id}
role="dialog"
class={["sk-panel sk-anim", @pad && "sk-panel--pad", @class]}
popover="manual"
data-state="closed"
data-placement={@placement}
style={@width && "width:#{@width}"}
>
{render_slot(@inner_block)}
</div>
</span>
"""
end
@doc """
A modal dialog built on the native `<dialog>` element + `showModal()`.
<.dialog id="confirm">
<:title>Delete project?</:title>
<:subtitle>This cannot be undone.</:subtitle>
<p>All data will be permanently removed.</p>
<:footer>
<.button data-sk-close>Cancel</.button>
<.button variant="danger" phx-click="delete">Delete</.button>
</:footer>
</.dialog>
Open it from any element with `phx-click={Skua.Components.Overlay.open_dialog("confirm")}`.
Native `showModal()` provides the inert backdrop, focus trap, and Esc-to-close;
`JS.ignore_attributes("open")` keeps it open across LiveView re-renders.
"""
attr :id, :string, required: true
attr :class, :any, default: nil
attr :on_close, JS, default: %JS{}
slot :title
slot :subtitle
slot :footer
slot :inner_block, required: true
def dialog(assigns) do
~H"""
<dialog
id={@id}
class={["sk-dialog", @class]}
phx-hook="SkuaDialog"
phx-mounted={JS.ignore_attributes("open")}
data-on-close={@on_close}
aria-labelledby={@title != [] && "#{@id}-title"}
aria-describedby={@subtitle != [] && "#{@id}-sub"}
>
<div :if={@title != [] || @subtitle != []} class="sk-dialog-head">
<div>
<h2 :if={@title != []} id={"#{@id}-title"} class="sk-dialog-title">
{render_slot(@title)}
</h2>
<p :if={@subtitle != []} id={"#{@id}-sub"} class="sk-dialog-sub">
{render_slot(@subtitle)}
</p>
</div>
<button type="button" class="sk-dialog-x sk-focusable" data-sk-close aria-label="Close">
<svg class="sk-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="sk-dialog-body">{render_slot(@inner_block)}</div>
<div :if={@footer != []} class="sk-dialog-foot">{render_slot(@footer)}</div>
</dialog>
"""
end
@doc """
A drawer / slide-over sheet — a native `<dialog>` anchored to a viewport edge.
It shares the dialog engine (native `showModal()` backdrop, focus trap, and
Esc-to-close), so open and close it exactly like a dialog:
<.button phx-click={Skua.Components.Overlay.open_dialog("filters")}>Filters</.button>
<.drawer id="filters" side="right">
<:title>Filters</:title>
<:subtitle>Narrow the results.</:subtitle>
<p>…controls…</p>
<:footer>
<.button data-sk-close>Cancel</.button>
<.button variant="primary" data-sk-close>Apply</.button>
</:footer>
</.drawer>
`side` is `right` (default) | `left` | `top` | `bottom`.
"""
attr :id, :string, required: true
attr :side, :string, default: "right", values: ~w(right left top bottom)
attr :class, :any, default: nil
attr :on_close, JS, default: %JS{}
slot :title
slot :subtitle
slot :footer
slot :inner_block, required: true
def drawer(assigns) do
~H"""
<dialog
id={@id}
class={["sk-drawer", "sk-drawer--#{@side}", @class]}
phx-hook="SkuaDialog"
phx-mounted={JS.ignore_attributes("open")}
data-on-close={@on_close}
aria-labelledby={@title != [] && "#{@id}-title"}
aria-describedby={@subtitle != [] && "#{@id}-sub"}
>
<div :if={@title != [] || @subtitle != []} class="sk-drawer-head">
<div>
<h2 :if={@title != []} id={"#{@id}-title"} class="sk-drawer-title">
{render_slot(@title)}
</h2>
<p :if={@subtitle != []} id={"#{@id}-sub"} class="sk-drawer-sub">
{render_slot(@subtitle)}
</p>
</div>
<button type="button" class="sk-dialog-x sk-focusable" data-sk-close aria-label="Close">
<svg class="sk-glyph" viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="sk-drawer-body sk-scroll">{render_slot(@inner_block)}</div>
<div :if={@footer != []} class="sk-drawer-foot">{render_slot(@footer)}</div>
</dialog>
"""
end
@doc "JS command that opens the dialog with the given id (calls `showModal()` via the hook)."
def open_dialog(js \\ %JS{}, id) do
JS.dispatch(js, "skua:open", to: "##{id}")
end
@doc "JS command that closes the dialog with the given id."
def close_dialog(js \\ %JS{}, id) do
JS.dispatch(js, "skua:close", to: "##{id}")
end
end