swal_rails 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6239904a339f87b38a7e7c4b0430976d4517ad7595d79d1859e584a688205ff
4
- data.tar.gz: 951fb3d414a9f73f55384e2a5e59238e1c01ee31d3d5fe1a50e7237e17d278e1
3
+ metadata.gz: 958168a17777ae58b1ce198ab3de6df9945942a5065c7311201fc05bd4ee8714
4
+ data.tar.gz: 756c79ba6c1bf50bf288a934b807ddda9e09a49eb221097092c72ac3f2981c8b
5
5
  SHA512:
6
- metadata.gz: 10c4ecceed3749e4d1da256ef7a03ea13b3b912af04b146d192d0a56bf2b57bc883190bb114720488c171ad836b70361cddea8e0a575e6afabe9f562a245fcac
7
- data.tar.gz: 6cb939e34ca060fd5fd6266acdfee8e18061051ccab4cec66d26d048afd57837a31b5783f467d8a72eae67f0461ff8b4daa76f1c831707e98bbb001ad9eb5f20
6
+ metadata.gz: aa8b8b60bb0e2c37cd2669d5dbaaa1e14ccbd3c342f97496679b2829294a050be7250ef131bc2d94cb9d469a51daf3f71bd838edc949beefb2040cbb086b13b5
7
+ data.tar.gz: bf4db27a6c234df8aec4e881c99c67578cf575a3c6fdd53092f7f1e827ce01354c2334d677919159cf31a09e2ecc821193cf78f7438409bb89fdcd1e8c71544b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-05-27
10
+
11
+ ### Added
12
+ - **npm companion package** (`swal_rails` on npm). The install generator now runs
13
+ `yarn add swal_rails@<version>` (or `npm install`) in jsbundling mode, placing
14
+ the gem's JS runtime in `node_modules/swal_rails` so esbuild / webpack / vite
15
+ can resolve `import "swal_rails"` as a bare specifier — with no bundler
16
+ config required. All sub-paths (`swal_rails/confirm`, `/flash`, `/chain`,
17
+ `/controllers/swal_controller`) are covered via the package `exports` map.
18
+ Closes [#24](https://github.com/Metalzoid/swal_rails/issues/24).
19
+ - **`rake npm:sync`** — copies JS files from `app/assets/javascripts/swal_rails/`
20
+ into `npm/` and bumps `npm/package.json` version to match `SwalRails::VERSION`.
21
+ Run before any npm publish.
22
+ - **`rake npm:publish`** — runs `npm:sync` then `npm publish --access public`.
23
+ - **`_persistent` flash option** — adding `_persistent: true` to a flash entry
24
+ (via `swal_flash :key, msg, _persistent: true` or directly in `flash_map`)
25
+ removes the auto-close timer and forces a visible close button, requiring
26
+ manual dismissal.
27
+
28
+ ### Fixed
29
+ - **Stacked flash clone timing** — `didRender` replaced by `willOpen` + `didOpen`
30
+ for cloning SA2 popups into the stack container. The previous `timer: 1` could
31
+ race the `setTimeout(0)` SA2 uses to schedule `didOpen`, leaving slots empty.
32
+ Timer raised to 50 ms; the original popup is hidden via `willOpen` (opacity 0,
33
+ pointer-events none) so the clone is the only visible element.
34
+ - **Stacked toast CSS scope** — SA2 scopes element rules under
35
+ `div:where(.swal2-container)` (zero-specificity `:where()`). Clones rendered
36
+ outside that container lost color, border-radius, and the close-button layout.
37
+ Added explicit `div.swal2-popup`, `div.swal2-html-container`, and
38
+ `button.swal2-close` rules in `index.css` that apply regardless of scope.
39
+ - **`install_jsbundling` generator** — previously only installed `sweetalert2`;
40
+ now also installs `swal_rails` from the npm registry.
41
+
42
+ ### Changed
43
+ - Release workflow (`.github/workflows/release.yml`) auto-publishes the npm
44
+ package after every gem push via the `NPM_TOKEN` repository secret.
45
+
9
46
  ## [0.4.0] - 2026-05-03
10
47
 
11
48
  ### Added
data/README.md CHANGED
@@ -285,6 +285,19 @@ Signature: `swal_flash(key, messages, mode: nil, delay: nil, now: false, **optio
285
285
  `_stackDelay` meta-keys, extracted by the JS runtime before the options are
286
286
  handed to `Swal.fire` — they never leak into SA2.
287
287
 
288
+ #### Persistent (non-auto-closing) entries
289
+
290
+ Pass `_persistent: true` to any `swal_flash` call — or add it directly to a
291
+ `flash_map` entry — to remove the auto-close timer and force a visible close
292
+ button, requiring the user to dismiss manually:
293
+
294
+ ```ruby
295
+ swal_flash :alert, "Your session will expire soon.", _persistent: true
296
+ ```
297
+
298
+ The runtime strips `_persistent` before calling `Swal.fire`, then deletes
299
+ `timer` / `timerProgressBar` and sets `showCloseButton: true` on the item.
300
+
288
301
  Behind the scenes, the engine serializes the flash into a meta tag
289
302
  (`<meta name="swal-flash" content="...">`) and the JS runtime reads it and
290
303
  calls `Swal.fire(...)` with your per-key options.
@@ -765,7 +778,7 @@ Flash entries are `{ key: "notice", options: { text: "..." } }` for string value
765
778
  Per-mode side effects:
766
779
 
767
780
  - **importmap**: appends `pin "sweetalert2", to: "sweetalert2.esm.all.js"` and `pin "swal_rails", to: "swal_rails/index.js"` to `config/importmap.rb`; appends `import "swal_rails"` to `app/javascript/application.js`.
768
- - **jsbundling**: runs `yarn add sweetalert2@<pinned>` or `npm install sweetalert2@<pinned>` (based on the lockfile present); appends `import "swal_rails"` to `app/javascript/application.js`.
781
+ - **jsbundling**: runs `yarn add sweetalert2@<pinned> swal_rails@<version>` or `npm install sweetalert2@<pinned> swal_rails@<version>` (based on the lockfile present); appends `import "swal_rails"` to `app/javascript/application.js`. The `swal_rails` npm package ships the same JS runtime as the gem, making the bare specifier `import "swal_rails"` resolvable by any bundler (esbuild, webpack, vite, rollup) without additional configuration.
769
782
  - **sprockets**: appends `//= link sweetalert2.js` and `//= link sweetalert2.css` to `app/assets/config/manifest.js`.
770
783
 
771
784
  All append operations are idempotent — running the generator twice is safe.
@@ -948,7 +961,7 @@ right template automatically.
948
961
  └──────────────────────────────────────────────────────────────────────┘
949
962
 
950
963
  ┌─ jsbundling (esbuild / vite / rollup) ───────────────────────────────┐
951
- │ package.json → "sweetalert2": "^11" (your bundler resolves it)
964
+ │ package.json → "sweetalert2": "^11", "swal_rails": "^0.5"
952
965
  │ app/javascript/application.js │
953
966
  │ import "swal_rails" │
954
967
  └──────────────────────────────────────────────────────────────────────┘
data/Rakefile CHANGED
@@ -10,3 +10,42 @@ require "rubocop/rake_task"
10
10
  RuboCop::RakeTask.new
11
11
 
12
12
  task default: %i[spec rubocop]
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # npm companion package tasks
16
+ # ---------------------------------------------------------------------------
17
+ require "fileutils"
18
+ require "json"
19
+
20
+ NPM_JS_SRC = "app/assets/javascripts/swal_rails"
21
+ NPM_JS_DEST = "npm"
22
+
23
+ def npm_sync_files
24
+ require_relative "lib/swal_rails/version"
25
+
26
+ Dir.glob("#{NPM_JS_SRC}/**/*.js").each do |src|
27
+ rel = src.sub("#{NPM_JS_SRC}/", "")
28
+ dest = "#{NPM_JS_DEST}/#{rel}"
29
+ FileUtils.mkdir_p(File.dirname(dest))
30
+ FileUtils.cp(src, dest)
31
+ puts " synced #{rel}"
32
+ end
33
+
34
+ pkg_path = "#{NPM_JS_DEST}/package.json"
35
+ pkg = JSON.parse(File.read(pkg_path))
36
+ pkg["version"] = SwalRails::VERSION
37
+ File.write(pkg_path, "#{JSON.pretty_generate(pkg)}\n")
38
+ puts " version → #{SwalRails::VERSION}"
39
+ end
40
+
41
+ namespace :npm do
42
+ desc "Sync JS files from #{NPM_JS_SRC}/ → #{NPM_JS_DEST}/ and bump version in package.json"
43
+ task(:sync) { npm_sync_files }
44
+
45
+ desc "Publish npm package to registry (runs npm:sync first)"
46
+ task publish: :sync do
47
+ Dir.chdir(NPM_JS_DEST) do
48
+ system("npm publish --access public") || abort("npm publish failed")
49
+ end
50
+ end
51
+ end
@@ -29,6 +29,18 @@ const extractMeta = (queue) => {
29
29
  return { mode, delay };
30
30
  };
31
31
 
32
+ // Per-item: _persistent removes the auto-close timer and ensures the user
33
+ // must dismiss manually via the close button.
34
+ const applyPersistent = (queue) => {
35
+ for (const item of queue) {
36
+ if (!item._persistent) continue;
37
+ delete item._persistent;
38
+ delete item.timer;
39
+ delete item.timerProgressBar;
40
+ item.showCloseButton = true;
41
+ }
42
+ };
43
+
32
44
  const STACK_ID = "swal-rails-stack";
33
45
 
34
46
  const ensureStackContainer = () => {
@@ -85,42 +97,36 @@ const fireStacked = async (Swal, queue, delay) => {
85
97
  Swal.fire({
86
98
  ...opts,
87
99
  toast: true,
88
- // Close the SA2 original immediately; the clone in `slot` persists.
89
- // Animations disabled on the decoy so the clone captures the popup
90
- // in its normal "shown" state (no opacity-0 from close transition).
91
- timer: 1,
92
100
  timerProgressBar: false,
93
101
  showClass: { popup: "", backdrop: "", icon: "" },
94
102
  hideClass: { popup: "", backdrop: "", icon: "" },
95
- didRender: (popup) => {
103
+ // timer:1 races with the setTimeout(0) SA2 uses to schedule didOpen
104
+ // when animations are disabled — didOpen can lose that race and never
105
+ // fire, leaving the slot empty. 50 ms gives the event loop a safe
106
+ // margin while remaining imperceptible to the user.
107
+ timer: 50,
108
+ // Suppress the SA2 original so only our clone in the stack is visible.
109
+ willOpen: (popup) => {
110
+ popup.style.opacity = "0";
111
+ popup.style.pointerEvents = "none";
112
+ },
113
+ // Clone at didOpen: SA2 has applied all inline styles at this point
114
+ // (display:grid, icon classes, close-button grid placement, etc.),
115
+ // so the clone requires no manual fixups.
116
+ didOpen: (popup) => {
96
117
  const clone = popup.cloneNode(true);
118
+ // willOpen set opacity:0 on the original; clear it on the clone.
97
119
  clone.style.opacity = "";
98
- // SA2 only flips `popup.style.display` to `grid` at didOpen. We
99
- // clone at didRender — earlier in the lifecycle — so the inline
100
- // display is missing. Outside SA2's `.swal2-container` the toast
101
- // therefore falls back to `display: block`, which collapses the
102
- // grid layout (`.swal2-toast { grid-template-columns: … }`) and
103
- // ends up rendering icon/title/content stacked vertically — the
104
- // "vachement haute" symptom of stacked flashes.
105
- clone.style.display = "grid";
106
120
  clone
107
121
  .querySelectorAll(".swal2-timer-progress-bar-container")
108
122
  .forEach((e) => e.remove());
109
- // SA2 adds `.swal2-icon-show` only after didOpen, but we clone
110
- // earlier (in didRender) to beat the close animation. Apply it
111
- // manually so the icon's SVG is visibly drawn in the clone.
112
- clone
113
- .querySelectorAll(".swal2-icon")
114
- .forEach((icon) => icon.classList.add("swal2-icon-show"));
115
123
  slot.appendChild(clone);
116
124
  const dismiss = () => {
117
125
  if (slot.isConnected) slot.remove();
118
126
  if (stack.isConnected && stack.children.length === 0)
119
127
  stack.remove();
120
128
  };
121
- clone
122
- .querySelector(".swal2-close")
123
- ?.addEventListener("click", dismiss);
129
+ clone.querySelector(".swal2-close")?.addEventListener("click", dismiss);
124
130
  if (timerMs) setTimeout(dismiss, timerMs);
125
131
  },
126
132
  didClose: () => resolve(),
@@ -151,6 +157,7 @@ export const installFlash = (Swal, config) => {
151
157
  });
152
158
 
153
159
  const meta = extractMeta(queue);
160
+ applyPersistent(queue);
154
161
  const mode = meta.mode || config.flashArrayMode || "sequential";
155
162
  const delay =
156
163
  meta.delay != null
@@ -3,3 +3,51 @@
3
3
  * Imports the vendored SweetAlert2 CSS; override variables in your own stylesheet.
4
4
  */
5
5
  @import "sweetalert2.css";
6
+
7
+ /*
8
+ * SA2 scopes all element styles under `div:where(.swal2-container)` for CSS
9
+ * isolation (zero-specificity :where() = easy to override, but only inside the
10
+ * container). This breaks stacked-toast clones that live outside that scope.
11
+ *
12
+ * Fix: re-declare the affected rules without the container ancestor. Our
13
+ * selectors have higher specificity than SA2's :where()-based ones, so they
14
+ * win everywhere — values are identical for native popups, so no regression.
15
+ * Risk of page pollution is near-zero: .swal2-* class names are distinctive.
16
+ */
17
+ div.swal2-popup {
18
+ color: var(--swal2-color);
19
+ font-size: 1rem;
20
+ border-radius: var(--swal2-border-radius);
21
+ container-name: swal2-popup;
22
+ }
23
+ div.swal2-html-container {
24
+ color: inherit;
25
+ font-weight: normal;
26
+ line-height: normal;
27
+ overflow-wrap: break-word;
28
+ word-break: break-word;
29
+ cursor: initial;
30
+ }
31
+ button.swal2-close {
32
+ z-index: 2;
33
+ align-items: center;
34
+ justify-content: center;
35
+ padding: 0;
36
+ overflow: hidden;
37
+ transition: var(--swal2-close-button-transition);
38
+ border: none;
39
+ border-radius: var(--swal2-border-radius);
40
+ outline: var(--swal2-close-button-outline);
41
+ background: transparent;
42
+ color: var(--swal2-close-button-color);
43
+ font-family: monospace;
44
+ cursor: pointer;
45
+ }
46
+ button.swal2-close:hover {
47
+ background: transparent;
48
+ color: #f27474;
49
+ }
50
+ button.swal2-close:focus-visible {
51
+ outline: none;
52
+ box-shadow: var(--swal2-close-button-focus-box-shadow);
53
+ }
@@ -99,8 +99,13 @@ module SwalRails
99
99
 
100
100
  def install_jsbundling
101
101
  if file_exists?("package.json")
102
- run "yarn add sweetalert2@#{SwalRails::SWEETALERT2_VERSION}" if file_exists?("yarn.lock")
103
- run "npm install sweetalert2@#{SwalRails::SWEETALERT2_VERSION}" if file_exists?("package-lock.json") && !file_exists?("yarn.lock")
102
+ if file_exists?("yarn.lock")
103
+ run "yarn add sweetalert2@#{SwalRails::SWEETALERT2_VERSION}"
104
+ run "yarn add swal_rails@#{SwalRails::VERSION}"
105
+ elsif file_exists?("package-lock.json")
106
+ run "npm install sweetalert2@#{SwalRails::SWEETALERT2_VERSION}"
107
+ run "npm install swal_rails@#{SwalRails::VERSION}"
108
+ end
104
109
  else
105
110
  say_status(:warn, "package.json not found", :yellow)
106
111
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SwalRails
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  SWEETALERT2_VERSION = "11.26.24"
6
6
  end
data/npm/chain.js ADDED
@@ -0,0 +1,60 @@
1
+ // Runs a sequence of SweetAlert2 modals, advancing only on confirm.
2
+ //
3
+ // Semantics (per step):
4
+ // - isDismissed → abort the chain, return false
5
+ // - isConfirmed → run onConfirmed sub-chain if present, else continue
6
+ // - isDenied → run onDenied sub-chain if present, else abort
7
+ //
8
+ // A chain resolves to `true` iff it ran to completion along a path without
9
+ // abort. That boolean is the contract expected by Turbo.setConfirmMethod
10
+ // and by the data-attribute re-dispatch logic in confirm.js.
11
+ export const CHAIN_DEFAULTS = {
12
+ showCancelButton: true,
13
+ focusCancel: true,
14
+ icon: "warning"
15
+ }
16
+
17
+ // JSON-delivered steps cannot ship functions (e.g. inputValidator).
18
+ // `inputExpected` provides a declarative guard for typed confirmations.
19
+ const normalizeStep = (step) => {
20
+ const {
21
+ onConfirmed,
22
+ onDenied,
23
+ inputExpected,
24
+ inputExpectedError,
25
+ ...sa2Options
26
+ } = step || {}
27
+
28
+ if (typeof inputExpected === "string") {
29
+ const expected = inputExpected
30
+ const error = inputExpectedError || `Type "${expected}" to continue`
31
+ sa2Options.inputValidator = (value) => (
32
+ (value || "").trim() === expected ? undefined : error
33
+ )
34
+ }
35
+
36
+ return { onConfirmed, onDenied, sa2Options }
37
+ }
38
+
39
+ export const chainDialogs = async (Swal, steps) => {
40
+ if (!Array.isArray(steps) || steps.length === 0) return true
41
+
42
+ for (const step of steps) {
43
+ // Strip our own control keys — SA2 ignores unknown options, but leaking
44
+ // `onConfirmed`/`onDenied` into the popup options keeps the serialized
45
+ // payload noisy and invites confusion.
46
+ const { onConfirmed, onDenied, sa2Options } = normalizeStep(step)
47
+ const result = await Swal.fire({ ...CHAIN_DEFAULTS, ...sa2Options })
48
+
49
+ if (result.isDismissed) return false
50
+ if (result.isConfirmed) {
51
+ if (Array.isArray(onConfirmed)) return chainDialogs(Swal, onConfirmed)
52
+ continue
53
+ }
54
+ if (result.isDenied) {
55
+ if (Array.isArray(onDenied)) return chainDialogs(Swal, onDenied)
56
+ return false
57
+ }
58
+ }
59
+ return true
60
+ }
data/npm/confirm.js ADDED
@@ -0,0 +1,103 @@
1
+ import { chainDialogs } from "swal_rails/chain"
2
+
3
+ const parseJSON = (value) => {
4
+ if (!value) return null
5
+ try { return JSON.parse(value) } catch { return null }
6
+ }
7
+
8
+ // When Rails serializes `data: { turbo_confirm: { icon: "error" } }`, the
9
+ // attribute value is a JSON string. Detect that, and accept both Object
10
+ // (single-step options) and Array (multi-step chain) shapes.
11
+ const messagePayload = (message) => {
12
+ if (typeof message !== "string") return null
13
+ const trimmed = message.trim()
14
+ if (trimmed[0] !== "{" && trimmed[0] !== "[") return null
15
+ const parsed = parseJSON(trimmed)
16
+ if (Array.isArray(parsed)) return parsed
17
+ if (parsed && typeof parsed === "object") return parsed
18
+ return null
19
+ }
20
+
21
+ const confirmDialog = (Swal, message, element) => {
22
+ const dataset = element?.dataset || {}
23
+ const payload = messagePayload(message)
24
+ const fromMessage = payload && !Array.isArray(payload) ? payload : null
25
+ const text = fromMessage ? undefined : message
26
+
27
+ const options = {
28
+ title: dataset.swalTitle || text || "Are you sure?",
29
+ text: dataset.swalText || (dataset.swalTitle ? text : undefined),
30
+ icon: dataset.swalIcon || "warning",
31
+ showCancelButton: true,
32
+ focusCancel: true
33
+ }
34
+ if (dataset.swalConfirmText) options.confirmButtonText = dataset.swalConfirmText
35
+ if (dataset.swalCancelText) options.cancelButtonText = dataset.swalCancelText
36
+
37
+ // Merge order (later wins): defaults → data-swal-* shortcuts → JSON message
38
+ // (turbo_confirm: {}) → data-swal-options (most specific).
39
+ const extras = parseJSON(dataset.swalOptions) || {}
40
+ return Swal.fire({ ...options, ...(fromMessage || {}), ...extras }).then((result) => result.isConfirmed)
41
+ }
42
+
43
+ // Dispatches to either a multi-step chain or a single-step confirm. Called
44
+ // from both the Turbo override and the data-attribute listener so both
45
+ // paths behave identically.
46
+ const confirmFlow = (Swal, message, element) => {
47
+ const fromDataset = parseJSON(element?.dataset?.swalSteps)
48
+ if (Array.isArray(fromDataset) && fromDataset.length) return chainDialogs(Swal, fromDataset)
49
+
50
+ const payload = messagePayload(message)
51
+ if (Array.isArray(payload) && payload.length) return chainDialogs(Swal, payload)
52
+
53
+ return confirmDialog(Swal, message, element)
54
+ }
55
+
56
+ const installTurboOverride = (Swal) => {
57
+ if (typeof window.Turbo === "undefined") return false
58
+ const handler = (message, element) => confirmFlow(Swal, message, element)
59
+ // Turbo 8.1+ renamed the API to `Turbo.config.forms.confirm`. The legacy
60
+ // `setConfirmMethod` still works but emits a deprecation warning. Prefer
61
+ // the new path, fall back to the old one for older Turbo versions.
62
+ if (window.Turbo.config?.forms) {
63
+ window.Turbo.config.forms.confirm = handler
64
+ return true
65
+ }
66
+ if (typeof window.Turbo.setConfirmMethod === "function") {
67
+ window.Turbo.setConfirmMethod(handler)
68
+ return true
69
+ }
70
+ return false
71
+ }
72
+
73
+ const installDataAttribute = (Swal) => {
74
+ const handler = (event) => {
75
+ const el = event.target.closest("[data-swal-confirm], [data-swal-steps]")
76
+ if (!el) return
77
+ const message = el.getAttribute("data-swal-confirm")
78
+ event.preventDefault()
79
+ event.stopPropagation()
80
+ confirmFlow(Swal, message, el).then((confirmed) => {
81
+ if (!confirmed) return
82
+ el.removeAttribute("data-swal-confirm")
83
+ el.removeAttribute("data-swal-steps")
84
+ if (typeof el.click === "function" && event.type === "click") {
85
+ el.click()
86
+ } else if (el.tagName === "FORM") {
87
+ // requestSubmit() fires the 'submit' event, so Turbo and any UJS
88
+ // handlers stay in the loop — unlike the raw .submit() which skips them.
89
+ if (typeof el.requestSubmit === "function") el.requestSubmit()
90
+ else el.submit()
91
+ }
92
+ })
93
+ }
94
+ document.addEventListener("click", handler, true)
95
+ document.addEventListener("submit", handler, true)
96
+ }
97
+
98
+ export const installConfirm = (Swal, config) => {
99
+ const mode = config.confirmMode || "data_attribute"
100
+ if (mode === "off") return
101
+ if (mode === "turbo_override" || mode === "both") installTurboOverride(Swal)
102
+ if (mode === "data_attribute" || mode === "both") installDataAttribute(Swal)
103
+ }
@@ -0,0 +1,54 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Swal from "sweetalert2"
3
+ import { chainDialogs } from "swal_rails/chain"
4
+
5
+ // Fire a Swal modal/toast or a multi-step chain from markup.
6
+ //
7
+ // <button data-controller="swal"
8
+ // data-action="click->swal#fire"
9
+ // data-swal-options-value='{"title":"Hi","icon":"info"}'>
10
+ // Ping
11
+ // </button>
12
+ //
13
+ // <button data-controller="swal"
14
+ // data-action="click->swal#chain"
15
+ // data-swal-steps-value='[{"title":"Sure?"},{"title":"Really?"}]'>
16
+ // Ping
17
+ // </button>
18
+ export default class extends Controller {
19
+ static values = {
20
+ options: { type: Object, default: {} },
21
+ steps: { type: Array, default: [] }
22
+ }
23
+
24
+ fire(event) {
25
+ if (this.element.tagName === "A" || this.element.tagName === "BUTTON") {
26
+ event?.preventDefault?.()
27
+ }
28
+ return Swal.fire(this.optionsValue)
29
+ }
30
+
31
+ confirm(event) {
32
+ event?.preventDefault?.()
33
+ const form = event?.target?.closest?.("form") || this.element
34
+ Swal.fire({
35
+ showCancelButton: true,
36
+ focusCancel: true,
37
+ ...this.optionsValue
38
+ }).then((result) => {
39
+ if (result.isConfirmed && form?.tagName === "FORM") {
40
+ typeof form.requestSubmit === "function" ? form.requestSubmit() : form.submit()
41
+ }
42
+ })
43
+ }
44
+
45
+ async chain(event) {
46
+ event?.preventDefault?.()
47
+ const form = event?.target?.closest?.("form") || this.element
48
+ const ok = await chainDialogs(window.Swal || Swal, this.stepsValue)
49
+ if (ok && form?.tagName === "FORM") {
50
+ typeof form.requestSubmit === "function" ? form.requestSubmit() : form.submit()
51
+ }
52
+ return ok
53
+ }
54
+ }
data/npm/flash.js ADDED
@@ -0,0 +1,174 @@
1
+ // Read the meta tag exactly once per page load. Boot() fires on both
2
+ // DOMContentLoaded and turbo:load, so without this guard an array flash
3
+ // would have its fireNext chain launched twice, racing and cascading via
4
+ // Swal.fire's replace-current-popup behavior — only the last message wins.
5
+ const readFlash = () => {
6
+ const el = document.querySelector('meta[name="swal-flash"]');
7
+ if (!el || el.dataset.swalConsumed === "1") return [];
8
+ el.dataset.swalConsumed = "1";
9
+ try {
10
+ return JSON.parse(el.getAttribute("content")) || [];
11
+ } catch {
12
+ return [];
13
+ }
14
+ };
15
+
16
+ // Keys attached by `swal_flash` helper for per-request mode / delay override.
17
+ // Stripped from the options before being passed to Swal.fire so they never
18
+ // leak into SA2.
19
+ const META_KEYS = ["_arrayMode", "_stackDelay"];
20
+
21
+ const extractMeta = (queue) => {
22
+ let mode = null;
23
+ let delay = null;
24
+ for (const item of queue) {
25
+ if (mode === null && item._arrayMode) mode = item._arrayMode;
26
+ if (delay === null && item._stackDelay != null) delay = item._stackDelay;
27
+ for (const k of META_KEYS) delete item[k];
28
+ }
29
+ return { mode, delay };
30
+ };
31
+
32
+ // Per-item: _persistent removes the auto-close timer and ensures the user
33
+ // must dismiss manually via the close button.
34
+ const applyPersistent = (queue) => {
35
+ for (const item of queue) {
36
+ if (!item._persistent) continue;
37
+ delete item._persistent;
38
+ delete item.timer;
39
+ delete item.timerProgressBar;
40
+ item.showCloseButton = true;
41
+ }
42
+ };
43
+
44
+ const STACK_ID = "swal-rails-stack";
45
+
46
+ const ensureStackContainer = () => {
47
+ let el = document.getElementById(STACK_ID);
48
+ if (!el) {
49
+ el = document.createElement("div");
50
+ el.id = STACK_ID;
51
+ // 360px matches SA2's `body.swal2-toast-shown .swal2-container` width;
52
+ // without it the cloned popups inherit `width: 100%` from SA2 and
53
+ // visually span the whole screen.
54
+ el.style.cssText = [
55
+ "position:fixed",
56
+ "top:1rem",
57
+ "right:1rem",
58
+ "width:360px",
59
+ "max-width:calc(100vw - 2rem)",
60
+ "display:flex",
61
+ "flex-direction:column",
62
+ "gap:.5rem",
63
+ "z-index:10000",
64
+ "pointer-events:none",
65
+ ].join(";");
66
+ document.body.appendChild(el);
67
+ }
68
+ return el;
69
+ };
70
+
71
+ const fireSequential = (Swal, queue) => {
72
+ const fireNext = () => {
73
+ const opts = queue.shift();
74
+ if (!opts) return;
75
+ Swal.fire(opts).then(fireNext);
76
+ };
77
+ fireNext();
78
+ };
79
+
80
+ // SA2 is singleton — two concurrent Swal.fire calls collapse into one
81
+ // popup (the second replaces the first). To stack multiple toasts we let
82
+ // SA2 render each popup, clone it into its own slot, then close the
83
+ // original fast so the next fire is unblocked. The clones live on in our
84
+ // stack with their own timer and click-to-dismiss handlers. Empiler des
85
+ // modales bloquantes n'a pas de sens — on force toast: true.
86
+ const fireStacked = async (Swal, queue, delay) => {
87
+ const stack = ensureStackContainer();
88
+ for (let i = 0; i < queue.length; i++) {
89
+ const opts = queue[i];
90
+ const slot = document.createElement("div");
91
+ slot.className = "swal-rails-stack-slot";
92
+ slot.style.cssText = "width:100%;pointer-events:auto;";
93
+ stack.appendChild(slot);
94
+
95
+ const timerMs = opts.timer;
96
+ await new Promise((resolve) => {
97
+ Swal.fire({
98
+ ...opts,
99
+ toast: true,
100
+ timerProgressBar: false,
101
+ showClass: { popup: "", backdrop: "", icon: "" },
102
+ hideClass: { popup: "", backdrop: "", icon: "" },
103
+ // timer:1 races with the setTimeout(0) SA2 uses to schedule didOpen
104
+ // when animations are disabled — didOpen can lose that race and never
105
+ // fire, leaving the slot empty. 50 ms gives the event loop a safe
106
+ // margin while remaining imperceptible to the user.
107
+ timer: 50,
108
+ // Suppress the SA2 original so only our clone in the stack is visible.
109
+ willOpen: (popup) => {
110
+ popup.style.opacity = "0";
111
+ popup.style.pointerEvents = "none";
112
+ },
113
+ // Clone at didOpen: SA2 has applied all inline styles at this point
114
+ // (display:grid, icon classes, close-button grid placement, etc.),
115
+ // so the clone requires no manual fixups.
116
+ didOpen: (popup) => {
117
+ const clone = popup.cloneNode(true);
118
+ // willOpen set opacity:0 on the original; clear it on the clone.
119
+ clone.style.opacity = "";
120
+ clone
121
+ .querySelectorAll(".swal2-timer-progress-bar-container")
122
+ .forEach((e) => e.remove());
123
+ slot.appendChild(clone);
124
+ const dismiss = () => {
125
+ if (slot.isConnected) slot.remove();
126
+ if (stack.isConnected && stack.children.length === 0)
127
+ stack.remove();
128
+ };
129
+ clone.querySelector(".swal2-close")?.addEventListener("click", dismiss);
130
+ if (timerMs) setTimeout(dismiss, timerMs);
131
+ },
132
+ didClose: () => resolve(),
133
+ });
134
+ });
135
+
136
+ if (i < queue.length - 1 && delay > 0) {
137
+ await new Promise((r) => setTimeout(r, delay));
138
+ }
139
+ }
140
+ };
141
+
142
+ export const installFlash = (Swal, config) => {
143
+ const flashes = readFlash();
144
+ if (!flashes.length) return;
145
+
146
+ const map = config.flashMap || {};
147
+ const queue = flashes.map((flash) => {
148
+ const spec = map[flash.key] ||
149
+ map[flash.key.toLowerCase()] || {
150
+ icon: "info",
151
+ toast: true,
152
+ position: "top-end",
153
+ timer: 3000,
154
+ };
155
+ // Per-request options win over the per-key defaults from flash_map.
156
+ return { ...spec, ...(flash.options || {}) };
157
+ });
158
+
159
+ const meta = extractMeta(queue);
160
+ applyPersistent(queue);
161
+ const mode = meta.mode || config.flashArrayMode || "sequential";
162
+ const delay =
163
+ meta.delay != null
164
+ ? meta.delay
165
+ : config.flashStackDelay != null
166
+ ? config.flashStackDelay
167
+ : 500;
168
+
169
+ if (mode === "stacked" && queue.length > 1) {
170
+ fireStacked(Swal, queue, delay);
171
+ } else {
172
+ fireSequential(Swal, queue);
173
+ }
174
+ };
data/npm/index.js ADDED
@@ -0,0 +1,69 @@
1
+ import Swal from "sweetalert2"
2
+ import { installConfirm } from "swal_rails/confirm"
3
+ import { installFlash } from "swal_rails/flash"
4
+
5
+ const readMeta = (name) => {
6
+ const el = document.querySelector(`meta[name="${name}"]`)
7
+ if (!el) return null
8
+ try { return JSON.parse(el.getAttribute("content")) } catch { return null }
9
+ }
10
+
11
+ const prefersReducedMotion = () =>
12
+ window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches
13
+
14
+ const buildMixin = (config) => {
15
+ const base = { ...(config.defaultOptions || {}) }
16
+ if (config.respectReducedMotion && prefersReducedMotion()) {
17
+ base.showClass = { popup: "" }
18
+ base.hideClass = { popup: "" }
19
+ }
20
+ if (config.i18n?.confirm_button_text) base.confirmButtonText = config.i18n.confirm_button_text
21
+ if (config.i18n?.cancel_button_text) base.cancelButtonText = config.i18n.cancel_button_text
22
+ if (config.i18n?.deny_button_text) base.denyButtonText = config.i18n.deny_button_text
23
+ if (config.i18n?.close_button_aria_label) base.closeButtonAriaLabel = config.i18n.close_button_aria_label
24
+ return base
25
+ }
26
+
27
+ // Module-scoped so repeated calls to boot() (DOMContentLoaded + every
28
+ // turbo:load) don't stack a new click/submit listener per navigation.
29
+ let booted = null
30
+
31
+ const boot = () => {
32
+ if (!booted) {
33
+ const config = readMeta("swal-config") || {}
34
+ const Mixin = Swal.mixin(buildMixin(config))
35
+
36
+ if (config.exposeWindowSwal !== false) {
37
+ window.Swal = Mixin
38
+ }
39
+
40
+ installConfirm(Mixin, config)
41
+ booted = { Swal: Mixin, config }
42
+ document.dispatchEvent(new CustomEvent("swal-rails:ready", { detail: booted }))
43
+ }
44
+
45
+ // Flash meta is re-rendered per request, so read and fire on every page.
46
+ installFlash(booted.Swal, booted.config)
47
+ return booted.Swal
48
+ }
49
+
50
+ const ready = (fn) => {
51
+ if (document.readyState === "loading") {
52
+ document.addEventListener("DOMContentLoaded", fn, { once: true })
53
+ } else {
54
+ fn()
55
+ }
56
+ }
57
+
58
+ ready(boot)
59
+ // `turbo:load` covers full Turbo Drive navigations + initial page loads.
60
+ // `turbo:render` additionally covers form submissions that render with
61
+ // a non-redirect status (e.g. 422 unprocessable_entity for `flash.now`)
62
+ // — Turbo replaces the body but does NOT fire turbo:load in that path.
63
+ // The `data-swal-consumed` guard on the meta tag dedupes the double-fire
64
+ // on full navigations where both events run.
65
+ document.addEventListener("turbo:load", boot)
66
+ document.addEventListener("turbo:render", boot)
67
+
68
+ export { Swal }
69
+ export default Swal
data/npm/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "swal_rails",
3
+ "version": "0.5.0",
4
+ "description": "SweetAlert2 for Rails 7+ — batteries included (jsbundling companion)",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "module": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./confirm": "./confirm.js",
11
+ "./flash": "./flash.js",
12
+ "./chain": "./chain.js",
13
+ "./controllers/swal_controller": "./controllers/swal_controller.js"
14
+ },
15
+ "files": [
16
+ "*.js",
17
+ "controllers/"
18
+ ],
19
+ "peerDependencies": {
20
+ "sweetalert2": ">=11.0.0"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/Metalzoid/swal_rails"
25
+ },
26
+ "homepage": "https://github.com/Metalzoid/swal_rails",
27
+ "bugs": {
28
+ "url": "https://github.com/Metalzoid/swal_rails/issues"
29
+ },
30
+ "license": "MIT",
31
+ "author": "Florian Gagnaire <gagnaire.flo@gmail.com>",
32
+ "keywords": [
33
+ "rails",
34
+ "sweetalert2",
35
+ "flash",
36
+ "confirm",
37
+ "jsbundling",
38
+ "esbuild",
39
+ "webpack",
40
+ "vite"
41
+ ]
42
+ }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: swal_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Gagnaire
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-08 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -76,6 +76,12 @@ files:
76
76
  - lib/swal_rails/helpers.rb
77
77
  - lib/swal_rails/initializer_version_check.rb
78
78
  - lib/swal_rails/version.rb
79
+ - npm/chain.js
80
+ - npm/confirm.js
81
+ - npm/controllers/swal_controller.js
82
+ - npm/flash.js
83
+ - npm/index.js
84
+ - npm/package.json
79
85
  - vendor/javascript/sweetalert2/LICENSE
80
86
  - vendor/javascript/sweetalert2/sweetalert2.all.js
81
87
  - vendor/javascript/sweetalert2/sweetalert2.all.min.js