@skutally/ui-library 0.1.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.
@@ -0,0 +1,836 @@
1
+ /**
2
+ * ui-library — Component Controllers
3
+ *
4
+ * Single entry point for the demo page.
5
+ * Loads Stimulus from the global UMD build and registers every controller.
6
+ *
7
+ * Usage (CDN):
8
+ * <script src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus/dist/stimulus.umd.js"></script>
9
+ * <script src="src/controllers.js"></script>
10
+ */
11
+
12
+ const { Application, Controller } = Stimulus
13
+ const application = Application.start()
14
+
15
+ /* ───────────────────────────── Dark Mode ──────────────────────────── */
16
+
17
+ class DarkmodeController extends Controller {
18
+ static targets = ["sun", "moon"]
19
+
20
+ connect() {
21
+ const saved = localStorage.getItem("theme")
22
+ if (saved === "dark" || (!saved && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
23
+ document.documentElement.classList.add("dark")
24
+ }
25
+ this.render()
26
+ }
27
+
28
+ toggle() {
29
+ document.documentElement.classList.toggle("dark")
30
+ const isDark = document.documentElement.classList.contains("dark")
31
+ localStorage.setItem("theme", isDark ? "dark" : "light")
32
+ this.render()
33
+ }
34
+
35
+ render() {
36
+ const isDark = document.documentElement.classList.contains("dark")
37
+ if (this.hasSunTarget && this.hasMoonTarget) {
38
+ this.sunTarget.classList.toggle("hidden", isDark)
39
+ this.moonTarget.classList.toggle("hidden", !isDark)
40
+ }
41
+ }
42
+ }
43
+
44
+ /* ───────────────────────── Preview / Code Toggle ─────────────────── */
45
+
46
+ class PreviewController extends Controller {
47
+ static targets = ["preview", "code", "previewTab", "codeTab", "copyBtn", "raw"]
48
+
49
+ showPreview() {
50
+ this.previewTarget.classList.remove("hidden")
51
+ this.codeTarget.classList.add("hidden")
52
+ this.previewTabTarget.classList.add("bg-accent", "text-accent-foreground")
53
+ this.previewTabTarget.classList.remove("text-muted-foreground")
54
+ this.codeTabTarget.classList.remove("bg-accent", "text-accent-foreground")
55
+ this.codeTabTarget.classList.add("text-muted-foreground")
56
+ }
57
+
58
+ showCode() {
59
+ this.previewTarget.classList.add("hidden")
60
+ this.codeTarget.classList.remove("hidden")
61
+ this.codeTabTarget.classList.add("bg-accent", "text-accent-foreground")
62
+ this.codeTabTarget.classList.remove("text-muted-foreground")
63
+ this.previewTabTarget.classList.remove("bg-accent", "text-accent-foreground")
64
+ this.previewTabTarget.classList.add("text-muted-foreground")
65
+ }
66
+
67
+ copy() {
68
+ const text = this.hasRawTarget ? this.rawTarget.value : this.previewTarget.innerHTML
69
+ navigator.clipboard.writeText(text).then(() => {
70
+ const btn = this.copyBtnTarget
71
+ const original = btn.innerHTML
72
+ btn.innerHTML = `<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> Copied!`
73
+ setTimeout(() => { btn.innerHTML = original }, 2000)
74
+ })
75
+ }
76
+ }
77
+
78
+ /* ───────────────────────────── Search ─────────────────────────────── */
79
+
80
+ class SearchController extends Controller {
81
+ connect() {
82
+ this._keydown = (e) => {
83
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
84
+ e.preventDefault()
85
+ this.open()
86
+ }
87
+ }
88
+ document.addEventListener("keydown", this._keydown)
89
+ }
90
+
91
+ disconnect() {
92
+ document.removeEventListener("keydown", this._keydown)
93
+ }
94
+
95
+ open() {
96
+ if (document.getElementById("search-modal")) return
97
+ const sections = document.querySelectorAll("[data-component]")
98
+ let items = ""
99
+ sections.forEach(s => {
100
+ const label = s.dataset.label || s.dataset.component
101
+ const id = s.id
102
+ items += `<a href="#${id}" class="search-item flex items-center gap-3 px-4 py-3 text-sm hover:bg-accent rounded-md transition-colors" data-label="${label.toLowerCase()}" onclick="document.getElementById('search-modal').remove()">
103
+ <svg class="w-4 h-4 text-muted-foreground shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
104
+ <span>${label}</span>
105
+ </a>`
106
+ })
107
+
108
+ const modal = document.createElement("div")
109
+ modal.id = "search-modal"
110
+ modal.className = "fixed inset-0 z-[100] flex items-start justify-center pt-[20vh]"
111
+ modal.innerHTML = `
112
+ <div class="absolute inset-0 bg-black/50 backdrop-blur-sm" onclick="this.parentElement.remove()"></div>
113
+ <div class="relative w-full max-w-lg bg-background border border-border rounded-xl shadow-2xl overflow-hidden">
114
+ <div class="flex items-center border-b border-border px-4">
115
+ <svg class="w-4 h-4 text-muted-foreground shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
116
+ <input id="search-input" type="text" placeholder="Search components..." class="flex-1 bg-transparent border-0 px-3 py-3 text-sm outline-none placeholder:text-muted-foreground" oninput="filterSearch(this.value)" autofocus />
117
+ <kbd class="pointer-events-none inline-flex h-5 select-none items-center rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">ESC</kbd>
118
+ </div>
119
+ <div id="search-results" class="max-h-72 overflow-y-auto p-2">${items}</div>
120
+ </div>
121
+ `
122
+ document.body.appendChild(modal)
123
+ document.getElementById("search-input").focus()
124
+ const escHandler = (e) => {
125
+ if (e.key === "Escape") { modal.remove(); document.removeEventListener("keydown", escHandler) }
126
+ }
127
+ document.addEventListener("keydown", escHandler)
128
+ }
129
+ }
130
+
131
+ // Global search filter function
132
+ window.filterSearch = function(query) {
133
+ const items = document.querySelectorAll(".search-item")
134
+ const q = query.toLowerCase()
135
+ items.forEach(item => {
136
+ item.style.display = item.dataset.label.includes(q) ? "" : "none"
137
+ })
138
+ }
139
+
140
+ /* ───────────────────────────── Accordion ───────────────────────────── */
141
+
142
+ class AccordionController extends Controller {
143
+ static targets = ["item", "body", "icon"]
144
+
145
+ connect() {
146
+ this.openIndex = -1
147
+ }
148
+
149
+ toggle(e) {
150
+ const button = e.currentTarget
151
+ const item = button.closest("[data-accordion-item]")
152
+ const body = item.querySelector("[data-accordion-target='body']")
153
+ const icon = item.querySelector("[data-accordion-target='icon']")
154
+ const isOpen = !body.classList.contains("hidden")
155
+
156
+ // Close all
157
+ this.element.querySelectorAll("[data-accordion-target='body']").forEach(b => b.classList.add("hidden"))
158
+ this.element.querySelectorAll("[data-accordion-target='icon']").forEach(i => i.style.transform = "")
159
+
160
+ // Open clicked if was closed
161
+ if (!isOpen) {
162
+ body.classList.remove("hidden")
163
+ if (icon) icon.style.transform = "rotate(180deg)"
164
+ }
165
+ }
166
+ }
167
+
168
+ /* ───────────────────────────── Alert ───────────────────────────────── */
169
+
170
+ class AlertController extends Controller {
171
+ static values = {
172
+ variant: { type: String, default: "info" },
173
+ dismissible: { type: Boolean, default: true },
174
+ }
175
+
176
+ connect() {
177
+ this.applyVariant()
178
+ if (this.dismissibleValue) this.addCloseButton()
179
+ }
180
+
181
+ applyVariant() {
182
+ const base = "relative flex items-start gap-3 px-4 py-3 rounded-lg border text-sm transition-all [&>svg]:shrink-0 [&>svg]:mt-0.5"
183
+ const map = {
184
+ info: "bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-900 dark:text-blue-200",
185
+ success: "bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-900 dark:text-emerald-200",
186
+ warning: "bg-amber-50 border-amber-200 text-amber-800 dark:bg-amber-950 dark:border-amber-900 dark:text-amber-200",
187
+ danger: "bg-red-50 border-red-200 text-red-800 dark:bg-red-950 dark:border-red-900 dark:text-red-200",
188
+ }
189
+ this.element.className = `${base} ${map[this.variantValue] ?? map.info}`
190
+ }
191
+
192
+ addCloseButton() {
193
+ if (this.element.querySelector("[data-alert-close]")) return
194
+ const btn = document.createElement("button")
195
+ btn.setAttribute("data-alert-close", "")
196
+ btn.className = "absolute top-3 right-3 text-lg leading-none opacity-40 hover:opacity-80 cursor-pointer"
197
+ btn.textContent = "\u2715"
198
+ btn.addEventListener("click", () => this.dismiss())
199
+ this.element.appendChild(btn)
200
+ }
201
+
202
+ dismiss() {
203
+ this.element.classList.add("opacity-0", "-translate-y-1")
204
+ setTimeout(() => this.element.remove(), 200)
205
+ }
206
+ }
207
+
208
+ /* ───────────────────────────── Badge ───────────────────────────────── */
209
+
210
+ class BadgeController extends Controller {
211
+ static values = {
212
+ variant: { type: String, default: "default" },
213
+ dot: { type: Boolean, default: false },
214
+ }
215
+
216
+ connect() {
217
+ this.applyVariant()
218
+ if (this.dotValue) this.prependDot()
219
+ }
220
+
221
+ applyVariant() {
222
+ const base = "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors"
223
+ const map = {
224
+ default: "bg-primary text-primary-foreground",
225
+ secondary: "bg-muted text-muted-foreground",
226
+ success: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
227
+ warning: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
228
+ danger: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
229
+ info: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
230
+ outline: "bg-transparent border border-border text-foreground",
231
+ }
232
+ this.element.className = `${base} ${map[this.variantValue] ?? map.default}`
233
+ }
234
+
235
+ prependDot() {
236
+ if (this.element.querySelector("[data-badge-dot]")) return
237
+ const dot = document.createElement("span")
238
+ dot.setAttribute("data-badge-dot", "")
239
+ const colors = {
240
+ default: "bg-primary-foreground", secondary: "bg-muted-foreground",
241
+ success: "bg-emerald-500", warning: "bg-amber-500",
242
+ danger: "bg-red-500", info: "bg-blue-500", outline: "bg-foreground",
243
+ }
244
+ dot.className = `w-1.5 h-1.5 rounded-full ${colors[this.variantValue] ?? colors.default}`
245
+ this.element.prepend(dot)
246
+ }
247
+ }
248
+
249
+ /* ───────────────────────────── Button ──────────────────────────────── */
250
+
251
+ class ButtonController extends Controller {
252
+ static values = {
253
+ variant: { type: String, default: "default" },
254
+ size: { type: String, default: "default" },
255
+ loading: { type: Boolean, default: false },
256
+ loadingText: { type: String, default: "Loading..." },
257
+ clickable: { type: Boolean, default: false },
258
+ }
259
+
260
+ connect() {
261
+ this._originalHTML = this.element.innerHTML
262
+ this.applyBase()
263
+ this.applyVariant()
264
+ this.applySize()
265
+ if (this.loadingValue) this.setLoading(true)
266
+ if (this.clickableValue) {
267
+ this.element.addEventListener("click", () => this.handleClick())
268
+ }
269
+ }
270
+
271
+ applyBase() {
272
+ this.element.classList.add(
273
+ "inline-flex", "items-center", "justify-center", "gap-2",
274
+ "whitespace-nowrap", "rounded-md", "text-sm", "font-medium",
275
+ "transition-colors", "cursor-pointer", "focus-visible:outline-none",
276
+ "focus-visible:ring-2", "focus-visible:ring-ring", "focus-visible:ring-offset-2",
277
+ "disabled:pointer-events-none", "disabled:opacity-50"
278
+ )
279
+ }
280
+
281
+ applyVariant() {
282
+ const map = {
283
+ default: ["bg-primary", "text-primary-foreground", "hover:bg-primary/90"],
284
+ destructive: ["bg-red-600", "text-white", "hover:bg-red-700", "dark:bg-red-700", "dark:hover:bg-red-800"],
285
+ outline: ["border", "border-border", "bg-background", "hover:bg-accent", "hover:text-accent-foreground"],
286
+ secondary: ["bg-muted", "text-foreground", "hover:bg-muted/80"],
287
+ ghost: ["hover:bg-accent", "hover:text-accent-foreground"],
288
+ link: ["text-primary", "underline-offset-4", "hover:underline"],
289
+ success: ["bg-emerald-600", "text-white", "hover:bg-emerald-700"],
290
+ warning: ["bg-amber-500", "text-white", "hover:bg-amber-600"],
291
+ }
292
+ this.element.classList.add(...(map[this.variantValue] ?? map.default))
293
+ }
294
+
295
+ applySize() {
296
+ const map = {
297
+ xs: ["h-7", "px-2.5", "text-xs", "rounded"],
298
+ sm: ["h-8", "px-3", "text-xs", "rounded-md"],
299
+ default: ["h-9", "px-4", "py-2"],
300
+ lg: ["h-11", "px-6", "text-base"],
301
+ xl: ["h-12", "px-8", "text-lg", "rounded-lg"],
302
+ icon: ["h-9", "w-9", "p-0"],
303
+ }
304
+ this.element.classList.add(...(map[this.sizeValue] ?? map.default))
305
+ }
306
+
307
+ setLoading(on) {
308
+ if (on) {
309
+ this.element.disabled = true
310
+ this.element.innerHTML = `
311
+ <svg class="animate-spin h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none">
312
+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25"/>
313
+ <path d="M4 12a8 8 0 018-8V0C5.3 0 0 5.3 0 12h4z" fill="currentColor" class="opacity-75"/>
314
+ </svg>
315
+ ${this.loadingTextValue}`
316
+ } else {
317
+ this.element.disabled = false
318
+ this.element.innerHTML = this._originalHTML
319
+ }
320
+ }
321
+
322
+ handleClick() {
323
+ if (this.element.disabled) return
324
+ this.setLoading(true)
325
+ setTimeout(() => this.setLoading(false), 2000)
326
+ }
327
+ }
328
+
329
+ /* ───────────────────────────── Card ───────────────────────────────── */
330
+
331
+ class CardController extends Controller {
332
+ connect() {
333
+ this.element.classList.add(
334
+ "rounded-lg", "border", "border-border", "bg-card",
335
+ "text-card-foreground", "shadow-sm"
336
+ )
337
+ }
338
+ }
339
+
340
+ /* ───────────────────────────── Dropdown ────────────────────────────── */
341
+
342
+ class DropdownController extends Controller {
343
+ static targets = ["menu"]
344
+
345
+ connect() {
346
+ this._outside = (e) => { if (!this.element.contains(e.target)) this.close() }
347
+ this._escape = (e) => { if (e.key === "Escape") this.close() }
348
+ document.addEventListener("click", this._outside)
349
+ document.addEventListener("keydown", this._escape)
350
+ }
351
+
352
+ disconnect() {
353
+ document.removeEventListener("click", this._outside)
354
+ document.removeEventListener("keydown", this._escape)
355
+ }
356
+
357
+ toggle() {
358
+ this.menuTarget.classList.contains("hidden") ? this.open() : this.close()
359
+ }
360
+
361
+ open() {
362
+ this.menuTarget.classList.remove("hidden")
363
+ requestAnimationFrame(() => {
364
+ this.menuTarget.classList.remove("opacity-0", "scale-95")
365
+ this.menuTarget.classList.add("opacity-100", "scale-100")
366
+ })
367
+ }
368
+
369
+ close() {
370
+ this.menuTarget.classList.add("opacity-0", "scale-95")
371
+ this.menuTarget.classList.remove("opacity-100", "scale-100")
372
+ setTimeout(() => this.menuTarget.classList.add("hidden"), 100)
373
+ }
374
+ }
375
+
376
+ /* ───────────────────────────── Input ───────────────────────────────── */
377
+
378
+ class InputController extends Controller {
379
+ static values = {
380
+ variant: { type: String, default: "default" },
381
+ icon: { type: Boolean, default: false },
382
+ }
383
+
384
+ connect() {
385
+ this.applyVariant()
386
+ }
387
+
388
+ applyVariant() {
389
+ const base = "flex h-10 w-full rounded-md border px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
390
+ const map = {
391
+ default: "border-border bg-background",
392
+ error: "border-red-400 bg-red-50 text-red-900 focus-visible:ring-red-300 dark:bg-red-950 dark:text-red-200 dark:border-red-800",
393
+ success: "border-emerald-400 bg-emerald-50 text-emerald-900 focus-visible:ring-emerald-300 dark:bg-emerald-950 dark:text-emerald-200 dark:border-emerald-800",
394
+ }
395
+ const variant = map[this.variantValue] ?? map.default
396
+ const iconPad = this.iconValue ? "pl-9" : ""
397
+ this.element.className = `${base} ${variant} ${iconPad}`.trim()
398
+ }
399
+ }
400
+
401
+ /* ───────────────────────────── Modal ───────────────────────────────── */
402
+
403
+ class ModalController extends Controller {
404
+ static targets = ["overlay", "box"]
405
+
406
+ connect() {
407
+ this._key = (e) => { if (e.key === "Escape") this.close() }
408
+ document.addEventListener("keydown", this._key)
409
+ }
410
+
411
+ disconnect() {
412
+ document.removeEventListener("keydown", this._key)
413
+ }
414
+
415
+ open() {
416
+ this.overlayTarget.classList.remove("hidden")
417
+ document.body.style.overflow = "hidden"
418
+ requestAnimationFrame(() => {
419
+ this.overlayTarget.classList.remove("opacity-0")
420
+ this.boxTarget.classList.remove("scale-95", "opacity-0")
421
+ this.boxTarget.classList.add("scale-100", "opacity-100")
422
+ this.boxTarget.querySelector("button, input")?.focus()
423
+ })
424
+ }
425
+
426
+ close() {
427
+ this.overlayTarget.classList.add("opacity-0")
428
+ this.boxTarget.classList.add("scale-95", "opacity-0")
429
+ this.boxTarget.classList.remove("scale-100", "opacity-100")
430
+ setTimeout(() => {
431
+ this.overlayTarget.classList.add("hidden")
432
+ document.body.style.overflow = ""
433
+ }, 200)
434
+ }
435
+
436
+ backdrop(e) {
437
+ if (e.target === this.overlayTarget) this.close()
438
+ }
439
+ }
440
+
441
+ /* ───────────────────────────── Tabs ────────────────────────────────── */
442
+
443
+ class TabsController extends Controller {
444
+ static targets = ["tab", "panel"]
445
+
446
+ connect() {
447
+ this.idx = 0
448
+ this.render()
449
+ }
450
+
451
+ switch(e) {
452
+ this.idx = this.tabTargets.indexOf(e.currentTarget)
453
+ this.render()
454
+ }
455
+
456
+ render() {
457
+ this.tabTargets.forEach((t, i) => {
458
+ const active = i === this.idx
459
+ t.classList.toggle("border-foreground", active)
460
+ t.classList.toggle("text-foreground", active)
461
+ t.classList.toggle("font-semibold", active)
462
+ t.classList.toggle("bg-background", active)
463
+ t.classList.toggle("border-transparent", !active)
464
+ t.classList.toggle("text-muted-foreground", !active)
465
+ t.classList.toggle("font-medium", !active)
466
+ })
467
+ this.panelTargets.forEach((p, i) => p.classList.toggle("hidden", i !== this.idx))
468
+ }
469
+ }
470
+
471
+ /* ───────────────────────────── Toast ───────────────────────────────── */
472
+
473
+ class ToastController extends Controller {
474
+ static targets = ["container"]
475
+
476
+ show({ params: { type = "info", msg = "Notification" } }) {
477
+ const map = {
478
+ info: { ring: "bg-background border-border text-foreground", dot: "bg-blue-500" },
479
+ success: { ring: "bg-background border-border text-foreground", dot: "bg-emerald-500" },
480
+ warning: { ring: "bg-background border-border text-foreground", dot: "bg-amber-500" },
481
+ danger: { ring: "bg-background border-border text-foreground", dot: "bg-red-500" },
482
+ }
483
+ const cfg = map[type] ?? map.info
484
+ const el = document.createElement("div")
485
+ el.className = `flex items-center gap-3 px-4 py-3 rounded-lg border text-sm shadow-lg ${cfg.ring} transition-all duration-300 opacity-0 translate-y-2`
486
+ el.innerHTML = `
487
+ <span class="w-2 h-2 rounded-full shrink-0 ${cfg.dot}"></span>
488
+ <span class="flex-1">${this._escape(msg)}</span>
489
+ <button class="text-lg leading-none opacity-40 hover:opacity-80 ml-1 cursor-pointer">\u2715</button>`
490
+ el.querySelector("button").addEventListener("click", () => this._dismiss(el))
491
+ this.containerTarget.appendChild(el)
492
+ requestAnimationFrame(() => {
493
+ el.classList.replace("opacity-0", "opacity-100")
494
+ el.classList.replace("translate-y-2", "translate-y-0")
495
+ })
496
+ setTimeout(() => this._dismiss(el), 4000)
497
+ }
498
+
499
+ _dismiss(el) {
500
+ if (!el.parentNode) return
501
+ el.classList.replace("opacity-100", "opacity-0")
502
+ el.classList.add("-translate-y-1")
503
+ setTimeout(() => el.remove(), 300)
504
+ }
505
+
506
+ _escape(str) {
507
+ const div = document.createElement("div")
508
+ div.textContent = str
509
+ return div.innerHTML
510
+ }
511
+ }
512
+
513
+ /* ───────────────────────────── Toggle / Switch ──────────────────────── */
514
+
515
+ class ToggleController extends Controller {
516
+ static targets = ["track", "thumb"]
517
+ static values = {
518
+ checked: { type: Boolean, default: false },
519
+ }
520
+
521
+ connect() {
522
+ this.render()
523
+ }
524
+
525
+ flip() {
526
+ this.checkedValue = !this.checkedValue
527
+ this.render()
528
+ this.dispatch("change", { detail: { checked: this.checkedValue } })
529
+ }
530
+
531
+ render() {
532
+ const on = this.checkedValue
533
+ const btn = this.element.querySelector("[role=switch]")
534
+ if (btn) btn.setAttribute("aria-checked", on)
535
+ this.trackTarget.classList.toggle("bg-primary", on)
536
+ this.trackTarget.classList.toggle("bg-muted", !on)
537
+ this.thumbTarget.style.transform = on ? "translateX(20px)" : "translateX(0)"
538
+ }
539
+ }
540
+
541
+ /* ───────────────────────────── Tooltip ─────────────────────────────── */
542
+
543
+ class TooltipController extends Controller {
544
+ static targets = ["content"]
545
+ static values = {
546
+ position: { type: String, default: "top" },
547
+ }
548
+
549
+ show() {
550
+ this.contentTarget.classList.remove("hidden", "opacity-0")
551
+ this.contentTarget.classList.add("opacity-100")
552
+ }
553
+
554
+ hide() {
555
+ this.contentTarget.classList.remove("opacity-100")
556
+ this.contentTarget.classList.add("opacity-0")
557
+ setTimeout(() => this.contentTarget.classList.add("hidden"), 150)
558
+ }
559
+ }
560
+
561
+ /* ───────────────────────────── Popover ─────────────────────────────── */
562
+
563
+ class PopoverController extends Controller {
564
+ static targets = ["content"]
565
+
566
+ connect() {
567
+ this._outside = (e) => { if (!this.element.contains(e.target)) this.close() }
568
+ this._escape = (e) => { if (e.key === "Escape") this.close() }
569
+ document.addEventListener("click", this._outside)
570
+ document.addEventListener("keydown", this._escape)
571
+ }
572
+
573
+ disconnect() {
574
+ document.removeEventListener("click", this._outside)
575
+ document.removeEventListener("keydown", this._escape)
576
+ }
577
+
578
+ toggle() {
579
+ this.contentTarget.classList.contains("hidden") ? this.open() : this.close()
580
+ }
581
+
582
+ open() {
583
+ this.contentTarget.classList.remove("hidden")
584
+ requestAnimationFrame(() => {
585
+ this.contentTarget.classList.remove("opacity-0", "scale-95")
586
+ this.contentTarget.classList.add("opacity-100", "scale-100")
587
+ })
588
+ }
589
+
590
+ close() {
591
+ this.contentTarget.classList.add("opacity-0", "scale-95")
592
+ this.contentTarget.classList.remove("opacity-100", "scale-100")
593
+ setTimeout(() => this.contentTarget.classList.add("hidden"), 100)
594
+ }
595
+ }
596
+
597
+ /* ───────────────────────────── Sheet / Drawer ────────────────────────── */
598
+
599
+ class SheetController extends Controller {
600
+ static targets = ["overlay", "panel"]
601
+ static values = {
602
+ side: { type: String, default: "right" },
603
+ }
604
+
605
+ open() {
606
+ this.overlayTarget.classList.remove("hidden")
607
+ document.body.style.overflow = "hidden"
608
+ requestAnimationFrame(() => {
609
+ this.overlayTarget.classList.remove("opacity-0")
610
+ this.panelTarget.classList.remove(this._hiddenTransform())
611
+ this.panelTarget.classList.add("translate-x-0", "translate-y-0")
612
+ })
613
+ }
614
+
615
+ close() {
616
+ this.overlayTarget.classList.add("opacity-0")
617
+ this.panelTarget.classList.remove("translate-x-0", "translate-y-0")
618
+ this.panelTarget.classList.add(this._hiddenTransform())
619
+ setTimeout(() => {
620
+ this.overlayTarget.classList.add("hidden")
621
+ document.body.style.overflow = ""
622
+ }, 300)
623
+ }
624
+
625
+ backdrop(e) {
626
+ if (e.target === this.overlayTarget) this.close()
627
+ }
628
+
629
+ _hiddenTransform() {
630
+ const map = { right: "translate-x-full", left: "-translate-x-full", top: "-translate-y-full", bottom: "translate-y-full" }
631
+ return map[this.sideValue] || "translate-x-full"
632
+ }
633
+ }
634
+
635
+ /* ───────────────────────────── Checkbox ──────────────────────────────── */
636
+
637
+ class CheckboxController extends Controller {
638
+ static targets = ["icon"]
639
+ static values = {
640
+ checked: { type: Boolean, default: false },
641
+ }
642
+
643
+ connect() {
644
+ this.render()
645
+ }
646
+
647
+ toggle() {
648
+ this.checkedValue = !this.checkedValue
649
+ this.render()
650
+ this.dispatch("change", { detail: { checked: this.checkedValue } })
651
+ }
652
+
653
+ render() {
654
+ const on = this.checkedValue
655
+ const box = this.element.querySelector("[role=checkbox]")
656
+ if (box) {
657
+ box.setAttribute("aria-checked", on)
658
+ box.classList.toggle("bg-primary", on)
659
+ box.classList.toggle("text-primary-foreground", on)
660
+ box.classList.toggle("border-primary", on)
661
+ box.classList.toggle("border-border", !on)
662
+ }
663
+ if (this.hasIconTarget) {
664
+ this.iconTarget.classList.toggle("hidden", !on)
665
+ }
666
+ }
667
+ }
668
+
669
+ /* ───────────────────────────── Radio Group ───────────────────────────── */
670
+
671
+ class RadioController extends Controller {
672
+ static targets = ["option", "dot"]
673
+ static values = {
674
+ selected: { type: Number, default: -1 },
675
+ }
676
+
677
+ connect() {
678
+ if (this.selectedValue >= 0) this.render()
679
+ }
680
+
681
+ select(e) {
682
+ this.selectedValue = this.optionTargets.indexOf(e.currentTarget)
683
+ this.render()
684
+ this.dispatch("change", { detail: { selected: this.selectedValue } })
685
+ }
686
+
687
+ render() {
688
+ this.optionTargets.forEach((opt, i) => {
689
+ const active = i === this.selectedValue
690
+ const ring = opt.querySelector("[data-radio-target='dot']")
691
+ if (ring) {
692
+ ring.classList.toggle("border-primary", active)
693
+ const inner = ring.querySelector("span")
694
+ if (inner) inner.classList.toggle("hidden", !active)
695
+ }
696
+ })
697
+ }
698
+ }
699
+
700
+ /* ───────────────────────────── Progress ──────────────────────────────── */
701
+
702
+ class ProgressController extends Controller {
703
+ static targets = ["bar"]
704
+ static values = {
705
+ value: { type: Number, default: 0 },
706
+ }
707
+
708
+ connect() {
709
+ this.render()
710
+ }
711
+
712
+ render() {
713
+ const pct = Math.min(100, Math.max(0, this.valueValue))
714
+ this.barTarget.style.width = `${pct}%`
715
+ this.element.setAttribute("aria-valuenow", pct)
716
+ }
717
+
718
+ valueValueChanged() {
719
+ this.render()
720
+ }
721
+ }
722
+
723
+ /* ───────────────────────────── Slider ────────────────────────────────── */
724
+
725
+ class SliderController extends Controller {
726
+ static targets = ["track", "fill", "thumb", "output"]
727
+ static values = {
728
+ min: { type: Number, default: 0 },
729
+ max: { type: Number, default: 100 },
730
+ val: { type: Number, default: 50 },
731
+ step: { type: Number, default: 1 },
732
+ }
733
+
734
+ connect() {
735
+ this._dragging = false
736
+ this._onMove = (e) => this._handleMove(e)
737
+ this._onUp = () => this._handleUp()
738
+ this.render()
739
+ }
740
+
741
+ startDrag(e) {
742
+ e.preventDefault()
743
+ this._dragging = true
744
+ document.addEventListener("mousemove", this._onMove)
745
+ document.addEventListener("mouseup", this._onUp)
746
+ document.addEventListener("touchmove", this._onMove)
747
+ document.addEventListener("touchend", this._onUp)
748
+ this._handleMove(e)
749
+ }
750
+
751
+ _handleMove(e) {
752
+ if (!this._dragging) return
753
+ const rect = this.trackTarget.getBoundingClientRect()
754
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
755
+ let pct = (clientX - rect.left) / rect.width
756
+ pct = Math.max(0, Math.min(1, pct))
757
+ const range = this.maxValue - this.minValue
758
+ let val = this.minValue + pct * range
759
+ val = Math.round(val / this.stepValue) * this.stepValue
760
+ this.valValue = Math.max(this.minValue, Math.min(this.maxValue, val))
761
+ this.render()
762
+ this.dispatch("change", { detail: { value: this.valValue } })
763
+ }
764
+
765
+ _handleUp() {
766
+ this._dragging = false
767
+ document.removeEventListener("mousemove", this._onMove)
768
+ document.removeEventListener("mouseup", this._onUp)
769
+ document.removeEventListener("touchmove", this._onMove)
770
+ document.removeEventListener("touchend", this._onUp)
771
+ }
772
+
773
+ render() {
774
+ const pct = ((this.valValue - this.minValue) / (this.maxValue - this.minValue)) * 100
775
+ if (this.hasFillTarget) this.fillTarget.style.width = `${pct}%`
776
+ if (this.hasThumbTarget) this.thumbTarget.style.left = `${pct}%`
777
+ if (this.hasOutputTarget) this.outputTarget.textContent = this.valValue
778
+ }
779
+ }
780
+
781
+ /* ───────────────────────────── Table Sort ────────────────────────────── */
782
+
783
+ class TableController extends Controller {
784
+ static targets = ["header", "body"]
785
+
786
+ sort(e) {
787
+ const th = e.currentTarget
788
+ const col = parseInt(th.dataset.col)
789
+ const dir = th.dataset.dir === "asc" ? "desc" : "asc"
790
+
791
+ // Reset all headers
792
+ this.headerTargets.forEach(h => { h.dataset.dir = ""; h.querySelector("[data-sort-icon]")?.classList.add("opacity-0") })
793
+ th.dataset.dir = dir
794
+ const icon = th.querySelector("[data-sort-icon]")
795
+ if (icon) {
796
+ icon.classList.remove("opacity-0")
797
+ icon.style.transform = dir === "desc" ? "rotate(180deg)" : ""
798
+ }
799
+
800
+ const rows = Array.from(this.bodyTarget.querySelectorAll("tr"))
801
+ rows.sort((a, b) => {
802
+ const aVal = a.children[col]?.textContent.trim() ?? ""
803
+ const bVal = b.children[col]?.textContent.trim() ?? ""
804
+ const aNum = parseFloat(aVal.replace(/[^0-9.-]/g, ""))
805
+ const bNum = parseFloat(bVal.replace(/[^0-9.-]/g, ""))
806
+ if (!isNaN(aNum) && !isNaN(bNum)) return dir === "asc" ? aNum - bNum : bNum - aNum
807
+ return dir === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
808
+ })
809
+ rows.forEach(r => this.bodyTarget.appendChild(r))
810
+ }
811
+ }
812
+
813
+ /* ───────────────── Register all controllers ───────────────────────── */
814
+
815
+ application.register("darkmode", DarkmodeController)
816
+ application.register("preview", PreviewController)
817
+ application.register("search", SearchController)
818
+ application.register("accordion", AccordionController)
819
+ application.register("alert", AlertController)
820
+ application.register("badge", BadgeController)
821
+ application.register("button", ButtonController)
822
+ application.register("card", CardController)
823
+ application.register("dropdown", DropdownController)
824
+ application.register("input", InputController)
825
+ application.register("modal", ModalController)
826
+ application.register("tabs", TabsController)
827
+ application.register("toast", ToastController)
828
+ application.register("toggle", ToggleController)
829
+ application.register("tooltip", TooltipController)
830
+ application.register("popover", PopoverController)
831
+ application.register("sheet", SheetController)
832
+ application.register("checkbox", CheckboxController)
833
+ application.register("radio", RadioController)
834
+ application.register("progress", ProgressController)
835
+ application.register("slider", SliderController)
836
+ application.register("table", TableController)