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:
@@ -11,11 +11,13 @@
|
||||
[opts]
|
||||
(str/join " " (dialog-class-list opts)))
|
||||
|
||||
(defn dialog
|
||||
"Render a dialog element.
|
||||
;; ── Native <dialog> ─────────────────────────────────────────────────
|
||||
|
||||
Clicking the backdrop (the area outside the dialog box) will close the
|
||||
dialog automatically when opened via .showModal().
|
||||
(defn dialog
|
||||
"Render a native <dialog> element.
|
||||
|
||||
Use with .showModal() for built-in backdrop and focus trapping.
|
||||
Clicking the backdrop closes the dialog automatically.
|
||||
|
||||
Props:
|
||||
:open - boolean, whether the dialog is open
|
||||
@@ -57,6 +59,84 @@
|
||||
attrs)]
|
||||
(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
|
||||
"Render a dialog header section."
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
|
||||
@@ -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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -26,36 +69,65 @@
|
||||
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;
|
||||
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,
|
||||
.dialog-header h2,
|
||||
.dialog-header h3,
|
||||
.dialog-header h4,
|
||||
.dialog-header h5,
|
||||
.dialog-header h6 {
|
||||
margin: 0;
|
||||
@keyframes dialog-panel-in {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(var(--size-2)); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.dialog-header p {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--fg-2);
|
||||
margin: 0;
|
||||
/* ── Dark mode ───────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme="dark"] .dialog-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
overflow-y: auto;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .dialog-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-body > *:first-child { margin-top: 0; }
|
||||
.dialog-body > *:last-child { margin-bottom: 0; }
|
||||
/* ── Mobile ──────────────────────────────────────────────────────── */
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-2);
|
||||
@media (max-width: 768px) {
|
||||
.dialog-panel {
|
||||
max-width: none;
|
||||
padding: var(--size-4);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user