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,161 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Vaul Drawer behavior (swipe to dismiss, snap points)
4
+ export default class extends Controller {
5
+ static targets = ["trigger", "overlay", "content", "close", "handle"]
6
+ static values = {
7
+ open: { type: Boolean, default: false },
8
+ direction: { type: String, default: "bottom" },
9
+ closeOnOverlay: { type: Boolean, default: true },
10
+ closeThreshold: { type: Number, default: 0.25 },
11
+ }
12
+
13
+ connect() {
14
+ this._onKeydown = this._handleKeydown.bind(this)
15
+ this._onPointerDown = this._handlePointerDown.bind(this)
16
+ this._onPointerMove = this._handlePointerMove.bind(this)
17
+ this._onPointerUp = this._handlePointerUp.bind(this)
18
+ this._dragging = false
19
+ this._startY = 0
20
+ this._startX = 0
21
+ this._currentTranslate = 0
22
+ this._hideTimeouts = []
23
+ this._syncState()
24
+ }
25
+
26
+ disconnect() {
27
+ this._hideTimeouts.forEach(id => clearTimeout(id))
28
+ this._hideTimeouts = []
29
+ document.removeEventListener("keydown", this._onKeydown)
30
+ this._unlockScroll()
31
+ this._removeDragListeners()
32
+ }
33
+
34
+ toggle() { this.openValue = !this.openValue }
35
+ show() { this.openValue = true }
36
+ hide() { this.openValue = false }
37
+
38
+ openValueChanged() { this._syncState() }
39
+
40
+ clickOverlay(event) {
41
+ if (this.closeOnOverlayValue && event.target === event.currentTarget) this.hide()
42
+ }
43
+
44
+ _syncState() {
45
+ if (!this._hideTimeouts) return
46
+ const state = this.openValue ? "open" : "closed"
47
+ this.element.dataset.state = state
48
+
49
+ this.overlayTargets.forEach((el) => {
50
+ el.dataset.state = state
51
+ if (this.openValue) el.hidden = false
52
+ else this._hideAfterAnimation(el)
53
+ })
54
+
55
+ this.contentTargets.forEach((el) => {
56
+ el.dataset.state = state
57
+ el.style.transform = ""
58
+ if (this.openValue) {
59
+ el.hidden = false
60
+ this._addDragListeners(el)
61
+ } else {
62
+ this._hideAfterAnimation(el)
63
+ this._removeDragListeners()
64
+ }
65
+ })
66
+
67
+ if (this.openValue) {
68
+ this._lockScroll()
69
+ document.addEventListener("keydown", this._onKeydown)
70
+ } else {
71
+ this._unlockScroll()
72
+ document.removeEventListener("keydown", this._onKeydown)
73
+ }
74
+ }
75
+
76
+ _addDragListeners(el) {
77
+ el.addEventListener("pointerdown", this._onPointerDown)
78
+ }
79
+
80
+ _removeDragListeners() {
81
+ document.removeEventListener("pointermove", this._onPointerMove)
82
+ document.removeEventListener("pointerup", this._onPointerUp)
83
+ }
84
+
85
+ _handlePointerDown(event) {
86
+ // Only drag from handle or top area
87
+ const handle = event.target.closest('[data-slot="drawer-handle"], [class*="bg-muted"]')
88
+ if (!handle && this.directionValue === "bottom") return
89
+
90
+ this._dragging = true
91
+ this._startY = event.clientY
92
+ this._startX = event.clientX
93
+ this._currentTranslate = 0
94
+
95
+ document.addEventListener("pointermove", this._onPointerMove)
96
+ document.addEventListener("pointerup", this._onPointerUp)
97
+ }
98
+
99
+ _handlePointerMove(event) {
100
+ if (!this._dragging || !this.hasContentTarget) return
101
+
102
+ const content = this.contentTarget
103
+ const dir = this.directionValue
104
+
105
+ let delta
106
+ if (dir === "bottom") {
107
+ delta = Math.max(0, event.clientY - this._startY)
108
+ content.style.transform = `translateY(${delta}px)`
109
+ } else if (dir === "top") {
110
+ delta = Math.max(0, this._startY - event.clientY)
111
+ content.style.transform = `translateY(-${delta}px)`
112
+ } else if (dir === "right") {
113
+ delta = Math.max(0, event.clientX - this._startX)
114
+ content.style.transform = `translateX(${delta}px)`
115
+ } else if (dir === "left") {
116
+ delta = Math.max(0, this._startX - event.clientX)
117
+ content.style.transform = `translateX(-${delta}px)`
118
+ }
119
+ this._currentTranslate = delta || 0
120
+ }
121
+
122
+ _handlePointerUp() {
123
+ this._dragging = false
124
+ this._removeDragListeners()
125
+
126
+ if (!this.hasContentTarget) return
127
+ const content = this.contentTarget
128
+ const size = this.directionValue === "left" || this.directionValue === "right"
129
+ ? content.offsetWidth : content.offsetHeight
130
+
131
+ if (this._currentTranslate / size > this.closeThresholdValue) {
132
+ this.hide()
133
+ } else {
134
+ content.style.transition = "transform 300ms ease"
135
+ content.style.transform = ""
136
+ const onEnd = () => {
137
+ content.style.transition = ""
138
+ content.removeEventListener("transitionend", onEnd)
139
+ }
140
+ content.addEventListener("transitionend", onEnd)
141
+ }
142
+ }
143
+
144
+ _handleKeydown(event) {
145
+ if (event.key === "Escape") { event.preventDefault(); this.hide() }
146
+ }
147
+
148
+ _lockScroll() {
149
+ document.body.style.overflow = "hidden"
150
+ document.body.style.paddingRight = `${window.innerWidth - document.documentElement.clientWidth}px`
151
+ }
152
+
153
+ _unlockScroll() {
154
+ document.body.style.overflow = ""
155
+ document.body.style.paddingRight = ""
156
+ }
157
+
158
+ _hideAfterAnimation(el) {
159
+ this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 500))
160
+ }
161
+ }
@@ -0,0 +1,189 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix DropdownMenu behavior
4
+ // Uses Stimulus declarative click@window for outside click (no flicker)
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "content", "item", "sub", "subTrigger", "subContent"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ closeOnSelect: { type: Boolean, default: true },
10
+ }
11
+
12
+ connect() {
13
+ this._hideTimeouts = []
14
+ this.contentTargets.forEach((el) => { el.dataset.state = "closed"; el.hidden = true })
15
+ this.triggerTargets.forEach((el) => { el.dataset.state = "closed"; el.setAttribute("aria-expanded", "false") })
16
+ }
17
+
18
+ disconnect() {
19
+ this._hideTimeouts.forEach(id => clearTimeout(id))
20
+ this._hideTimeouts = []
21
+ }
22
+
23
+ toggle(event) {
24
+ event?.preventDefault()
25
+ this.openValue = !this.openValue
26
+ }
27
+
28
+ // Wired as click@window->shadcn--dropdown-menu#hide
29
+ hide(event) {
30
+ if (!this.openValue) return
31
+ if (event && event.target && this.element.contains(event.target)) return
32
+ this.openValue = false
33
+ }
34
+
35
+ // Wired as keydown.esc@window->shadcn--dropdown-menu#hideOnEscape
36
+ hideOnEscape() {
37
+ if (!this.openValue) return
38
+ this.openValue = false
39
+ this.triggerTargets[0]?.focus()
40
+ }
41
+
42
+ close() { this.openValue = false }
43
+
44
+ selectItem(event) {
45
+ const item = event.currentTarget
46
+ if (item.dataset.disabled) return
47
+ this.dispatch("select", { detail: { value: item.dataset.value } })
48
+ if (this.closeOnSelectValue) this.close()
49
+ }
50
+
51
+ // Keyboard nav within the open menu
52
+ navigate(event) {
53
+ if (!this.openValue) return
54
+
55
+ switch (event.key) {
56
+ case "ArrowDown": {
57
+ event.preventDefault()
58
+ const items = this._getMenuItems()
59
+ const current = items.indexOf(document.activeElement)
60
+ items[(current + 1) % items.length]?.focus()
61
+ break
62
+ }
63
+ case "ArrowUp": {
64
+ event.preventDefault()
65
+ const items = this._getMenuItems()
66
+ const current = items.indexOf(document.activeElement)
67
+ items[(current - 1 + items.length) % items.length]?.focus()
68
+ break
69
+ }
70
+ case "Home":
71
+ event.preventDefault()
72
+ this._getMenuItems()[0]?.focus()
73
+ break
74
+ case "End": {
75
+ event.preventDefault()
76
+ const items = this._getMenuItems()
77
+ items[items.length - 1]?.focus()
78
+ break
79
+ }
80
+ case "Enter":
81
+ case " ":
82
+ if (document.activeElement?.matches('[data-slot*="menu-item"]')) {
83
+ event.preventDefault()
84
+ document.activeElement.click()
85
+ }
86
+ break
87
+ case "ArrowRight": {
88
+ const subTrigger = document.activeElement?.closest('[data-slot*="sub-trigger"]')
89
+ if (subTrigger) {
90
+ event.preventDefault()
91
+ const sub = subTrigger.closest('[data-slot*="sub"]')
92
+ const subContent = sub?.querySelector('[data-slot*="sub-content"]')
93
+ if (subContent) {
94
+ subContent.hidden = false
95
+ subContent.dataset.state = "open"
96
+ subContent.querySelector('[data-slot*="menu-item"]:not([data-disabled])')?.focus()
97
+ }
98
+ }
99
+ break
100
+ }
101
+ case "ArrowLeft": {
102
+ const subContent = document.activeElement?.closest('[data-slot*="sub-content"]')
103
+ if (subContent) {
104
+ event.preventDefault()
105
+ subContent.dataset.state = "closed"
106
+ subContent.hidden = true
107
+ const subTrigger = subContent.previousElementSibling ||
108
+ subContent.closest('[data-slot*="sub"]')?.querySelector('[data-slot*="sub-trigger"]')
109
+ subTrigger?.focus()
110
+ }
111
+ break
112
+ }
113
+ }
114
+ }
115
+
116
+ openValueChanged() {
117
+ if (!this._hideTimeouts) return
118
+ this._render()
119
+ }
120
+
121
+ _render() {
122
+ const open = this.openValue
123
+ const state = open ? "open" : "closed"
124
+
125
+ this._hideTimeouts.forEach(id => clearTimeout(id))
126
+ this._hideTimeouts = []
127
+
128
+ this.element.dataset.state = state
129
+ this.triggerTargets.forEach((el) => {
130
+ el.dataset.state = state
131
+ el.setAttribute("aria-expanded", String(open))
132
+ el.setAttribute("aria-haspopup", "menu")
133
+ })
134
+
135
+ this.contentTargets.forEach((el) => {
136
+ if (open) {
137
+ el.getAnimations().forEach(a => a.cancel())
138
+ el.hidden = false
139
+ el.dataset.state = "open"
140
+ requestAnimationFrame(() => this._positionContent(el))
141
+ requestAnimationFrame(() => {
142
+ el.querySelector('[data-slot*="menu-item"]:not([data-disabled])')?.focus()
143
+ })
144
+ } else {
145
+ el.dataset.state = "closed"
146
+ const animations = el.getAnimations()
147
+ if (animations.length > 0) {
148
+ Promise.all(animations.map(a => a.finished)).then(() => {
149
+ if (el.dataset.state === "closed") el.hidden = true
150
+ }).catch(() => {})
151
+ } else {
152
+ el.hidden = true
153
+ }
154
+ }
155
+ })
156
+ }
157
+
158
+ _positionContent(content) {
159
+ if (!this.hasTriggerTarget) return
160
+ const rect = this.triggerTarget.getBoundingClientRect()
161
+
162
+ content.style.position = "fixed"
163
+ content.style.zIndex = "50"
164
+
165
+ let top = rect.bottom + 4
166
+ let left = rect.left
167
+ const contentRect = content.getBoundingClientRect()
168
+
169
+ if (top + contentRect.height > window.innerHeight) {
170
+ top = rect.top - contentRect.height - 4
171
+ content.dataset.side = "top"
172
+ } else {
173
+ content.dataset.side = "bottom"
174
+ }
175
+
176
+ if (left + contentRect.width > window.innerWidth) left = window.innerWidth - contentRect.width - 8
177
+ if (left < 8) left = 8
178
+
179
+ content.style.top = `${top}px`
180
+ content.style.left = `${left}px`
181
+ }
182
+
183
+ _getMenuItems() {
184
+ if (!this.hasContentTarget) return []
185
+ return Array.from(this.contentTarget.querySelectorAll(
186
+ '[data-slot*="menu-item"]:not([data-disabled]), [data-slot*="checkbox-item"]:not([data-disabled]), [data-slot*="radio-item"]:not([data-disabled]), [data-slot*="sub-trigger"]:not([data-disabled])'
187
+ )).filter((el) => !el.closest("[hidden]"))
188
+ }
189
+ }
@@ -0,0 +1,85 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix HoverCard behavior
4
+ // Show on hover with delay, content stays open while hovering content
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "content"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ openDelay: { type: Number, default: 700 },
10
+ closeDelay: { type: Number, default: 300 },
11
+ }
12
+
13
+ connect() {
14
+ this._showTimeout = null
15
+ this._hideTimeout = null
16
+ this._hideTimeouts = []
17
+ this._syncState()
18
+ }
19
+
20
+ disconnect() {
21
+ clearTimeout(this._showTimeout)
22
+ clearTimeout(this._hideTimeout)
23
+ this._hideTimeouts.forEach(id => clearTimeout(id))
24
+ this._hideTimeouts = []
25
+ }
26
+
27
+ triggerEnter() {
28
+ clearTimeout(this._hideTimeout)
29
+ this._showTimeout = setTimeout(() => { this.openValue = true }, this.openDelayValue)
30
+ }
31
+
32
+ triggerLeave() {
33
+ clearTimeout(this._showTimeout)
34
+ this._hideTimeout = setTimeout(() => { this.openValue = false }, this.closeDelayValue)
35
+ }
36
+
37
+ contentEnter() {
38
+ clearTimeout(this._hideTimeout)
39
+ }
40
+
41
+ contentLeave() {
42
+ this._hideTimeout = setTimeout(() => { this.openValue = false }, this.closeDelayValue)
43
+ }
44
+
45
+ openValueChanged() { this._syncState() }
46
+
47
+ _syncState() {
48
+ if (!this._hideTimeouts) return
49
+ const state = this.openValue ? "open" : "closed"
50
+
51
+ this.contentTargets.forEach((el) => {
52
+ el.dataset.state = state
53
+ if (this.openValue) {
54
+ el.hidden = false
55
+ this._position(el)
56
+ } else {
57
+ this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 200))
58
+ }
59
+ })
60
+ }
61
+
62
+ _position(content) {
63
+ if (!this.hasTriggerTarget) return
64
+ const rect = this.triggerTarget.getBoundingClientRect()
65
+
66
+ content.style.position = "fixed"
67
+ content.style.zIndex = "50"
68
+
69
+ const contentRect = content.getBoundingClientRect()
70
+ let top = rect.bottom + 4
71
+ let left = rect.left + (rect.width - contentRect.width) / 2
72
+
73
+ if (top + contentRect.height > window.innerHeight) {
74
+ top = rect.top - contentRect.height - 4
75
+ content.dataset.side = "top"
76
+ } else {
77
+ content.dataset.side = "bottom"
78
+ }
79
+
80
+ left = Math.max(8, Math.min(left, window.innerWidth - contentRect.width - 8))
81
+
82
+ content.style.top = `${top}px`
83
+ content.style.left = `${left}px`
84
+ }
85
+ }
@@ -0,0 +1,89 @@
1
+ // Shadcn Phlex Stimulus Controllers
2
+ // Register all controllers with your Stimulus application
3
+ //
4
+ // Usage with importmap-rails or jsbundling:
5
+ // import { application } from "controllers/application"
6
+ // import { registerShadcnControllers } from "shadcn/controllers"
7
+ // registerShadcnControllers(application)
8
+
9
+ import AccordionController from "./accordion_controller"
10
+ import DarkModeController from "./dark_mode_controller"
11
+ import CheckboxController from "./checkbox_controller"
12
+ import CollapsibleController from "./collapsible_controller"
13
+ import ComboboxController from "./combobox_controller"
14
+ import CommandController from "./command_controller"
15
+ import ContextMenuController from "./context_menu_controller"
16
+ import DialogController from "./dialog_controller"
17
+ import DrawerController from "./drawer_controller"
18
+ import DropdownMenuController from "./dropdown_menu_controller"
19
+ import HoverCardController from "./hover_card_controller"
20
+ import MenubarController from "./menubar_controller"
21
+ import NavigationMenuController from "./navigation_menu_controller"
22
+ import PopoverController from "./popover_controller"
23
+ import RadioGroupController from "./radio_group_controller"
24
+ import ScrollAreaController from "./scroll_area_controller"
25
+ import SelectController from "./select_controller"
26
+ import SheetController from "./sheet_controller"
27
+ import SliderController from "./slider_controller"
28
+ import SwitchController from "./switch_controller"
29
+ import TabsController from "./tabs_controller"
30
+ import ToastController from "./toast_controller"
31
+ import ToggleController from "./toggle_controller"
32
+ import ToggleGroupController from "./toggle_group_controller"
33
+ import TooltipController from "./tooltip_controller"
34
+
35
+ export function registerShadcnControllers(application) {
36
+ application.register("shadcn--accordion", AccordionController)
37
+ application.register("shadcn--dark-mode", DarkModeController)
38
+ application.register("shadcn--checkbox", CheckboxController)
39
+ application.register("shadcn--collapsible", CollapsibleController)
40
+ application.register("shadcn--combobox", ComboboxController)
41
+ application.register("shadcn--command", CommandController)
42
+ application.register("shadcn--context-menu", ContextMenuController)
43
+ application.register("shadcn--dialog", DialogController)
44
+ application.register("shadcn--drawer", DrawerController)
45
+ application.register("shadcn--dropdown-menu", DropdownMenuController)
46
+ application.register("shadcn--hover-card", HoverCardController)
47
+ application.register("shadcn--menubar", MenubarController)
48
+ application.register("shadcn--navigation-menu", NavigationMenuController)
49
+ application.register("shadcn--popover", PopoverController)
50
+ application.register("shadcn--radio-group", RadioGroupController)
51
+ application.register("shadcn--scroll-area", ScrollAreaController)
52
+ application.register("shadcn--select", SelectController)
53
+ application.register("shadcn--sheet", SheetController)
54
+ application.register("shadcn--slider", SliderController)
55
+ application.register("shadcn--switch", SwitchController)
56
+ application.register("shadcn--tabs", TabsController)
57
+ application.register("shadcn--toast", ToastController)
58
+ application.register("shadcn--toggle", ToggleController)
59
+ application.register("shadcn--toggle-group", ToggleGroupController)
60
+ application.register("shadcn--tooltip", TooltipController)
61
+ }
62
+
63
+ export {
64
+ AccordionController,
65
+ DarkModeController,
66
+ CheckboxController,
67
+ CollapsibleController,
68
+ ComboboxController,
69
+ CommandController,
70
+ ContextMenuController,
71
+ DialogController,
72
+ DrawerController,
73
+ DropdownMenuController,
74
+ HoverCardController,
75
+ MenubarController,
76
+ NavigationMenuController,
77
+ PopoverController,
78
+ RadioGroupController,
79
+ ScrollAreaController,
80
+ SelectController,
81
+ SheetController,
82
+ SliderController,
83
+ SwitchController,
84
+ TabsController,
85
+ ToastController,
86
+ ToggleController,
87
+ ToggleGroupController,
88
+ TooltipController,
89
+ }
@@ -0,0 +1,171 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Menubar behavior
4
+ // Horizontal menu bar with dropdown submenus, keyboard navigation
5
+ export default class extends Controller {
6
+ static targets = ["menu", "trigger", "content", "item"]
7
+ static values = {
8
+ activeMenu: { type: String, default: "" },
9
+ }
10
+
11
+ connect() {
12
+ this._onClickOutside = this._handleClickOutside.bind(this)
13
+ this._onKeydown = this._handleKeydown.bind(this)
14
+ this._hideTimeouts = []
15
+ }
16
+
17
+ disconnect() {
18
+ this._hideTimeouts.forEach(id => clearTimeout(id))
19
+ this._hideTimeouts = []
20
+ this._removeListeners()
21
+ }
22
+
23
+ toggleMenu(event) {
24
+ const trigger = event.currentTarget
25
+ const value = trigger.dataset.value
26
+
27
+ if (this.activeMenuValue === value) {
28
+ this.activeMenuValue = ""
29
+ } else {
30
+ this.activeMenuValue = value
31
+ }
32
+ }
33
+
34
+ enterTrigger(event) {
35
+ // If another menu is already open, switch to this one on hover
36
+ if (this.activeMenuValue) {
37
+ const value = event.currentTarget.dataset.value
38
+ if (value !== this.activeMenuValue) {
39
+ this.activeMenuValue = value
40
+ }
41
+ }
42
+ }
43
+
44
+ selectItem(event) {
45
+ const item = event.currentTarget
46
+ if (item.dataset.disabled) return
47
+ this.dispatch("select", { detail: { value: item.dataset.value } })
48
+ this.activeMenuValue = ""
49
+ }
50
+
51
+ activeMenuValueChanged() {
52
+ this._syncState()
53
+ if (this.activeMenuValue) {
54
+ document.addEventListener("click", this._onClickOutside, true)
55
+ document.addEventListener("keydown", this._onKeydown)
56
+ } else {
57
+ this._removeListeners()
58
+ }
59
+ }
60
+
61
+ _syncState() {
62
+ if (!this._hideTimeouts) return
63
+ this.triggerTargets.forEach((trigger) => {
64
+ const isActive = trigger.dataset.value === this.activeMenuValue
65
+ trigger.dataset.state = isActive ? "open" : "closed"
66
+ trigger.setAttribute("aria-expanded", String(isActive))
67
+ })
68
+
69
+ this.contentTargets.forEach((content) => {
70
+ const isActive = content.dataset.value === this.activeMenuValue
71
+ content.dataset.state = isActive ? "open" : "closed"
72
+
73
+ if (isActive) {
74
+ content.hidden = false
75
+ this._positionContent(content)
76
+ requestAnimationFrame(() => {
77
+ const firstItem = content.querySelector('[data-slot*="menubar-item"]:not([data-disabled])')
78
+ firstItem?.focus()
79
+ })
80
+ } else {
81
+ this._hideTimeouts.push(setTimeout(() => { if (content.dataset.state === "closed") content.hidden = true }, 200))
82
+ }
83
+ })
84
+ }
85
+
86
+ _positionContent(content) {
87
+ const trigger = this.triggerTargets.find((t) => t.dataset.value === this.activeMenuValue)
88
+ if (!trigger) return
89
+
90
+ const rect = trigger.getBoundingClientRect()
91
+ content.style.position = "fixed"
92
+ content.style.zIndex = "50"
93
+ content.style.top = `${rect.bottom + 4}px`
94
+ content.style.left = `${rect.left}px`
95
+
96
+ // Adjust if overflowing
97
+ requestAnimationFrame(() => {
98
+ const contentRect = content.getBoundingClientRect()
99
+ if (contentRect.right > window.innerWidth) {
100
+ content.style.left = `${window.innerWidth - contentRect.width - 8}px`
101
+ }
102
+ if (contentRect.bottom > window.innerHeight) {
103
+ content.style.top = `${rect.top - contentRect.height - 4}px`
104
+ }
105
+ })
106
+ }
107
+
108
+ _handleClickOutside(event) {
109
+ if (!this.element.contains(event.target)) {
110
+ this.activeMenuValue = ""
111
+ }
112
+ }
113
+
114
+ _handleKeydown(event) {
115
+ if (event.key === "Escape") {
116
+ event.preventDefault()
117
+ const trigger = this.triggerTargets.find((t) => t.dataset.value === this.activeMenuValue)
118
+ this.activeMenuValue = ""
119
+ trigger?.focus()
120
+ return
121
+ }
122
+
123
+ // Navigate between menus with Left/Right
124
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
125
+ const triggers = this.triggerTargets
126
+ const currentIdx = triggers.findIndex((t) => t.dataset.value === this.activeMenuValue)
127
+ if (currentIdx === -1) return
128
+
129
+ const isInContent = document.activeElement?.closest('[data-slot*="menubar-content"]')
130
+ if (isInContent) {
131
+ event.preventDefault()
132
+ const nextIdx = event.key === "ArrowRight"
133
+ ? (currentIdx + 1) % triggers.length
134
+ : (currentIdx - 1 + triggers.length) % triggers.length
135
+ this.activeMenuValue = triggers[nextIdx].dataset.value
136
+ return
137
+ }
138
+ }
139
+
140
+ // Arrow down/up within menu items
141
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
142
+ const items = this._getActiveItems()
143
+ const current = items.indexOf(document.activeElement)
144
+ event.preventDefault()
145
+ const next = event.key === "ArrowDown"
146
+ ? (current + 1) % items.length
147
+ : (current - 1 + items.length) % items.length
148
+ items[next]?.focus()
149
+ }
150
+
151
+ if (event.key === "Enter" || event.key === " ") {
152
+ if (document.activeElement?.matches('[data-slot*="menubar-item"]')) {
153
+ event.preventDefault()
154
+ document.activeElement.click()
155
+ }
156
+ }
157
+ }
158
+
159
+ _getActiveItems() {
160
+ const content = this.contentTargets.find((c) => c.dataset.value === this.activeMenuValue)
161
+ if (!content) return []
162
+ return Array.from(content.querySelectorAll(
163
+ '[data-slot*="menubar-item"]:not([data-disabled]), [data-slot*="checkbox-item"]:not([data-disabled]), [data-slot*="radio-item"]:not([data-disabled])'
164
+ )).filter((el) => !el.closest("[hidden]"))
165
+ }
166
+
167
+ _removeListeners() {
168
+ document.removeEventListener("click", this._onClickOutside, true)
169
+ document.removeEventListener("keydown", this._onKeydown)
170
+ }
171
+ }