diff --git a/src/ui/dialog.cljc b/src/ui/dialog.cljc index 9c97aab..9bf7a33 100644 --- a/src/ui/dialog.cljc +++ b/src/ui/dialog.cljc @@ -11,11 +11,13 @@ [opts] (str/join " " (dialog-class-list opts))) -(defn dialog - "Render a dialog element. +;; ── Native ───────────────────────────────────────────────── - Clicking the backdrop (the area outside the dialog box) will close the - dialog automatically when opened via .showModal(). +(defn dialog + "Render a native 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 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] diff --git a/src/ui/dialog.css b/src/ui/dialog.css index a9b0d32..eff7a20 100644 --- a/src/ui/dialog.css +++ b/src/ui/dialog.css @@ -1,3 +1,46 @@ +/* ── Shared dialog inner styles ───────────────────────────────────── */ +/* Used by both native 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 { 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); + } }