rails-markup 1.4.3 → 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: 385e2a5ce158bd1832873c6edb4ec22f689209b7e5c7ceb33ac500c3a5e4e4fc
4
- data.tar.gz: be53ae296ff60727333058950f374ebf80a57e2e85a953db216614aeb2837c1b
3
+ metadata.gz: 88eacfb8b1c5006e209a9152bda3f464c54675d0460388624aefdae0c106e838
4
+ data.tar.gz: 34abe3d1f0f3c3cb0304114543bc1649c6c87a47fa7faa67577f37c1cd041bee
5
5
  SHA512:
6
- metadata.gz: 33bd8549180fb872d8a5cb07d68ce154e9df3fd82a2e6f36e16f36c434d2f2c82f6583977ca0e623b4c705b0054ed9e60d523db2fcfbab2f601fa5cb4548a690
7
- data.tar.gz: 78cf0596ec74242a08857c23b1cc27deb2594616a497157b60cb2f561726f628f5fd5c4ed5be40d9e0aa16074300ad024f09caa4a09c86f734a7df83975ee34a
6
+ metadata.gz: 1910ed15f034f2c343a29e5f2060ad35f868f6cc6a66619e43f21127304bbeb1eef3008a7490d584e3efa2c05964344d5eda6815b2ae0628e4d1a271227df094
7
+ data.tar.gz: 62cb48444c13a8c2c4812a3e065aff880b87646620166e92f7c21e0f6fb05bf3c0032fa82df423ce6e536b7c34cd1826b5dcc3e21cbc2cec4146b5ac1bd504a7
@@ -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
+ });