shadcn-phlex 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.
- checksums.yaml +7 -0
- data/README.md +195 -0
- data/app.css +20 -0
- data/css/shadcn-source.css +3 -0
- data/css/shadcn-tailwind.css +160 -0
- data/css/themes/mauve.css +62 -0
- data/css/themes/mist.css +62 -0
- data/css/themes/neutral.css +74 -0
- data/css/themes/olive.css +62 -0
- data/css/themes/stone.css +62 -0
- data/css/themes/taupe.css +62 -0
- data/css/themes/zinc.css +62 -0
- data/js/controllers/accordion_controller.js +135 -0
- data/js/controllers/checkbox_controller.js +52 -0
- data/js/controllers/collapsible_controller.js +85 -0
- data/js/controllers/combobox_controller.js +168 -0
- data/js/controllers/command_controller.js +171 -0
- data/js/controllers/context_menu_controller.js +132 -0
- data/js/controllers/dark_mode_controller.js +106 -0
- data/js/controllers/dialog_controller.js +205 -0
- data/js/controllers/drawer_controller.js +161 -0
- data/js/controllers/dropdown_menu_controller.js +189 -0
- data/js/controllers/hover_card_controller.js +85 -0
- data/js/controllers/index.js +89 -0
- data/js/controllers/menubar_controller.js +171 -0
- data/js/controllers/navigation_menu_controller.js +160 -0
- data/js/controllers/popover_controller.js +151 -0
- data/js/controllers/radio_group_controller.js +78 -0
- data/js/controllers/scroll_area_controller.js +117 -0
- data/js/controllers/select_controller.js +198 -0
- data/js/controllers/sheet_controller.js +130 -0
- data/js/controllers/slider_controller.js +142 -0
- data/js/controllers/switch_controller.js +40 -0
- data/js/controllers/tabs_controller.js +96 -0
- data/js/controllers/toast_controller.js +206 -0
- data/js/controllers/toggle_controller.js +30 -0
- data/js/controllers/toggle_group_controller.js +73 -0
- data/js/controllers/tooltip_controller.js +146 -0
- data/lib/generators/shadcn_phlex/component_generator.rb +79 -0
- data/lib/generators/shadcn_phlex/install_generator.rb +217 -0
- data/lib/shadcn/base.rb +27 -0
- data/lib/shadcn/engine.rb +24 -0
- data/lib/shadcn/kit.rb +1158 -0
- data/lib/shadcn/themes/accent_colors.rb +106 -0
- data/lib/shadcn/themes/base_colors.rb +313 -0
- data/lib/shadcn/ui/accordion.rb +135 -0
- data/lib/shadcn/ui/alert.rb +79 -0
- data/lib/shadcn/ui/alert_dialog.rb +220 -0
- data/lib/shadcn/ui/aspect_ratio.rb +35 -0
- data/lib/shadcn/ui/avatar.rb +134 -0
- data/lib/shadcn/ui/badge.rb +48 -0
- data/lib/shadcn/ui/breadcrumb.rb +180 -0
- data/lib/shadcn/ui/button.rb +63 -0
- data/lib/shadcn/ui/button_group.rb +58 -0
- data/lib/shadcn/ui/card.rb +133 -0
- data/lib/shadcn/ui/checkbox.rb +72 -0
- data/lib/shadcn/ui/collapsible.rb +76 -0
- data/lib/shadcn/ui/combobox.rb +229 -0
- data/lib/shadcn/ui/command.rb +256 -0
- data/lib/shadcn/ui/context_menu.rb +319 -0
- data/lib/shadcn/ui/dialog.rb +226 -0
- data/lib/shadcn/ui/direction.rb +23 -0
- data/lib/shadcn/ui/drawer.rb +217 -0
- data/lib/shadcn/ui/dropdown_menu.rb +384 -0
- data/lib/shadcn/ui/empty.rb +97 -0
- data/lib/shadcn/ui/field.rb +126 -0
- data/lib/shadcn/ui/hover_card.rb +75 -0
- data/lib/shadcn/ui/input.rb +36 -0
- data/lib/shadcn/ui/input_group.rb +32 -0
- data/lib/shadcn/ui/input_otp.rb +112 -0
- data/lib/shadcn/ui/item.rb +115 -0
- data/lib/shadcn/ui/kbd.rb +45 -0
- data/lib/shadcn/ui/label.rb +28 -0
- data/lib/shadcn/ui/menubar.rb +345 -0
- data/lib/shadcn/ui/native_select.rb +31 -0
- data/lib/shadcn/ui/navigation_menu.rb +238 -0
- data/lib/shadcn/ui/pagination.rb +224 -0
- data/lib/shadcn/ui/popover.rb +147 -0
- data/lib/shadcn/ui/progress.rb +40 -0
- data/lib/shadcn/ui/radio_group.rb +92 -0
- data/lib/shadcn/ui/resizable.rb +108 -0
- data/lib/shadcn/ui/scroll_area.rb +75 -0
- data/lib/shadcn/ui/select.rb +235 -0
- data/lib/shadcn/ui/separator.rb +36 -0
- data/lib/shadcn/ui/sheet.rb +231 -0
- data/lib/shadcn/ui/sidebar.rb +420 -0
- data/lib/shadcn/ui/skeleton.rb +23 -0
- data/lib/shadcn/ui/slider.rb +72 -0
- data/lib/shadcn/ui/sonner.rb +177 -0
- data/lib/shadcn/ui/spinner.rb +58 -0
- data/lib/shadcn/ui/switch.rb +75 -0
- data/lib/shadcn/ui/table.rb +154 -0
- data/lib/shadcn/ui/tabs.rb +154 -0
- data/lib/shadcn/ui/text_field.rb +146 -0
- data/lib/shadcn/ui/textarea.rb +32 -0
- data/lib/shadcn/ui/theme_toggle.rb +74 -0
- data/lib/shadcn/ui/toggle.rb +66 -0
- data/lib/shadcn/ui/toggle_group.rb +75 -0
- data/lib/shadcn/ui/tooltip.rb +78 -0
- data/lib/shadcn/ui/typography.rb +217 -0
- data/lib/shadcn/version.rb +5 -0
- data/lib/shadcn-phlex.rb +6 -0
- data/lib/shadcn.rb +80 -0
- data/package.json +14 -0
- data/skills/shadcn-phlex/SKILL.md +190 -0
- data/skills/shadcn-phlex/evals/evals.json +90 -0
- data/skills/shadcn-phlex/references/component-catalog.md +355 -0
- data/skills/shadcn-phlex/rules/composition.md +235 -0
- data/skills/shadcn-phlex/rules/forms.md +151 -0
- data/skills/shadcn-phlex/rules/helpers.md +54 -0
- data/skills/shadcn-phlex/rules/stimulus.md +61 -0
- data/skills/shadcn-phlex/rules/styling.md +177 -0
- metadata +209 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Command palette (cmdk-style)
|
|
4
|
+
// Cmd+K to open, search/filter, keyboard navigation, grouping
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["dialog", "overlay", "input", "list", "group", "item", "empty"]
|
|
7
|
+
static values = {
|
|
8
|
+
open: { type: Boolean, default: false },
|
|
9
|
+
shortcut: { type: String, default: "k" },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
this._onGlobalKeydown = this._handleGlobalKeydown.bind(this)
|
|
14
|
+
this._onKeydown = this._handleKeydown.bind(this)
|
|
15
|
+
|
|
16
|
+
this._allItems = this.itemTargets.map((el) => ({
|
|
17
|
+
element: el,
|
|
18
|
+
label: el.textContent.trim().toLowerCase(),
|
|
19
|
+
value: el.dataset.value || "",
|
|
20
|
+
group: el.closest('[data-slot="command-group"]'),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
this._hideTimeouts = []
|
|
24
|
+
document.addEventListener("keydown", this._onGlobalKeydown)
|
|
25
|
+
this._syncState()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
disconnect() {
|
|
29
|
+
this._hideTimeouts.forEach(id => clearTimeout(id))
|
|
30
|
+
this._hideTimeouts = []
|
|
31
|
+
document.removeEventListener("keydown", this._onGlobalKeydown)
|
|
32
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toggle() { this.openValue = !this.openValue }
|
|
36
|
+
show() { this.openValue = true }
|
|
37
|
+
hide() { this.openValue = false }
|
|
38
|
+
|
|
39
|
+
filter(event) {
|
|
40
|
+
const query = event.currentTarget.value.toLowerCase()
|
|
41
|
+
let totalVisible = 0
|
|
42
|
+
|
|
43
|
+
// Track visible items per group
|
|
44
|
+
const groupCounts = new Map()
|
|
45
|
+
|
|
46
|
+
this._allItems.forEach(({ element, label, group }) => {
|
|
47
|
+
const match = !query || label.includes(query)
|
|
48
|
+
element.hidden = !match
|
|
49
|
+
if (match) {
|
|
50
|
+
totalVisible++
|
|
51
|
+
if (group) {
|
|
52
|
+
groupCounts.set(group, (groupCounts.get(group) || 0) + 1)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Hide groups with no visible items
|
|
58
|
+
this.groupTargets.forEach((group) => {
|
|
59
|
+
group.hidden = (groupCounts.get(group) || 0) === 0
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Show/hide empty state
|
|
63
|
+
this.emptyTargets.forEach((el) => {
|
|
64
|
+
el.hidden = totalVisible > 0
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
selectItem(event) {
|
|
69
|
+
const item = event.currentTarget
|
|
70
|
+
if (item.dataset.disabled) return
|
|
71
|
+
|
|
72
|
+
this.dispatch("select", { detail: { value: item.dataset.value } })
|
|
73
|
+
|
|
74
|
+
// Execute callback if specified
|
|
75
|
+
const callback = item.dataset.commandCallback
|
|
76
|
+
if (callback && window[callback]) {
|
|
77
|
+
window[callback]()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.hide()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clickOverlay(event) {
|
|
84
|
+
if (event.target === event.currentTarget) this.hide()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
openValueChanged() { this._syncState() }
|
|
88
|
+
|
|
89
|
+
_syncState() {
|
|
90
|
+
if (!this._hideTimeouts) return
|
|
91
|
+
if (this.openValue) {
|
|
92
|
+
this._open()
|
|
93
|
+
} else {
|
|
94
|
+
this._close()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_open() {
|
|
99
|
+
this.dialogTargets.forEach((el) => { el.dataset.state = "open"; el.hidden = false })
|
|
100
|
+
this.overlayTargets.forEach((el) => { el.dataset.state = "open"; el.hidden = false })
|
|
101
|
+
|
|
102
|
+
document.body.style.overflow = "hidden"
|
|
103
|
+
document.addEventListener("keydown", this._onKeydown)
|
|
104
|
+
|
|
105
|
+
requestAnimationFrame(() => {
|
|
106
|
+
if (this.hasInputTarget) {
|
|
107
|
+
this.inputTarget.value = ""
|
|
108
|
+
this.inputTarget.focus()
|
|
109
|
+
}
|
|
110
|
+
// Reset filter
|
|
111
|
+
this._allItems.forEach(({ element }) => { element.hidden = false })
|
|
112
|
+
this.groupTargets.forEach((g) => { g.hidden = false })
|
|
113
|
+
this.emptyTargets.forEach((el) => { el.hidden = true })
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_close() {
|
|
118
|
+
this.dialogTargets.forEach((el) => {
|
|
119
|
+
el.dataset.state = "closed"
|
|
120
|
+
this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 200))
|
|
121
|
+
})
|
|
122
|
+
this.overlayTargets.forEach((el) => {
|
|
123
|
+
el.dataset.state = "closed"
|
|
124
|
+
this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 200))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
document.body.style.overflow = ""
|
|
128
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_handleGlobalKeydown(event) {
|
|
132
|
+
if ((event.metaKey || event.ctrlKey) && event.key === this.shortcutValue) {
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
this.toggle()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_handleKeydown(event) {
|
|
139
|
+
if (event.key === "Escape") {
|
|
140
|
+
event.preventDefault()
|
|
141
|
+
this.hide()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const items = this._getVisibleItems()
|
|
146
|
+
const current = items.indexOf(document.activeElement)
|
|
147
|
+
|
|
148
|
+
switch (event.key) {
|
|
149
|
+
case "ArrowDown":
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
if (current < items.length - 1) items[current + 1].focus()
|
|
152
|
+
else if (items.length > 0) items[0].focus()
|
|
153
|
+
break
|
|
154
|
+
case "ArrowUp":
|
|
155
|
+
event.preventDefault()
|
|
156
|
+
if (current > 0) items[current - 1].focus()
|
|
157
|
+
else if (this.hasInputTarget) this.inputTarget.focus()
|
|
158
|
+
break
|
|
159
|
+
case "Enter":
|
|
160
|
+
if (document.activeElement?.dataset.slot === "command-item") {
|
|
161
|
+
event.preventDefault()
|
|
162
|
+
document.activeElement.click()
|
|
163
|
+
}
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_getVisibleItems() {
|
|
169
|
+
return this.itemTargets.filter((el) => !el.hidden && !el.dataset.disabled)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix ContextMenu behavior
|
|
4
|
+
// Right-click to open, same keyboard navigation as DropdownMenu
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["trigger", "content", "item"]
|
|
7
|
+
static values = {
|
|
8
|
+
open: { type: Boolean, default: false },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this._onClickOutside = this._handleClickOutside.bind(this)
|
|
13
|
+
this._onKeydown = this._handleKeydown.bind(this)
|
|
14
|
+
this._onContextMenu = this._handleContextMenu.bind(this)
|
|
15
|
+
this._hideTimeouts = []
|
|
16
|
+
|
|
17
|
+
this.triggerTargets.forEach((el) => {
|
|
18
|
+
el.addEventListener("contextmenu", this._onContextMenu)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
this._syncState()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() {
|
|
25
|
+
this._hideTimeouts.forEach(id => clearTimeout(id))
|
|
26
|
+
this._hideTimeouts = []
|
|
27
|
+
this._removeListeners()
|
|
28
|
+
this.triggerTargets.forEach((el) => {
|
|
29
|
+
el.removeEventListener("contextmenu", this._onContextMenu)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
hide() { this.openValue = false }
|
|
34
|
+
|
|
35
|
+
openValueChanged() { this._syncState() }
|
|
36
|
+
|
|
37
|
+
selectItem(event) {
|
|
38
|
+
const item = event.currentTarget
|
|
39
|
+
if (item.dataset.disabled) return
|
|
40
|
+
this.dispatch("select", { detail: { value: item.dataset.value } })
|
|
41
|
+
this.hide()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_handleContextMenu(event) {
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
this._mouseX = event.clientX
|
|
47
|
+
this._mouseY = event.clientY
|
|
48
|
+
this.openValue = true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_syncState() {
|
|
52
|
+
if (!this._hideTimeouts) return
|
|
53
|
+
const state = this.openValue ? "open" : "closed"
|
|
54
|
+
|
|
55
|
+
this.contentTargets.forEach((el) => {
|
|
56
|
+
el.dataset.state = state
|
|
57
|
+
if (this.openValue) {
|
|
58
|
+
el.hidden = false
|
|
59
|
+
this._positionAt(el, this._mouseX || 0, this._mouseY || 0)
|
|
60
|
+
requestAnimationFrame(() => {
|
|
61
|
+
const firstItem = el.querySelector('[data-slot*="menu-item"]:not([data-disabled])')
|
|
62
|
+
firstItem?.focus()
|
|
63
|
+
})
|
|
64
|
+
} else {
|
|
65
|
+
this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 200))
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (this.openValue) {
|
|
70
|
+
document.addEventListener("click", this._onClickOutside, true)
|
|
71
|
+
document.addEventListener("keydown", this._onKeydown)
|
|
72
|
+
} else {
|
|
73
|
+
this._removeListeners()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_positionAt(content, x, y) {
|
|
78
|
+
content.style.position = "fixed"
|
|
79
|
+
content.style.zIndex = "50"
|
|
80
|
+
content.style.top = `${y}px`
|
|
81
|
+
content.style.left = `${x}px`
|
|
82
|
+
|
|
83
|
+
// Adjust if overflowing
|
|
84
|
+
requestAnimationFrame(() => {
|
|
85
|
+
const rect = content.getBoundingClientRect()
|
|
86
|
+
if (rect.right > window.innerWidth) {
|
|
87
|
+
content.style.left = `${x - rect.width}px`
|
|
88
|
+
}
|
|
89
|
+
if (rect.bottom > window.innerHeight) {
|
|
90
|
+
content.style.top = `${y - rect.height}px`
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_handleClickOutside(event) {
|
|
96
|
+
if (!this.contentTargets.some((el) => el.contains(event.target))) {
|
|
97
|
+
this.hide()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_handleKeydown(event) {
|
|
102
|
+
if (event.key === "Escape") { event.preventDefault(); this.hide(); return }
|
|
103
|
+
|
|
104
|
+
const items = this._getItems()
|
|
105
|
+
const current = items.indexOf(document.activeElement)
|
|
106
|
+
|
|
107
|
+
if (event.key === "ArrowDown") {
|
|
108
|
+
event.preventDefault()
|
|
109
|
+
items[(current + 1) % items.length]?.focus()
|
|
110
|
+
} else if (event.key === "ArrowUp") {
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
items[(current - 1 + items.length) % items.length]?.focus()
|
|
113
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
114
|
+
if (document.activeElement?.matches('[data-slot*="menu-item"]')) {
|
|
115
|
+
event.preventDefault()
|
|
116
|
+
document.activeElement.click()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_getItems() {
|
|
122
|
+
if (!this.hasContentTarget) return []
|
|
123
|
+
return Array.from(this.contentTarget.querySelectorAll(
|
|
124
|
+
'[data-slot*="menu-item"]:not([data-disabled])'
|
|
125
|
+
)).filter((el) => !el.closest("[hidden]"))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_removeListeners() {
|
|
129
|
+
document.removeEventListener("click", this._onClickOutside, true)
|
|
130
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Dark mode controller for Shadcn Phlex
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// <div data-controller="shadcn--dark-mode"
|
|
7
|
+
// data-shadcn--dark-mode-mode-value="system">
|
|
8
|
+
// <button data-action="click->shadcn--dark-mode#toggle">Toggle</button>
|
|
9
|
+
// </div>
|
|
10
|
+
//
|
|
11
|
+
// Values:
|
|
12
|
+
// mode: "light" | "dark" | "system" (default: "system")
|
|
13
|
+
//
|
|
14
|
+
// Actions:
|
|
15
|
+
// toggle() - Cycles: light → dark → system
|
|
16
|
+
// setLight() - Force light mode
|
|
17
|
+
// setDark() - Force dark mode
|
|
18
|
+
// setSystem() - Follow OS preference
|
|
19
|
+
|
|
20
|
+
export default class extends Controller {
|
|
21
|
+
static values = {
|
|
22
|
+
mode: { type: String, default: "system" }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connect() {
|
|
26
|
+
this._connected = false
|
|
27
|
+
|
|
28
|
+
// Listen for OS preference changes
|
|
29
|
+
this._mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
|
30
|
+
this._onMediaChange = this._handleMediaChange.bind(this)
|
|
31
|
+
this._mediaQuery.addEventListener("change", this._onMediaChange)
|
|
32
|
+
|
|
33
|
+
// Read stored preference — set AFTER listeners are ready
|
|
34
|
+
const stored = localStorage.getItem("theme")
|
|
35
|
+
if (stored && ["light", "dark", "system"].includes(stored)) {
|
|
36
|
+
this.modeValue = stored
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this._connected = true
|
|
40
|
+
this._apply()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
disconnect() {
|
|
44
|
+
if (this._mediaQuery) {
|
|
45
|
+
this._mediaQuery.removeEventListener("change", this._onMediaChange)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
modeValueChanged() {
|
|
50
|
+
// Only apply after connect has finished to avoid overwriting localStorage
|
|
51
|
+
// from the default value race
|
|
52
|
+
if (this._connected) {
|
|
53
|
+
this._apply()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Actions ──────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
toggle() {
|
|
60
|
+
const cycle = { light: "dark", dark: "system", system: "light" }
|
|
61
|
+
this.modeValue = cycle[this.modeValue] || "system"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setLight() {
|
|
65
|
+
this.modeValue = "light"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setDark() {
|
|
69
|
+
this.modeValue = "dark"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setSystem() {
|
|
73
|
+
this.modeValue = "system"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Private ──────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
_apply() {
|
|
79
|
+
const isDark = this._shouldBeDark()
|
|
80
|
+
|
|
81
|
+
document.documentElement.classList.toggle("dark", isDark)
|
|
82
|
+
|
|
83
|
+
// Persist preference
|
|
84
|
+
localStorage.setItem("theme", this.modeValue)
|
|
85
|
+
|
|
86
|
+
// Dispatch custom event
|
|
87
|
+
document.dispatchEvent(
|
|
88
|
+
new CustomEvent("theme:change", {
|
|
89
|
+
detail: { mode: this.modeValue, dark: isDark }
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_shouldBeDark() {
|
|
95
|
+
if (this.modeValue === "dark") return true
|
|
96
|
+
if (this.modeValue === "light") return false
|
|
97
|
+
// system
|
|
98
|
+
return this._mediaQuery?.matches ?? false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_handleMediaChange() {
|
|
102
|
+
if (this.modeValue === "system") {
|
|
103
|
+
this._apply()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix Dialog behavior
|
|
4
|
+
// Focus trap, scroll lock, escape to close, overlay click to close
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["trigger", "overlay", "content", "close", "title", "description"]
|
|
7
|
+
static values = {
|
|
8
|
+
open: { type: Boolean, default: false },
|
|
9
|
+
modal: { type: Boolean, default: true },
|
|
10
|
+
closeOnOverlay: { type: Boolean, default: true },
|
|
11
|
+
closeOnEscape: { type: Boolean, default: true },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
this._onKeydown = this._handleKeydown.bind(this)
|
|
16
|
+
this._previouslyFocused = null
|
|
17
|
+
this._hideTimeouts = []
|
|
18
|
+
this._syncState()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
this._hideTimeouts.forEach(id => clearTimeout(id))
|
|
23
|
+
this._hideTimeouts = []
|
|
24
|
+
this._removeListeners()
|
|
25
|
+
this._unlockScroll()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
toggle() {
|
|
29
|
+
this.openValue = !this.openValue
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
show() {
|
|
33
|
+
this.openValue = true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
hide() {
|
|
37
|
+
this.openValue = false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
openValueChanged() {
|
|
41
|
+
this._syncState()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clickOverlay(event) {
|
|
45
|
+
if (this.closeOnOverlayValue && event.target === event.currentTarget) {
|
|
46
|
+
this.hide()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_syncState() {
|
|
51
|
+
if (!this._hideTimeouts) return
|
|
52
|
+
if (this.openValue) {
|
|
53
|
+
this._open()
|
|
54
|
+
} else {
|
|
55
|
+
this._close()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_open() {
|
|
60
|
+
this._previouslyFocused = document.activeElement
|
|
61
|
+
|
|
62
|
+
this.element.dataset.state = "open"
|
|
63
|
+
|
|
64
|
+
this.overlayTargets.forEach((el) => {
|
|
65
|
+
el.dataset.state = "open"
|
|
66
|
+
el.hidden = false
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
this.contentTargets.forEach((el) => {
|
|
70
|
+
el.dataset.state = "open"
|
|
71
|
+
el.hidden = false
|
|
72
|
+
el.setAttribute("role", "dialog")
|
|
73
|
+
el.setAttribute("aria-modal", String(this.modalValue))
|
|
74
|
+
|
|
75
|
+
// Link title/description for ARIA
|
|
76
|
+
if (this.hasTitleTarget) {
|
|
77
|
+
const titleId = this._ensureId(this.titleTarget, "dialog-title")
|
|
78
|
+
el.setAttribute("aria-labelledby", titleId)
|
|
79
|
+
}
|
|
80
|
+
if (this.hasDescriptionTarget) {
|
|
81
|
+
const descId = this._ensureId(this.descriptionTarget, "dialog-desc")
|
|
82
|
+
el.setAttribute("aria-describedby", descId)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (this.modalValue) {
|
|
87
|
+
this._lockScroll()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
document.addEventListener("keydown", this._onKeydown)
|
|
91
|
+
|
|
92
|
+
// Focus first focusable element in content
|
|
93
|
+
requestAnimationFrame(() => {
|
|
94
|
+
if (this.hasContentTarget) {
|
|
95
|
+
const focusable = this._getFocusableElements(this.contentTarget)
|
|
96
|
+
if (focusable.length > 0) {
|
|
97
|
+
focusable[0].focus()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_close() {
|
|
104
|
+
this.element.dataset.state = "closed"
|
|
105
|
+
|
|
106
|
+
this.overlayTargets.forEach((el) => {
|
|
107
|
+
el.dataset.state = "closed"
|
|
108
|
+
// Delay hiding for exit animation
|
|
109
|
+
this._hideAfterAnimation(el)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
this.contentTargets.forEach((el) => {
|
|
113
|
+
el.dataset.state = "closed"
|
|
114
|
+
this._hideAfterAnimation(el)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
this._removeListeners()
|
|
118
|
+
this._unlockScroll()
|
|
119
|
+
|
|
120
|
+
// Restore focus
|
|
121
|
+
if (this._previouslyFocused && this._previouslyFocused.focus) {
|
|
122
|
+
this._previouslyFocused.focus()
|
|
123
|
+
this._previouslyFocused = null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_handleKeydown(event) {
|
|
128
|
+
if (event.key === "Escape" && this.closeOnEscapeValue) {
|
|
129
|
+
event.preventDefault()
|
|
130
|
+
event.stopPropagation()
|
|
131
|
+
this.hide()
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Focus trap
|
|
136
|
+
if (event.key === "Tab" && this.modalValue && this.hasContentTarget) {
|
|
137
|
+
const focusable = this._getFocusableElements(this.contentTarget)
|
|
138
|
+
if (focusable.length === 0) return
|
|
139
|
+
|
|
140
|
+
const first = focusable[0]
|
|
141
|
+
const last = focusable[focusable.length - 1]
|
|
142
|
+
|
|
143
|
+
if (event.shiftKey) {
|
|
144
|
+
if (document.activeElement === first) {
|
|
145
|
+
event.preventDefault()
|
|
146
|
+
last.focus()
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
if (document.activeElement === last) {
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
first.focus()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_getFocusableElements(container) {
|
|
158
|
+
const selector = [
|
|
159
|
+
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
|
160
|
+
'select:not([disabled])', 'textarea:not([disabled])',
|
|
161
|
+
'[tabindex]:not([tabindex="-1"])', '[contenteditable]'
|
|
162
|
+
].join(", ")
|
|
163
|
+
return Array.from(container.querySelectorAll(selector)).filter(
|
|
164
|
+
(el) => !el.closest("[hidden]") && el.offsetParent !== null
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_lockScroll() {
|
|
169
|
+
this._scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
|
170
|
+
document.body.style.overflow = "hidden"
|
|
171
|
+
document.body.style.paddingRight = `${this._scrollbarWidth}px`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
_unlockScroll() {
|
|
175
|
+
document.body.style.overflow = ""
|
|
176
|
+
document.body.style.paddingRight = ""
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_removeListeners() {
|
|
180
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_hideAfterAnimation(el) {
|
|
184
|
+
const onAnimEnd = () => {
|
|
185
|
+
if (el.dataset.state === "closed") {
|
|
186
|
+
el.hidden = true
|
|
187
|
+
}
|
|
188
|
+
el.removeEventListener("animationend", onAnimEnd)
|
|
189
|
+
}
|
|
190
|
+
el.addEventListener("animationend", onAnimEnd)
|
|
191
|
+
// Fallback if no animation
|
|
192
|
+
this._hideTimeouts.push(setTimeout(() => {
|
|
193
|
+
if (el.dataset.state === "closed") {
|
|
194
|
+
el.hidden = true
|
|
195
|
+
}
|
|
196
|
+
}, 300))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_ensureId(el, prefix) {
|
|
200
|
+
if (!el.id) {
|
|
201
|
+
el.id = `${prefix}-${Math.random().toString(36).slice(2, 9)}`
|
|
202
|
+
}
|
|
203
|
+
return el.id
|
|
204
|
+
}
|
|
205
|
+
}
|