@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.
- package/README.md +514 -0
- package/bin/cli.js +272 -0
- package/package.json +36 -0
- package/registry/registry.json +207 -0
- package/src/base.css +82 -0
- package/src/controllers/accordion.js +27 -0
- package/src/controllers/alert.js +39 -0
- package/src/controllers/badge.js +40 -0
- package/src/controllers/button.js +79 -0
- package/src/controllers/card.js +10 -0
- package/src/controllers/checkbox.js +33 -0
- package/src/controllers/dropdown.js +35 -0
- package/src/controllers/input.js +24 -0
- package/src/controllers/modal.js +39 -0
- package/src/controllers/popover.js +35 -0
- package/src/controllers/progress.js +22 -0
- package/src/controllers/radio.js +30 -0
- package/src/controllers/sheet.js +37 -0
- package/src/controllers/slider.js +57 -0
- package/src/controllers/table.js +31 -0
- package/src/controllers/tabs.js +29 -0
- package/src/controllers/toast.js +41 -0
- package/src/controllers/toggle.js +27 -0
- package/src/controllers/tooltip.js +19 -0
- package/src/controllers.js +836 -0
- package/templates/components.json +13 -0
|
@@ -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)
|