@lancar/lxui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/LICENSE +21 -0
  3. package/README.md +5072 -0
  4. package/css/base/reset.css +91 -0
  5. package/css/base/tokens-extended.css +119 -0
  6. package/css/base/tokens.css +105 -0
  7. package/css/base/typography.css +35 -0
  8. package/css/base/utils.css +26 -0
  9. package/css/components/accordion.css +25 -0
  10. package/css/components/alert.css +22 -0
  11. package/css/components/animations.css +26 -0
  12. package/css/components/avatar.css +38 -0
  13. package/css/components/back-top.css +32 -0
  14. package/css/components/badge.css +37 -0
  15. package/css/components/breadcrumb.css +13 -0
  16. package/css/components/button.css +103 -0
  17. package/css/components/callout.css +20 -0
  18. package/css/components/card.css +42 -0
  19. package/css/components/carousel.css +31 -0
  20. package/css/components/chip.css +52 -0
  21. package/css/components/code-block.css +22 -0
  22. package/css/components/collapse.css +6 -0
  23. package/css/components/compat.css +27 -0
  24. package/css/components/dark-mode.css +35 -0
  25. package/css/components/divider.css +36 -0
  26. package/css/components/dropdown.css +39 -0
  27. package/css/components/empty.css +34 -0
  28. package/css/components/fab.css +28 -0
  29. package/css/components/file-drop.css +47 -0
  30. package/css/components/forms.css +107 -0
  31. package/css/components/kbd.css +5 -0
  32. package/css/components/list-group.css +17 -0
  33. package/css/components/modal.css +50 -0
  34. package/css/components/nav.css +25 -0
  35. package/css/components/navbar.css +44 -0
  36. package/css/components/number-input.css +52 -0
  37. package/css/components/offcanvas.css +25 -0
  38. package/css/components/pagination.css +17 -0
  39. package/css/components/popover.css +12 -0
  40. package/css/components/progress.css +26 -0
  41. package/css/components/rating.css +28 -0
  42. package/css/components/section.css +18 -0
  43. package/css/components/skeleton.css +19 -0
  44. package/css/components/spinner.css +38 -0
  45. package/css/components/stat.css +58 -0
  46. package/css/components/steps.css +76 -0
  47. package/css/components/table.css +29 -0
  48. package/css/components/tag.css +29 -0
  49. package/css/components/timeline.css +11 -0
  50. package/css/components/toast.css +14 -0
  51. package/css/components/toggler.css +20 -0
  52. package/css/components/tooltip.css +10 -0
  53. package/css/index.css +59 -0
  54. package/css/layout/grid.css +71 -0
  55. package/css/layout/utilities.css +257 -0
  56. package/js/breakpoint.js +13 -0
  57. package/js/carousel.js +62 -0
  58. package/js/clipboard.js +28 -0
  59. package/js/collapse.js +36 -0
  60. package/js/counter.js +38 -0
  61. package/js/dropdown.js +27 -0
  62. package/js/index.js +19 -0
  63. package/js/init.js +89 -0
  64. package/js/modal.js +44 -0
  65. package/js/number-input.js +44 -0
  66. package/js/offcanvas.js +28 -0
  67. package/js/popover.js +39 -0
  68. package/js/rating.js +39 -0
  69. package/js/scrollspy.js +24 -0
  70. package/js/tab.js +18 -0
  71. package/js/theme.js +9 -0
  72. package/js/toast.js +73 -0
  73. package/js/tooltip.js +39 -0
  74. package/js/utils.js +20 -0
  75. package/lx-grid.min.css +2 -0
  76. package/lx-utilities.min.css +2 -0
  77. package/lxeditor.min.css +2 -0
  78. package/lxfonts.min.css +2 -0
  79. package/lxicons.min.css +2 -0
  80. package/lxmarked.js +276 -0
  81. package/lxthemes.min.css +2 -0
  82. package/lxui.bundle.js +540 -0
  83. package/lxui.bundle.min.js +13 -0
  84. package/lxui.css +2163 -0
  85. package/lxui.esm.js +669 -0
  86. package/lxui.esm.min.js +8 -0
  87. package/lxui.js +859 -0
  88. package/lxui.min.css +2 -0
  89. package/lxui.min.js +7 -0
  90. package/lxui.rtl.css +2466 -0
  91. package/lxui.rtl.min.css +2 -0
  92. package/marked.min.js +69 -0
  93. package/package.json +183 -0
  94. package/types/index.d.ts +284 -0
package/lxui.js ADDED
@@ -0,0 +1,859 @@
1
+ /*!
2
+ * LxUI v1.0.0 — Modern CSS Framework
3
+ * https://ui.lancar.id | https://cdn.lancar.id/ui/lxui.js
4
+ * Copyright (c) 2025 lancar.id
5
+ * Licensed under MIT
6
+ */
7
+ (function (global, factory) {
8
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
9
+ typeof define === 'function' && define.amd ? define(factory) :
10
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.LxUI = factory());
11
+ })(this, function () {
12
+ 'use strict';
13
+
14
+ /* ── Helpers ── */
15
+ function qs(sel, ctx) { return (ctx || document).querySelector(sel); }
16
+ function qsa(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }
17
+ function on(el, ev, fn) { el && el.addEventListener(ev, fn); }
18
+ function off(el, ev, fn) { el && el.removeEventListener(ev, fn); }
19
+ function emit(el, name, detail) {
20
+ el.dispatchEvent(new CustomEvent('lx.' + name, { bubbles: true, cancelable: true, detail }));
21
+ }
22
+ function trap(el) {
23
+ const focusable = qsa('button,a,[href],[tabindex]:not([tabindex="-1"]),input,select,textarea', el)
24
+ .filter(e => !e.disabled && e.offsetParent !== null);
25
+ if (!focusable.length) return;
26
+ const first = focusable[0], last = focusable[focusable.length - 1];
27
+ on(el, 'keydown', e => {
28
+ if (e.key !== 'Tab') return;
29
+ if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } }
30
+ else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } }
31
+ });
32
+ }
33
+
34
+ /* ================================================================
35
+ * Modal
36
+ * ================================================================ */
37
+ class Modal {
38
+ constructor(sel, opts = {}) {
39
+ this.el = typeof sel === 'string' ? qs(sel) : sel;
40
+ if (!this.el) return;
41
+ this.opts = Object.assign({ backdrop: true, keyboard: true, focus: true }, opts);
42
+ this._backdrop = null;
43
+ this._prevFocus = null;
44
+ }
45
+ show() {
46
+ if (!this.el) return;
47
+ emit(this.el, 'modal.show');
48
+ this._prevFocus = document.activeElement;
49
+ this._backdrop = document.createElement('div');
50
+ this._backdrop.className = 'lx-modal-backdrop lx-fade lx-show';
51
+ document.body.appendChild(this._backdrop);
52
+ document.body.classList.add('lx-modal-open');
53
+ this.el.classList.add('lx-show');
54
+ this.el.style.display = 'flex';
55
+ this.el.removeAttribute('aria-hidden');
56
+ if (this.opts.focus) trap(this.el);
57
+ if (this.opts.keyboard) {
58
+ this._keyHandler = e => { if (e.key === 'Escape') this.hide(); };
59
+ on(document, 'keydown', this._keyHandler);
60
+ }
61
+ if (this.opts.backdrop) on(this._backdrop, 'click', () => this.hide());
62
+ requestAnimationFrame(() => emit(this.el, 'modal.shown'));
63
+ }
64
+ hide() {
65
+ if (!this.el) return;
66
+ emit(this.el, 'modal.hide');
67
+ this.el.classList.remove('lx-show');
68
+ this.el.style.display = '';
69
+ this.el.setAttribute('aria-hidden', 'true');
70
+ if (this._backdrop) { this._backdrop.remove(); this._backdrop = null; }
71
+ document.body.classList.remove('lx-modal-open');
72
+ if (this._keyHandler) off(document, 'keydown', this._keyHandler);
73
+ if (this._prevFocus) this._prevFocus.focus();
74
+ emit(this.el, 'modal.hidden');
75
+ }
76
+ toggle() { this.el.classList.contains('lx-show') ? this.hide() : this.show(); }
77
+ }
78
+
79
+ /* ================================================================
80
+ * Toast
81
+ * ================================================================ */
82
+ const Toast = {
83
+ _container(pos) {
84
+ const id = 'lx-toast-' + pos;
85
+ let c = document.getElementById(id);
86
+ if (!c) {
87
+ c = document.createElement('div');
88
+ c.id = id;
89
+ const posMap = {
90
+ 'top-right':'top:1rem;right:1rem', 'top-left':'top:1rem;left:1rem',
91
+ 'bottom-right':'bottom:1rem;right:1rem', 'bottom-left':'bottom:1rem;left:1rem',
92
+ 'top-center':'top:1rem;left:50%;transform:translateX(-50%)'
93
+ };
94
+ c.setAttribute('style', `position:fixed;z-index:1090;display:flex;flex-direction:column;gap:.5rem;${posMap[pos]||posMap['top-right']}`);
95
+ document.body.appendChild(c);
96
+ }
97
+ return c;
98
+ },
99
+ show(opts = {}) {
100
+ const { message = '', type = 'default', duration = 4000, position = 'top-right', title = '' } = opts;
101
+ const colors = { success:'#22c55e', warning:'#f59e0b', danger:'#ef4444', info:'#06b6d4', default:'#3b82f6' };
102
+ const t = document.createElement('div');
103
+ t.setAttribute('role','alert');
104
+ t.setAttribute('aria-live','assertive');
105
+ t.setAttribute('aria-atomic','true');
106
+ t.style.cssText = 'background:var(--surface,#fff);border:1px solid var(--border-default,#e5e7eb);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.12);padding:.875rem 1rem;min-width:280px;max-width:380px;display:flex;align-items:flex-start;gap:.75rem;animation:lxSlideIn .2s ease';
107
+ // dot indicator
108
+ const dot = document.createElement('span');
109
+ dot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:' + (colors[type] || colors.default) + ';flex-shrink:0;margin-top:4px';
110
+ t.appendChild(dot);
111
+ // body — textContent prevents XSS
112
+ if (title) {
113
+ const wrap = document.createElement('div');
114
+ const strong = document.createElement('strong');
115
+ strong.style.cssText = 'display:block;margin-bottom:2px;font-size:.875rem';
116
+ strong.textContent = title;
117
+ const span = document.createElement('span');
118
+ span.style.cssText = 'font-size:.875rem;color:var(--text-secondary,#6b7280)';
119
+ span.textContent = message;
120
+ wrap.appendChild(strong); wrap.appendChild(span);
121
+ t.appendChild(wrap);
122
+ } else {
123
+ const span = document.createElement('span');
124
+ span.style.cssText = 'font-size:.875rem;color:var(--text-primary,#111827);flex:1';
125
+ span.textContent = message;
126
+ t.appendChild(span);
127
+ }
128
+ // close button
129
+ const close = document.createElement('button');
130
+ close.style.cssText = 'background:none;border:none;cursor:pointer;padding:0;color:var(--text-muted,#9ca3af);font-size:1.125rem;line-height:1;margin-left:auto';
131
+ close.setAttribute('aria-label', 'Close');
132
+ close.textContent = '×';
133
+ close.addEventListener('click', () => t.remove());
134
+ t.appendChild(close);
135
+ this._container(position).appendChild(t);
136
+ if (!document.getElementById('lx-toast-style')) {
137
+ const s = document.createElement('style');
138
+ s.id = 'lx-toast-style';
139
+ s.textContent = '@keyframes lxSlideIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}';
140
+ document.head.appendChild(s);
141
+ }
142
+ if (duration > 0) setTimeout(() => { t.style.opacity='0'; t.style.transition='opacity .2s'; setTimeout(()=>t.remove(), 200); }, duration);
143
+ },
144
+ success(msg, opts={}) { this.show({...opts, message:msg, type:'success'}); },
145
+ error(msg, opts={}) { this.show({...opts, message:msg, type:'danger'}); },
146
+ warning(msg, opts={}) { this.show({...opts, message:msg, type:'warning'}); },
147
+ info(msg, opts={}) { this.show({...opts, message:msg, type:'info'}); },
148
+ };
149
+
150
+ /* ================================================================
151
+ * Collapse / Accordion
152
+ * ================================================================ */
153
+ class Collapse {
154
+ constructor(sel) {
155
+ this.el = typeof sel === 'string' ? qs(sel) : sel;
156
+ }
157
+ show() {
158
+ if (!this.el) return;
159
+ emit(this.el, 'collapse.show');
160
+ this.el.style.height = '0px';
161
+ this.el.classList.add('lx-collapsing');
162
+ this.el.classList.remove('lx-collapse', 'lx-show');
163
+ const h = this.el.scrollHeight;
164
+ this.el.style.height = h + 'px';
165
+ this.el.addEventListener('transitionend', () => {
166
+ this.el.classList.remove('lx-collapsing');
167
+ this.el.classList.add('lx-collapse', 'lx-show');
168
+ this.el.style.height = '';
169
+ emit(this.el, 'collapse.shown');
170
+ }, { once: true });
171
+ }
172
+ hide() {
173
+ if (!this.el) return;
174
+ emit(this.el, 'collapse.hide');
175
+ this.el.style.height = this.el.scrollHeight + 'px';
176
+ this.el.classList.add('lx-collapsing');
177
+ this.el.classList.remove('lx-collapse', 'lx-show');
178
+ requestAnimationFrame(() => { this.el.style.height = '0px'; });
179
+ this.el.addEventListener('transitionend', () => {
180
+ this.el.classList.remove('lx-collapsing');
181
+ this.el.classList.add('lx-collapse');
182
+ this.el.style.height = '';
183
+ emit(this.el, 'collapse.hidden');
184
+ }, { once: true });
185
+ }
186
+ toggle() { this.el && this.el.classList.contains('lx-show') ? this.hide() : this.show(); }
187
+ }
188
+
189
+ /* ================================================================
190
+ * Dropdown
191
+ * ================================================================ */
192
+ class Dropdown {
193
+ constructor(trigger) {
194
+ this.trigger = typeof trigger === 'string' ? qs(trigger) : trigger;
195
+ this.menu = this.trigger && this.trigger.nextElementSibling;
196
+ this._outside = e => { if (!this.trigger.closest('.lx-dropdown').contains(e.target)) this.hide(); };
197
+ }
198
+ show() {
199
+ if (!this.menu) return;
200
+ emit(this.trigger, 'dropdown.show');
201
+ this.menu.classList.add('lx-show');
202
+ this.trigger.setAttribute('aria-expanded', 'true');
203
+ setTimeout(() => on(document, 'click', this._outside), 0);
204
+ emit(this.trigger, 'dropdown.shown');
205
+ }
206
+ hide() {
207
+ if (!this.menu) return;
208
+ emit(this.trigger, 'dropdown.hide');
209
+ this.menu.classList.remove('lx-show');
210
+ this.trigger.setAttribute('aria-expanded', 'false');
211
+ off(document, 'click', this._outside);
212
+ emit(this.trigger, 'dropdown.hidden');
213
+ }
214
+ toggle() { this.menu && this.menu.classList.contains('lx-show') ? this.hide() : this.show(); }
215
+ }
216
+
217
+ /* ================================================================
218
+ * Offcanvas / Drawer
219
+ * ================================================================ */
220
+ class Offcanvas {
221
+ constructor(sel) {
222
+ this.el = typeof sel === 'string' ? qs(sel) : sel;
223
+ this._backdrop = null;
224
+ }
225
+ show() {
226
+ if (!this.el) return;
227
+ this._backdrop = document.createElement('div');
228
+ this._backdrop.className = 'lx-offcanvas-backdrop';
229
+ this._backdrop.style.cssText = 'position:fixed;inset:0;z-index:1039;background:rgba(0,0,0,.5)';
230
+ document.body.appendChild(this._backdrop);
231
+ document.body.style.overflow = 'hidden';
232
+ this.el.classList.add('lx-show');
233
+ this.el.removeAttribute('aria-hidden');
234
+ trap(this.el);
235
+ on(this._backdrop, 'click', () => this.hide());
236
+ on(document, 'keydown', this._esc = e => { if (e.key === 'Escape') this.hide(); });
237
+ }
238
+ hide() {
239
+ if (!this.el) return;
240
+ this.el.classList.remove('lx-show');
241
+ this.el.setAttribute('aria-hidden', 'true');
242
+ if (this._backdrop) { this._backdrop.remove(); this._backdrop = null; }
243
+ document.body.style.overflow = '';
244
+ off(document, 'keydown', this._esc);
245
+ }
246
+ toggle() { this.el && this.el.classList.contains('lx-show') ? this.hide() : this.show(); }
247
+ }
248
+
249
+ /* ================================================================
250
+ * Tooltip
251
+ * ================================================================ */
252
+ class Tooltip {
253
+ constructor(el, opts = {}) {
254
+ this.el = typeof el === 'string' ? qs(el) : el;
255
+ if (!this.el) return;
256
+ this.opts = Object.assign({ placement: 'top', trigger: 'hover', html: false, delay: { show: 0, hide: 0 } }, opts);
257
+ this._tip = null;
258
+ this._attach();
259
+ }
260
+ _attach() {
261
+ const show = () => { clearTimeout(this._hideTimer); this._showTimer = setTimeout(() => this._show(), this.opts.delay.show); };
262
+ const hide = () => { clearTimeout(this._showTimer); this._hideTimer = setTimeout(() => this._hide(), this.opts.delay.hide); };
263
+ if (this.opts.trigger.includes('hover')) { on(this.el, 'mouseenter', show); on(this.el, 'mouseleave', hide); }
264
+ if (this.opts.trigger.includes('focus')) { on(this.el, 'focusin', show); on(this.el, 'focusout', hide); }
265
+ }
266
+ _show() {
267
+ const text = this.el.getAttribute('title') || this.el.dataset.lxOrigTitle || '';
268
+ if (!text) return;
269
+ if (this.el.getAttribute('title')) { this.el.dataset.lxOrigTitle = text; this.el.removeAttribute('title'); }
270
+ this._tip = document.createElement('div');
271
+ this._tip.className = 'lx-tooltip lx-tooltip-' + this.opts.placement;
272
+ this._tip.setAttribute('role','tooltip');
273
+ this._tip.style.cssText = 'position:absolute;z-index:1080;padding:4px 8px;background:rgba(0,0,0,.85);color:#fff;border-radius:4px;font-size:.75rem;white-space:nowrap;pointer-events:none';
274
+ this._tip.textContent = text;
275
+ document.body.appendChild(this._tip);
276
+ const r = this.el.getBoundingClientRect(), t = this._tip.getBoundingClientRect(), s = window.scrollY;
277
+ const p = this.opts.placement;
278
+ let top = 0, left = 0;
279
+ if (p === 'top') { top = r.top + s - t.height - 6; left = r.left + (r.width - t.width) / 2; }
280
+ else if (p === 'bottom') { top = r.bottom + s + 6; left = r.left + (r.width - t.width) / 2; }
281
+ else if (p === 'left') { top = r.top + s + (r.height - t.height) / 2; left = r.left - t.width - 6; }
282
+ else { top = r.top + s + (r.height - t.height) / 2; left = r.right + 6; }
283
+ this._tip.style.top = top + 'px';
284
+ this._tip.style.left = left + 'px';
285
+ }
286
+ _hide() { if (this._tip) { this._tip.remove(); this._tip = null; } }
287
+ }
288
+
289
+ /* ================================================================
290
+ * Popover
291
+ * ================================================================ */
292
+ class Popover {
293
+ constructor(el, opts = {}) {
294
+ this.el = typeof el === 'string' ? qs(el) : el;
295
+ if (!this.el) return;
296
+ this.opts = Object.assign({ placement: 'top', trigger: 'click', html: false }, opts);
297
+ this._pop = null;
298
+ const toggle = () => this._pop ? this._hide() : this._show();
299
+ if (this.opts.trigger.includes('click')) on(this.el, 'click', toggle);
300
+ }
301
+ _show() {
302
+ const title = this.el.dataset.lxTitle || '';
303
+ const content = this.el.dataset.lxContent || '';
304
+ this._pop = document.createElement('div');
305
+ this._pop.className = 'lx-popover';
306
+ this._pop.setAttribute('role','tooltip');
307
+ this._pop.style.cssText = 'position:absolute;z-index:1070;background:var(--surface,#fff);border:1px solid var(--border-default,#e5e7eb);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.12);padding:.75rem 1rem;min-width:200px;max-width:300px';
308
+ // build with DOM API — textContent prevents XSS from data attributes
309
+ if (title) {
310
+ const h = document.createElement('strong');
311
+ h.style.cssText = 'display:block;margin-bottom:.5rem;font-size:.875rem';
312
+ h.textContent = title;
313
+ this._pop.appendChild(h);
314
+ }
315
+ const body = document.createElement('p');
316
+ body.style.cssText = 'margin:0;font-size:.875rem;color:var(--text-secondary,#6b7280)';
317
+ body.textContent = content;
318
+ this._pop.appendChild(body);
319
+ document.body.appendChild(this._pop);
320
+ const r = this.el.getBoundingClientRect(), s = window.scrollY;
321
+ this._pop.style.top = (r.bottom + s + 8) + 'px';
322
+ this._pop.style.left = r.left + 'px';
323
+ setTimeout(() => on(document, 'click', this._outside = e => { if (!this.el.contains(e.target) && !this._pop?.contains(e.target)) this._hide(); }), 0);
324
+ }
325
+ _hide() { if (this._pop) { this._pop.remove(); this._pop = null; off(document, 'click', this._outside); } }
326
+ }
327
+
328
+ /* ================================================================
329
+ * Scrollspy
330
+ * ================================================================ */
331
+ class Scrollspy {
332
+ constructor(el, opts = {}) {
333
+ this.el = typeof el === 'string' ? qs(el) : el;
334
+ if (!this.el) return;
335
+ this.opts = Object.assign({ offset: 70 }, opts);
336
+ const targetSel = this.el.dataset.lxTarget;
337
+ this.nav = targetSel ? qs(targetSel) : null;
338
+ this._update = this._update.bind(this);
339
+ on(this.el === document.body ? window : this.el, 'scroll', this._update);
340
+ this._update();
341
+ }
342
+ _update() {
343
+ if (!this.nav) return;
344
+ const links = qsa('a[href^="#"]', this.nav);
345
+ const scrollTop = (this.el === document.body ? window.scrollY : this.el.scrollTop) + this.opts.offset;
346
+ let active = null;
347
+ links.forEach(a => {
348
+ const target = qs(a.getAttribute('href'));
349
+ if (target && target.offsetTop <= scrollTop) active = a;
350
+ });
351
+ links.forEach(a => a.classList.remove('lx-active'));
352
+ if (active) active.classList.add('lx-active');
353
+ }
354
+ }
355
+
356
+ /* ================================================================
357
+ * Tabs
358
+ * ================================================================ */
359
+ class Tab {
360
+ constructor(trigger) {
361
+ this.trigger = typeof trigger === 'string' ? qs(trigger) : trigger;
362
+ }
363
+ show() {
364
+ if (!this.trigger) return;
365
+ const targetSel = this.trigger.dataset.lxTarget;
366
+ const target = targetSel ? qs(targetSel) : null;
367
+ if (!target) return;
368
+ const tablist = this.trigger.closest('[role="tablist"]') || this.trigger.parentElement.parentElement;
369
+ qsa('.lx-nav-link, .lx-tab-item', tablist).forEach(t => { t.classList.remove('lx-active'); t.setAttribute('aria-selected','false'); });
370
+ qsa('.lx-tab-pane', target.parentElement).forEach(p => p.classList.remove('lx-active', 'lx-show'));
371
+ this.trigger.classList.add('lx-active');
372
+ this.trigger.setAttribute('aria-selected','true');
373
+ target.classList.add('lx-active', 'lx-show');
374
+ }
375
+ }
376
+
377
+ /* ================================================================
378
+ * Carousel
379
+ * ================================================================ */
380
+ class Carousel {
381
+ constructor(sel, opts = {}) {
382
+ this.el = typeof sel === 'string' ? qs(sel) : sel;
383
+ if (!this.el) return;
384
+ this.opts = Object.assign({ interval: 5000, pause: 'hover', wrap: true, touch: true }, opts);
385
+ this.inner = qs('.lx-carousel-inner', this.el);
386
+ this.items = qsa('.lx-carousel-item', this.el);
387
+ this.current = this.items.findIndex(i => i.classList.contains('lx-active'));
388
+ if (this.current < 0) { this.current = 0; if (this.items[0]) this.items[0].classList.add('lx-active'); }
389
+ this._timer = null;
390
+ this._sliding = false;
391
+ if (this.inner) this._applyTransform(this.current, false);
392
+ if (this.opts.interval) this.cycle();
393
+ if (this.opts.pause === 'hover') {
394
+ on(this.el, 'mouseenter', () => this.pause());
395
+ on(this.el, 'mouseleave', () => this.cycle());
396
+ }
397
+ if (this.opts.touch) this._initTouch();
398
+ this._updateIndicators();
399
+ }
400
+ _applyTransform(idx, animate) {
401
+ if (!this.inner) return;
402
+ if (!animate) this.inner.style.transition = 'none';
403
+ this.inner.style.transform = 'translateX(-' + (idx * 100) + '%)';
404
+ if (!animate) {
405
+ this.inner.getBoundingClientRect();
406
+ this.inner.style.transition = '';
407
+ }
408
+ }
409
+ _go(idx) {
410
+ const n = this.items.length;
411
+ if (!this.opts.wrap && (idx < 0 || idx >= n)) return;
412
+ if (this._sliding) return;
413
+ this._sliding = true;
414
+ const next = ((idx % n) + n) % n;
415
+ this.items.forEach((item, i) => item.classList.toggle('lx-active', i === next));
416
+ this.current = next;
417
+ if (this.inner) {
418
+ this._applyTransform(this.current, true);
419
+ const done = () => { this._sliding = false; emit(this.el, 'carousel.slid'); };
420
+ this.inner.addEventListener('transitionend', done, { once: true });
421
+ setTimeout(() => { this._sliding = false; }, 400);
422
+ } else {
423
+ this._sliding = false;
424
+ }
425
+ this._updateIndicators();
426
+ emit(this.el, 'carousel.slide');
427
+ }
428
+ _updateIndicators() {
429
+ qsa('.lx-carousel-indicators button, .lx-carousel-indicators [data-lx-slide-to]', this.el).forEach((btn, i) => {
430
+ btn.classList.toggle('lx-active', i === this.current);
431
+ btn.setAttribute('aria-current', i === this.current ? 'true' : 'false');
432
+ });
433
+ }
434
+ next() { this._go(this.current + 1); }
435
+ prev() { this._go(this.current - 1); }
436
+ to(idx) { this._go(idx); }
437
+ cycle() { this.pause(); if (this.opts.interval) this._timer = setInterval(() => this.next(), this.opts.interval); }
438
+ pause() { clearInterval(this._timer); this._timer = null; }
439
+ _initTouch() {
440
+ let startX = 0, startY = 0;
441
+ on(this.el, 'touchstart', e => { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }, { passive: true });
442
+ on(this.el, 'touchend', e => {
443
+ const dx = e.changedTouches[0].clientX - startX;
444
+ const dy = e.changedTouches[0].clientY - startY;
445
+ if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) dx > 0 ? this.prev() : this.next();
446
+ });
447
+ }
448
+ }
449
+
450
+ /* ================================================================
451
+ * Alert dismiss
452
+ * ================================================================ */
453
+ function initAlerts() {
454
+ on(document, 'click', e => {
455
+ const btn = e.target.closest('[data-lx-dismiss="alert"]');
456
+ if (btn) { const alert = btn.closest('.lx-alert'); if (alert) { alert.style.opacity = '0'; alert.style.transition = 'opacity .15s'; setTimeout(() => alert.remove(), 150); } }
457
+ });
458
+ }
459
+
460
+ /* ================================================================
461
+ * Breakpoint utilities
462
+ * ================================================================ */
463
+ const breakpoint = {
464
+ _bp: { xs: 0, sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 },
465
+ current() {
466
+ const w = window.innerWidth;
467
+ return Object.entries(this._bp).reverse().find(([, v]) => w >= v)?.[0] || 'xs';
468
+ },
469
+ on(bp, fn) {
470
+ const mq = window.matchMedia(`(min-width: ${this._bp[bp]}px)`);
471
+ mq.addEventListener('change', e => { if (e.matches) fn(); });
472
+ if (mq.matches) fn();
473
+ }
474
+ };
475
+
476
+ /* ================================================================
477
+ * Theme switcher
478
+ * ================================================================ */
479
+ const theme = {
480
+ set(t, mode) {
481
+ document.documentElement.dataset.theme = t;
482
+ if (mode) document.documentElement.dataset.mode = mode;
483
+ },
484
+ setMode(mode) { document.documentElement.dataset.mode = mode; },
485
+ toggle() {
486
+ const cur = document.documentElement.dataset.mode || 'light';
487
+ this.setMode(cur === 'dark' ? 'light' : 'dark');
488
+ },
489
+ current() { return { theme: document.documentElement.dataset.theme, mode: document.documentElement.dataset.mode }; },
490
+ save(t, mode) { this.set(t, mode); localStorage.setItem('lx-theme', t); if (mode) localStorage.setItem('lx-mode', mode); },
491
+ restore() {
492
+ const t = localStorage.getItem('lx-theme'); const m = localStorage.getItem('lx-mode');
493
+ if (t) this.set(t, m || undefined);
494
+ }
495
+ };
496
+
497
+ /* ================================================================
498
+ * Data-attribute bridge (no-JS approach)
499
+ * ================================================================ */
500
+ function initDataBridge() {
501
+ on(document, 'click', e => {
502
+ const el = e.target.closest('[data-lx-toggle], [data-lx-dismiss], [data-lx-slide], [data-lx-slide-to]');
503
+ if (!el) return;
504
+ const toggle = el.dataset.lxToggle;
505
+ const dismiss = el.dataset.lxDismiss;
506
+ const target = el.dataset.lxTarget ? qs(el.dataset.lxTarget) : null;
507
+
508
+ if (dismiss === 'modal') { const m = el.closest('.lx-modal'); if (m) new Modal(m).hide(); }
509
+ else if (dismiss === 'offcanvas') { const o = el.closest('.lx-offcanvas'); if (o) new Offcanvas(o).hide(); }
510
+ else if (dismiss === 'alert') { const a = el.closest('.lx-alert'); if (a) { a.style.opacity='0'; a.style.transition='opacity .15s'; setTimeout(()=>a.remove(),150); } }
511
+ else if (dismiss === 'toast') { const t = el.closest('.lx-toast'); if (t) { t.style.opacity='0'; t.style.transition='opacity .15s'; setTimeout(()=>t.remove(),150); } }
512
+
513
+ if (toggle === 'modal' && target) { e.preventDefault(); new Modal(target).toggle(); }
514
+ else if (toggle === 'collapse' && target) { e.preventDefault(); new Collapse(target).toggle(); const expanded = target.classList.contains('lx-show'); el.setAttribute('aria-expanded', (!expanded).toString()); }
515
+ else if (toggle === 'accordion' && target) {
516
+ e.preventDefault();
517
+ const isOpen = target.classList.contains('lx-show');
518
+ const accordion = el.closest('.lx-accordion');
519
+ if (accordion) {
520
+ qsa('.lx-accordion-body.lx-show', accordion).forEach(b => { if (b !== target) new Collapse(b).hide(); });
521
+ qsa('.lx-accordion-trigger', accordion).forEach(t => { if (t !== el) { t.classList.remove('lx-active'); t.setAttribute('aria-expanded','false'); } });
522
+ }
523
+ new Collapse(target)[isOpen ? 'hide' : 'show']();
524
+ el.classList.toggle('lx-active', !isOpen);
525
+ el.setAttribute('aria-expanded', (!isOpen).toString());
526
+ }
527
+ else if (toggle === 'dropdown') { e.preventDefault(); e.stopPropagation(); new Dropdown(el).toggle(); }
528
+ else if (toggle === 'offcanvas' && target) { e.preventDefault(); new Offcanvas(target).toggle(); }
529
+ else if (toggle === 'tab' && target) { e.preventDefault(); new Tab(el).show(); }
530
+ else if (toggle === 'popover') { /* handled by Popover instance */ }
531
+
532
+ const slideTo = el.dataset.lxSlideTo;
533
+ const slideDir = el.dataset.lxSlide;
534
+ if (slideTo !== undefined || slideDir) {
535
+ const carouselEl = qs(el.dataset.lxTarget) || el.closest('.lx-carousel');
536
+ if (carouselEl && carouselEl._lxCarousel) {
537
+ if (slideDir === 'next') carouselEl._lxCarousel.next();
538
+ else if (slideDir === 'prev') carouselEl._lxCarousel.prev();
539
+ else if (slideTo !== undefined) carouselEl._lxCarousel.to(parseInt(slideTo));
540
+ }
541
+ }
542
+ });
543
+ }
544
+
545
+ /* ================================================================
546
+ * Scrollspy auto-init
547
+ * ================================================================ */
548
+ function initScrollspy() {
549
+ qsa('[data-lx-spy="scroll"]').forEach(el => new Scrollspy(el));
550
+ }
551
+
552
+ /* ================================================================
553
+ * Tooltip auto-init
554
+ * ================================================================ */
555
+ function initTooltips() {
556
+ qsa('[data-lx-toggle="tooltip"]').forEach(el => new Tooltip(el, {
557
+ placement: el.dataset.lxPlacement || 'top',
558
+ trigger: 'hover focus',
559
+ delay: { show: 100, hide: 0 }
560
+ }));
561
+ }
562
+
563
+ /* ================================================================
564
+ * Carousel auto-init
565
+ * ================================================================ */
566
+ function initCarousels() {
567
+ qsa('.lx-carousel').forEach(el => {
568
+ const c = new Carousel(el, { interval: parseInt(el.dataset.lxInterval) || 5000 });
569
+ el._lxCarousel = c;
570
+ });
571
+ }
572
+
573
+ /* ================================================================
574
+ * Clipboard
575
+ * ================================================================ */
576
+ const Clipboard = {
577
+ copy(text) {
578
+ if (navigator.clipboard && window.isSecureContext) {
579
+ return navigator.clipboard.writeText(text);
580
+ }
581
+ const el = document.createElement('textarea');
582
+ el.value = text;
583
+ el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0';
584
+ document.body.appendChild(el);
585
+ el.focus(); el.select();
586
+ try { document.execCommand('copy'); } catch(e) {}
587
+ el.remove();
588
+ return Promise.resolve();
589
+ },
590
+ bind(sel, getText) {
591
+ qsa(sel).forEach(btn => {
592
+ on(btn, 'click', async () => {
593
+ const text = typeof getText === 'function' ? getText(btn) : (btn.dataset.lxCopy || btn.textContent);
594
+ await this.copy(text);
595
+ const orig = btn.textContent;
596
+ btn.textContent = btn.dataset.lxCopiedText || 'Copied!';
597
+ btn.classList.add('lx-copied');
598
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove('lx-copied'); }, 2000);
599
+ });
600
+ });
601
+ }
602
+ };
603
+
604
+ /* ================================================================
605
+ * Counter — animated number counter
606
+ * ================================================================ */
607
+ class Counter {
608
+ constructor(el, opts = {}) {
609
+ this.el = typeof el === 'string' ? qs(el) : el;
610
+ if (!this.el) return;
611
+ this.opts = Object.assign({
612
+ start: 0,
613
+ end: parseInt(this.el.dataset.lxEnd || this.el.textContent) || 0,
614
+ duration: parseInt(this.el.dataset.lxDuration) || 2000,
615
+ prefix: this.el.dataset.lxPrefix || '',
616
+ suffix: this.el.dataset.lxSuffix || '',
617
+ decimals: parseInt(this.el.dataset.lxDecimals) || 0,
618
+ easing: true
619
+ }, opts);
620
+ }
621
+ _ease(t) { return t < .5 ? 2*t*t : -1+(4-2*t)*t; }
622
+ _format(n) {
623
+ return this.opts.prefix + n.toFixed(this.opts.decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + this.opts.suffix;
624
+ }
625
+ start() {
626
+ const { start, end, duration, easing } = this.opts;
627
+ const range = end - start;
628
+ let startTime = null;
629
+ const step = ts => {
630
+ if (!startTime) startTime = ts;
631
+ const elapsed = Math.min(ts - startTime, duration);
632
+ const progress = easing ? this._ease(elapsed / duration) : elapsed / duration;
633
+ this.el.textContent = this._format(start + range * progress);
634
+ if (elapsed < duration) requestAnimationFrame(step);
635
+ else this.el.textContent = this._format(end);
636
+ };
637
+ requestAnimationFrame(step);
638
+ }
639
+ static observeAll(sel, opts) {
640
+ if ('IntersectionObserver' in window) {
641
+ const io = new IntersectionObserver(entries => {
642
+ entries.forEach(e => { if (e.isIntersecting) { new Counter(e.target, opts).start(); io.unobserve(e.target); } });
643
+ }, { threshold: .5 });
644
+ qsa(sel).forEach(el => io.observe(el));
645
+ } else {
646
+ qsa(sel).forEach(el => new Counter(el, opts).start());
647
+ }
648
+ }
649
+ }
650
+
651
+ /* ================================================================
652
+ * NumberInput — increment / decrement control
653
+ * ================================================================ */
654
+ class NumberInput {
655
+ constructor(el, opts = {}) {
656
+ this.wrap = typeof el === 'string' ? qs(el) : el;
657
+ if (!this.wrap) return;
658
+ this.input = qs('input', this.wrap);
659
+ if (!this.input) return;
660
+ this.opts = Object.assign({ step: 1, min: -Infinity, max: Infinity }, opts);
661
+ this.opts.min = parseFloat(this.input.min || this.opts.min);
662
+ this.opts.max = parseFloat(this.input.max || this.opts.max);
663
+ this.opts.step = parseFloat(this.input.step || this.opts.step) || 1;
664
+ this._attach();
665
+ }
666
+ _val() { return parseFloat(this.input.value) || 0; }
667
+ _set(v) {
668
+ const clamped = Math.min(this.opts.max, Math.max(this.opts.min, v));
669
+ this.input.value = clamped;
670
+ emit(this.input, 'change', { value: clamped });
671
+ this._updateBtns();
672
+ }
673
+ _updateBtns() {
674
+ const v = this._val();
675
+ const dec = qs('.lx-number-btn[data-lx-dec]', this.wrap) || qs('.lx-number-dec', this.wrap);
676
+ const inc = qs('.lx-number-btn[data-lx-inc]', this.wrap) || qs('.lx-number-inc', this.wrap);
677
+ if (dec) dec.disabled = v <= this.opts.min;
678
+ if (inc) inc.disabled = v >= this.opts.max;
679
+ }
680
+ _attach() {
681
+ on(this.wrap, 'click', e => {
682
+ const btn = e.target.closest('[data-lx-dec],[data-lx-inc],.lx-number-dec,.lx-number-inc');
683
+ if (!btn) return;
684
+ if (btn.matches('[data-lx-dec],.lx-number-dec')) this._set(this._val() - this.opts.step);
685
+ if (btn.matches('[data-lx-inc],.lx-number-inc')) this._set(this._val() + this.opts.step);
686
+ });
687
+ on(this.input, 'change', () => this._set(this._val()));
688
+ on(this.input, 'keydown', e => {
689
+ if (e.key === 'ArrowUp') { e.preventDefault(); this._set(this._val() + this.opts.step); }
690
+ if (e.key === 'ArrowDown') { e.preventDefault(); this._set(this._val() - this.opts.step); }
691
+ });
692
+ this._updateBtns();
693
+ }
694
+ }
695
+
696
+ /* ================================================================
697
+ * Rating — interactive star rating
698
+ * ================================================================ */
699
+ class Rating {
700
+ constructor(el, opts = {}) {
701
+ this.el = typeof el === 'string' ? qs(el) : el;
702
+ if (!this.el) return;
703
+ this.opts = Object.assign({ stars: 5, value: 0, readonly: this.el.dataset.lxReadonly !== undefined }, opts);
704
+ this.value = parseFloat(this.el.dataset.lxValue || this.opts.value) || 0;
705
+ this._render();
706
+ }
707
+ _render() {
708
+ this.el.innerHTML = '';
709
+ this.el.setAttribute('role', 'radiogroup');
710
+ for (let i = 1; i <= this.opts.stars; i++) {
711
+ const s = document.createElement('span');
712
+ s.className = 'lx-rating-star' + (i <= this.value ? ' lx-filled' : '');
713
+ s.setAttribute('role', 'radio');
714
+ s.setAttribute('aria-label', i + ' star' + (i !== 1 ? 's' : ''));
715
+ s.setAttribute('aria-checked', i <= this.value ? 'true' : 'false');
716
+ s.dataset.value = i;
717
+ this.el.appendChild(s);
718
+ }
719
+ if (this.opts.readonly) { this.el.dataset.readonly = ''; return; }
720
+ on(this.el, 'click', e => {
721
+ const s = e.target.closest('.lx-rating-star');
722
+ if (!s) return;
723
+ this.value = parseInt(s.dataset.value);
724
+ qsa('.lx-rating-star', this.el).forEach((st, i) => {
725
+ const filled = i + 1 <= this.value;
726
+ st.classList.toggle('lx-filled', filled);
727
+ st.setAttribute('aria-checked', filled ? 'true' : 'false');
728
+ });
729
+ emit(this.el, 'rating.change', { value: this.value });
730
+ });
731
+ on(this.el, 'mouseover', e => {
732
+ const s = e.target.closest('.lx-rating-star');
733
+ if (!s) return;
734
+ const hover = parseInt(s.dataset.value);
735
+ qsa('.lx-rating-star', this.el).forEach((st, i) => st.classList.toggle('lx-hovered', i + 1 <= hover));
736
+ });
737
+ on(this.el, 'mouseleave', () => qsa('.lx-rating-star', this.el).forEach(st => st.classList.remove('lx-hovered')));
738
+ }
739
+ setValue(v) { this.value = v; this._render(); }
740
+ getValue() { return this.value; }
741
+ }
742
+
743
+ /* ================================================================
744
+ * Lazy load — IntersectionObserver images / iframes
745
+ * ================================================================ */
746
+ function initLazy(sel) {
747
+ const targets = qsa(sel || '[data-lx-lazy]');
748
+ if (!targets.length) return;
749
+ if ('IntersectionObserver' in window) {
750
+ const io = new IntersectionObserver(entries => {
751
+ entries.forEach(e => {
752
+ if (!e.isIntersecting) return;
753
+ const el = e.target;
754
+ if (el.dataset.lxSrc) el.src = el.dataset.lxSrc;
755
+ if (el.dataset.lxSrcset) el.srcset = el.dataset.lxSrcset;
756
+ if (el.dataset.lxBg) el.style.backgroundImage = 'url(' + el.dataset.lxBg + ')';
757
+ el.removeAttribute('data-lx-lazy');
758
+ el.classList.add('lx-lazy-loaded');
759
+ io.unobserve(el);
760
+ });
761
+ }, { rootMargin: '200px' });
762
+ targets.forEach(el => io.observe(el));
763
+ } else {
764
+ targets.forEach(el => {
765
+ if (el.dataset.lxSrc) el.src = el.dataset.lxSrc;
766
+ if (el.dataset.lxSrcset) el.srcset = el.dataset.lxSrcset;
767
+ if (el.dataset.lxBg) el.style.backgroundImage = 'url(' + el.dataset.lxBg + ')';
768
+ });
769
+ }
770
+ }
771
+
772
+ /* ================================================================
773
+ * Back-to-top
774
+ * ================================================================ */
775
+ function initBackTop() {
776
+ const btns = qsa('.lx-back-top');
777
+ if (!btns.length) return;
778
+ const toggle = () => { const show = window.scrollY > 300; btns.forEach(b => b.classList.toggle('lx-show', show)); };
779
+ on(window, 'scroll', toggle, { passive: true });
780
+ btns.forEach(btn => on(btn, 'click', e => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }));
781
+ toggle();
782
+ }
783
+
784
+ /* ================================================================
785
+ * Code block copy buttons
786
+ * ================================================================ */
787
+ function initCodeCopy() {
788
+ qsa('[data-lx-copy-trigger]').forEach(btn => {
789
+ on(btn, 'click', async () => {
790
+ const target = btn.dataset.lxCopyTarget ? qs(btn.dataset.lxCopyTarget) : btn.closest('.lx-code-block')?.querySelector('pre');
791
+ if (!target) return;
792
+ await Clipboard.copy(target.textContent);
793
+ btn.classList.add('lx-copied');
794
+ const orig = btn.textContent;
795
+ btn.textContent = 'Copied!';
796
+ setTimeout(() => { btn.textContent = orig; btn.classList.remove('lx-copied'); }, 2000);
797
+ });
798
+ });
799
+ }
800
+
801
+ /* ================================================================
802
+ * File drop zone
803
+ * ================================================================ */
804
+ function initFileDrops() {
805
+ qsa('.lx-file-drop').forEach(zone => {
806
+ const input = qs('input[type="file"]', zone);
807
+ ['dragenter', 'dragover'].forEach(ev => on(zone, ev, e => { e.preventDefault(); zone.classList.add('lx-drag-over'); }));
808
+ ['dragleave', 'drop'].forEach(ev => on(zone, ev, e => { e.preventDefault(); zone.classList.remove('lx-drag-over'); }));
809
+ on(zone, 'drop', e => { const files = e.dataTransfer.files; if (input) { try { input.files = files; } catch(x){} } emit(zone, 'file.drop', { files }); });
810
+ on(zone, 'click', () => input && input.click());
811
+ if (input) on(input, 'change', () => emit(zone, 'file.select', { files: input.files }));
812
+ });
813
+ }
814
+
815
+ /* ================================================================
816
+ * Number inputs auto-init
817
+ * ================================================================ */
818
+ function initNumberInputs() {
819
+ qsa('.lx-number-input').forEach(el => new NumberInput(el));
820
+ }
821
+
822
+ /* ================================================================
823
+ * Rating auto-init
824
+ * ================================================================ */
825
+ function initRatings() {
826
+ qsa('.lx-rating').forEach(el => new Rating(el));
827
+ }
828
+
829
+ /* ================================================================
830
+ * LxUI.init() — initialize everything
831
+ * ================================================================ */
832
+ function init() {
833
+ initDataBridge();
834
+ initAlerts();
835
+ initScrollspy();
836
+ initTooltips();
837
+ initCarousels();
838
+ initLazy();
839
+ initBackTop();
840
+ initCodeCopy();
841
+ initFileDrops();
842
+ initNumberInputs();
843
+ initRatings();
844
+ theme.restore();
845
+ }
846
+
847
+ if (document.readyState === 'loading') { on(document, 'DOMContentLoaded', init); }
848
+ else { init(); }
849
+
850
+ return {
851
+ init,
852
+ Modal, Toast, Collapse, Dropdown, Offcanvas, Tooltip, Popover, Tab, Carousel, Scrollspy,
853
+ Clipboard, Counter, NumberInput, Rating,
854
+ breakpoint, theme,
855
+ LxToast: Toast,
856
+ version: '1.0.0',
857
+ homepage: 'https://ui.lancar.id'
858
+ };
859
+ });