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,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
|
+
}
|