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,160 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix NavigationMenu behavior
4
+ // Hover/click to open submenus, keyboard navigation, viewport
5
+ export default class extends Controller {
6
+ static targets = ["item", "trigger", "content", "viewport", "link"]
7
+ static values = {
8
+ activeItem: { type: String, default: "" },
9
+ delayDuration: { type: Number, default: 200 },
10
+ }
11
+
12
+ connect() {
13
+ this._showTimeout = null
14
+ this._hideTimeout = null
15
+ this._onClickOutside = this._handleClickOutside.bind(this)
16
+ document.addEventListener("click", this._onClickOutside, true)
17
+ }
18
+
19
+ disconnect() {
20
+ clearTimeout(this._showTimeout)
21
+ clearTimeout(this._hideTimeout)
22
+ document.removeEventListener("click", this._onClickOutside, true)
23
+ }
24
+
25
+ enterTrigger(event) {
26
+ clearTimeout(this._hideTimeout)
27
+ const value = event.currentTarget.dataset.value
28
+ this._showTimeout = setTimeout(() => {
29
+ this.activeItemValue = value
30
+ }, this.delayDurationValue)
31
+ }
32
+
33
+ leaveTrigger() {
34
+ clearTimeout(this._showTimeout)
35
+ this._hideTimeout = setTimeout(() => {
36
+ this.activeItemValue = ""
37
+ }, this.delayDurationValue)
38
+ }
39
+
40
+ clickTrigger(event) {
41
+ const value = event.currentTarget.dataset.value
42
+ if (this.activeItemValue === value) {
43
+ this.activeItemValue = ""
44
+ } else {
45
+ this.activeItemValue = value
46
+ }
47
+ }
48
+
49
+ enterContent() {
50
+ clearTimeout(this._hideTimeout)
51
+ }
52
+
53
+ leaveContent() {
54
+ this._hideTimeout = setTimeout(() => {
55
+ this.activeItemValue = ""
56
+ }, this.delayDurationValue)
57
+ }
58
+
59
+ keydown(event) {
60
+ const triggers = this.triggerTargets
61
+ const current = triggers.indexOf(event.currentTarget)
62
+
63
+ switch (event.key) {
64
+ case "ArrowRight":
65
+ event.preventDefault()
66
+ triggers[(current + 1) % triggers.length]?.focus()
67
+ break
68
+ case "ArrowLeft":
69
+ event.preventDefault()
70
+ triggers[(current - 1 + triggers.length) % triggers.length]?.focus()
71
+ break
72
+ case "ArrowDown":
73
+ event.preventDefault()
74
+ if (this.activeItemValue) {
75
+ // Focus first link in active content
76
+ const content = this._getActiveContent()
77
+ const firstLink = content?.querySelector('[data-slot="navigation-menu-link"]')
78
+ firstLink?.focus()
79
+ } else {
80
+ this.activeItemValue = event.currentTarget.dataset.value
81
+ }
82
+ break
83
+ case "Escape":
84
+ event.preventDefault()
85
+ this.activeItemValue = ""
86
+ event.currentTarget.focus()
87
+ break
88
+ case "Enter":
89
+ case " ":
90
+ event.preventDefault()
91
+ this.clickTrigger(event)
92
+ break
93
+ }
94
+ }
95
+
96
+ contentKeydown(event) {
97
+ if (event.key === "Escape") {
98
+ event.preventDefault()
99
+ this.activeItemValue = ""
100
+ // Focus the trigger that opened this content
101
+ const trigger = this.triggerTargets.find(
102
+ (t) => t.dataset.value === this._lastActiveItem
103
+ )
104
+ trigger?.focus()
105
+ }
106
+
107
+ // Arrow navigation within content links
108
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
109
+ event.preventDefault()
110
+ const links = Array.from(
111
+ this._getActiveContent()?.querySelectorAll('[data-slot="navigation-menu-link"]') || []
112
+ )
113
+ const current = links.indexOf(document.activeElement)
114
+ const next = event.key === "ArrowDown"
115
+ ? (current + 1) % links.length
116
+ : (current - 1 + links.length) % links.length
117
+ links[next]?.focus()
118
+ }
119
+ }
120
+
121
+ activeItemValueChanged() {
122
+ this._lastActiveItem = this.activeItemValue || this._lastActiveItem
123
+ this._syncState()
124
+ }
125
+
126
+ _syncState() {
127
+ this.triggerTargets.forEach((trigger) => {
128
+ const isActive = trigger.dataset.value === this.activeItemValue
129
+ trigger.dataset.state = isActive ? "open" : "closed"
130
+ trigger.setAttribute("aria-expanded", String(isActive))
131
+ })
132
+
133
+ this.contentTargets.forEach((content) => {
134
+ const isActive = content.dataset.value === this.activeItemValue
135
+ content.dataset.state = isActive ? "open" : "closed"
136
+ content.hidden = !isActive
137
+
138
+ if (isActive) {
139
+ content.dataset.motion = "from-start"
140
+ }
141
+ })
142
+
143
+ // Update viewport
144
+ this.viewportTargets.forEach((viewport) => {
145
+ const hasActive = this.activeItemValue !== ""
146
+ viewport.dataset.state = hasActive ? "open" : "closed"
147
+ viewport.hidden = !hasActive
148
+ })
149
+ }
150
+
151
+ _getActiveContent() {
152
+ return this.contentTargets.find((c) => c.dataset.value === this.activeItemValue)
153
+ }
154
+
155
+ _handleClickOutside(event) {
156
+ if (!this.element.contains(event.target)) {
157
+ this.activeItemValue = ""
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,151 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Popover behavior
4
+ // Uses Stimulus declarative click@window for outside click (no flicker)
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "content", "anchor", "close"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ side: { type: String, default: "bottom" },
10
+ align: { type: String, default: "center" },
11
+ sideOffset: { type: Number, default: 4 },
12
+ }
13
+
14
+ connect() {
15
+ this._hideTimeouts = []
16
+ // Ensure closed on connect
17
+ this.contentTargets.forEach((el) => { el.dataset.state = "closed"; el.hidden = true })
18
+ this.triggerTargets.forEach((el) => { el.dataset.state = "closed"; el.setAttribute("aria-expanded", "false") })
19
+ }
20
+
21
+ disconnect() {
22
+ this._hideTimeouts.forEach(id => clearTimeout(id))
23
+ this._hideTimeouts = []
24
+ window.removeEventListener("resize", this._onResize)
25
+ }
26
+
27
+ toggle() { this.openValue = !this.openValue }
28
+
29
+ // Wired as click@window->shadcn--popover#hide on the controller element
30
+ hide(event) {
31
+ if (!this.openValue) return
32
+ if (event && event.target && this.element.contains(event.target)) return
33
+ this.openValue = false
34
+ }
35
+
36
+ // Wired as keydown.esc@window->shadcn--popover#hideOnEscape
37
+ hideOnEscape() {
38
+ if (!this.openValue) return
39
+ this.openValue = false
40
+ this.triggerTargets[0]?.focus()
41
+ }
42
+
43
+ close() { this.openValue = false }
44
+
45
+ openValueChanged() {
46
+ if (!this._hideTimeouts) return
47
+ this._render()
48
+ }
49
+
50
+ _render() {
51
+ const open = this.openValue
52
+ const state = open ? "open" : "closed"
53
+
54
+ this._hideTimeouts.forEach(id => clearTimeout(id))
55
+ this._hideTimeouts = []
56
+
57
+ this.element.dataset.state = state
58
+ this.triggerTargets.forEach((el) => {
59
+ el.dataset.state = state
60
+ el.setAttribute("aria-expanded", String(open))
61
+ })
62
+
63
+ this.contentTargets.forEach((el) => {
64
+ if (open) {
65
+ el.getAnimations().forEach(a => a.cancel())
66
+ el.hidden = false
67
+ el.dataset.state = "open"
68
+ requestAnimationFrame(() => this._position(el))
69
+ } else {
70
+ el.dataset.state = "closed"
71
+ const animations = el.getAnimations()
72
+ if (animations.length > 0) {
73
+ Promise.all(animations.map(a => a.finished)).then(() => {
74
+ if (el.dataset.state === "closed") el.hidden = true
75
+ }).catch(() => {})
76
+ } else {
77
+ el.hidden = true
78
+ }
79
+ }
80
+ })
81
+
82
+ if (open) {
83
+ this._onResize = () => {
84
+ if (this.openValue && this.hasContentTarget) this._position(this.contentTarget)
85
+ }
86
+ window.addEventListener("resize", this._onResize)
87
+ } else {
88
+ window.removeEventListener("resize", this._onResize)
89
+ }
90
+ }
91
+
92
+ _position(content) {
93
+ const anchor = this.hasAnchorTarget ? this.anchorTarget : this.hasTriggerTarget ? this.triggerTarget : null
94
+ if (!anchor) return
95
+
96
+ const anchorRect = anchor.getBoundingClientRect()
97
+ const offset = this.sideOffsetValue
98
+
99
+ content.style.position = "fixed"
100
+ content.style.zIndex = "50"
101
+
102
+ const contentRect = content.getBoundingClientRect()
103
+ let top, left
104
+
105
+ switch (this.sideValue) {
106
+ case "top":
107
+ top = anchorRect.top - contentRect.height - offset
108
+ content.dataset.side = "top"
109
+ break
110
+ case "bottom":
111
+ top = anchorRect.bottom + offset
112
+ content.dataset.side = "bottom"
113
+ break
114
+ case "left":
115
+ top = anchorRect.top
116
+ left = anchorRect.left - contentRect.width - offset
117
+ content.dataset.side = "left"
118
+ break
119
+ case "right":
120
+ top = anchorRect.top
121
+ left = anchorRect.right + offset
122
+ content.dataset.side = "right"
123
+ break
124
+ default:
125
+ top = anchorRect.bottom + offset
126
+ content.dataset.side = "bottom"
127
+ }
128
+
129
+ if (this.sideValue === "top" || this.sideValue === "bottom") {
130
+ switch (this.alignValue) {
131
+ case "start": left = anchorRect.left; break
132
+ case "end": left = anchorRect.right - contentRect.width; break
133
+ default: left = anchorRect.left + (anchorRect.width - contentRect.width) / 2
134
+ }
135
+ }
136
+
137
+ if (top + contentRect.height > window.innerHeight && this.sideValue === "bottom") {
138
+ top = anchorRect.top - contentRect.height - offset
139
+ content.dataset.side = "top"
140
+ } else if (top < 0 && this.sideValue === "top") {
141
+ top = anchorRect.bottom + offset
142
+ content.dataset.side = "bottom"
143
+ }
144
+
145
+ left = Math.max(8, Math.min(left, window.innerWidth - contentRect.width - 8))
146
+ top = Math.max(8, Math.min(top, window.innerHeight - contentRect.height - 8))
147
+
148
+ content.style.top = `${top}px`
149
+ content.style.left = `${left}px`
150
+ }
151
+ }
@@ -0,0 +1,78 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix RadioGroup behavior
4
+ // Arrow key navigation, single selection, form integration
5
+ export default class extends Controller {
6
+ static targets = ["item", "input"]
7
+ static values = {
8
+ value: { type: String, default: "" },
9
+ orientation: { type: String, default: "vertical" },
10
+ disabled: { type: Boolean, default: false },
11
+ }
12
+
13
+ connect() {
14
+ this._syncState()
15
+ }
16
+
17
+ select(event) {
18
+ if (this.disabledValue) return
19
+ const item = event.currentTarget
20
+ const value = item.dataset.value
21
+ if (value && value !== this.valueValue) {
22
+ this.valueValue = value
23
+ this.dispatch("change", { detail: { value } })
24
+ }
25
+ }
26
+
27
+ keydown(event) {
28
+ const items = this.itemTargets.filter((el) => !el.dataset.disabled)
29
+ const current = items.indexOf(event.currentTarget)
30
+ if (current === -1) return
31
+
32
+ const isVertical = this.orientationValue === "vertical"
33
+ const nextKey = isVertical ? "ArrowDown" : "ArrowRight"
34
+ const prevKey = isVertical ? "ArrowUp" : "ArrowLeft"
35
+ let nextIndex
36
+
37
+ switch (event.key) {
38
+ case nextKey:
39
+ event.preventDefault()
40
+ nextIndex = (current + 1) % items.length
41
+ items[nextIndex].focus()
42
+ this.valueValue = items[nextIndex].dataset.value
43
+ break
44
+ case prevKey:
45
+ event.preventDefault()
46
+ nextIndex = (current - 1 + items.length) % items.length
47
+ items[nextIndex].focus()
48
+ this.valueValue = items[nextIndex].dataset.value
49
+ break
50
+ case " ":
51
+ event.preventDefault()
52
+ this.valueValue = event.currentTarget.dataset.value
53
+ break
54
+ }
55
+ }
56
+
57
+ valueValueChanged() { this._syncState() }
58
+
59
+ _syncState() {
60
+ this.itemTargets.forEach((item) => {
61
+ const isChecked = item.dataset.value === this.valueValue
62
+ item.dataset.state = isChecked ? "checked" : "unchecked"
63
+ item.setAttribute("aria-checked", String(isChecked))
64
+ item.setAttribute("tabindex", isChecked ? "0" : "-1")
65
+
66
+ // Show/hide indicator
67
+ const indicator = item.querySelector("span")
68
+ if (indicator) {
69
+ indicator.hidden = !isChecked
70
+ }
71
+ })
72
+
73
+ // Update hidden input for form submission
74
+ this.inputTargets.forEach((input) => {
75
+ input.value = this.valueValue
76
+ })
77
+ }
78
+ }
@@ -0,0 +1,117 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix ScrollArea behavior
4
+ // Custom scrollbar overlay, tracks scroll position
5
+ export default class extends Controller {
6
+ static targets = ["viewport", "scrollbar", "thumb"]
7
+ static values = {
8
+ orientation: { type: String, default: "vertical" },
9
+ }
10
+
11
+ connect() {
12
+ this._onScroll = this._handleScroll.bind(this)
13
+ this._onPointerDown = this._handlePointerDown.bind(this)
14
+ this._onPointerMove = this._handlePointerMove.bind(this)
15
+ this._onPointerUp = this._handlePointerUp.bind(this)
16
+ this._dragging = false
17
+
18
+ if (this.hasViewportTarget) {
19
+ this.viewportTarget.addEventListener("scroll", this._onScroll, { passive: true })
20
+ }
21
+
22
+ this._updateThumb()
23
+
24
+ // Auto-hide scrollbar when not needed
25
+ this._observer = new ResizeObserver(() => this._updateThumb())
26
+ if (this.hasViewportTarget) {
27
+ this._observer.observe(this.viewportTarget)
28
+ }
29
+ }
30
+
31
+ disconnect() {
32
+ if (this.hasViewportTarget) {
33
+ this.viewportTarget.removeEventListener("scroll", this._onScroll)
34
+ }
35
+ this._observer?.disconnect()
36
+ document.removeEventListener("pointermove", this._onPointerMove)
37
+ document.removeEventListener("pointerup", this._onPointerUp)
38
+ }
39
+
40
+ startDrag(event) {
41
+ event.preventDefault()
42
+ this._dragging = true
43
+ this._dragStart = this.orientationValue === "vertical" ? event.clientY : event.clientX
44
+ this._scrollStart = this.orientationValue === "vertical"
45
+ ? this.viewportTarget.scrollTop
46
+ : this.viewportTarget.scrollLeft
47
+ document.addEventListener("pointermove", this._onPointerMove)
48
+ document.addEventListener("pointerup", this._onPointerUp)
49
+ }
50
+
51
+ _handleScroll() {
52
+ this._updateThumb()
53
+ }
54
+
55
+ _handlePointerDown(event) {
56
+ this.startDrag(event)
57
+ }
58
+
59
+ _handlePointerMove(event) {
60
+ if (!this._dragging || !this.hasViewportTarget) return
61
+
62
+ const viewport = this.viewportTarget
63
+ const isVertical = this.orientationValue === "vertical"
64
+
65
+ const delta = isVertical
66
+ ? event.clientY - this._dragStart
67
+ : event.clientX - this._dragStart
68
+
69
+ const viewportSize = isVertical ? viewport.clientHeight : viewport.clientWidth
70
+ const scrollSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth
71
+ const scrollRatio = scrollSize / viewportSize
72
+
73
+ if (isVertical) {
74
+ viewport.scrollTop = this._scrollStart + delta * scrollRatio
75
+ } else {
76
+ viewport.scrollLeft = this._scrollStart + delta * scrollRatio
77
+ }
78
+ }
79
+
80
+ _handlePointerUp() {
81
+ this._dragging = false
82
+ document.removeEventListener("pointermove", this._onPointerMove)
83
+ document.removeEventListener("pointerup", this._onPointerUp)
84
+ }
85
+
86
+ _updateThumb() {
87
+ if (!this.hasViewportTarget || !this.hasThumbTarget) return
88
+
89
+ const viewport = this.viewportTarget
90
+ const isVertical = this.orientationValue === "vertical"
91
+
92
+ const viewportSize = isVertical ? viewport.clientHeight : viewport.clientWidth
93
+ const scrollSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth
94
+ const scrollPos = isVertical ? viewport.scrollTop : viewport.scrollLeft
95
+
96
+ if (scrollSize <= viewportSize) {
97
+ // No scroll needed, hide scrollbar
98
+ this.scrollbarTargets.forEach((el) => { el.style.display = "none" })
99
+ return
100
+ }
101
+
102
+ this.scrollbarTargets.forEach((el) => { el.style.display = "" })
103
+
104
+ const thumbSize = Math.max((viewportSize / scrollSize) * viewportSize, 20)
105
+ const thumbPos = (scrollPos / (scrollSize - viewportSize)) * (viewportSize - thumbSize)
106
+
107
+ this.thumbTargets.forEach((thumb) => {
108
+ if (isVertical) {
109
+ thumb.style.height = `${thumbSize}px`
110
+ thumb.style.transform = `translateY(${thumbPos}px)`
111
+ } else {
112
+ thumb.style.width = `${thumbSize}px`
113
+ thumb.style.transform = `translateX(${thumbPos}px)`
114
+ }
115
+ })
116
+ }
117
+ }
@@ -0,0 +1,198 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Select behavior
4
+ // Uses Stimulus's declarative event wiring (click@window) for click-outside
5
+ // instead of programmatic document listeners — this avoids flicker.
6
+ export default class extends Controller {
7
+ static targets = ["trigger", "content", "item", "value", "input"]
8
+ static values = {
9
+ open: { type: Boolean, default: false },
10
+ value: { type: String, default: "" },
11
+ placeholder: { type: String, default: "Select..." },
12
+ disabled: { type: Boolean, default: false },
13
+ }
14
+
15
+ connect() {
16
+ this._hideTimeouts = []
17
+ this._syncValueState()
18
+ // Ensure closed on connect
19
+ this.contentTargets.forEach((el) => { el.dataset.state = "closed"; el.hidden = true })
20
+ this.triggerTargets.forEach((el) => { el.dataset.state = "closed"; el.setAttribute("aria-expanded", "false") })
21
+ }
22
+
23
+ disconnect() {
24
+ this._hideTimeouts.forEach(id => clearTimeout(id))
25
+ this._hideTimeouts = []
26
+ }
27
+
28
+ // Wired as: data-action="click->shadcn--select#toggle"
29
+ toggle() {
30
+ if (this.disabledValue) return
31
+ this.openValue = !this.openValue
32
+ }
33
+
34
+ // Wired as: data-action="click@window->shadcn--select#hide"
35
+ // This fires on EVERY click on the page. The guard ensures it only
36
+ // closes when the click is outside this element.
37
+ hide(event) {
38
+ if (!this.openValue) return
39
+ if (event && event.target && this.element.contains(event.target)) return
40
+ this.openValue = false
41
+ }
42
+
43
+ // Wired as: data-action="keydown.esc@window->shadcn--select#hideOnEscape"
44
+ hideOnEscape(event) {
45
+ if (!this.openValue) return
46
+ this.openValue = false
47
+ this.triggerTargets[0]?.focus()
48
+ }
49
+
50
+ // Wired as: data-action="click->shadcn--select#selectItem"
51
+ selectItem(event) {
52
+ const item = event.currentTarget
53
+ if (item.dataset.disabled) return
54
+
55
+ const value = item.dataset.value
56
+ const label = item.textContent.trim()
57
+
58
+ this.valueValue = value
59
+
60
+ this.valueTargets.forEach((el) => {
61
+ el.textContent = label
62
+ el.removeAttribute("data-placeholder")
63
+ })
64
+
65
+ this.dispatch("change", { detail: { value, label } })
66
+ this.openValue = false
67
+ this.triggerTargets[0]?.focus()
68
+ }
69
+
70
+ openValueChanged() {
71
+ if (!this._hideTimeouts) return
72
+ this._render()
73
+ }
74
+
75
+ valueValueChanged() {
76
+ if (!this._hideTimeouts) return
77
+ this._syncValueState()
78
+ }
79
+
80
+ // ── Private ─────────────────────────────────────────
81
+
82
+ _render() {
83
+ const open = this.openValue
84
+ const state = open ? "open" : "closed"
85
+
86
+ // Clear pending hides
87
+ this._hideTimeouts.forEach(id => clearTimeout(id))
88
+ this._hideTimeouts = []
89
+
90
+ this.triggerTargets.forEach((el) => {
91
+ el.dataset.state = state
92
+ el.setAttribute("aria-expanded", String(open))
93
+ })
94
+
95
+ this.contentTargets.forEach((el) => {
96
+ if (open) {
97
+ // Cancel any in-progress close animation
98
+ el.getAnimations().forEach(a => a.cancel())
99
+ el.hidden = false
100
+ el.dataset.state = "open"
101
+ this._position(el)
102
+ requestAnimationFrame(() => {
103
+ const selected = el.querySelector(`[data-value="${this.valueValue}"]`)
104
+ const target = selected || el.querySelector('[data-slot="select-item"]:not([data-disabled])')
105
+ target?.focus()
106
+ })
107
+ } else {
108
+ el.dataset.state = "closed"
109
+ // Wait for the CSS close animation to finish, then hide
110
+ const animations = el.getAnimations()
111
+ if (animations.length > 0) {
112
+ Promise.all(animations.map(a => a.finished)).then(() => {
113
+ if (el.dataset.state === "closed") el.hidden = true
114
+ }).catch(() => {
115
+ // Animation was cancelled (reopened before finishing)
116
+ })
117
+ } else {
118
+ el.hidden = true
119
+ }
120
+ }
121
+ })
122
+ }
123
+
124
+ _syncValueState() {
125
+ this.itemTargets.forEach((item) => {
126
+ const isSelected = item.dataset.value === this.valueValue
127
+ item.dataset.state = isSelected ? "checked" : "unchecked"
128
+ item.setAttribute("aria-selected", String(isSelected))
129
+ const indicator = item.querySelector("span")
130
+ if (indicator) indicator.style.visibility = isSelected ? "visible" : "hidden"
131
+ })
132
+
133
+ this.inputTargets.forEach((input) => { input.value = this.valueValue })
134
+
135
+ if (!this.valueValue) {
136
+ this.valueTargets.forEach((el) => {
137
+ el.textContent = this.placeholderValue
138
+ el.dataset.placeholder = ""
139
+ })
140
+ }
141
+ }
142
+
143
+ _position(content) {
144
+ if (!this.hasTriggerTarget) return
145
+ const rect = this.triggerTarget.getBoundingClientRect()
146
+
147
+ content.style.position = "fixed"
148
+ content.style.zIndex = "50"
149
+ content.style.width = `${rect.width}px`
150
+ content.style.minWidth = `${Math.max(rect.width, 128)}px`
151
+
152
+ let top = rect.bottom + 4
153
+ const contentHeight = content.scrollHeight
154
+ if (top + contentHeight > window.innerHeight) {
155
+ top = rect.top - contentHeight - 4
156
+ }
157
+ content.style.top = `${top}px`
158
+ content.style.left = `${rect.left}px`
159
+ }
160
+
161
+ // Keyboard nav within the open dropdown
162
+ navigate(event) {
163
+ if (!this.openValue) return
164
+ const items = this._getItems()
165
+ const current = items.indexOf(document.activeElement)
166
+
167
+ switch (event.key) {
168
+ case "ArrowDown":
169
+ event.preventDefault()
170
+ items[(current + 1) % items.length]?.focus()
171
+ break
172
+ case "ArrowUp":
173
+ event.preventDefault()
174
+ items[(current - 1 + items.length) % items.length]?.focus()
175
+ break
176
+ case "Home":
177
+ event.preventDefault()
178
+ items[0]?.focus()
179
+ break
180
+ case "End":
181
+ event.preventDefault()
182
+ items[items.length - 1]?.focus()
183
+ break
184
+ case "Enter":
185
+ case " ":
186
+ if (document.activeElement?.matches('[data-slot="select-item"]')) {
187
+ event.preventDefault()
188
+ document.activeElement.click()
189
+ }
190
+ break
191
+ }
192
+ }
193
+
194
+ _getItems() {
195
+ if (!this.hasContentTarget) return []
196
+ return Array.from(this.contentTarget.querySelectorAll('[data-slot="select-item"]:not([data-disabled])'))
197
+ }
198
+ }