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,130 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix Dialog behavior for Sheet (slide-out panel)
|
|
4
|
+
// Same as Dialog but without zoom animation, uses slide instead
|
|
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
|
+
side: { type: String, default: "right" },
|
|
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
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
25
|
+
this._unlockScroll()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
toggle() { this.openValue = !this.openValue }
|
|
29
|
+
show() { this.openValue = true }
|
|
30
|
+
hide() { this.openValue = false }
|
|
31
|
+
|
|
32
|
+
openValueChanged() { this._syncState() }
|
|
33
|
+
|
|
34
|
+
clickOverlay(event) {
|
|
35
|
+
if (this.closeOnOverlayValue && event.target === event.currentTarget) {
|
|
36
|
+
this.hide()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_syncState() {
|
|
41
|
+
if (!this._hideTimeouts) return
|
|
42
|
+
const state = this.openValue ? "open" : "closed"
|
|
43
|
+
this.element.dataset.state = state
|
|
44
|
+
|
|
45
|
+
this.overlayTargets.forEach((el) => {
|
|
46
|
+
el.dataset.state = state
|
|
47
|
+
if (this.openValue) {
|
|
48
|
+
el.hidden = false
|
|
49
|
+
} else {
|
|
50
|
+
this._hideAfterAnimation(el)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
this.contentTargets.forEach((el) => {
|
|
55
|
+
el.dataset.state = state
|
|
56
|
+
el.dataset.side = this.sideValue
|
|
57
|
+
if (this.openValue) {
|
|
58
|
+
el.hidden = false
|
|
59
|
+
el.setAttribute("role", "dialog")
|
|
60
|
+
el.setAttribute("aria-modal", "true")
|
|
61
|
+
} else {
|
|
62
|
+
this._hideAfterAnimation(el)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (this.openValue) {
|
|
67
|
+
this._lockScroll()
|
|
68
|
+
document.addEventListener("keydown", this._onKeydown)
|
|
69
|
+
requestAnimationFrame(() => this._trapFocus())
|
|
70
|
+
} else {
|
|
71
|
+
this._unlockScroll()
|
|
72
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
73
|
+
if (this._previouslyFocused?.focus) {
|
|
74
|
+
this._previouslyFocused.focus()
|
|
75
|
+
this._previouslyFocused = null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_trapFocus() {
|
|
81
|
+
this._previouslyFocused = document.activeElement
|
|
82
|
+
if (this.hasContentTarget) {
|
|
83
|
+
const focusable = this._getFocusable(this.contentTarget)
|
|
84
|
+
if (focusable.length > 0) focusable[0].focus()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_handleKeydown(event) {
|
|
89
|
+
if (event.key === "Escape" && this.closeOnEscapeValue) {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
this.hide()
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (event.key === "Tab" && this.hasContentTarget) {
|
|
95
|
+
const focusable = this._getFocusable(this.contentTarget)
|
|
96
|
+
if (focusable.length === 0) return
|
|
97
|
+
const first = focusable[0]
|
|
98
|
+
const last = focusable[focusable.length - 1]
|
|
99
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
100
|
+
event.preventDefault()
|
|
101
|
+
last.focus()
|
|
102
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
103
|
+
event.preventDefault()
|
|
104
|
+
first.focus()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_getFocusable(container) {
|
|
110
|
+
return Array.from(container.querySelectorAll(
|
|
111
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
|
112
|
+
)).filter((el) => !el.closest("[hidden]") && el.offsetParent !== null)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_lockScroll() {
|
|
116
|
+
document.body.style.overflow = "hidden"
|
|
117
|
+
document.body.style.paddingRight = `${window.innerWidth - document.documentElement.clientWidth}px`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_unlockScroll() {
|
|
121
|
+
document.body.style.overflow = ""
|
|
122
|
+
document.body.style.paddingRight = ""
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_hideAfterAnimation(el) {
|
|
126
|
+
const onEnd = () => { if (el.dataset.state === "closed") el.hidden = true; el.removeEventListener("animationend", onEnd) }
|
|
127
|
+
el.addEventListener("animationend", onEnd)
|
|
128
|
+
this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 500))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix Slider behavior
|
|
4
|
+
// Drag, keyboard (arrows, page up/down, home/end), ARIA, form integration
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["track", "range", "thumb", "input"]
|
|
7
|
+
static values = {
|
|
8
|
+
value: { type: Number, default: 0 },
|
|
9
|
+
min: { type: Number, default: 0 },
|
|
10
|
+
max: { type: Number, default: 100 },
|
|
11
|
+
step: { type: Number, default: 1 },
|
|
12
|
+
disabled: { type: Boolean, default: false },
|
|
13
|
+
orientation: { type: String, default: "horizontal" },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
this._onPointerMove = this._handlePointerMove.bind(this)
|
|
18
|
+
this._onPointerUp = this._handlePointerUp.bind(this)
|
|
19
|
+
this._dragging = false
|
|
20
|
+
this._syncState()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
disconnect() {
|
|
24
|
+
document.removeEventListener("pointermove", this._onPointerMove)
|
|
25
|
+
document.removeEventListener("pointerup", this._onPointerUp)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Click on track to set value
|
|
29
|
+
clickTrack(event) {
|
|
30
|
+
if (this.disabledValue) return
|
|
31
|
+
this._setValueFromPointer(event)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Start dragging thumb
|
|
35
|
+
startDrag(event) {
|
|
36
|
+
if (this.disabledValue) return
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
this._dragging = true
|
|
39
|
+
document.addEventListener("pointermove", this._onPointerMove)
|
|
40
|
+
document.addEventListener("pointerup", this._onPointerUp)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Keyboard control
|
|
44
|
+
keydown(event) {
|
|
45
|
+
if (this.disabledValue) return
|
|
46
|
+
|
|
47
|
+
const step = this.stepValue
|
|
48
|
+
const bigStep = (this.maxValue - this.minValue) / 10
|
|
49
|
+
|
|
50
|
+
switch (event.key) {
|
|
51
|
+
case "ArrowRight":
|
|
52
|
+
case "ArrowUp":
|
|
53
|
+
event.preventDefault()
|
|
54
|
+
this.valueValue = Math.min(this.maxValue, this.valueValue + step)
|
|
55
|
+
break
|
|
56
|
+
case "ArrowLeft":
|
|
57
|
+
case "ArrowDown":
|
|
58
|
+
event.preventDefault()
|
|
59
|
+
this.valueValue = Math.max(this.minValue, this.valueValue - step)
|
|
60
|
+
break
|
|
61
|
+
case "PageUp":
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
this.valueValue = Math.min(this.maxValue, this.valueValue + bigStep)
|
|
64
|
+
break
|
|
65
|
+
case "PageDown":
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
this.valueValue = Math.max(this.minValue, this.valueValue - bigStep)
|
|
68
|
+
break
|
|
69
|
+
case "Home":
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
this.valueValue = this.minValue
|
|
72
|
+
break
|
|
73
|
+
case "End":
|
|
74
|
+
event.preventDefault()
|
|
75
|
+
this.valueValue = this.maxValue
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.dispatch("change", { detail: { value: this.valueValue } })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
valueValueChanged() { this._syncState() }
|
|
83
|
+
|
|
84
|
+
_handlePointerMove(event) {
|
|
85
|
+
if (!this._dragging) return
|
|
86
|
+
this._setValueFromPointer(event)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_handlePointerUp() {
|
|
90
|
+
this._dragging = false
|
|
91
|
+
document.removeEventListener("pointermove", this._onPointerMove)
|
|
92
|
+
document.removeEventListener("pointerup", this._onPointerUp)
|
|
93
|
+
this.dispatch("change", { detail: { value: this.valueValue } })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_setValueFromPointer(event) {
|
|
97
|
+
if (!this.hasTrackTarget) return
|
|
98
|
+
|
|
99
|
+
const rect = this.trackTarget.getBoundingClientRect()
|
|
100
|
+
let ratio
|
|
101
|
+
|
|
102
|
+
if (this.orientationValue === "horizontal") {
|
|
103
|
+
ratio = (event.clientX - rect.left) / rect.width
|
|
104
|
+
} else {
|
|
105
|
+
ratio = 1 - (event.clientY - rect.top) / rect.height
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
ratio = Math.max(0, Math.min(1, ratio))
|
|
109
|
+
const raw = this.minValue + ratio * (this.maxValue - this.minValue)
|
|
110
|
+
const stepped = Math.round(raw / this.stepValue) * this.stepValue
|
|
111
|
+
this.valueValue = Math.max(this.minValue, Math.min(this.maxValue, stepped))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_syncState() {
|
|
115
|
+
const pct = ((this.valueValue - this.minValue) / (this.maxValue - this.minValue)) * 100
|
|
116
|
+
|
|
117
|
+
this.rangeTargets.forEach((el) => {
|
|
118
|
+
if (this.orientationValue === "horizontal") {
|
|
119
|
+
el.style.width = `${pct}%`
|
|
120
|
+
} else {
|
|
121
|
+
el.style.height = `${pct}%`
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
this.thumbTargets.forEach((el) => {
|
|
126
|
+
if (this.orientationValue === "horizontal") {
|
|
127
|
+
el.style.left = `${pct}%`
|
|
128
|
+
el.style.transform = "translateX(-50%)"
|
|
129
|
+
} else {
|
|
130
|
+
el.style.bottom = `${pct}%`
|
|
131
|
+
el.style.transform = "translateY(50%)"
|
|
132
|
+
}
|
|
133
|
+
el.setAttribute("aria-valuenow", String(this.valueValue))
|
|
134
|
+
el.setAttribute("aria-valuemin", String(this.minValue))
|
|
135
|
+
el.setAttribute("aria-valuemax", String(this.maxValue))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
this.inputTargets.forEach((input) => {
|
|
139
|
+
input.value = String(this.valueValue)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix Switch behavior
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["button", "thumb", "input"]
|
|
6
|
+
static values = {
|
|
7
|
+
checked: { type: Boolean, default: false },
|
|
8
|
+
disabled: { type: Boolean, default: false },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this._syncState()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
toggle() {
|
|
16
|
+
if (this.disabledValue) return
|
|
17
|
+
this.checkedValue = !this.checkedValue
|
|
18
|
+
this.dispatch("change", { detail: { checked: this.checkedValue } })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
checkedValueChanged() { this._syncState() }
|
|
22
|
+
|
|
23
|
+
_syncState() {
|
|
24
|
+
const state = this.checkedValue ? "checked" : "unchecked"
|
|
25
|
+
|
|
26
|
+
this.buttonTargets.forEach((btn) => {
|
|
27
|
+
btn.dataset.state = state
|
|
28
|
+
btn.setAttribute("aria-checked", String(this.checkedValue))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
this.thumbTargets.forEach((thumb) => {
|
|
32
|
+
thumb.dataset.state = state
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
this.inputTargets.forEach((input) => {
|
|
36
|
+
input.value = this.checkedValue ? "1" : "0"
|
|
37
|
+
input.checked = this.checkedValue
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix Tabs behavior
|
|
4
|
+
// Arrow key navigation, automatic activation, ARIA
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["list", "trigger", "content"]
|
|
7
|
+
static values = {
|
|
8
|
+
value: { type: String, default: "" },
|
|
9
|
+
orientation: { type: String, default: "horizontal" },
|
|
10
|
+
activationMode: { type: String, default: "automatic" }, // "automatic" or "manual"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
// Auto-select first tab if no value set
|
|
15
|
+
if (!this.valueValue && this.triggerTargets.length > 0) {
|
|
16
|
+
this.valueValue = this.triggerTargets[0].dataset.value || ""
|
|
17
|
+
}
|
|
18
|
+
this._syncState()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
select(event) {
|
|
22
|
+
const trigger = event.currentTarget
|
|
23
|
+
const value = trigger.dataset.value
|
|
24
|
+
if (value) this.valueValue = value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
keydown(event) {
|
|
28
|
+
const triggers = this.triggerTargets
|
|
29
|
+
const current = triggers.indexOf(event.currentTarget)
|
|
30
|
+
if (current === -1) return
|
|
31
|
+
|
|
32
|
+
const isHorizontal = this.orientationValue === "horizontal"
|
|
33
|
+
const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown"
|
|
34
|
+
const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp"
|
|
35
|
+
let nextIndex
|
|
36
|
+
|
|
37
|
+
switch (event.key) {
|
|
38
|
+
case nextKey:
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
nextIndex = (current + 1) % triggers.length
|
|
41
|
+
triggers[nextIndex].focus()
|
|
42
|
+
if (this.activationModeValue === "automatic") {
|
|
43
|
+
this.valueValue = triggers[nextIndex].dataset.value
|
|
44
|
+
}
|
|
45
|
+
break
|
|
46
|
+
case prevKey:
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
nextIndex = (current - 1 + triggers.length) % triggers.length
|
|
49
|
+
triggers[nextIndex].focus()
|
|
50
|
+
if (this.activationModeValue === "automatic") {
|
|
51
|
+
this.valueValue = triggers[nextIndex].dataset.value
|
|
52
|
+
}
|
|
53
|
+
break
|
|
54
|
+
case "Home":
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
triggers[0].focus()
|
|
57
|
+
if (this.activationModeValue === "automatic") {
|
|
58
|
+
this.valueValue = triggers[0].dataset.value
|
|
59
|
+
}
|
|
60
|
+
break
|
|
61
|
+
case "End":
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
const last = triggers[triggers.length - 1]
|
|
64
|
+
last.focus()
|
|
65
|
+
if (this.activationModeValue === "automatic") {
|
|
66
|
+
this.valueValue = last.dataset.value
|
|
67
|
+
}
|
|
68
|
+
break
|
|
69
|
+
case "Enter":
|
|
70
|
+
case " ":
|
|
71
|
+
if (this.activationModeValue === "manual") {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
this.valueValue = event.currentTarget.dataset.value
|
|
74
|
+
}
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
valueValueChanged() { this._syncState() }
|
|
80
|
+
|
|
81
|
+
_syncState() {
|
|
82
|
+
this.triggerTargets.forEach((trigger) => {
|
|
83
|
+
const isActive = trigger.dataset.value === this.valueValue
|
|
84
|
+
trigger.dataset.state = isActive ? "active" : "inactive"
|
|
85
|
+
trigger.setAttribute("aria-selected", String(isActive))
|
|
86
|
+
trigger.setAttribute("tabindex", isActive ? "0" : "-1")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
this.contentTargets.forEach((content) => {
|
|
90
|
+
const isActive = content.dataset.value === this.valueValue
|
|
91
|
+
content.dataset.state = isActive ? "active" : "inactive"
|
|
92
|
+
content.hidden = !isActive
|
|
93
|
+
content.setAttribute("tabindex", isActive ? "0" : "-1")
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Sonner toast behavior
|
|
4
|
+
// Auto-dismiss, queue management, swipe to dismiss
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["container", "toast"]
|
|
7
|
+
static values = {
|
|
8
|
+
position: { type: String, default: "bottom-right" },
|
|
9
|
+
duration: { type: Number, default: 5000 },
|
|
10
|
+
maxToasts: { type: Number, default: 5 },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
this._toasts = new Map()
|
|
15
|
+
this._counter = 0
|
|
16
|
+
this._hideTimeouts = []
|
|
17
|
+
|
|
18
|
+
// Listen for global toast events so buttons outside the controller scope can trigger toasts
|
|
19
|
+
this._globalShow = (e) => this.show(e)
|
|
20
|
+
this._globalSuccess = (e) => this.success(e)
|
|
21
|
+
this._globalError = (e) => this.error(e)
|
|
22
|
+
this._globalWarning = (e) => this.warning(e)
|
|
23
|
+
this._globalInfo = (e) => this.info(e)
|
|
24
|
+
document.addEventListener("toast:show", this._globalShow)
|
|
25
|
+
document.addEventListener("toast:success", this._globalSuccess)
|
|
26
|
+
document.addEventListener("toast:error", this._globalError)
|
|
27
|
+
document.addEventListener("toast:warning", this._globalWarning)
|
|
28
|
+
document.addEventListener("toast:info", this._globalInfo)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
disconnect() {
|
|
32
|
+
this._toasts.forEach((timer) => clearTimeout(timer))
|
|
33
|
+
this._toasts.clear()
|
|
34
|
+
this._hideTimeouts.forEach(id => clearTimeout(id))
|
|
35
|
+
this._hideTimeouts = []
|
|
36
|
+
document.removeEventListener("toast:show", this._globalShow)
|
|
37
|
+
document.removeEventListener("toast:success", this._globalSuccess)
|
|
38
|
+
document.removeEventListener("toast:error", this._globalError)
|
|
39
|
+
document.removeEventListener("toast:warning", this._globalWarning)
|
|
40
|
+
document.removeEventListener("toast:info", this._globalInfo)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Public API: works with event.detail (custom events) or event.params (Stimulus actions)
|
|
44
|
+
show(event) {
|
|
45
|
+
const data = event.detail || event.params || {}
|
|
46
|
+
const { title, description, variant = "default", duration, action } = data
|
|
47
|
+
this._addToast({ title, description, variant, duration: duration || this.durationValue, action })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
success(event) {
|
|
51
|
+
const data = event.detail || event.params || {}
|
|
52
|
+
this._addToast({ ...data, variant: "success", duration: data.duration || this.durationValue })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
error(event) {
|
|
56
|
+
const data = event.detail || event.params || {}
|
|
57
|
+
this._addToast({ ...data, variant: "error", duration: data.duration || this.durationValue })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
warning(event) {
|
|
61
|
+
const data = event.detail || event.params || {}
|
|
62
|
+
this._addToast({ ...data, variant: "warning", duration: data.duration || this.durationValue })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
info(event) {
|
|
66
|
+
const data = event.detail || event.params || {}
|
|
67
|
+
this._addToast({ ...data, variant: "info", duration: data.duration || this.durationValue })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
dismiss(event) {
|
|
71
|
+
const toast = event.currentTarget.closest('[data-slot="toast"]')
|
|
72
|
+
if (toast) this._removeToast(toast)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_addToast({ title, description, variant, duration, action }) {
|
|
76
|
+
const id = `toast-${++this._counter}`
|
|
77
|
+
const container = this.hasContainerTarget ? this.containerTarget : this.element
|
|
78
|
+
|
|
79
|
+
// Enforce max toasts
|
|
80
|
+
const existing = container.querySelectorAll('[data-slot="toast"]')
|
|
81
|
+
if (existing.length >= this.maxToastsValue) {
|
|
82
|
+
this._removeToast(existing[0])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const toast = document.createElement("div")
|
|
86
|
+
toast.dataset.slot = "toast"
|
|
87
|
+
toast.dataset.variant = variant
|
|
88
|
+
toast.dataset.state = "open"
|
|
89
|
+
toast.id = id
|
|
90
|
+
toast.setAttribute("role", "alert")
|
|
91
|
+
toast.className = this._getToastClasses(variant)
|
|
92
|
+
|
|
93
|
+
let html = '<div class="grid gap-1">'
|
|
94
|
+
if (title) html += `<div data-slot="toast-title" class="text-sm font-semibold">${this._escapeHtml(title)}</div>`
|
|
95
|
+
if (description) html += `<div data-slot="toast-description" class="text-sm opacity-90">${this._escapeHtml(description)}</div>`
|
|
96
|
+
html += "</div>"
|
|
97
|
+
|
|
98
|
+
if (action) {
|
|
99
|
+
html += `<button data-slot="toast-action" class="inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary">${this._escapeHtml(action.label)}</button>`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Close button
|
|
103
|
+
html += `<button data-action="shadcn--toast#dismiss" class="absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 hover:text-foreground">
|
|
104
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="size-4"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
105
|
+
</button>`
|
|
106
|
+
|
|
107
|
+
toast.innerHTML = html
|
|
108
|
+
|
|
109
|
+
// Add action handler
|
|
110
|
+
if (action?.handler) {
|
|
111
|
+
const actionBtn = toast.querySelector('[data-slot="toast-action"]')
|
|
112
|
+
if (actionBtn) {
|
|
113
|
+
actionBtn.addEventListener("click", () => {
|
|
114
|
+
action.handler()
|
|
115
|
+
this._removeToast(toast)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Swipe to dismiss
|
|
121
|
+
this._addSwipeToDismiss(toast)
|
|
122
|
+
|
|
123
|
+
container.appendChild(toast)
|
|
124
|
+
|
|
125
|
+
// Auto-dismiss
|
|
126
|
+
if (duration > 0) {
|
|
127
|
+
const timer = setTimeout(() => this._removeToast(toast), duration)
|
|
128
|
+
this._toasts.set(id, timer)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Pause on hover
|
|
132
|
+
toast.addEventListener("mouseenter", () => {
|
|
133
|
+
const timer = this._toasts.get(id)
|
|
134
|
+
if (timer) clearTimeout(timer)
|
|
135
|
+
})
|
|
136
|
+
toast.addEventListener("mouseleave", () => {
|
|
137
|
+
if (duration > 0) {
|
|
138
|
+
const timer = setTimeout(() => this._removeToast(toast), duration)
|
|
139
|
+
this._toasts.set(id, timer)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_removeToast(toast) {
|
|
145
|
+
const id = toast.id
|
|
146
|
+
const timer = this._toasts.get(id)
|
|
147
|
+
if (timer) clearTimeout(timer)
|
|
148
|
+
this._toasts.delete(id)
|
|
149
|
+
|
|
150
|
+
toast.dataset.state = "closed"
|
|
151
|
+
toast.style.transition = "opacity 200ms, transform 200ms"
|
|
152
|
+
toast.style.opacity = "0"
|
|
153
|
+
toast.style.transform = "translateX(100%)"
|
|
154
|
+
|
|
155
|
+
this._hideTimeouts.push(setTimeout(() => toast.remove(), 200))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_addSwipeToDismiss(toast) {
|
|
159
|
+
let startX = 0
|
|
160
|
+
let currentX = 0
|
|
161
|
+
|
|
162
|
+
toast.addEventListener("pointerdown", (e) => {
|
|
163
|
+
startX = e.clientX
|
|
164
|
+
toast.style.transition = "none"
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
toast.addEventListener("pointermove", (e) => {
|
|
168
|
+
if (!startX) return
|
|
169
|
+
currentX = e.clientX - startX
|
|
170
|
+
if (currentX > 0) {
|
|
171
|
+
toast.style.transform = `translateX(${currentX}px)`
|
|
172
|
+
toast.style.opacity = String(1 - currentX / 200)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
toast.addEventListener("pointerup", () => {
|
|
177
|
+
if (currentX > 100) {
|
|
178
|
+
this._removeToast(toast)
|
|
179
|
+
} else {
|
|
180
|
+
toast.style.transition = "transform 200ms, opacity 200ms"
|
|
181
|
+
toast.style.transform = ""
|
|
182
|
+
toast.style.opacity = ""
|
|
183
|
+
}
|
|
184
|
+
startX = 0
|
|
185
|
+
currentX = 0
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_getToastClasses(variant) {
|
|
190
|
+
const base = "group pointer-events-auto relative flex w-full items-center justify-between gap-4 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all"
|
|
191
|
+
const variants = {
|
|
192
|
+
default: "border bg-background text-foreground",
|
|
193
|
+
success: "border-green-500/50 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100",
|
|
194
|
+
error: "border-destructive/50 bg-destructive text-white",
|
|
195
|
+
warning: "border-yellow-500/50 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100",
|
|
196
|
+
info: "border-blue-500/50 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100",
|
|
197
|
+
}
|
|
198
|
+
return `${base} ${variants[variant] || variants.default}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_escapeHtml(str) {
|
|
202
|
+
const div = document.createElement("div")
|
|
203
|
+
div.textContent = str
|
|
204
|
+
return div.innerHTML
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix Toggle behavior
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["button"]
|
|
6
|
+
static values = {
|
|
7
|
+
pressed: { type: Boolean, default: false },
|
|
8
|
+
disabled: { type: Boolean, default: false },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this._syncState()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
toggle() {
|
|
16
|
+
if (this.disabledValue) return
|
|
17
|
+
this.pressedValue = !this.pressedValue
|
|
18
|
+
this.dispatch("change", { detail: { pressed: this.pressedValue } })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
pressedValueChanged() { this._syncState() }
|
|
22
|
+
|
|
23
|
+
_syncState() {
|
|
24
|
+
const state = this.pressedValue ? "on" : "off"
|
|
25
|
+
this.buttonTargets.forEach((btn) => {
|
|
26
|
+
btn.dataset.state = state
|
|
27
|
+
btn.setAttribute("aria-pressed", String(this.pressedValue))
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|