feat(dialog): add overlay-based dialog pattern (dialog-overlay + dialog-panel)

For reactive frameworks (Eucalypt, etc.) where native <dialog> with
.showModal() isn't practical. dialog-overlay provides the fixed backdrop,
dialog-panel provides the content box with padding/gap/animation.

Shared inner styles (dialog-header, dialog-body, dialog-footer) work
with both native <dialog> and overlay-based patterns.
This commit is contained in:
Florian Schroedl
2026-03-29 13:21:19 +02:00
parent 5994f1d1da
commit 5449d3c0f3
2 changed files with 177 additions and 25 deletions

View File

@@ -11,11 +11,13 @@
[opts] [opts]
(str/join " " (dialog-class-list opts))) (str/join " " (dialog-class-list opts)))
(defn dialog ;; ── Native <dialog> ─────────────────────────────────────────────────
"Render a dialog element.
Clicking the backdrop (the area outside the dialog box) will close the (defn dialog
dialog automatically when opened via .showModal(). "Render a native <dialog> element.
Use with .showModal() for built-in backdrop and focus trapping.
Clicking the backdrop closes the dialog automatically.
Props: Props:
:open - boolean, whether the dialog is open :open - boolean, whether the dialog is open
@@ -57,6 +59,84 @@
attrs)] attrs)]
(into [:dialog base-attrs] children)))) (into [:dialog base-attrs] children))))
;; ── Overlay-based dialog ────────────────────────────────────────────
;; For reactive frameworks (Eucalypt, etc.) where native <dialog> isn't
;; practical. Renders a backdrop + panel as plain divs, controlled by
;; show/hide in app state.
(defn dialog-overlay
"Render a dialog backdrop overlay.
Covers the viewport and centers its children. Clicking the backdrop
fires :on-close. Wrap a dialog-panel inside this.
Props:
:on-close - callback when backdrop is clicked
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [on-close class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> "dialog-overlay"
class (str " " class))
base-attrs (merge {:class classes
:on-click (when on-close
(fn [e]
(when (identical? (.-target e) (.-currentTarget e))
(on-close))))}
attrs)]
(into [:div base-attrs] children))
:cljs
(let [classes (cond-> ["dialog-overlay"]
class (conj class))
base-attrs (merge {:class classes
:on (when on-close
{:click (fn [e]
(when (identical? (.-target e) (.-currentTarget e))
(on-close)))})}
attrs)]
(into [:div base-attrs] children))
:clj
(let [classes (cond-> "dialog-overlay"
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs] children))))
(defn dialog-panel
"Render a dialog panel (the visible box inside an overlay).
Use inside dialog-overlay. Stops click propagation so clicks
inside the panel don't close the overlay.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> "dialog-panel"
class (str " " class))
base-attrs (merge {:class classes
:on-click (fn [e] (.stopPropagation e))}
attrs)]
(into [:div base-attrs] children))
:cljs
(let [classes (cond-> ["dialog-panel"]
class (conj class))
base-attrs (merge {:class classes
:on {:click (fn [e] (.stopPropagation e))}}
attrs)]
(into [:div base-attrs] children))
:clj
(let [classes (cond-> "dialog-panel"
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs] children))))
;; ── Shared sections ─────────────────────────────────────────────────
(defn dialog-header (defn dialog-header
"Render a dialog header section." "Render a dialog header section."
[{:keys [class attrs] :as _props} & children] [{:keys [class attrs] :as _props} & children]

View File

@@ -1,3 +1,46 @@
/* ── Shared dialog inner styles ───────────────────────────────────── */
/* Used by both native <dialog> and overlay-based .dialog-panel */
.dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-4);
}
.dialog-header h1,
.dialog-header h2,
.dialog-header h3,
.dialog-header h4,
.dialog-header h5,
.dialog-header h6 {
margin: 0;
}
.dialog-header p {
font-size: var(--font-sm);
color: var(--fg-2);
margin: 0;
}
.dialog-body {
display: flex;
flex-direction: column;
gap: var(--size-3);
overflow-y: auto;
}
.dialog-body > *:first-child { margin-top: 0; }
.dialog-body > *:last-child { margin-bottom: 0; }
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: var(--size-2);
}
/* ── Native <dialog> ─────────────────────────────────────────────── */
.dialog { .dialog {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -26,36 +69,65 @@
cursor: pointer; cursor: pointer;
} }
.dialog-header { /* ── Overlay-based dialog ────────────────────────────────────────── */
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var(--size-4);
background: rgba(0, 0, 0, 0.3);
animation: dialog-overlay-in 0.15s ease;
}
@keyframes dialog-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.dialog-panel {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--size-1); gap: var(--size-3);
width: 100%;
max-width: 28rem;
max-height: 85vh;
padding: var(--size-6);
background: var(--bg-1);
color: var(--fg-0);
border: var(--border-0);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-3);
overflow: hidden;
animation: dialog-panel-in 0.15s ease;
} }
.dialog-header h1, @keyframes dialog-panel-in {
.dialog-header h2, from { opacity: 0; transform: scale(0.96) translateY(var(--size-2)); }
.dialog-header h3, to { opacity: 1; transform: scale(1) translateY(0); }
.dialog-header h4,
.dialog-header h5,
.dialog-header h6 {
margin: 0;
} }
.dialog-header p { /* ── Dark mode ───────────────────────────────────────────────────── */
font-size: var(--font-sm);
color: var(--fg-2); [data-theme="dark"] .dialog-overlay {
margin: 0; background: rgba(0, 0, 0, 0.5);
} }
.dialog-body { @media (prefers-color-scheme: dark) {
overflow-y: auto; :root:not([data-theme="light"]) .dialog-overlay {
background: rgba(0, 0, 0, 0.5);
}
} }
.dialog-body > *:first-child { margin-top: 0; } /* ── Mobile ──────────────────────────────────────────────────────── */
.dialog-body > *:last-child { margin-bottom: 0; }
.dialog-footer { @media (max-width: 768px) {
display: flex; .dialog-panel {
justify-content: flex-end; max-width: none;
gap: var(--size-2); padding: var(--size-4);
}
} }