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.
Files changed (113) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +195 -0
  3. data/app.css +20 -0
  4. data/css/shadcn-source.css +3 -0
  5. data/css/shadcn-tailwind.css +160 -0
  6. data/css/themes/mauve.css +62 -0
  7. data/css/themes/mist.css +62 -0
  8. data/css/themes/neutral.css +74 -0
  9. data/css/themes/olive.css +62 -0
  10. data/css/themes/stone.css +62 -0
  11. data/css/themes/taupe.css +62 -0
  12. data/css/themes/zinc.css +62 -0
  13. data/js/controllers/accordion_controller.js +135 -0
  14. data/js/controllers/checkbox_controller.js +52 -0
  15. data/js/controllers/collapsible_controller.js +85 -0
  16. data/js/controllers/combobox_controller.js +168 -0
  17. data/js/controllers/command_controller.js +171 -0
  18. data/js/controllers/context_menu_controller.js +132 -0
  19. data/js/controllers/dark_mode_controller.js +106 -0
  20. data/js/controllers/dialog_controller.js +205 -0
  21. data/js/controllers/drawer_controller.js +161 -0
  22. data/js/controllers/dropdown_menu_controller.js +189 -0
  23. data/js/controllers/hover_card_controller.js +85 -0
  24. data/js/controllers/index.js +89 -0
  25. data/js/controllers/menubar_controller.js +171 -0
  26. data/js/controllers/navigation_menu_controller.js +160 -0
  27. data/js/controllers/popover_controller.js +151 -0
  28. data/js/controllers/radio_group_controller.js +78 -0
  29. data/js/controllers/scroll_area_controller.js +117 -0
  30. data/js/controllers/select_controller.js +198 -0
  31. data/js/controllers/sheet_controller.js +130 -0
  32. data/js/controllers/slider_controller.js +142 -0
  33. data/js/controllers/switch_controller.js +40 -0
  34. data/js/controllers/tabs_controller.js +96 -0
  35. data/js/controllers/toast_controller.js +206 -0
  36. data/js/controllers/toggle_controller.js +30 -0
  37. data/js/controllers/toggle_group_controller.js +73 -0
  38. data/js/controllers/tooltip_controller.js +146 -0
  39. data/lib/generators/shadcn_phlex/component_generator.rb +79 -0
  40. data/lib/generators/shadcn_phlex/install_generator.rb +217 -0
  41. data/lib/shadcn/base.rb +27 -0
  42. data/lib/shadcn/engine.rb +24 -0
  43. data/lib/shadcn/kit.rb +1158 -0
  44. data/lib/shadcn/themes/accent_colors.rb +106 -0
  45. data/lib/shadcn/themes/base_colors.rb +313 -0
  46. data/lib/shadcn/ui/accordion.rb +135 -0
  47. data/lib/shadcn/ui/alert.rb +79 -0
  48. data/lib/shadcn/ui/alert_dialog.rb +220 -0
  49. data/lib/shadcn/ui/aspect_ratio.rb +35 -0
  50. data/lib/shadcn/ui/avatar.rb +134 -0
  51. data/lib/shadcn/ui/badge.rb +48 -0
  52. data/lib/shadcn/ui/breadcrumb.rb +180 -0
  53. data/lib/shadcn/ui/button.rb +63 -0
  54. data/lib/shadcn/ui/button_group.rb +58 -0
  55. data/lib/shadcn/ui/card.rb +133 -0
  56. data/lib/shadcn/ui/checkbox.rb +72 -0
  57. data/lib/shadcn/ui/collapsible.rb +76 -0
  58. data/lib/shadcn/ui/combobox.rb +229 -0
  59. data/lib/shadcn/ui/command.rb +256 -0
  60. data/lib/shadcn/ui/context_menu.rb +319 -0
  61. data/lib/shadcn/ui/dialog.rb +226 -0
  62. data/lib/shadcn/ui/direction.rb +23 -0
  63. data/lib/shadcn/ui/drawer.rb +217 -0
  64. data/lib/shadcn/ui/dropdown_menu.rb +384 -0
  65. data/lib/shadcn/ui/empty.rb +97 -0
  66. data/lib/shadcn/ui/field.rb +126 -0
  67. data/lib/shadcn/ui/hover_card.rb +75 -0
  68. data/lib/shadcn/ui/input.rb +36 -0
  69. data/lib/shadcn/ui/input_group.rb +32 -0
  70. data/lib/shadcn/ui/input_otp.rb +112 -0
  71. data/lib/shadcn/ui/item.rb +115 -0
  72. data/lib/shadcn/ui/kbd.rb +45 -0
  73. data/lib/shadcn/ui/label.rb +28 -0
  74. data/lib/shadcn/ui/menubar.rb +345 -0
  75. data/lib/shadcn/ui/native_select.rb +31 -0
  76. data/lib/shadcn/ui/navigation_menu.rb +238 -0
  77. data/lib/shadcn/ui/pagination.rb +224 -0
  78. data/lib/shadcn/ui/popover.rb +147 -0
  79. data/lib/shadcn/ui/progress.rb +40 -0
  80. data/lib/shadcn/ui/radio_group.rb +92 -0
  81. data/lib/shadcn/ui/resizable.rb +108 -0
  82. data/lib/shadcn/ui/scroll_area.rb +75 -0
  83. data/lib/shadcn/ui/select.rb +235 -0
  84. data/lib/shadcn/ui/separator.rb +36 -0
  85. data/lib/shadcn/ui/sheet.rb +231 -0
  86. data/lib/shadcn/ui/sidebar.rb +420 -0
  87. data/lib/shadcn/ui/skeleton.rb +23 -0
  88. data/lib/shadcn/ui/slider.rb +72 -0
  89. data/lib/shadcn/ui/sonner.rb +177 -0
  90. data/lib/shadcn/ui/spinner.rb +58 -0
  91. data/lib/shadcn/ui/switch.rb +75 -0
  92. data/lib/shadcn/ui/table.rb +154 -0
  93. data/lib/shadcn/ui/tabs.rb +154 -0
  94. data/lib/shadcn/ui/text_field.rb +146 -0
  95. data/lib/shadcn/ui/textarea.rb +32 -0
  96. data/lib/shadcn/ui/theme_toggle.rb +74 -0
  97. data/lib/shadcn/ui/toggle.rb +66 -0
  98. data/lib/shadcn/ui/toggle_group.rb +75 -0
  99. data/lib/shadcn/ui/tooltip.rb +78 -0
  100. data/lib/shadcn/ui/typography.rb +217 -0
  101. data/lib/shadcn/version.rb +5 -0
  102. data/lib/shadcn-phlex.rb +6 -0
  103. data/lib/shadcn.rb +80 -0
  104. data/package.json +14 -0
  105. data/skills/shadcn-phlex/SKILL.md +190 -0
  106. data/skills/shadcn-phlex/evals/evals.json +90 -0
  107. data/skills/shadcn-phlex/references/component-catalog.md +355 -0
  108. data/skills/shadcn-phlex/rules/composition.md +235 -0
  109. data/skills/shadcn-phlex/rules/forms.md +151 -0
  110. data/skills/shadcn-phlex/rules/helpers.md +54 -0
  111. data/skills/shadcn-phlex/rules/stimulus.md +61 -0
  112. data/skills/shadcn-phlex/rules/styling.md +177 -0
  113. 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
+ }