millas 0.2.13 → 0.2.14

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.
Files changed (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +14 -1
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -0,0 +1,585 @@
1
+ /**
2
+ * change_form.js — Millas Admin Form & Detail Pages
3
+ *
4
+ * Tab switching, rich text editor sync, client-side field validation,
5
+ * slug auto-fill (prepopulate), FK dropdown widget, and detail page
6
+ * tab / inline form behaviour.
7
+ *
8
+ * Requires: ui.js, core.js, jQuery
9
+ */
10
+ (function ($) {
11
+ 'use strict';
12
+
13
+ // ── Tab ID helper ────────────────────────────────────────────────────────────
14
+ // Converts a tab name to a CSS-safe id fragment.
15
+ // 'Role & Access' → 'Role--Access'
16
+ function _tabId(name) {
17
+ return name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
18
+ }
19
+
20
+ // ── Form tab switching ────────────────────────────────────────────────────────
21
+ window.switchFormTab = function (name, btn) {
22
+ $('.tab-btn').removeClass('active');
23
+ $('.tab-form-panel').removeClass('active').hide();
24
+ $(btn).addClass('active');
25
+ $('#fpanel-' + _tabId(name)).addClass('active').show();
26
+ };
27
+
28
+ // ── Detail page tab switching ─────────────────────────────────────────────────
29
+ window.switchTab = function (idx) {
30
+ document.querySelectorAll('.tab-btn').forEach(function (b, i) {
31
+ b.classList.toggle('active', i === idx);
32
+ });
33
+ document.querySelectorAll('.tab-panel').forEach(function (p, i) {
34
+ p.classList.toggle('active', i === idx);
35
+ });
36
+ };
37
+
38
+ // Wire tab buttons via data attributes (removes need for onclick)
39
+ $(document).on('click', '.tab-btn[data-tab]', function () {
40
+ switchFormTab($(this).data('tab'), this);
41
+ });
42
+
43
+ $(document).on('click', '.tab-btn[data-tab-index]', function () {
44
+ switchTab(parseInt($(this).data('tab-index'), 10));
45
+ });
46
+
47
+ // ── Rich text editor ──────────────────────────────────────────────────────────
48
+ window.rtCmd = function (cmd) {
49
+ document.execCommand(cmd, false, null);
50
+ };
51
+
52
+ window.rtLink = function () {
53
+ var url = prompt('Enter URL:');
54
+ if (url) document.execCommand('createLink', false, url);
55
+ };
56
+
57
+ // Sync rich text editor content back to the hidden textarea on input
58
+ $(document).on('input', '.richtext-editor', function () {
59
+ var fieldName = this.id.replace('rt-', '');
60
+ $('#field-' + fieldName).val(this.innerHTML);
61
+ });
62
+
63
+ // Wire rich text toolbar buttons via data attributes
64
+ $(document).on('click', '[data-rt-cmd]', function () {
65
+ rtCmd($(this).data('rt-cmd'));
66
+ });
67
+
68
+ $(document).on('click', '[data-rt-link]', function () {
69
+ rtLink();
70
+ });
71
+
72
+ // ── Password reveal in form widgets ──────────────────────────────────────────
73
+ $(document).on('click', '[data-pw-toggle]', function () {
74
+ var $input = $(this).prev('input');
75
+ $input.attr('type', $input.attr('type') === 'password' ? 'text' : 'password');
76
+ });
77
+
78
+ // ── Inline form show / hide (detail page) ────────────────────────────────────
79
+ $(document).on('click', '[data-inline-show]', function () {
80
+ var id = $(this).data('inline-show');
81
+ $('#' + id).show();
82
+ $(this).hide();
83
+ });
84
+
85
+ $(document).on('click', '[data-inline-hide]', function () {
86
+ var id = $(this).data('inline-hide');
87
+ var $card = $(this).closest('.card');
88
+ $('#' + id).hide();
89
+ $card.find('[data-inline-show="' + id + '"]').show();
90
+ });
91
+
92
+ // Native confirm() replacement for inline delete buttons
93
+ $(document).on('click', '[data-confirm-inline]', function (e) {
94
+ var msg = $(this).data('confirm-inline') || 'Are you sure?';
95
+ if (!window.confirm(msg)) {
96
+ e.preventDefault();
97
+ return false;
98
+ }
99
+ });
100
+
101
+ // data-confirm-delete wired in detail page (same as core.js confirmDelete)
102
+ $(document).on('click', '[data-confirm-delete]', function () {
103
+ confirmDelete($(this).data('confirm-delete'), $(this).data('confirm-label'));
104
+ });
105
+
106
+
107
+ function showFieldError(name, msg) {
108
+ var errorIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
109
+ + '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>'
110
+ + '<line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
111
+ $('#field-' + name + ', [name="' + name + '"]').first().addClass('error');
112
+ $('#feedback-' + name).html('<span class="form-error">' + errorIcon + ' ' + msg + '</span>');
113
+ }
114
+
115
+ function clearFieldError(name) {
116
+ $('#field-' + name + ', [name="' + name + '"]').first().removeClass('error');
117
+ var $fb = $('#feedback-' + name);
118
+ var help = $fb.data('help');
119
+ $fb.html(help ? '<span class="form-help">' + help + '</span>' : '');
120
+ }
121
+
122
+ function validateField(input) {
123
+ var $input = $(input);
124
+ var name = $input.attr('name');
125
+ if (!name) return true;
126
+
127
+ var val = $.trim($input.val());
128
+ var required = $input.data('required') === true || $input.data('required') === 'true';
129
+ var validate = $input.data('validate');
130
+ clearFieldError(name);
131
+
132
+ if (required && val === '') {
133
+ var label = $('label[for="field-' + name + '"]').text().replace('*', '').trim() || name;
134
+ showFieldError(name, label + ' is required');
135
+ return false;
136
+ }
137
+ if (val && validate === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
138
+ showFieldError(name, 'Please enter a valid email address');
139
+ return false;
140
+ }
141
+ if (val && validate === 'url') {
142
+ try { new URL(val); } catch (e) {
143
+ showFieldError(name, 'Please enter a valid URL (include https://)');
144
+ return false;
145
+ }
146
+ }
147
+ if (val && validate === 'json') {
148
+ try { JSON.parse(val); } catch (e) {
149
+ showFieldError(name, 'Invalid JSON format');
150
+ return false;
151
+ }
152
+ }
153
+
154
+ var min = $input.data('min');
155
+ var max = $input.data('max');
156
+ if (val && min !== undefined && Number(val) < Number(min)) {
157
+ showFieldError(name, 'Minimum value is ' + min);
158
+ return false;
159
+ }
160
+ if (val && max !== undefined && Number(val) > Number(max)) {
161
+ showFieldError(name, 'Maximum value is ' + max);
162
+ return false;
163
+ }
164
+ return true;
165
+ }
166
+
167
+ // Live validation
168
+ $(document).on('blur', '.form-control', function () { validateField(this); });
169
+ $(document).on('input', '.form-control', function () {
170
+ if ($(this).hasClass('error')) validateField(this);
171
+ });
172
+
173
+ // ── Form submit: validate + loading state ─────────────────────────────────────
174
+ $(document).on('submit', '#record-form', function (e) {
175
+ var valid = true;
176
+ var $first = null;
177
+
178
+ $(this).find('.form-control').each(function () {
179
+ if (!validateField(this)) {
180
+ valid = false;
181
+ if (!$first) $first = $(this);
182
+ }
183
+ });
184
+
185
+ if (!valid) {
186
+ e.preventDefault();
187
+ $first.focus();
188
+ $first[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
189
+ return;
190
+ }
191
+
192
+ var $btn = $('#submit-btn');
193
+ $btn.prop('disabled', true).html(
194
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"'
195
+ + ' stroke-linecap="round" stroke-linejoin="round" style="animation:spin 1s linear infinite">'
196
+ + '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>'
197
+ + '<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>'
198
+ + '</svg> Saving\u2026'
199
+ );
200
+ });
201
+
202
+ // ── Auto-switch to tab containing the first server-side error ────────────────
203
+ $(function () {
204
+ var $firstErr = $('.form-control.error').first();
205
+ if ($firstErr.length) {
206
+ var $panel = $firstErr.closest('.tab-form-panel');
207
+ if ($panel.length) {
208
+ var panelId = $panel.attr('id').replace('fpanel-', '');
209
+ var $tabBtn = $('.tab-btn').filter(function () {
210
+ return _tabId($(this).data('tab')) === panelId;
211
+ }).first();
212
+ if ($tabBtn.length) switchFormTab($tabBtn.data('tab'), $tabBtn[0]);
213
+ }
214
+ }
215
+ });
216
+
217
+ // ── Slug auto-fill (prepopulate) ──────────────────────────────────────────────
218
+ function slugify(str) {
219
+ return str.toLowerCase()
220
+ .replace(/[^\w\s-]/g, '')
221
+ .replace(/[\s_]+/g, '-')
222
+ .replace(/^-+|-+$/g, '');
223
+ }
224
+
225
+ $(function () {
226
+ $('[data-prepopulate]').each(function () {
227
+ var targetName = this.name;
228
+ var sourceName = $(this).data('prepopulate');
229
+ var $src = $('[name="' + sourceName + '"]');
230
+ var $tgt = $('[name="' + targetName + '"]');
231
+ if (!$src.length || !$tgt.length) return;
232
+
233
+ var userEdited = !!$tgt.val();
234
+ $tgt.on('input', function () { userEdited = true; });
235
+ $src.on('input', function () {
236
+ if (!userEdited) $tgt.val(slugify($src.val()));
237
+ });
238
+ });
239
+ });
240
+
241
+ // ════════════════════════════════════════════════════════════════════════════
242
+ // FK DROPDOWN WIDGET
243
+ // Self-contained — one instance per FK field on the page.
244
+ // Async, paginated, searchable, keyboard nav.
245
+ // ════════════════════════════════════════════════════════════════════════════
246
+ (function () {
247
+ var ADMIN_PREFIX = window.MILLAS_ADMIN_PREFIX || '/admin';
248
+ var SOURCE_RESOURCE = window.MILLAS_RESOURCE_SLUG || '';
249
+ var PER_PAGE = 20;
250
+ var DEBOUNCE_MS = 220;
251
+
252
+ function _initAllFKWidgets() {
253
+ document.querySelectorAll('.fk-widget').forEach(function (widget) {
254
+ initFKWidget(widget);
255
+ });
256
+ }
257
+
258
+ if (document.readyState === 'loading') {
259
+ document.addEventListener('DOMContentLoaded', _initAllFKWidgets);
260
+ } else {
261
+ _initAllFKWidgets();
262
+ }
263
+
264
+ function initFKWidget(widget) {
265
+ var name = widget.dataset.name;
266
+ var resource = widget.dataset.resource;
267
+ var fkField = widget.dataset.fkField || name;
268
+ var nullable = widget.dataset.nullable === 'true';
269
+ var currentId = widget.dataset.currentId || '';
270
+
271
+ var hidden = document.getElementById('field-' + name);
272
+ var trigger = document.getElementById('fktrig-' + name);
273
+ var panel = document.getElementById('fkpanel-' + name);
274
+ var list = document.getElementById('fklist-' + name);
275
+ var search = document.getElementById('fksearch-' + name);
276
+ var clearBtn = document.getElementById('fkclear-' + name);
277
+ var footer = document.getElementById('fkfoot-' + name);
278
+ var countEl = document.getElementById('fkcount-' + name);
279
+ var pageInfo = document.getElementById('fkpageinfo-' + name);
280
+ var prevBtn = document.getElementById('fkprev-' + name);
281
+ var nextBtn = document.getElementById('fknext-' + name);
282
+ var searchClear = widget.querySelector('.fk-search-clear');
283
+
284
+ if (!resource) {
285
+ console.warn('[FK Widget] field "' + name + '" has no fkResource — cannot load dropdown.');
286
+ return;
287
+ }
288
+ if (!hidden || !trigger || !panel) {
289
+ console.warn('[FK Widget] field "' + name + '" is missing DOM elements.');
290
+ return;
291
+ }
292
+
293
+ var state = {
294
+ open: false,
295
+ query: '',
296
+ page: 1,
297
+ total: 0,
298
+ data: [],
299
+ loading: false,
300
+ focusIndex: -1,
301
+ selectedId: currentId,
302
+ selectedLabel: '',
303
+ };
304
+
305
+ var debounceTimer = null;
306
+
307
+ // Resolve initial label for pre-selected value
308
+ if (currentId) {
309
+ fetch(ADMIN_PREFIX + '/api/' + resource + '/options?q=&page=1&limit=100&field=' + fkField + '&from=' + SOURCE_RESOURCE)
310
+ .then(function (r) { return r.json(); })
311
+ .then(function (json) {
312
+ var match = (json.data || []).find(function (r) { return String(r.id) === String(currentId); });
313
+ if (match) {
314
+ state.selectedLabel = match.label;
315
+ renderTriggerLabel();
316
+ }
317
+ })
318
+ .catch(function () {});
319
+ }
320
+
321
+ var dropdown = UI.Dropdown.create({
322
+ anchor: trigger,
323
+ content: panel,
324
+ placement: 'bottom-start',
325
+ offset: 0,
326
+ minWidth: true,
327
+ maxHeight: 320,
328
+ className: 'fk-panel',
329
+ onClose: function () {
330
+ state.open = false;
331
+ trigger.setAttribute('aria-expanded', 'false');
332
+ state.focusIndex = -1;
333
+ document.removeEventListener('keydown', globalKeydown);
334
+ },
335
+ });
336
+
337
+ function open() {
338
+ if (state.open) return;
339
+ state.open = true;
340
+ state.page = 1;
341
+ trigger.setAttribute('aria-expanded', 'true');
342
+ search.value = '';
343
+ if (searchClear) searchClear.hidden = true;
344
+ state.query = '';
345
+ dropdown.open();
346
+ fetchOptions();
347
+ requestAnimationFrame(function () { search.focus(); });
348
+ document.addEventListener('keydown', globalKeydown);
349
+ }
350
+
351
+ function close() {
352
+ if (!state.open) return;
353
+ dropdown.close();
354
+ }
355
+
356
+ trigger.addEventListener('click', function () { state.open ? close() : open(); });
357
+ trigger.addEventListener('keydown', function (e) {
358
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
359
+ e.preventDefault();
360
+ open();
361
+ }
362
+ });
363
+
364
+ if (search) {
365
+ search.addEventListener('input', function () {
366
+ var q = search.value;
367
+ if (searchClear) searchClear.hidden = !q;
368
+ clearTimeout(debounceTimer);
369
+ debounceTimer = setTimeout(function () {
370
+ state.query = q;
371
+ state.page = 1;
372
+ fetchOptions();
373
+ }, DEBOUNCE_MS);
374
+ });
375
+ }
376
+
377
+ if (searchClear) {
378
+ searchClear.addEventListener('click', function () {
379
+ search.value = '';
380
+ searchClear.hidden = true;
381
+ state.query = '';
382
+ state.page = 1;
383
+ fetchOptions();
384
+ search.focus();
385
+ });
386
+ }
387
+
388
+ if (prevBtn) {
389
+ prevBtn.addEventListener('click', function () {
390
+ if (state.page > 1) { state.page--; fetchOptions(); }
391
+ });
392
+ }
393
+ if (nextBtn) {
394
+ nextBtn.addEventListener('click', function () {
395
+ var totalPages = Math.ceil(state.total / PER_PAGE);
396
+ if (state.page < totalPages) { state.page++; fetchOptions(); }
397
+ });
398
+ }
399
+ if (clearBtn) {
400
+ clearBtn.addEventListener('click', function () {
401
+ select(null, '');
402
+ close();
403
+ });
404
+ }
405
+
406
+ function fetchOptions() {
407
+ state.loading = true;
408
+ renderList();
409
+
410
+ var params = new URLSearchParams({
411
+ q: state.query,
412
+ page: state.page,
413
+ limit: PER_PAGE,
414
+ field: fkField,
415
+ from: SOURCE_RESOURCE,
416
+ });
417
+
418
+ fetch(ADMIN_PREFIX + '/api/' + resource + '/options?' + params)
419
+ .then(function (r) {
420
+ if (!r.ok) throw new Error('HTTP ' + r.status + ': ' + r.statusText);
421
+ return r.json();
422
+ })
423
+ .then(function (json) {
424
+ state.data = json.data || [];
425
+ state.total = json.total || 0;
426
+ state.loading = false;
427
+ state.focusIndex = -1;
428
+ if (json.labelCol && search) {
429
+ search.placeholder = 'Search by ' + json.labelCol.replace(/_/g, ' ') + '\u2026';
430
+ }
431
+ renderList();
432
+ renderFooter();
433
+ })
434
+ .catch(function (err) {
435
+ state.loading = false;
436
+ state.data = [];
437
+ console.error('[FK Widget] fetch error for resource "' + resource + '":', err);
438
+ list.innerHTML = '<div class="fk-empty-row">Failed to load \u2014 check console</div>';
439
+ });
440
+ }
441
+
442
+ function renderList() {
443
+ if (state.loading) {
444
+ list.innerHTML =
445
+ '<div class="fk-skeleton-row"><span class="fk-skel fk-skel-chip"></span><span class="fk-skel fk-skel-text"></span></div>' +
446
+ '<div class="fk-skeleton-row"><span class="fk-skel fk-skel-chip"></span><span class="fk-skel fk-skel-text" style="width:55%"></span></div>' +
447
+ '<div class="fk-skeleton-row"><span class="fk-skel fk-skel-chip"></span><span class="fk-skel fk-skel-text" style="width:70%"></span></div>';
448
+ return;
449
+ }
450
+
451
+ if (!state.data.length) {
452
+ var emptyMsg = state.query
453
+ ? 'No results for <strong style="color:var(--text)">&ldquo;' + escapeHtml(state.query) + '&rdquo;</strong>'
454
+ : 'No records found';
455
+ list.innerHTML =
456
+ '<div class="fk-empty-row">' +
457
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--border);flex-shrink:0"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
458
+ '<span>' + emptyMsg + '</span>' +
459
+ '</div>';
460
+ return;
461
+ }
462
+
463
+ list.innerHTML = state.data.map(function (row, i) {
464
+ var isSelected = String(row.id) === String(state.selectedId);
465
+ var isFocused = i === state.focusIndex;
466
+ return '<div class="fk-option' + (isSelected ? ' fk-selected' : '') + (isFocused ? ' fk-focused' : '') + '"'
467
+ + ' role="option" aria-selected="' + isSelected + '"'
468
+ + ' data-id="' + row.id + '"'
469
+ + ' data-label="' + escapeAttr(String(row.label != null ? row.label : row.id)) + '">'
470
+ + '<span class="fk-id-chip">#' + row.id + '</span>'
471
+ + '<span class="fk-opt-label">' + escapeHtml(String(row.label != null ? row.label : row.id)) + '</span>'
472
+ + (isSelected ? '<svg class="fk-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' : '')
473
+ + '</div>';
474
+ }).join('');
475
+
476
+ list.querySelectorAll('.fk-option').forEach(function (opt) {
477
+ opt.addEventListener('mousedown', function (e) {
478
+ e.preventDefault();
479
+ select(opt.dataset.id, opt.dataset.label);
480
+ close();
481
+ });
482
+ opt.addEventListener('mousemove', function () {
483
+ var idx = Array.from(list.querySelectorAll('.fk-option')).indexOf(opt);
484
+ setFocus(idx);
485
+ });
486
+ });
487
+ }
488
+
489
+ function renderFooter() {
490
+ if (!footer) return;
491
+ var totalPages = Math.ceil(state.total / PER_PAGE);
492
+ if (state.total === 0) { footer.hidden = true; return; }
493
+ footer.hidden = false;
494
+ var from = (state.page - 1) * PER_PAGE + 1;
495
+ var to = Math.min(state.page * PER_PAGE, state.total);
496
+ if (countEl) countEl.textContent = from + '\u2013' + to + ' of ' + state.total;
497
+ if (pageInfo) pageInfo.textContent = state.page + ' / ' + totalPages;
498
+ if (prevBtn) prevBtn.disabled = state.page <= 1;
499
+ if (nextBtn) nextBtn.disabled = state.page >= totalPages;
500
+ }
501
+
502
+ function select(id, label) {
503
+ state.selectedId = id || '';
504
+ state.selectedLabel = label || '';
505
+ hidden.value = state.selectedId;
506
+ renderTriggerLabel();
507
+ hidden.dispatchEvent(new Event('change', { bubbles: true }));
508
+ }
509
+
510
+ function renderTriggerLabel() {
511
+ var labelEl = trigger.querySelector('.fk-trigger-label');
512
+ if (!labelEl) return;
513
+ if (state.selectedId) {
514
+ labelEl.innerHTML =
515
+ '<span class="fk-id-chip">#' + state.selectedId + '</span>' +
516
+ '<span class="fk-selected-label">' + escapeHtml(state.selectedLabel || state.selectedId) + '</span>';
517
+ } else {
518
+ labelEl.innerHTML = '<span class="fk-placeholder">\u2014 Select \u2014</span>';
519
+ }
520
+ }
521
+
522
+ function globalKeydown(e) {
523
+ if (!state.open) return;
524
+ var opts = Array.from(list.querySelectorAll('.fk-option'));
525
+ switch (e.key) {
526
+ case 'ArrowDown':
527
+ e.preventDefault();
528
+ setFocus(Math.min(state.focusIndex + 1, opts.length - 1));
529
+ break;
530
+ case 'ArrowUp':
531
+ e.preventDefault();
532
+ setFocus(Math.max(state.focusIndex - 1, 0));
533
+ break;
534
+ case 'Enter':
535
+ e.preventDefault();
536
+ if (state.focusIndex >= 0 && opts[state.focusIndex]) {
537
+ var opt = opts[state.focusIndex];
538
+ select(opt.dataset.id, opt.dataset.label);
539
+ close();
540
+ }
541
+ break;
542
+ case 'Escape':
543
+ e.preventDefault();
544
+ close();
545
+ trigger.focus();
546
+ break;
547
+ case 'Tab':
548
+ close();
549
+ break;
550
+ case 'PageDown':
551
+ e.preventDefault();
552
+ if (nextBtn && !nextBtn.disabled) { state.page++; fetchOptions(); }
553
+ break;
554
+ case 'PageUp':
555
+ e.preventDefault();
556
+ if (prevBtn && !prevBtn.disabled) { state.page--; fetchOptions(); }
557
+ break;
558
+ }
559
+ }
560
+
561
+ function setFocus(idx) {
562
+ var opts = Array.from(list.querySelectorAll('.fk-option'));
563
+ opts.forEach(function (o) { o.classList.remove('fk-focused'); });
564
+ state.focusIndex = idx;
565
+ if (idx >= 0 && opts[idx]) {
566
+ opts[idx].classList.add('fk-focused');
567
+ opts[idx].scrollIntoView({ block: 'nearest' });
568
+ }
569
+ }
570
+
571
+ function escapeHtml(str) {
572
+ return str
573
+ .replace(/&/g, '&amp;')
574
+ .replace(/</g, '&lt;')
575
+ .replace(/>/g, '&gt;')
576
+ .replace(/"/g, '&quot;');
577
+ }
578
+
579
+ function escapeAttr(str) {
580
+ return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
581
+ }
582
+ }
583
+ }());
584
+
585
+ }(jQuery));