rails-markup 1.4.2 → 1.4.4

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: 34e44f1b30e4c51432e862ee09efd9d1f72e906a41e0e38f9c7553267f737d6f
4
- data.tar.gz: '0909684467d87ed6a1248657ea2fc0e120956b66203c55b47750e74b57971bcb'
3
+ metadata.gz: 88eacfb8b1c5006e209a9152bda3f464c54675d0460388624aefdae0c106e838
4
+ data.tar.gz: 34abe3d1f0f3c3cb0304114543bc1649c6c87a47fa7faa67577f37c1cd041bee
5
5
  SHA512:
6
- metadata.gz: ea571e4c90037645f9f81ddc6e95710ffd6333addee88f6b43b3352ee8be8bcf9afb9b4d0d2868965f30825c370bf668a6e3a61e7fe345c6ae2469e8e205216c
7
- data.tar.gz: ec0f2ff1ae946b95e7e6c6a7c0fb90429faa8bab7e8cbe30763a09440056760ab9049aed14b296a36c52095f9c1eae5d45941c6a1ccae8bcc346e5f12d03a5d3
6
+ metadata.gz: 1910ed15f034f2c343a29e5f2060ad35f868f6cc6a66619e43f21127304bbeb1eef3008a7490d584e3efa2c05964344d5eda6815b2ae0628e4d1a271227df094
7
+ data.tar.gz: 62cb48444c13a8c2c4812a3e065aff880b87646620166e92f7c21e0f6fb05bf3c0032fa82df423ce6e536b7c34cd1826b5dcc3e21cbc2cec4146b5ac1bd504a7
data/README.md CHANGED
@@ -33,6 +33,24 @@ bin/rails railties:install:migrations FROM=rails_markup
33
33
  bin/rails db:migrate
34
34
  ```
35
35
 
36
+ ### Compatibility: `connection_pool` 3.x
37
+
38
+ Rails Markup does **not** use Redis or `connection_pool`. However, Rails 7.2 and
39
+ Rails 8.0 apps configured with `:redis_cache_store` fail to boot against
40
+ `connection_pool` 3.x (released Dec 2025): Active Support passes pool options as
41
+ a positional hash, while `connection_pool` 3 requires keyword arguments, raising
42
+ `ArgumentError: wrong number of arguments (given 1, expected 0)` during
43
+ `Rails.application.initialize!`. This is an upstream Rails/`connection_pool`
44
+ incompatibility (fixed in Rails 8.1), unrelated to this gem — but because it
45
+ breaks boot, it will also break `bin/dev`, `db:migrate`, and the migration-copy
46
+ step above whenever this gem happens to be installed.
47
+
48
+ If you hit it, pin the dependency in your app's Gemfile until you're on Rails 8.1+:
49
+
50
+ ```ruby
51
+ gem "connection_pool", "< 3.0"
52
+ ```
53
+
36
54
  ### Rails Markup 1.2 rolling UUID upgrade
37
55
 
38
56
  The 1.2 upgrade migration adds, repairs, and uniquely indexes `client_uuid`, but
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Rails Markup Toolbar — self-contained annotation UI
3
+ * No dependencies (no Stimulus, no importmap). Works in any Rails app.
4
+ *
5
+ * Usage: include via <script> tag, then call:
6
+ * RailsMarkupToolbar.init({ endpoint: "/feedback/api", accent: "indigo" })
7
+ */
8
+ (function(global) {
9
+ "use strict";
10
+
11
+ if (global.RailsMarkupToolbar) return;
12
+
13
+ const RailsMarkupToolbar = {
14
+ // State
15
+ annotations: [],
16
+ outbox: {},
17
+ legacyMigrations: {},
18
+ nextId: 1,
19
+ active: false,
20
+ serverOnline: false,
21
+ sessionId: null,
22
+ sseSource: null,
23
+ healthInterval: null,
24
+ hoveredElement: null,
25
+ selectedText: null,
26
+ clickedElement: null,
27
+ activeFilter: "all",
28
+ editingId: null,
29
+ _currentScreenshot: null,
30
+ _storageError: null,
31
+ _outboxFlushScheduled: false,
32
+ _outboxFlushTimer: null,
33
+ _flushPromise: null,
34
+ _flushAfterPullPromise: null,
35
+ _healthPromise: null,
36
+ _pullPromise: null,
37
+ _pullPageUrl: null,
38
+ _pullNeeded: true,
39
+ _syncRetryTimer: null,
40
+ _syncRetryAttempt: 0,
41
+ _syncRetryDelay: 0,
42
+ _syncBaseRetryDelay: 1000,
43
+ _syncMaxRetryDelay: 30000,
44
+ _syncMalformedLimit: 3,
45
+ _syncUnavailable: null,
46
+ legacyStorageEndpoint: null,
47
+
48
+ // Drawing state
49
+ drawingMode: null, // null | "arrow" | "rect" | "highlight"
50
+ drawingCanvas: null,
51
+ drawingCtx: null,
52
+ drawingStart: null,
53
+ drawingHistory: [],
54
+ _screenshotImg: null,
55
+
56
+ // Config
57
+ endpoint: "/feedback/api",
58
+ accent: "indigo",
59
+ position: "bl",
60
+ size: "default",
61
+ fabVisible: true,
62
+ enableScreenshots: true,
63
+
64
+ // DOM refs (set in init)
65
+ root: null,
66
+ _currentPathname: null,
67
+ _currentPageUrl: null
68
+ };
@@ -0,0 +1,82 @@
1
+ Object.assign(RailsMarkupToolbar, {
2
+ _injectStyles() {
3
+ if (document.getElementById("rm-toolbar-styles")) return;
4
+ const style = document.createElement("style");
5
+ style.id = "rm-toolbar-styles";
6
+ style.textContent = `
7
+ @keyframes rm-pulse { 0%,100%{box-shadow:0 2px 8px rgba(0,0,0,0.2)} 50%{box-shadow:0 2px 12px rgba(0,0,0,0.3),0 0 0 4px rgba(99,102,241,0.15)} }
8
+ @keyframes rm-toast-in { from{opacity:0;transform:translateY(16px) scale(0.95)} to{opacity:1;transform:translateY(0) scale(1)} }
9
+ @keyframes rm-toast-out { from{opacity:1;transform:translateY(0) scale(1)} to{opacity:0;transform:translateY(16px) scale(0.95)} }
10
+ #rm-toolbar-root { position:fixed; inset:0; pointer-events:none; z-index:9979; }
11
+ #rm-toolbar-root * { box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; }
12
+ .rm-fab, .rm-panel-toggle, .rm-panel, .rm-popup { pointer-events:auto; }
13
+ .rm-fab { position:fixed; z-index:9980; border-radius:50%; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; transition:all 0.2s; box-shadow:0 4px 12px rgba(0,0,0,0.15); }
14
+ .rm-fab svg { width:20px; height:20px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
15
+ .rm-fab-badge { position:absolute; top:-4px; right:-4px; min-width:20px; height:20px; padding:0 4px; border-radius:10px; background:#ef4444; color:#fff; font-size:11px; font-weight:700; display:none; align-items:center; justify-content:center; }
16
+ .rm-panel-toggle { position:fixed; z-index:9980; width:32px; height:32px; border-radius:50%; border:1px solid #e5e7eb; background:rgba(255,255,255,0.9); cursor:pointer; display:none; align-items:center; justify-content:center; color:#6b7280; transition:all 0.2s; backdrop-filter:blur(8px); }
17
+ .rm-panel-toggle:hover { color:#4361ee; }
18
+ .rm-panel-toggle svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
19
+ .rm-toast-container { position:fixed; z-index:9983; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
20
+ .rm-pins-container { position:absolute; top:0; left:0; width:100%; z-index:9979; pointer-events:none; }
21
+ .rm-pin { pointer-events:auto; }
22
+ .rm-popup { display:none; position:fixed; z-index:9982; width:360px; max-width:calc(100vw - 24px); background:rgba(255,255,255,0.95); backdrop-filter:blur(12px); border-radius:16px; box-shadow:0 25px 50px rgba(0,0,0,0.1); border:1px solid rgba(229,231,235,0.8); padding:16px; }
23
+ #rm-toolbar-root .rm-popup textarea { display:block; width:100%; max-width:100%; font-size:13px; font-weight:400; line-height:1.4; color:#1f2937; height:auto; margin:0; border:1px solid #e5e7eb; border-radius:12px; padding:12px; resize:none; outline:none; font-family:inherit; background:#fff; background-image:none; box-shadow:none; appearance:none; -webkit-appearance:none; transition:border-color 0.15s,box-shadow 0.15s; }
24
+ #rm-toolbar-root .rm-popup textarea:focus { border:1px solid #818cf8; box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
25
+ #rm-toolbar-root .rm-menu { position:relative; display:inline-block; vertical-align:middle; }
26
+ #rm-toolbar-root .rm-menu-btn { display:inline-flex; align-items:center; gap:4px; width:auto; height:auto; margin:0; font-size:11px; font-weight:500; line-height:1.4; color:#374151; border:1px solid #e5e7eb; border-radius:8px; padding:6px 8px; background:#fff; background-image:none; box-shadow:none; outline:none; text-transform:none; appearance:none; -webkit-appearance:none; cursor:pointer; }
27
+ #rm-toolbar-root .rm-menu-btn:hover { border-color:#d1d5db; background:#fff; }
28
+ #rm-toolbar-root .rm-menu-btn:focus { outline:none; border-color:#818cf8; box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
29
+ #rm-toolbar-root .rm-menu-btn[aria-expanded="true"] { border-color:#818cf8; }
30
+ #rm-toolbar-root .rm-menu-chevron { width:10px; height:10px; flex-shrink:0; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; opacity:0.55; }
31
+ #rm-toolbar-root .rm-menu-list { display:none; position:absolute; top:calc(100% + 4px); left:0; z-index:9984; min-width:100%; padding:4px; margin:0; list-style:none; background:#fff; border:1px solid #e5e7eb; border-radius:10px; box-shadow:0 10px 24px rgba(0,0,0,0.12); }
32
+ #rm-toolbar-root .rm-menu-list.rm-menu-open { display:block; }
33
+ #rm-toolbar-root .rm-menu-option { display:block; width:100%; margin:0; padding:6px 10px; font-size:11px; font-weight:500; line-height:1.4; color:#374151; text-align:left; border:none; border-radius:6px; background:transparent; background-image:none; box-shadow:none; cursor:pointer; appearance:none; -webkit-appearance:none; }
34
+ #rm-toolbar-root .rm-menu-option:hover, #rm-toolbar-root .rm-menu-option:focus { background:#f3f4f6; outline:none; }
35
+ #rm-toolbar-root .rm-menu-option-active { background:#eef2ff; color:#4338ca; }
36
+ #rm-toolbar-root .rm-menu-compact .rm-menu-btn { font-size:10px; padding:2px 6px; border-radius:4px; color:#6b7280; }
37
+ #rm-toolbar-root .rm-menu-compact .rm-menu-list, #rm-toolbar-root .rm-menu-list-compact { min-width:120px; right:0; left:auto; }
38
+ #rm-toolbar-root .rm-menu-compact .rm-menu-option, #rm-toolbar-root .rm-menu-list-compact .rm-menu-option { font-size:10px; padding:5px 8px; }
39
+ .rm-popup-el { font-size:11px; color:#9ca3af; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; line-height:1.4; }
40
+ .rm-popup-text { font-size:12px; color:#6b7280; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-top:2px; line-height:1.4; }
41
+ .rm-popup-actions { display:flex; align-items:center; gap:8px; margin-top:8px; }
42
+ .rm-popup-actions .rm-count { font-size:10px; color:#d1d5db; margin-left:auto; font-variant-numeric:tabular-nums; }
43
+ #rm-toolbar-root .rm-btn-cancel { padding:6px 12px; font-size:12px; color:#9ca3af; background:none; background-image:none; border:none; box-shadow:none; cursor:pointer; border-radius:8px; appearance:none; -webkit-appearance:none; }
44
+ #rm-toolbar-root .rm-btn-cancel:hover { color:#6b7280; }
45
+ #rm-toolbar-root .rm-btn-submit { padding:6px 16px; font-size:12px; font-weight:500; color:#fff; border:none; border-radius:8px; cursor:pointer; display:inline-flex; align-items:center; gap:6px; box-shadow:none; appearance:none; -webkit-appearance:none; }
46
+ #rm-toolbar-root .rm-btn-submit kbd { font-size:9px; opacity:0.6; font-family:sans-serif; }
47
+ .rm-panel { display:none; position:fixed; z-index:9981; width:380px; max-width:calc(100vw - 48px); max-height:60vh; background:rgba(255,255,255,0.95); backdrop-filter:blur(12px); border-radius:16px; box-shadow:0 25px 50px rgba(0,0,0,0.1); border:1px solid rgba(229,231,235,0.8); flex-direction:column; }
48
+ .rm-panel-header { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid #f3f4f6; }
49
+ .rm-panel-header h3 { font-size:14px; font-weight:600; color:#1f2937; }
50
+ .rm-panel-count { min-width:20px; text-align:center; padding:2px 6px; font-size:10px; font-weight:600; border-radius:10px; }
51
+ .rm-panel-close { padding:6px; color:#d1d5db; background:none; border:none; cursor:pointer; border-radius:8px; }
52
+ .rm-panel-close:hover { color:#6b7280; }
53
+ .rm-panel-close svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
54
+ .rm-filter-chips { display:flex; gap:6px; padding:8px 16px; border-bottom:1px solid #f9fafb; }
55
+ .rm-chip { padding:4px 8px; font-size:10px; font-weight:500; border-radius:10px; cursor:pointer; transition:all 0.15s; border:none; }
56
+ .rm-chip-active { color:#fff; }
57
+ .rm-chip-inactive { background:#f9fafb; color:#9ca3af; }
58
+ .rm-chip-inactive:hover { color:#6b7280; }
59
+ .rm-panel-list { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:8px; }
60
+ .rm-panel-footer { display:flex; align-items:center; gap:8px; padding:10px 16px; border-top:1px solid #f3f4f6; font-size:11px; color:#9ca3af; }
61
+ .rm-status-dot { width:8px; height:8px; border-radius:50%; background:#d1d5db; }
62
+ .rm-card { padding:12px; background:#fff; border-radius:8px; border:1px solid #f3f4f6; border-left:3px solid; cursor:pointer; transition:all 0.15s; }
63
+ .rm-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.05); }
64
+ .rm-card-top { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
65
+ .rm-card-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
66
+ .rm-card-id { font-size:10px; font-weight:600; color:#9ca3af; }
67
+ .rm-card-badge { padding:2px 6px; font-size:10px; font-weight:500; border-radius:10px; }
68
+ .rm-card-body { margin-top:6px; font-size:13px; line-height:1.5; color:#1f2937; }
69
+ .rm-card-path { margin-top:4px; font-size:10px; color:#d1d5db; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
70
+ .rm-card-thread { margin-top:8px; padding:8px; background:rgba(249,250,251,0.8); border-radius:8px; font-size:12px; color:#6b7280; border-left:2px solid; }
71
+ .rm-card-thread-role { font-size:10px; font-weight:500; color:#9ca3af; text-transform:uppercase; letter-spacing:0.05em; }
72
+ .rm-empty { text-align:center; padding:32px 16px; color:#9ca3af; }
73
+ .rm-empty-icon { font-size:32px; margin-bottom:8px; }
74
+ .rm-empty-text { font-size:13px; }
75
+ .rm-pin { position:absolute; display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%; color:#fff; font-size:10px; font-weight:700; cursor:pointer; transition:transform 0.2s; z-index:9979; box-shadow:0 2px 8px rgba(0,0,0,0.2); }
76
+ .rm-pin:hover { transform:scale(1.25); }
77
+ .rm-pin-active { animation:rm-pulse 2s ease-in-out infinite; }
78
+ .rm-toast { padding:8px 12px; border-radius:8px; border:1px solid; font-size:12px; font-weight:500; box-shadow:0 2px 8px rgba(0,0,0,0.05); animation:rm-toast-in 0.3s ease; }
79
+ `;
80
+ document.head.appendChild(style);
81
+ },
82
+ });
@@ -0,0 +1,95 @@
1
+ Object.assign(RailsMarkupToolbar, {
2
+ _injectDOM() {
3
+ const root = document.createElement("div");
4
+ root.id = "rm-toolbar-root";
5
+
6
+ const accentBg = this._accentBg();
7
+ const accentBgHover = this._accentBgHover();
8
+ const accentLight = this._accentLight();
9
+ const accentText = this._accentText();
10
+
11
+ // Position: bl (bottom-left), br (bottom-right), tl (top-left), tr (top-right)
12
+ const posMap = { bl: "bottom:24px;left:24px;", br: "bottom:24px;right:24px;", tl: "top:24px;left:24px;", tr: "top:24px;right:24px;" };
13
+ const fabPos = posMap[this.position] || posMap.bl;
14
+
15
+ // Size: default (48px), compact (40px), slim (32px)
16
+ const sizeMap = { "default": { dim: 48, icon: 20 }, compact: { dim: 40, icon: 18 }, slim: { dim: 32, icon: 16 } };
17
+ const fabSize = sizeMap[this.size] || sizeMap["default"];
18
+
19
+ // Panel offset matches FAB position
20
+ const isRight = this.position === "br" || this.position === "tr";
21
+ const isTop = this.position === "tl" || this.position === "tr";
22
+ const panelToggleStyle = isRight ? `${isTop ? 'top' : 'bottom'}:24px;right:${fabSize.dim + 8 + 24}px;` : `${isTop ? 'top' : 'bottom'}:24px;left:${fabSize.dim + 8 + 24}px;`;
23
+ const panelStyle = isRight
24
+ ? `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;right:24px;`
25
+ : `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;left:24px;`;
26
+ const toastStyle = isRight
27
+ ? `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;right:24px;`
28
+ : `${isTop ? 'top' : 'bottom'}:${fabSize.dim + 32}px;left:24px;`;
29
+
30
+ root.innerHTML = `
31
+ <button class="rm-fab" id="rm-fab" style="${fabPos}width:${fabSize.dim}px;height:${fabSize.dim}px;background:${accentBg};color:#fff;${this.fabVisible ? "" : "display:none;"}" title="Toggle annotation mode" aria-label="Toggle annotation mode" aria-expanded="false" aria-controls="rm-panel">
32
+ <svg viewBox="0 0 24 24" style="width:${fabSize.icon}px;height:${fabSize.icon}px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
33
+ <span class="rm-fab-badge" id="rm-fab-badge"></span>
34
+ </button>
35
+ <button class="rm-panel-toggle" id="rm-panel-toggle" style="${panelToggleStyle}" title="View annotations" aria-label="View annotations" aria-controls="rm-panel">
36
+ <svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h7"/></svg>
37
+ </button>
38
+ <div class="rm-toast-container" id="rm-toast-container" style="${toastStyle}"></div>
39
+ <div class="rm-popup" id="rm-popup" role="dialog" aria-label="Add annotation" aria-modal="false">
40
+ <div style="margin-bottom:12px">
41
+ <p class="rm-popup-el" id="rm-popup-el"></p>
42
+ <p class="rm-popup-text" id="rm-popup-text"></p>
43
+ </div>
44
+ <textarea id="rm-popup-input" rows="3" placeholder="What should change?"></textarea>
45
+ <div style="display:flex;align-items:center;gap:8px;margin-top:12px">
46
+ ${this._menuMarkup({ inputId: "rm-intent-select", label: "Intent", value: "change", options: this._intentOptions() })}
47
+ ${this._menuMarkup({ inputId: "rm-severity-select", label: "Severity", value: "suggestion", options: this._severityOptions() })}
48
+ <span class="rm-count" id="rm-char-count"></span>
49
+ </div>
50
+ <div class="rm-popup-actions">
51
+ <button class="rm-btn-cancel" id="rm-btn-cancel">Cancel</button>
52
+ <button class="rm-btn-submit" id="rm-btn-submit" style="background:${accentBg}">
53
+ <span id="rm-submit-label">Add</span>
54
+ <kbd>⌘↩</kbd>
55
+ </button>
56
+ </div>
57
+ </div>
58
+ <div class="rm-panel" id="rm-panel" style="${panelStyle}" role="dialog" aria-label="Annotations panel">
59
+ <div class="rm-panel-header">
60
+ <div style="display:flex;align-items:center;gap:8px">
61
+ <h3>Feedback</h3>
62
+ <span class="rm-panel-count" id="rm-panel-count" style="background:${accentLight};color:${accentText}">0</span>
63
+ </div>
64
+ <button class="rm-panel-close" id="rm-panel-close" aria-label="Close annotations panel">
65
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18L18 6M6 6l12 12"/></svg>
66
+ </button>
67
+ </div>
68
+ <div class="rm-filter-chips" id="rm-filter-chips">
69
+ <button class="rm-chip rm-chip-active" data-filter="all" style="background:${accentBg}">All</button>
70
+ <button class="rm-chip rm-chip-inactive" data-filter="pending">Pending</button>
71
+ <button class="rm-chip rm-chip-inactive" data-filter="resolved">Resolved</button>
72
+ </div>
73
+ <div class="rm-panel-list" id="rm-panel-list"></div>
74
+ <div class="rm-panel-footer">
75
+ <span class="rm-status-dot" id="rm-status-dot"></span>
76
+ <span id="rm-status-text">Offline</span>
77
+ </div>
78
+ </div>
79
+ `;
80
+
81
+ document.body.appendChild(root);
82
+ this.root = root;
83
+ // Pins container lives on body (not inside fixed root); scroll listener keeps them stuck to target elements
84
+ const pinsContainer = document.createElement("div");
85
+ pinsContainer.className = "rm-pins-container";
86
+ pinsContainer.id = "rm-pins-container";
87
+ document.body.appendChild(pinsContainer);
88
+ if (!this._onResize) {
89
+ this._onResize = this._debouncedRepositionPins(250);
90
+ this._onScroll = this._debouncedRepositionPins(50);
91
+ window.addEventListener("resize", this._onResize);
92
+ window.addEventListener("scroll", this._onScroll, { passive: true });
93
+ }
94
+ },
95
+ });
@@ -0,0 +1,176 @@
1
+ Object.assign(RailsMarkupToolbar, {
2
+ _intentOptions() {
3
+ return [
4
+ ["fix", "Fix"],
5
+ ["change", "Change"],
6
+ ["question", "Question"],
7
+ ["approve", "Approve"]
8
+ ];
9
+ },
10
+ _severityOptions() {
11
+ return [
12
+ ["suggestion", "Suggestion"],
13
+ ["important", "Important"],
14
+ ["blocking", "Blocking"]
15
+ ];
16
+ },
17
+ _statusOptions() {
18
+ return [
19
+ ["pending", "Pending"],
20
+ ["acknowledged", "Acknowledged"],
21
+ ["resolved", "Resolved"],
22
+ ["dismissed", "Dismissed"]
23
+ ];
24
+ },
25
+ _menuMarkup({ inputId, statusId, label, value, options, compact }) {
26
+ const current = options.find(([v]) => v === value) || options[0];
27
+ const currentValue = current[0];
28
+ const currentLabel = current[1];
29
+ const menuId = inputId ? `rm-menu-${inputId}` : `rm-menu-status-${statusId}`;
30
+ const listId = `${menuId}-list`;
31
+ const inputAttrs = inputId
32
+ ? `id="${inputId}"`
33
+ : `data-status-id="${statusId}"`;
34
+ const optionsHtml = options.map(([v, text]) => {
35
+ const active = v === currentValue;
36
+ return `<button type="button" class="rm-menu-option${active ? " rm-menu-option-active" : ""}" role="option" data-menu-owner="${menuId}" data-value="${v}" aria-selected="${active ? "true" : "false"}">${text}</button>`;
37
+ }).join("");
38
+ return `<div class="rm-menu${compact ? " rm-menu-compact" : ""}" id="${menuId}">` +
39
+ `<input type="hidden" ${inputAttrs} value="${currentValue}">` +
40
+ `<button type="button" class="rm-menu-btn" aria-haspopup="listbox" aria-controls="${listId}" aria-expanded="false" title="${this._esc(label)}" aria-label="${this._esc(label)}">` +
41
+ `<span class="rm-menu-label">${currentLabel}</span>` +
42
+ `<svg class="rm-menu-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>` +
43
+ `</button>` +
44
+ `<div class="rm-menu-list${compact ? " rm-menu-list-compact" : ""}" id="${listId}" role="listbox" data-menu-owner="${menuId}" aria-label="${this._esc(label)}">${optionsHtml}</div>` +
45
+ `</div>`;
46
+ },
47
+ _menuForElement(element) {
48
+ if (!element) return null;
49
+ const nestedMenu = element.closest?.(".rm-menu");
50
+ if (nestedMenu) return nestedMenu;
51
+ const ownerId = element.closest?.("[data-menu-owner]")?.dataset.menuOwner;
52
+ return ownerId ? document.getElementById(ownerId) : null;
53
+ },
54
+ _menuList(menu) {
55
+ if (!menu) return null;
56
+ return menu.querySelector(".rm-menu-list") ||
57
+ document.getElementById(`${menu.id}-list`);
58
+ },
59
+ _selectMenuOption(option) {
60
+ const menu = this._menuForElement(option);
61
+ if (!menu || !this.root?.contains(menu)) return;
62
+ const value = option.dataset.value;
63
+ const statusInput = menu.querySelector("[data-status-id]");
64
+ const statusId = statusInput?.dataset.statusId;
65
+ this._setMenuValue(menu, value);
66
+ if (!statusId) return;
67
+ const id = parseInt(statusId, 10);
68
+ if (Number.isNaN(id)) return;
69
+ this._changeStatus(id, value);
70
+ this.root.querySelector(`[data-status-id="${statusId}"]`)
71
+ ?.closest(".rm-menu")
72
+ ?.querySelector(".rm-menu-btn")
73
+ ?.focus();
74
+ },
75
+ _setMenuValue(inputOrMenu, value, restoreFocus = true) {
76
+ if (!inputOrMenu) return;
77
+ const menu = inputOrMenu.classList?.contains("rm-menu")
78
+ ? inputOrMenu
79
+ : inputOrMenu.closest?.(".rm-menu");
80
+ const input = menu
81
+ ? menu.querySelector('input[type="hidden"]')
82
+ : inputOrMenu;
83
+ if (!input) return;
84
+ const resolvedMenu = menu || input.closest(".rm-menu");
85
+ input.value = value;
86
+ if (!resolvedMenu) return;
87
+ const list = this._menuList(resolvedMenu);
88
+ const option = list?.querySelector(`.rm-menu-option[data-value="${value}"]`);
89
+ const label = resolvedMenu.querySelector(".rm-menu-label");
90
+ if (label && option) label.textContent = option.textContent;
91
+ list?.querySelectorAll(".rm-menu-option").forEach(opt => {
92
+ const active = opt.dataset.value === value;
93
+ opt.classList.toggle("rm-menu-option-active", active);
94
+ opt.setAttribute("aria-selected", active ? "true" : "false");
95
+ });
96
+ this._closeMenu(resolvedMenu, restoreFocus);
97
+ },
98
+ _toggleMenu(menu) {
99
+ const open = this._menuList(menu)?.classList.contains("rm-menu-open");
100
+ this._closeAllMenus();
101
+ if (!open) this._openMenu(menu);
102
+ },
103
+ _openMenu(menu) {
104
+ const list = this._menuList(menu);
105
+ const btn = menu.querySelector(".rm-menu-btn");
106
+ if (!list || !btn) return;
107
+ this._closeAllMenus();
108
+ const rect = btn.getBoundingClientRect();
109
+ const gap = 4;
110
+ const viewportPadding = 8;
111
+ const compactWidth = menu.classList.contains("rm-menu-compact") ? 120 : 0;
112
+ const minimumWidth = Math.max(rect.width || 0, compactWidth);
113
+
114
+ list._rmMenuPortalOrigin = {
115
+ parent: list.parentNode,
116
+ nextSibling: list.nextSibling
117
+ };
118
+ this.root.appendChild(list);
119
+ list.style.pointerEvents = "auto";
120
+ list.style.position = "fixed";
121
+ list.style.top = "0px";
122
+ list.style.bottom = "";
123
+ list.style.left = "0px";
124
+ list.style.right = "auto";
125
+ list.style.minWidth = `${minimumWidth}px`;
126
+ list.classList.add("rm-menu-open");
127
+ btn.setAttribute("aria-expanded", "true");
128
+
129
+ const menuWidth = Math.max(list.offsetWidth || 0, rect.width || 0, compactWidth);
130
+ const menuHeight = list.offsetHeight || 0;
131
+ const spaceBelow = window.innerHeight - rect.bottom - viewportPadding;
132
+ const spaceAbove = rect.top - viewportPadding;
133
+ const openUp = menuHeight > spaceBelow && spaceAbove > spaceBelow;
134
+ const preferredLeft = compactWidth ? rect.right - menuWidth : rect.left;
135
+ const maxLeft = Math.max(viewportPadding, window.innerWidth - menuWidth - viewportPadding);
136
+ const left = Math.min(Math.max(viewportPadding, preferredLeft), maxLeft);
137
+
138
+ list.style.position = "fixed";
139
+ list.style.top = openUp ? "" : `${rect.bottom + gap}px`;
140
+ list.style.bottom = openUp ? `${window.innerHeight - rect.top + gap}px` : "";
141
+ list.style.left = `${left}px`;
142
+ list.style.right = "auto";
143
+ list.style.minWidth = `${minimumWidth}px`;
144
+ const availableHeight = Math.max(0, (openUp ? spaceAbove : spaceBelow) - gap);
145
+ list.style.maxHeight = availableHeight ? `${availableHeight}px` : "";
146
+ list.style.overflowY = menuHeight > availableHeight && availableHeight > 0 ? "auto" : "";
147
+
148
+ const selected = list.querySelector('.rm-menu-option[aria-selected="true"]') ||
149
+ list.querySelector(".rm-menu-option");
150
+ selected?.focus();
151
+ },
152
+ _closeMenu(menu, restoreFocus = false) {
153
+ const list = this._menuList(menu);
154
+ const btn = menu.querySelector(".rm-menu-btn");
155
+ if (list) {
156
+ list.classList.remove("rm-menu-open");
157
+ ["position", "top", "bottom", "left", "right", "minWidth", "maxHeight", "overflowY", "pointerEvents"]
158
+ .forEach(property => { list.style[property] = ""; });
159
+ const origin = list._rmMenuPortalOrigin;
160
+ if (origin?.parent) {
161
+ if (origin.nextSibling?.parentNode === origin.parent) {
162
+ origin.parent.insertBefore(list, origin.nextSibling);
163
+ } else {
164
+ origin.parent.appendChild(list);
165
+ }
166
+ }
167
+ delete list._rmMenuPortalOrigin;
168
+ }
169
+ if (btn) btn.setAttribute("aria-expanded", "false");
170
+ if (restoreFocus) btn?.focus();
171
+ },
172
+ _closeAllMenus() {
173
+ if (!this.root) return;
174
+ this.root.querySelectorAll(".rm-menu").forEach(menu => this._closeMenu(menu));
175
+ },
176
+ });