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