millas 0.2.12-beta → 0.2.12-beta-2
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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui.js — Millas Admin UI Utilities
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies. No framework. No third-party libraries.
|
|
5
|
+
* Minify-safe: no dynamic property names, no eval, no template magic.
|
|
6
|
+
*
|
|
7
|
+
* What this solves:
|
|
8
|
+
* The classic CSS stacking context trap — position:fixed breaks inside any
|
|
9
|
+
* ancestor that has transform, filter, perspective, or will-change set.
|
|
10
|
+
* position:absolute is clipped by overflow:hidden parents.
|
|
11
|
+
* Both issues kill dropdowns and modals in complex layouts.
|
|
12
|
+
*
|
|
13
|
+
* The solution — Portal rendering:
|
|
14
|
+
* Every floating element (dropdown, modal, tooltip, drawer) is moved into
|
|
15
|
+
* document.body via a dedicated #ui-portal container that sits at the very
|
|
16
|
+
* top of the DOM stacking order. Coordinates are calculated from the anchor
|
|
17
|
+
* element using getBoundingClientRect() and applied as fixed pixel positions.
|
|
18
|
+
* Scroll and resize listeners keep them in sync.
|
|
19
|
+
*
|
|
20
|
+
* Exports (on window.UI):
|
|
21
|
+
* Portal — low-level portal mount/unmount
|
|
22
|
+
* Dropdown — anchor-attached floating panel (FK select, action menus)
|
|
23
|
+
* Modal — centered overlay dialog
|
|
24
|
+
* Drawer — slide-in side panel
|
|
25
|
+
* Tooltip — hover label
|
|
26
|
+
* Toast — ephemeral status notification
|
|
27
|
+
* Confirm — promise-based confirm dialog
|
|
28
|
+
* FocusTrap — keyboard focus containment for modals/drawers
|
|
29
|
+
* ScrollLock — body scroll lock / unlock
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
(function (root) {
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
var PORTAL_ID = 'ui-portal';
|
|
38
|
+
var Z_DROPDOWN = 1100;
|
|
39
|
+
var Z_MODAL = 1200;
|
|
40
|
+
var Z_DRAWER = 1200;
|
|
41
|
+
var Z_TOOLTIP = 1300;
|
|
42
|
+
var Z_TOAST = 1400;
|
|
43
|
+
var EASE_OUT = 'cubic-bezier(0.16,1,0.3,1)';
|
|
44
|
+
var EASE_IN = 'cubic-bezier(0.4,0,1,1)';
|
|
45
|
+
|
|
46
|
+
// ── Portal ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Portal — appends floating elements to document.body, bypassing all
|
|
50
|
+
* stacking context traps from transformed or overflow:hidden ancestors.
|
|
51
|
+
*
|
|
52
|
+
* Usage:
|
|
53
|
+
* var el = Portal.mount('<div class="my-panel">...</div>');
|
|
54
|
+
* Portal.unmount(el);
|
|
55
|
+
*/
|
|
56
|
+
var Portal = (function () {
|
|
57
|
+
function _container() {
|
|
58
|
+
var c = document.getElementById(PORTAL_ID);
|
|
59
|
+
if (!c) {
|
|
60
|
+
c = document.createElement('div');
|
|
61
|
+
c.id = PORTAL_ID;
|
|
62
|
+
c.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:' + Z_DROPDOWN + ';pointer-events:none';
|
|
63
|
+
document.body.appendChild(c);
|
|
64
|
+
}
|
|
65
|
+
return c;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Mount an element or HTML string into the portal container.
|
|
70
|
+
* Returns the mounted element.
|
|
71
|
+
*/
|
|
72
|
+
function mount(elOrHtml) {
|
|
73
|
+
var el;
|
|
74
|
+
if (typeof elOrHtml === 'string') {
|
|
75
|
+
var wrap = document.createElement('div');
|
|
76
|
+
wrap.innerHTML = elOrHtml;
|
|
77
|
+
el = wrap.firstElementChild;
|
|
78
|
+
} else {
|
|
79
|
+
el = elOrHtml;
|
|
80
|
+
}
|
|
81
|
+
el.style.pointerEvents = 'auto';
|
|
82
|
+
_container().appendChild(el);
|
|
83
|
+
return el;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove an element from the portal container.
|
|
88
|
+
*/
|
|
89
|
+
function unmount(el) {
|
|
90
|
+
if (el && el.parentNode && el.parentNode.id === PORTAL_ID) {
|
|
91
|
+
el.parentNode.removeChild(el);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove all portal children (nuclear reset).
|
|
97
|
+
*/
|
|
98
|
+
function clear() {
|
|
99
|
+
var c = document.getElementById(PORTAL_ID);
|
|
100
|
+
if (c) c.innerHTML = '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { mount: mount, unmount: unmount, clear: clear };
|
|
104
|
+
})();
|
|
105
|
+
|
|
106
|
+
// ── ScrollLock ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ScrollLock — locks body scroll when modals/drawers are open.
|
|
110
|
+
* Uses a reference count so nested lock/unlock calls are safe.
|
|
111
|
+
*/
|
|
112
|
+
var ScrollLock = (function () {
|
|
113
|
+
var _count = 0;
|
|
114
|
+
var _scrollY = 0;
|
|
115
|
+
var _origStyle = '';
|
|
116
|
+
|
|
117
|
+
function lock() {
|
|
118
|
+
_count++;
|
|
119
|
+
if (_count > 1) return;
|
|
120
|
+
_scrollY = window.pageYOffset || document.documentElement.scrollTop;
|
|
121
|
+
_origStyle = document.body.style.cssText;
|
|
122
|
+
document.body.style.cssText = _origStyle +
|
|
123
|
+
';overflow:hidden;position:fixed;top:-' + _scrollY + 'px;left:0;right:0;';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function unlock() {
|
|
127
|
+
_count = Math.max(0, _count - 1);
|
|
128
|
+
if (_count > 0) return;
|
|
129
|
+
document.body.style.cssText = _origStyle;
|
|
130
|
+
window.scrollTo(0, _scrollY);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { lock: lock, unlock: unlock };
|
|
134
|
+
})();
|
|
135
|
+
|
|
136
|
+
// ── FocusTrap ───────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* FocusTrap — keeps keyboard focus inside a container (modal, drawer).
|
|
140
|
+
* Call activate(el) to trap, deactivate() to release.
|
|
141
|
+
*/
|
|
142
|
+
var FocusTrap = (function () {
|
|
143
|
+
var _el = null;
|
|
144
|
+
var _prevFocus = null;
|
|
145
|
+
var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
146
|
+
|
|
147
|
+
function _onKeyDown(e) {
|
|
148
|
+
if (!_el || e.key !== 'Tab') return;
|
|
149
|
+
var focusable = Array.from(_el.querySelectorAll(FOCUSABLE)).filter(function (n) {
|
|
150
|
+
return !n.offsetParent === false; // visible only
|
|
151
|
+
});
|
|
152
|
+
if (!focusable.length) { e.preventDefault(); return; }
|
|
153
|
+
var first = focusable[0];
|
|
154
|
+
var last = focusable[focusable.length - 1];
|
|
155
|
+
if (e.shiftKey) {
|
|
156
|
+
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
157
|
+
} else {
|
|
158
|
+
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function activate(el) {
|
|
163
|
+
_el = el;
|
|
164
|
+
_prevFocus = document.activeElement;
|
|
165
|
+
document.addEventListener('keydown', _onKeyDown);
|
|
166
|
+
// Focus first focusable element
|
|
167
|
+
var first = el.querySelector(FOCUSABLE);
|
|
168
|
+
if (first) {
|
|
169
|
+
requestAnimationFrame(function () { first.focus(); });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function deactivate() {
|
|
174
|
+
document.removeEventListener('keydown', _onKeyDown);
|
|
175
|
+
_el = null;
|
|
176
|
+
if (_prevFocus && _prevFocus.focus) {
|
|
177
|
+
requestAnimationFrame(function () { _prevFocus.focus(); });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { activate: activate, deactivate: deactivate };
|
|
182
|
+
})();
|
|
183
|
+
|
|
184
|
+
// ── Dropdown ─────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Dropdown — an anchor-attached floating panel rendered in the portal.
|
|
188
|
+
* Handles position, scroll, resize, outside-click, and keyboard dismiss.
|
|
189
|
+
*
|
|
190
|
+
* Usage:
|
|
191
|
+
* var dd = Dropdown.create({
|
|
192
|
+
* anchor: document.getElementById('my-btn'),
|
|
193
|
+
* content: '<div class="my-menu">...</div>',
|
|
194
|
+
* placement: 'bottom-start', // 'bottom-start'|'bottom-end'|'top-start'|'top-end'
|
|
195
|
+
* offset: 4, // gap between anchor and panel (px)
|
|
196
|
+
* onClose: function() {},
|
|
197
|
+
* className: 'my-dropdown-panel',
|
|
198
|
+
* });
|
|
199
|
+
* dd.open();
|
|
200
|
+
* dd.close();
|
|
201
|
+
* dd.destroy();
|
|
202
|
+
*/
|
|
203
|
+
var Dropdown = (function () {
|
|
204
|
+
|
|
205
|
+
function create(opts) {
|
|
206
|
+
var anchor = opts.anchor;
|
|
207
|
+
var placement = opts.placement || 'bottom-start';
|
|
208
|
+
var offset = opts.offset !== undefined ? opts.offset : 4;
|
|
209
|
+
var onClose = opts.onClose || null;
|
|
210
|
+
var className = opts.className || 'ui-dropdown-panel';
|
|
211
|
+
var minWidth = opts.minWidth || null;
|
|
212
|
+
var maxHeight = opts.maxHeight || 300;
|
|
213
|
+
|
|
214
|
+
// Use the provided HTMLElement directly to avoid double-wrapping.
|
|
215
|
+
// For string content, create a wrapper div.
|
|
216
|
+
var panel;
|
|
217
|
+
if (opts.content instanceof HTMLElement) {
|
|
218
|
+
panel = opts.content;
|
|
219
|
+
if (className && !panel.className) panel.className = className;
|
|
220
|
+
} else {
|
|
221
|
+
panel = document.createElement('div');
|
|
222
|
+
panel.className = className;
|
|
223
|
+
if (typeof opts.content === 'string') panel.innerHTML = opts.content;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Own only the positioning properties — leave class-based styles
|
|
227
|
+
// (border, shadow, radius) untouched.
|
|
228
|
+
panel.style.position = 'fixed';
|
|
229
|
+
panel.style.zIndex = Z_DROPDOWN;
|
|
230
|
+
panel.style.opacity = '0';
|
|
231
|
+
panel.style.transform = 'translateY(-4px)';
|
|
232
|
+
panel.style.transition = 'opacity .15s,transform .15s ' + EASE_OUT;
|
|
233
|
+
panel.style.pointerEvents = 'none';
|
|
234
|
+
panel.style.maxHeight = maxHeight + 'px';
|
|
235
|
+
panel.style.overflowY = 'auto'; // scroll if content exceeds maxHeight
|
|
236
|
+
panel.style.display = 'none';
|
|
237
|
+
|
|
238
|
+
// Transparent backdrop — sits just below the panel, covers the whole
|
|
239
|
+
// viewport. Clicking it closes the dropdown. Gives the user a clear
|
|
240
|
+
// "everything else is off limits" signal without any visible overlay.
|
|
241
|
+
var backdrop = document.createElement('div');
|
|
242
|
+
backdrop.style.cssText =
|
|
243
|
+
'position:fixed;inset:0;z-index:' + (Z_DROPDOWN - 1) + ';' +
|
|
244
|
+
'background:transparent;cursor:default;display:none;';
|
|
245
|
+
backdrop.addEventListener('mousedown', function (e) {
|
|
246
|
+
e.preventDefault(); // don't steal focus from the trigger
|
|
247
|
+
close();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
var _isOpen = false;
|
|
251
|
+
var _closeTimer = null;
|
|
252
|
+
var _scrollParents = [];
|
|
253
|
+
|
|
254
|
+
function _onEscape(e) {
|
|
255
|
+
if (e.key === 'Escape') close();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function _getScrollParents() {
|
|
259
|
+
var parents = [];
|
|
260
|
+
var node = anchor.parentNode;
|
|
261
|
+
while (node && node !== document.body) {
|
|
262
|
+
var s = getComputedStyle(node);
|
|
263
|
+
if (/auto|scroll/.test(s.overflow + s.overflowX + s.overflowY)) {
|
|
264
|
+
parents.push(node);
|
|
265
|
+
}
|
|
266
|
+
node = node.parentNode;
|
|
267
|
+
}
|
|
268
|
+
parents.push(window);
|
|
269
|
+
return parents;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _position() {
|
|
273
|
+
var rect = anchor.getBoundingClientRect();
|
|
274
|
+
var vw = window.innerWidth;
|
|
275
|
+
var vh = window.innerHeight;
|
|
276
|
+
var pw = panel.offsetWidth || 220;
|
|
277
|
+
var ph = panel.offsetHeight || 120;
|
|
278
|
+
|
|
279
|
+
var preferTop = placement.startsWith('top');
|
|
280
|
+
var goUp = preferTop
|
|
281
|
+
? true
|
|
282
|
+
: (vh - rect.bottom < ph + offset && rect.top > vh - rect.bottom);
|
|
283
|
+
|
|
284
|
+
var top = goUp ? rect.top - ph - offset : rect.bottom + offset;
|
|
285
|
+
var left = placement.endsWith('end') ? rect.right - pw : rect.left;
|
|
286
|
+
|
|
287
|
+
left = Math.max(8, Math.min(left, vw - pw - 8));
|
|
288
|
+
top = Math.max(8, Math.min(top, vh - ph - 8));
|
|
289
|
+
|
|
290
|
+
panel.style.top = top + 'px';
|
|
291
|
+
panel.style.left = left + 'px';
|
|
292
|
+
|
|
293
|
+
if (minWidth === true) panel.style.minWidth = rect.width + 'px';
|
|
294
|
+
else if (minWidth) panel.style.minWidth = minWidth + 'px';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function open() {
|
|
298
|
+
if (_isOpen) return;
|
|
299
|
+
|
|
300
|
+
// Cancel any in-flight unmount for this panel
|
|
301
|
+
if (_closeTimer) { clearTimeout(_closeTimer); _closeTimer = null; }
|
|
302
|
+
|
|
303
|
+
_isOpen = true;
|
|
304
|
+
|
|
305
|
+
backdrop.style.display = '';
|
|
306
|
+
panel.style.display = '';
|
|
307
|
+
panel.style.opacity = '0';
|
|
308
|
+
panel.style.transform = 'translateY(-4px)';
|
|
309
|
+
panel.style.pointerEvents = 'none';
|
|
310
|
+
|
|
311
|
+
Portal.mount(backdrop);
|
|
312
|
+
Portal.mount(panel);
|
|
313
|
+
panel.getBoundingClientRect(); // force layout so dimensions are real
|
|
314
|
+
_position();
|
|
315
|
+
|
|
316
|
+
panel.style.opacity = '1';
|
|
317
|
+
panel.style.transform = 'translateY(0)';
|
|
318
|
+
panel.style.pointerEvents = 'auto';
|
|
319
|
+
|
|
320
|
+
// Per-instance listeners — added on open, removed on close
|
|
321
|
+
_scrollParents = _getScrollParents();
|
|
322
|
+
_scrollParents.forEach(function (p) {
|
|
323
|
+
p.addEventListener('scroll', _position, { passive: true });
|
|
324
|
+
});
|
|
325
|
+
window.addEventListener('resize', _position, { passive: true });
|
|
326
|
+
document.addEventListener('keydown', _onEscape);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function close() {
|
|
330
|
+
if (!_isOpen) return;
|
|
331
|
+
_isOpen = false;
|
|
332
|
+
|
|
333
|
+
// Remove all listeners this instance added
|
|
334
|
+
_scrollParents.forEach(function (p) {
|
|
335
|
+
p.removeEventListener('scroll', _position);
|
|
336
|
+
});
|
|
337
|
+
window.removeEventListener('resize', _position);
|
|
338
|
+
document.removeEventListener('keydown', _onEscape);
|
|
339
|
+
_scrollParents = [];
|
|
340
|
+
|
|
341
|
+
// Hide backdrop immediately — no animation needed
|
|
342
|
+
backdrop.style.display = 'none';
|
|
343
|
+
Portal.unmount(backdrop);
|
|
344
|
+
|
|
345
|
+
panel.style.opacity = '0';
|
|
346
|
+
panel.style.transform = 'translateY(-4px)';
|
|
347
|
+
panel.style.pointerEvents = 'none';
|
|
348
|
+
|
|
349
|
+
_closeTimer = setTimeout(function () {
|
|
350
|
+
_closeTimer = null;
|
|
351
|
+
panel.style.display = 'none';
|
|
352
|
+
Portal.unmount(panel);
|
|
353
|
+
if (onClose) onClose();
|
|
354
|
+
}, 160);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function toggle() { _isOpen ? close() : open(); }
|
|
358
|
+
|
|
359
|
+
function destroy() {
|
|
360
|
+
if (_closeTimer) clearTimeout(_closeTimer);
|
|
361
|
+
if (_isOpen) close();
|
|
362
|
+
if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
|
|
363
|
+
if (panel.parentNode) panel.parentNode.removeChild(panel);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function reposition() { requestAnimationFrame(_position); }
|
|
367
|
+
|
|
368
|
+
var instance = {
|
|
369
|
+
_el: panel,
|
|
370
|
+
_anchor: anchor,
|
|
371
|
+
open: open,
|
|
372
|
+
close: close,
|
|
373
|
+
toggle: toggle,
|
|
374
|
+
destroy: destroy,
|
|
375
|
+
isOpen: function () { return _isOpen; },
|
|
376
|
+
reposition: reposition,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
return instance;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { create: create };
|
|
383
|
+
})();
|
|
384
|
+
|
|
385
|
+
// ── Modal ───────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Modal — centered overlay dialog rendered in the portal.
|
|
389
|
+
* Traps focus, locks scroll, handles Escape.
|
|
390
|
+
*
|
|
391
|
+
* Usage:
|
|
392
|
+
* var m = Modal.create({
|
|
393
|
+
* title: 'Confirm Delete',
|
|
394
|
+
* content: '<p>Are you sure?</p>',
|
|
395
|
+
* size: 'sm', // 'sm'|'md'|'lg'|'xl' — default 'md'
|
|
396
|
+
* footer: '<button>OK</button>',
|
|
397
|
+
* onClose: function() {},
|
|
398
|
+
* closeable: true, // show ✕ and allow Escape/backdrop close
|
|
399
|
+
* });
|
|
400
|
+
* m.open();
|
|
401
|
+
* m.close();
|
|
402
|
+
*
|
|
403
|
+
* // Or use the shorthand:
|
|
404
|
+
* Modal.open({ title: 'Hello', content: '<p>World</p>' });
|
|
405
|
+
*/
|
|
406
|
+
var Modal = (function () {
|
|
407
|
+
var _stack = []; // support nested modals
|
|
408
|
+
|
|
409
|
+
var SIZES = { sm: '400px', md: '520px', lg: '700px', xl: '900px' };
|
|
410
|
+
|
|
411
|
+
// Escape closes topmost modal
|
|
412
|
+
document.addEventListener('keydown', function (e) {
|
|
413
|
+
if (e.key === 'Escape' && _stack.length) {
|
|
414
|
+
var top = _stack[_stack.length - 1];
|
|
415
|
+
if (top.closeable !== false) top.close();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
function create(opts) {
|
|
420
|
+
var title = opts.title || '';
|
|
421
|
+
var content = opts.content || '';
|
|
422
|
+
var footer = opts.footer || null;
|
|
423
|
+
var size = opts.size || 'md';
|
|
424
|
+
var closeable = opts.closeable !== false;
|
|
425
|
+
var onClose = opts.onClose || null;
|
|
426
|
+
var onOpen = opts.onOpen || null;
|
|
427
|
+
var className = opts.className || '';
|
|
428
|
+
|
|
429
|
+
var maxW = SIZES[size] || SIZES.md;
|
|
430
|
+
|
|
431
|
+
// Build overlay + dialog
|
|
432
|
+
var overlay = document.createElement('div');
|
|
433
|
+
overlay.style.cssText =
|
|
434
|
+
'position:fixed;inset:0;' +
|
|
435
|
+
'background:rgba(17,24,39,.55);' +
|
|
436
|
+
'backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);' +
|
|
437
|
+
'display:flex;align-items:center;justify-content:center;' +
|
|
438
|
+
'padding:24px;z-index:' + Z_MODAL + ';' +
|
|
439
|
+
'opacity:0;transition:opacity .18s;pointer-events:none;';
|
|
440
|
+
overlay.setAttribute('role', 'dialog');
|
|
441
|
+
overlay.setAttribute('aria-modal', 'true');
|
|
442
|
+
if (title) overlay.setAttribute('aria-label', title);
|
|
443
|
+
|
|
444
|
+
var dialog = document.createElement('div');
|
|
445
|
+
dialog.style.cssText =
|
|
446
|
+
'background:var(--surface,#fff);' +
|
|
447
|
+
'border:1px solid var(--border,#e3e6ec);' +
|
|
448
|
+
'border-radius:var(--radius-lg,12px);' +
|
|
449
|
+
'width:100%;max-width:' + maxW + ';' +
|
|
450
|
+
'max-height:90vh;overflow-y:auto;' +
|
|
451
|
+
'transform:translateY(12px) scale(.98);' +
|
|
452
|
+
'transition:transform .22s ' + EASE_OUT + ';' +
|
|
453
|
+
'box-shadow:0 20px 60px rgba(0,0,0,.18);' +
|
|
454
|
+
(className ? '' : '');
|
|
455
|
+
if (className) dialog.className = className;
|
|
456
|
+
|
|
457
|
+
// Header
|
|
458
|
+
var header = document.createElement('div');
|
|
459
|
+
header.style.cssText =
|
|
460
|
+
'padding:18px 22px;border-bottom:1px solid var(--border-soft,#edf0f5);' +
|
|
461
|
+
'display:flex;justify-content:space-between;align-items:center;' +
|
|
462
|
+
'position:sticky;top:0;background:var(--surface,#fff);z-index:1;';
|
|
463
|
+
|
|
464
|
+
var titleEl = document.createElement('span');
|
|
465
|
+
titleEl.style.cssText = 'font-size:15px;font-weight:600;color:var(--text,#111827)';
|
|
466
|
+
titleEl.textContent = title;
|
|
467
|
+
header.appendChild(titleEl);
|
|
468
|
+
|
|
469
|
+
if (closeable) {
|
|
470
|
+
var closeBtn = document.createElement('button');
|
|
471
|
+
closeBtn.type = 'button';
|
|
472
|
+
closeBtn.style.cssText =
|
|
473
|
+
'background:none;border:none;cursor:pointer;color:var(--text-muted,#6b7280);' +
|
|
474
|
+
'padding:4px;border-radius:4px;display:flex;align-items:center;' +
|
|
475
|
+
'transition:background .1s,color .1s;';
|
|
476
|
+
closeBtn.innerHTML =
|
|
477
|
+
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
|
478
|
+
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
479
|
+
closeBtn.addEventListener('mouseenter', function () {
|
|
480
|
+
closeBtn.style.background = 'var(--surface3,#eef0f4)';
|
|
481
|
+
closeBtn.style.color = 'var(--text,#111827)';
|
|
482
|
+
});
|
|
483
|
+
closeBtn.addEventListener('mouseleave', function () {
|
|
484
|
+
closeBtn.style.background = '';
|
|
485
|
+
closeBtn.style.color = '';
|
|
486
|
+
});
|
|
487
|
+
closeBtn.addEventListener('click', function () { instance.close(); });
|
|
488
|
+
header.appendChild(closeBtn);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Body
|
|
492
|
+
var body = document.createElement('div');
|
|
493
|
+
body.style.cssText = 'padding:20px 22px;';
|
|
494
|
+
if (typeof content === 'string') {
|
|
495
|
+
body.innerHTML = content;
|
|
496
|
+
} else if (content instanceof HTMLElement) {
|
|
497
|
+
body.appendChild(content);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Footer
|
|
501
|
+
var footerEl = null;
|
|
502
|
+
if (footer) {
|
|
503
|
+
footerEl = document.createElement('div');
|
|
504
|
+
footerEl.style.cssText =
|
|
505
|
+
'padding:14px 22px;border-top:1px solid var(--border-soft,#edf0f5);' +
|
|
506
|
+
'display:flex;justify-content:flex-end;gap:8px;' +
|
|
507
|
+
'position:sticky;bottom:0;background:var(--surface,#fff);';
|
|
508
|
+
if (typeof footer === 'string') {
|
|
509
|
+
footerEl.innerHTML = footer;
|
|
510
|
+
} else if (footer instanceof HTMLElement) {
|
|
511
|
+
footerEl.appendChild(footer);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
dialog.appendChild(header);
|
|
516
|
+
dialog.appendChild(body);
|
|
517
|
+
if (footerEl) dialog.appendChild(footerEl);
|
|
518
|
+
overlay.appendChild(dialog);
|
|
519
|
+
|
|
520
|
+
// Backdrop click to close
|
|
521
|
+
if (closeable) {
|
|
522
|
+
overlay.addEventListener('mousedown', function (e) {
|
|
523
|
+
if (e.target === overlay) instance.close();
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
var _isOpen = false;
|
|
528
|
+
|
|
529
|
+
function open() {
|
|
530
|
+
if (_isOpen) return;
|
|
531
|
+
_isOpen = true;
|
|
532
|
+
_stack.push(instance);
|
|
533
|
+
|
|
534
|
+
Portal.mount(overlay);
|
|
535
|
+
ScrollLock.lock();
|
|
536
|
+
FocusTrap.activate(dialog);
|
|
537
|
+
|
|
538
|
+
// Animate in
|
|
539
|
+
overlay.getBoundingClientRect(); // force layout
|
|
540
|
+
overlay.style.opacity = '1';
|
|
541
|
+
overlay.style.pointerEvents = 'auto';
|
|
542
|
+
dialog.style.transform = 'translateY(0) scale(1)';
|
|
543
|
+
|
|
544
|
+
if (onOpen) onOpen(instance);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function close() {
|
|
548
|
+
if (!_isOpen) return;
|
|
549
|
+
_isOpen = false;
|
|
550
|
+
_stack = _stack.filter(function (m) { return m !== instance; });
|
|
551
|
+
|
|
552
|
+
overlay.style.opacity = '0';
|
|
553
|
+
overlay.style.pointerEvents = 'none';
|
|
554
|
+
dialog.style.transform = 'translateY(12px) scale(.98)';
|
|
555
|
+
|
|
556
|
+
FocusTrap.deactivate();
|
|
557
|
+
ScrollLock.unlock();
|
|
558
|
+
|
|
559
|
+
setTimeout(function () {
|
|
560
|
+
Portal.unmount(overlay);
|
|
561
|
+
if (onClose) onClose(instance);
|
|
562
|
+
}, 200);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Expose body and footer for content updates
|
|
566
|
+
function setContent(html) {
|
|
567
|
+
if (typeof html === 'string') body.innerHTML = html;
|
|
568
|
+
else if (html instanceof HTMLElement) { body.innerHTML = ''; body.appendChild(html); }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function setTitle(t) {
|
|
572
|
+
titleEl.textContent = t;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
var instance = {
|
|
576
|
+
overlay: overlay,
|
|
577
|
+
dialog: dialog,
|
|
578
|
+
body: body,
|
|
579
|
+
closeable: closeable,
|
|
580
|
+
open: open,
|
|
581
|
+
close: close,
|
|
582
|
+
setContent: setContent,
|
|
583
|
+
setTitle: setTitle,
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
return instance;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Shorthand — create + immediately open
|
|
590
|
+
function open(opts) {
|
|
591
|
+
var m = create(opts);
|
|
592
|
+
m.open();
|
|
593
|
+
return m;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return { create: create, open: open };
|
|
597
|
+
})();
|
|
598
|
+
|
|
599
|
+
// ── Drawer ──────────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Drawer — slide-in panel from a screen edge.
|
|
603
|
+
*
|
|
604
|
+
* Usage:
|
|
605
|
+
* var d = Drawer.create({
|
|
606
|
+
* title: 'Filters',
|
|
607
|
+
* content: '<p>Filter controls here</p>',
|
|
608
|
+
* side: 'right', // 'right'|'left'|'bottom' — default 'right'
|
|
609
|
+
* width: '400px', // for left/right drawers
|
|
610
|
+
* height: '50vh', // for bottom drawer
|
|
611
|
+
* onClose: function() {},
|
|
612
|
+
* });
|
|
613
|
+
* d.open();
|
|
614
|
+
*/
|
|
615
|
+
var Drawer = (function () {
|
|
616
|
+
function create(opts) {
|
|
617
|
+
var title = opts.title || '';
|
|
618
|
+
var content = opts.content || '';
|
|
619
|
+
var side = opts.side || 'right';
|
|
620
|
+
var width = opts.width || '420px';
|
|
621
|
+
var height = opts.height || '50vh';
|
|
622
|
+
var closeable = opts.closeable !== false;
|
|
623
|
+
var onClose = opts.onClose || null;
|
|
624
|
+
|
|
625
|
+
// Backdrop
|
|
626
|
+
var backdrop = document.createElement('div');
|
|
627
|
+
backdrop.style.cssText =
|
|
628
|
+
'position:fixed;inset:0;background:rgba(17,24,39,.4);' +
|
|
629
|
+
'z-index:' + Z_DRAWER + ';opacity:0;pointer-events:none;' +
|
|
630
|
+
'transition:opacity .22s;';
|
|
631
|
+
|
|
632
|
+
// Panel
|
|
633
|
+
var panel = document.createElement('div');
|
|
634
|
+
var isHoriz = side === 'left' || side === 'right';
|
|
635
|
+
var panelBase =
|
|
636
|
+
'position:fixed;z-index:' + (Z_DRAWER + 1) + ';' +
|
|
637
|
+
'background:var(--surface,#fff);' +
|
|
638
|
+
'box-shadow:0 0 40px rgba(0,0,0,.18);' +
|
|
639
|
+
'display:flex;flex-direction:column;' +
|
|
640
|
+
'transition:transform .28s ' + EASE_OUT + ';';
|
|
641
|
+
|
|
642
|
+
if (side === 'right') {
|
|
643
|
+
panel.style.cssText = panelBase + 'top:0;right:0;bottom:0;width:' + width + ';max-width:95vw;transform:translateX(100%);';
|
|
644
|
+
} else if (side === 'left') {
|
|
645
|
+
panel.style.cssText = panelBase + 'top:0;left:0;bottom:0;width:' + width + ';max-width:95vw;transform:translateX(-100%);';
|
|
646
|
+
} else {
|
|
647
|
+
// bottom
|
|
648
|
+
panel.style.cssText = panelBase + 'left:0;right:0;bottom:0;height:' + height + ';border-radius:12px 12px 0 0;transform:translateY(100%);';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Header
|
|
652
|
+
var header = document.createElement('div');
|
|
653
|
+
header.style.cssText =
|
|
654
|
+
'padding:18px 20px;border-bottom:1px solid var(--border,#e3e6ec);' +
|
|
655
|
+
'display:flex;align-items:center;justify-content:space-between;flex-shrink:0;';
|
|
656
|
+
|
|
657
|
+
var titleEl = document.createElement('span');
|
|
658
|
+
titleEl.style.cssText = 'font-size:15px;font-weight:600;color:var(--text,#111827)';
|
|
659
|
+
titleEl.textContent = title;
|
|
660
|
+
header.appendChild(titleEl);
|
|
661
|
+
|
|
662
|
+
if (closeable) {
|
|
663
|
+
var closeBtn = document.createElement('button');
|
|
664
|
+
closeBtn.type = 'button';
|
|
665
|
+
closeBtn.style.cssText = 'background:none;border:none;cursor:pointer;color:var(--text-muted,#6b7280);padding:4px;border-radius:4px;display:flex;';
|
|
666
|
+
closeBtn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
667
|
+
closeBtn.addEventListener('click', function () { instance.close(); });
|
|
668
|
+
header.appendChild(closeBtn);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Body
|
|
672
|
+
var body = document.createElement('div');
|
|
673
|
+
body.style.cssText = 'flex:1;overflow-y:auto;padding:20px;';
|
|
674
|
+
if (typeof content === 'string') body.innerHTML = content;
|
|
675
|
+
else if (content instanceof HTMLElement) body.appendChild(content);
|
|
676
|
+
|
|
677
|
+
panel.appendChild(header);
|
|
678
|
+
panel.appendChild(body);
|
|
679
|
+
|
|
680
|
+
if (closeable) {
|
|
681
|
+
backdrop.addEventListener('click', function () { instance.close(); });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Escape
|
|
685
|
+
function _onEsc(e) { if (e.key === 'Escape' && closeable) instance.close(); }
|
|
686
|
+
|
|
687
|
+
var _isOpen = false;
|
|
688
|
+
|
|
689
|
+
function open() {
|
|
690
|
+
if (_isOpen) return;
|
|
691
|
+
_isOpen = true;
|
|
692
|
+
|
|
693
|
+
Portal.mount(backdrop);
|
|
694
|
+
Portal.mount(panel);
|
|
695
|
+
ScrollLock.lock();
|
|
696
|
+
FocusTrap.activate(panel);
|
|
697
|
+
|
|
698
|
+
backdrop.getBoundingClientRect();
|
|
699
|
+
backdrop.style.opacity = '1';
|
|
700
|
+
backdrop.style.pointerEvents = 'auto';
|
|
701
|
+
panel.style.transform = 'translate(0,0)';
|
|
702
|
+
|
|
703
|
+
document.addEventListener('keydown', _onEsc);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function close() {
|
|
707
|
+
if (!_isOpen) return;
|
|
708
|
+
_isOpen = false;
|
|
709
|
+
|
|
710
|
+
backdrop.style.opacity = '0';
|
|
711
|
+
backdrop.style.pointerEvents = 'none';
|
|
712
|
+
if (side === 'right') panel.style.transform = 'translateX(100%)';
|
|
713
|
+
else if (side === 'left') panel.style.transform = 'translateX(-100%)';
|
|
714
|
+
else panel.style.transform = 'translateY(100%)';
|
|
715
|
+
|
|
716
|
+
FocusTrap.deactivate();
|
|
717
|
+
ScrollLock.unlock();
|
|
718
|
+
document.removeEventListener('keydown', _onEsc);
|
|
719
|
+
|
|
720
|
+
setTimeout(function () {
|
|
721
|
+
Portal.unmount(backdrop);
|
|
722
|
+
Portal.unmount(panel);
|
|
723
|
+
if (onClose) onClose(instance);
|
|
724
|
+
}, 280);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function setContent(html) {
|
|
728
|
+
if (typeof html === 'string') body.innerHTML = html;
|
|
729
|
+
else if (html instanceof HTMLElement) { body.innerHTML = ''; body.appendChild(html); }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
var instance = {
|
|
733
|
+
panel: panel,
|
|
734
|
+
body: body,
|
|
735
|
+
open: open,
|
|
736
|
+
close: close,
|
|
737
|
+
setContent: setContent,
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
return instance;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function open(opts) {
|
|
744
|
+
var d = create(opts);
|
|
745
|
+
d.open();
|
|
746
|
+
return d;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return { create: create, open: open };
|
|
750
|
+
})();
|
|
751
|
+
|
|
752
|
+
// ── Tooltip ─────────────────────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Tooltip — lightweight hover label anchored to any element.
|
|
756
|
+
*
|
|
757
|
+
* Usage:
|
|
758
|
+
* // Declarative — add data-tooltip="text" to any element, auto-initialised
|
|
759
|
+
* <button data-tooltip="Save changes">Save</button>
|
|
760
|
+
*
|
|
761
|
+
* // Programmatic
|
|
762
|
+
* var t = Tooltip.create({ anchor: btn, text: 'Save changes', placement: 'top' });
|
|
763
|
+
* t.show(); t.hide();
|
|
764
|
+
*/
|
|
765
|
+
var Tooltip = (function () {
|
|
766
|
+
var _tip = null; // single shared tooltip element, re-used for all
|
|
767
|
+
|
|
768
|
+
function _ensureTip() {
|
|
769
|
+
if (!_tip) {
|
|
770
|
+
_tip = document.createElement('div');
|
|
771
|
+
_tip.style.cssText =
|
|
772
|
+
'position:fixed;z-index:' + Z_TOOLTIP + ';' +
|
|
773
|
+
'background:var(--text,#111827);color:#fff;' +
|
|
774
|
+
'font-size:12px;font-family:inherit;' +
|
|
775
|
+
'padding:5px 9px;border-radius:5px;' +
|
|
776
|
+
'pointer-events:none;white-space:nowrap;' +
|
|
777
|
+
'opacity:0;transition:opacity .12s;' +
|
|
778
|
+
'box-shadow:0 2px 8px rgba(0,0,0,.2);';
|
|
779
|
+
Portal.mount(_tip);
|
|
780
|
+
}
|
|
781
|
+
return _tip;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function _position(anchor, placement) {
|
|
785
|
+
var tip = _ensureTip();
|
|
786
|
+
var rect = anchor.getBoundingClientRect();
|
|
787
|
+
var tw = tip.offsetWidth;
|
|
788
|
+
var th = tip.offsetHeight;
|
|
789
|
+
var gap = 6;
|
|
790
|
+
var top, left;
|
|
791
|
+
|
|
792
|
+
placement = placement || 'top';
|
|
793
|
+
|
|
794
|
+
if (placement === 'top') {
|
|
795
|
+
top = rect.top - th - gap;
|
|
796
|
+
left = rect.left + rect.width / 2 - tw / 2;
|
|
797
|
+
} else if (placement === 'bottom') {
|
|
798
|
+
top = rect.bottom + gap;
|
|
799
|
+
left = rect.left + rect.width / 2 - tw / 2;
|
|
800
|
+
} else if (placement === 'left') {
|
|
801
|
+
top = rect.top + rect.height / 2 - th / 2;
|
|
802
|
+
left = rect.left - tw - gap;
|
|
803
|
+
} else {
|
|
804
|
+
top = rect.top + rect.height / 2 - th / 2;
|
|
805
|
+
left = rect.right + gap;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Clamp
|
|
809
|
+
var vw = window.innerWidth, vh = window.innerHeight;
|
|
810
|
+
left = Math.max(8, Math.min(left, vw - tw - 8));
|
|
811
|
+
top = Math.max(8, Math.min(top, vh - th - 8));
|
|
812
|
+
|
|
813
|
+
tip.style.top = top + 'px';
|
|
814
|
+
tip.style.left = left + 'px';
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function create(opts) {
|
|
818
|
+
var anchor = opts.anchor;
|
|
819
|
+
var text = opts.text || '';
|
|
820
|
+
var placement = opts.placement || 'top';
|
|
821
|
+
|
|
822
|
+
function show() {
|
|
823
|
+
var tip = _ensureTip();
|
|
824
|
+
tip.textContent = text;
|
|
825
|
+
_position(anchor, placement);
|
|
826
|
+
tip.style.opacity = '1';
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function hide() {
|
|
830
|
+
var tip = _ensureTip();
|
|
831
|
+
tip.style.opacity = '0';
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
anchor.addEventListener('mouseenter', show);
|
|
835
|
+
anchor.addEventListener('mouseleave', hide);
|
|
836
|
+
anchor.addEventListener('focus', show);
|
|
837
|
+
anchor.addEventListener('blur', hide);
|
|
838
|
+
|
|
839
|
+
return { show: show, hide: hide };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Auto-init all [data-tooltip] elements
|
|
843
|
+
function init(root) {
|
|
844
|
+
var scope = root || document;
|
|
845
|
+
scope.querySelectorAll('[data-tooltip]').forEach(function (el) {
|
|
846
|
+
create({
|
|
847
|
+
anchor: el,
|
|
848
|
+
text: el.dataset.tooltip,
|
|
849
|
+
placement: el.dataset.tooltipPlacement || 'top',
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return { create: create, init: init };
|
|
855
|
+
})();
|
|
856
|
+
|
|
857
|
+
// ── Toast ───────────────────────────────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Toast — ephemeral notification shown bottom-right.
|
|
861
|
+
*
|
|
862
|
+
* Usage:
|
|
863
|
+
* Toast.show('Record saved');
|
|
864
|
+
* Toast.show('Something went wrong', 'error');
|
|
865
|
+
* Toast.show('Copied!', 'info', 2000);
|
|
866
|
+
*/
|
|
867
|
+
var Toast = (function () {
|
|
868
|
+
var _container = null;
|
|
869
|
+
|
|
870
|
+
function _getContainer() {
|
|
871
|
+
if (!_container) {
|
|
872
|
+
_container = document.createElement('div');
|
|
873
|
+
_container.style.cssText =
|
|
874
|
+
'position:fixed;bottom:22px;right:22px;' +
|
|
875
|
+
'display:flex;flex-direction:column-reverse;gap:8px;' +
|
|
876
|
+
'z-index:' + Z_TOAST + ';pointer-events:none;';
|
|
877
|
+
Portal.mount(_container);
|
|
878
|
+
}
|
|
879
|
+
return _container;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
var ICONS = {
|
|
883
|
+
success: '<polyline points="20 6 9 17 4 12"/>',
|
|
884
|
+
error: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>',
|
|
885
|
+
warning: '<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
|
886
|
+
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
var DOT_COLORS = {
|
|
890
|
+
success: '#4ade80',
|
|
891
|
+
error: '#f87171',
|
|
892
|
+
warning: '#fbbf24',
|
|
893
|
+
info: '#60a5fa',
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
function show(message, type, duration) {
|
|
897
|
+
type = type || 'success';
|
|
898
|
+
duration = duration !== undefined ? duration : 3500;
|
|
899
|
+
|
|
900
|
+
var c = _getContainer();
|
|
901
|
+
var el = document.createElement('div');
|
|
902
|
+
var dot = DOT_COLORS[type] || DOT_COLORS.success;
|
|
903
|
+
|
|
904
|
+
el.style.cssText =
|
|
905
|
+
'background:var(--text,#111827);color:#fff;' +
|
|
906
|
+
'border-radius:var(--radius,8px);' +
|
|
907
|
+
'padding:11px 16px;font-size:13px;font-family:inherit;' +
|
|
908
|
+
'max-width:320px;box-shadow:0 8px 24px rgba(0,0,0,.18);' +
|
|
909
|
+
'pointer-events:auto;' +
|
|
910
|
+
'display:flex;align-items:center;gap:9px;' +
|
|
911
|
+
'transform:translateX(16px);opacity:0;' +
|
|
912
|
+
'transition:transform .2s ' + EASE_OUT + ',opacity .2s;';
|
|
913
|
+
|
|
914
|
+
el.innerHTML =
|
|
915
|
+
'<span style="flex-shrink:0;color:' + dot + '">' +
|
|
916
|
+
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
|
917
|
+
(ICONS[type] || ICONS.success) +
|
|
918
|
+
'</svg></span>' +
|
|
919
|
+
'<span>' + message + '</span>';
|
|
920
|
+
|
|
921
|
+
c.appendChild(el);
|
|
922
|
+
|
|
923
|
+
// Animate in
|
|
924
|
+
requestAnimationFrame(function () {
|
|
925
|
+
el.style.transform = 'translateX(0)';
|
|
926
|
+
el.style.opacity = '1';
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
if (duration > 0) {
|
|
930
|
+
setTimeout(function () {
|
|
931
|
+
el.style.transform = 'translateX(16px)';
|
|
932
|
+
el.style.opacity = '0';
|
|
933
|
+
setTimeout(function () { el.remove(); }, 220);
|
|
934
|
+
}, duration);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return el;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return { show: show };
|
|
941
|
+
})();
|
|
942
|
+
|
|
943
|
+
// ── Confirm ─────────────────────────────────────────────────────────────────
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Confirm — promise-based confirmation dialog.
|
|
947
|
+
* Resolves true on confirm, false on cancel.
|
|
948
|
+
*
|
|
949
|
+
* Usage:
|
|
950
|
+
* Confirm.show({
|
|
951
|
+
* title: 'Delete record',
|
|
952
|
+
* message: 'This cannot be undone.',
|
|
953
|
+
* confirm: 'Delete',
|
|
954
|
+
* cancel: 'Cancel',
|
|
955
|
+
* danger: true,
|
|
956
|
+
* }).then(function(ok) {
|
|
957
|
+
* if (ok) doDelete();
|
|
958
|
+
* });
|
|
959
|
+
*
|
|
960
|
+
* // async/await friendly
|
|
961
|
+
* if (await Confirm.show({ title: 'Sure?' })) doIt();
|
|
962
|
+
*/
|
|
963
|
+
var Confirm = (function () {
|
|
964
|
+
function show(opts) {
|
|
965
|
+
opts = opts || {};
|
|
966
|
+
var title = opts.title || 'Are you sure?';
|
|
967
|
+
var message = opts.message || '';
|
|
968
|
+
var confirmTxt = opts.confirm || 'Confirm';
|
|
969
|
+
var cancelTxt = opts.cancel || 'Cancel';
|
|
970
|
+
var danger = opts.danger !== false;
|
|
971
|
+
|
|
972
|
+
return new Promise(function (resolve) {
|
|
973
|
+
var confirmBtnStyle =
|
|
974
|
+
'padding:7px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:1px solid transparent;font-family:inherit;transition:all .12s;' +
|
|
975
|
+
(danger
|
|
976
|
+
? 'background:var(--danger,#dc2626);color:#fff;border-color:var(--danger,#dc2626);'
|
|
977
|
+
: 'background:var(--primary,#2563eb);color:#fff;border-color:var(--primary,#2563eb);');
|
|
978
|
+
|
|
979
|
+
var bodyContent = message
|
|
980
|
+
? '<p style="color:var(--text-soft,#374151);line-height:1.6;font-size:13.5px">' + message + '</p>'
|
|
981
|
+
: '';
|
|
982
|
+
|
|
983
|
+
var footerHtml =
|
|
984
|
+
'<button id="_confirm-cancel" style="padding:7px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;background:transparent;border:1px solid var(--border,#e3e6ec);color:var(--text-soft,#374151);font-family:inherit;transition:all .12s">' +
|
|
985
|
+
cancelTxt + '</button>' +
|
|
986
|
+
'<button id="_confirm-ok" style="' + confirmBtnStyle + '">' + confirmTxt + '</button>';
|
|
987
|
+
|
|
988
|
+
var m = Modal.create({
|
|
989
|
+
title: title,
|
|
990
|
+
content: bodyContent,
|
|
991
|
+
footer: footerHtml,
|
|
992
|
+
size: 'sm',
|
|
993
|
+
closeable: true,
|
|
994
|
+
onClose: function () { resolve(false); },
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
m.open();
|
|
998
|
+
|
|
999
|
+
// Wire buttons after DOM is ready
|
|
1000
|
+
requestAnimationFrame(function () {
|
|
1001
|
+
var okBtn = m.dialog.querySelector('#_confirm-ok');
|
|
1002
|
+
var cancelBtn = m.dialog.querySelector('#_confirm-cancel');
|
|
1003
|
+
|
|
1004
|
+
if (okBtn) {
|
|
1005
|
+
okBtn.addEventListener('click', function () {
|
|
1006
|
+
m.close();
|
|
1007
|
+
resolve(true);
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
if (cancelBtn) {
|
|
1011
|
+
cancelBtn.addEventListener('click', function () {
|
|
1012
|
+
m.close();
|
|
1013
|
+
resolve(false);
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return { show: show };
|
|
1021
|
+
})();
|
|
1022
|
+
|
|
1023
|
+
// ── Auto-init ────────────────────────────────────────────────────────────────
|
|
1024
|
+
|
|
1025
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
1026
|
+
// Init declarative tooltips
|
|
1027
|
+
Tooltip.init(document);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
1031
|
+
|
|
1032
|
+
root.UI = {
|
|
1033
|
+
Portal: Portal,
|
|
1034
|
+
Dropdown: Dropdown,
|
|
1035
|
+
Modal: Modal,
|
|
1036
|
+
Drawer: Drawer,
|
|
1037
|
+
Tooltip: Tooltip,
|
|
1038
|
+
Toast: Toast,
|
|
1039
|
+
Confirm: Confirm,
|
|
1040
|
+
FocusTrap: FocusTrap,
|
|
1041
|
+
ScrollLock: ScrollLock,
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
}(typeof window !== 'undefined' ? window : this));
|