shadcn-rails 0.1.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +69 -2
- data/README.md +102 -1398
- data/__mocks__/@floating-ui/dom.js +67 -0
- data/app/assets/javascripts/shadcn/controllers/base_menu_controller.js +266 -0
- data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +34 -8
- data/app/assets/javascripts/shadcn/controllers/command_controller.js +5 -1
- data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +64 -135
- data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +56 -186
- data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +29 -55
- data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +10 -7
- data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +10 -6
- data/app/assets/javascripts/shadcn/controllers/popover_controller.js +35 -60
- data/app/assets/javascripts/shadcn/controllers/select_controller.js +37 -17
- data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +24 -14
- data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +28 -59
- data/app/assets/javascripts/shadcn/index.js +9 -1
- data/app/assets/javascripts/shadcn/utils/floating.js +179 -0
- data/app/assets/stylesheets/shadcn/base.css +32 -0
- data/app/assets/stylesheets/shadcn/components.css +12 -0
- data/app/components/shadcn/accordion_component.html.erb +8 -0
- data/app/components/shadcn/accordion_component.rb +6 -15
- data/app/components/shadcn/alert_component.html.erb +6 -0
- data/app/components/shadcn/alert_component.rb +0 -18
- data/app/components/shadcn/alert_dialog_component.html.erb +12 -0
- data/app/components/shadcn/alert_dialog_component.rb +7 -27
- data/app/components/shadcn/aspect_ratio_component.html.erb +7 -0
- data/app/components/shadcn/aspect_ratio_component.rb +4 -19
- data/app/components/shadcn/avatar_component.html.erb +20 -0
- data/app/components/shadcn/avatar_component.rb +8 -36
- data/app/components/shadcn/badge_component.html.erb +1 -0
- data/app/components/shadcn/badge_component.rb +0 -11
- data/app/components/shadcn/base_component.rb +15 -2
- data/app/components/shadcn/breadcrumb_component.html.erb +5 -0
- data/app/components/shadcn/breadcrumb_component.rb +6 -16
- data/app/components/shadcn/button_component.html.erb +18 -0
- data/app/components/shadcn/button_component.rb +1 -41
- data/app/components/shadcn/card_component.html.erb +8 -0
- data/app/components/shadcn/card_component.rb +2 -6
- data/app/components/shadcn/checkbox_component.html.erb +32 -0
- data/app/components/shadcn/checkbox_component.rb +4 -43
- data/app/components/shadcn/collapsible_component.html.erb +8 -0
- data/app/components/shadcn/collapsible_component.rb +6 -15
- data/app/components/shadcn/command_list_component.rb +29 -14
- data/app/components/shadcn/context_menu_checkbox_item_component.rb +76 -0
- data/app/components/shadcn/context_menu_component.html.erb +11 -0
- data/app/components/shadcn/context_menu_component.rb +6 -26
- data/app/components/shadcn/context_menu_content_component.rb +37 -14
- data/app/components/shadcn/context_menu_item_component.rb +3 -2
- data/app/components/shadcn/context_menu_radio_group_component.rb +42 -0
- data/app/components/shadcn/context_menu_radio_item_component.rb +76 -0
- data/app/components/shadcn/dialog_component.html.erb +14 -0
- data/app/components/shadcn/dialog_component.rb +8 -29
- data/app/components/shadcn/drawer_component.html.erb +12 -0
- data/app/components/shadcn/drawer_component.rb +7 -27
- data/app/components/shadcn/dropdown_menu_checkbox_item_component.rb +76 -0
- data/app/components/shadcn/dropdown_menu_component.html.erb +14 -0
- data/app/components/shadcn/dropdown_menu_component.rb +9 -29
- data/app/components/shadcn/dropdown_menu_content_component.rb +45 -16
- data/app/components/shadcn/dropdown_menu_radio_group_component.rb +42 -0
- data/app/components/shadcn/dropdown_menu_radio_item_component.rb +76 -0
- data/app/components/shadcn/field_component.rb +7 -8
- data/app/components/shadcn/hover_card_component.html.erb +12 -0
- data/app/components/shadcn/hover_card_component.rb +7 -26
- data/app/components/shadcn/input_component.html.erb +18 -0
- data/app/components/shadcn/input_component.rb +2 -27
- data/app/components/shadcn/input_otp_component.rb +3 -3
- data/app/components/shadcn/kbd_component.html.erb +1 -0
- data/app/components/shadcn/kbd_component.rb +3 -10
- data/app/components/shadcn/label_component.html.erb +3 -0
- data/app/components/shadcn/label_component.rb +2 -18
- data/app/components/shadcn/menubar_component.html.erb +6 -0
- data/app/components/shadcn/menubar_component.rb +4 -15
- data/app/components/shadcn/menubar_content_component.rb +45 -20
- data/app/components/shadcn/menubar_sub_content_component.rb +21 -8
- data/app/components/shadcn/native_select_component.html.erb +22 -0
- data/app/components/shadcn/native_select_component.rb +9 -39
- data/app/components/shadcn/navigation_menu_component.html.erb +6 -0
- data/app/components/shadcn/navigation_menu_component.rb +4 -15
- data/app/components/shadcn/pagination_component.html.erb +5 -0
- data/app/components/shadcn/pagination_component.rb +11 -15
- data/app/components/shadcn/popover_component.html.erb +15 -0
- data/app/components/shadcn/popover_component.rb +10 -30
- data/app/components/shadcn/progress_component.html.erb +13 -0
- data/app/components/shadcn/progress_component.rb +6 -26
- data/app/components/shadcn/radio_group_component.html.erb +8 -0
- data/app/components/shadcn/radio_group_component.rb +12 -26
- data/app/components/shadcn/radio_group_item_component.rb +32 -6
- data/app/components/shadcn/resizable_panel_group_component.rb +27 -16
- data/app/components/shadcn/scroll_area_component.html.erb +7 -0
- data/app/components/shadcn/scroll_area_component.rb +4 -16
- data/app/components/shadcn/select_component.html.erb +46 -0
- data/app/components/shadcn/select_component.rb +29 -86
- data/app/components/shadcn/separator_component.html.erb +5 -0
- data/app/components/shadcn/separator_component.rb +6 -14
- data/app/components/shadcn/sheet_component.html.erb +12 -0
- data/app/components/shadcn/sheet_component.rb +7 -27
- data/app/components/shadcn/sidebar_component.rb +2 -2
- data/app/components/shadcn/skeleton_component.html.erb +1 -0
- data/app/components/shadcn/skeleton_component.rb +4 -2
- data/app/components/shadcn/slider_component.html.erb +12 -0
- data/app/components/shadcn/slider_component.rb +2 -21
- data/app/components/shadcn/spinner_component.html.erb +18 -0
- data/app/components/shadcn/spinner_component.rb +2 -30
- data/app/components/shadcn/switch_component.html.erb +72 -0
- data/app/components/shadcn/switch_component.rb +4 -82
- data/app/components/shadcn/table_component.html.erb +9 -0
- data/app/components/shadcn/table_component.rb +2 -10
- data/app/components/shadcn/tabs_component.html.erb +8 -0
- data/app/components/shadcn/tabs_component.rb +4 -17
- data/app/components/shadcn/textarea_component.html.erb +13 -0
- data/app/components/shadcn/textarea_component.rb +6 -22
- data/app/components/shadcn/toast_component.html.erb +36 -0
- data/app/components/shadcn/toast_component.rb +6 -54
- data/app/components/shadcn/toggle_component.html.erb +12 -0
- data/app/components/shadcn/toggle_component.rb +6 -21
- data/app/components/shadcn/toggle_group_component.html.erb +14 -0
- data/app/components/shadcn/toggle_group_component.rb +6 -29
- data/app/components/shadcn/tooltip_component.html.erb +20 -0
- data/app/components/shadcn/tooltip_component.rb +13 -38
- data/lib/generators/shadcn/add/USAGE +24 -0
- data/lib/generators/shadcn/add/add_generator.rb +279 -0
- data/lib/generators/shadcn/install/USAGE +22 -0
- data/lib/generators/shadcn/install/install_generator.rb +8 -3
- data/lib/generators/shadcn/install/templates/initializer.rb.tt +7 -27
- data/lib/generators/shadcn/install/templates/shadcn.yml.tt +15 -31
- data/lib/shadcn/rails/version.rb +1 -1
- metadata +54 -42
- data/.dockerignore +0 -40
- data/CLAUDE.md +0 -463
- data/PROGRESS.md +0 -485
- data/Rakefile +0 -29
- data/__tests__/controllers/__snapshots__/calendar_controller.test.js.snap +0 -13
- data/__tests__/controllers/__snapshots__/popover_controller.test.js.snap +0 -46
- data/__tests__/controllers/__snapshots__/sheet_controller.test.js.snap +0 -111
- data/__tests__/controllers/__snapshots__/tabs_controller.test.js.snap +0 -27
- data/__tests__/controllers/accordion_controller.test.js +0 -904
- data/__tests__/controllers/calendar_controller.test.js +0 -1370
- data/__tests__/controllers/carousel_controller.test.js +0 -912
- data/__tests__/controllers/checkbox_controller.test.js +0 -454
- data/__tests__/controllers/collapsible_controller.test.js +0 -407
- data/__tests__/controllers/combobox_controller.test.js +0 -966
- data/__tests__/controllers/context_menu_controller.test.js +0 -627
- data/__tests__/controllers/date_picker_controller.test.js +0 -636
- data/__tests__/controllers/dialog_controller.test.js +0 -878
- data/__tests__/controllers/drawer_controller.test.js +0 -995
- data/__tests__/controllers/menubar_controller.test.js +0 -736
- data/__tests__/controllers/navigation_menu_controller.test.js +0 -598
- data/__tests__/controllers/popover_controller.test.js +0 -1007
- data/__tests__/controllers/radio_group_controller.test.js +0 -640
- data/__tests__/controllers/resizable_controller.test.js +0 -680
- data/__tests__/controllers/select_controller.test.js +0 -674
- data/__tests__/controllers/sheet_controller.test.js +0 -986
- data/__tests__/controllers/slider_controller.test.js +0 -1036
- data/__tests__/controllers/switch_controller.test.js +0 -424
- data/__tests__/controllers/tabs_controller.test.js +0 -907
- data/__tests__/controllers/toggle_group_controller.test.js +0 -839
- data/__tests__/controllers/tooltip_controller.test.js +0 -808
- data/__tests__/helpers/stimulus-test-helper.js +0 -203
- data/babel.config.cjs +0 -5
- data/bin/console +0 -11
- data/bin/setup +0 -8
- data/jest.config.js +0 -19
- data/jest.setup.js +0 -8
- data/lib/generators/shadcn/component/component_generator.rb +0 -188
- data/lib/generators/shadcn/theme/theme_generator.rb +0 -128
- data/package-lock.json +0 -7415
- data/package.json +0 -68
- data/rollup.config.js +0 -29
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock for @floating-ui/dom
|
|
3
|
+
* Used in tests since JSDOM doesn't properly support the DOM APIs Floating UI needs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Store references for size middleware to apply
|
|
7
|
+
let _computeRef = null
|
|
8
|
+
let _computeFloating = null
|
|
9
|
+
|
|
10
|
+
export const computePosition = async (reference, floating, options = {}) => {
|
|
11
|
+
_computeRef = reference
|
|
12
|
+
_computeFloating = floating
|
|
13
|
+
|
|
14
|
+
// Call size middleware apply if present
|
|
15
|
+
if (options.middleware) {
|
|
16
|
+
for (const mw of options.middleware) {
|
|
17
|
+
if (mw.name === 'size' && mw.applyFn) {
|
|
18
|
+
mw.applyFn({
|
|
19
|
+
availableWidth: 400,
|
|
20
|
+
availableHeight: 300,
|
|
21
|
+
elements: { floating },
|
|
22
|
+
rects: {
|
|
23
|
+
reference: {
|
|
24
|
+
width: reference?.getBoundingClientRect?.()?.width || 100,
|
|
25
|
+
height: reference?.getBoundingClientRect?.()?.height || 40
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
x: 100,
|
|
35
|
+
y: 140,
|
|
36
|
+
placement: options.placement || 'bottom-start',
|
|
37
|
+
middlewareData: {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const autoUpdate = (reference, floating, update) => {
|
|
42
|
+
// Call update once immediately
|
|
43
|
+
update()
|
|
44
|
+
// Return cleanup function
|
|
45
|
+
return () => {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const flip = (options = {}) => ({
|
|
49
|
+
name: 'flip',
|
|
50
|
+
options
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export const shift = (options = {}) => ({
|
|
54
|
+
name: 'shift',
|
|
55
|
+
options
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export const offset = (value = 0) => ({
|
|
59
|
+
name: 'offset',
|
|
60
|
+
options: { mainAxis: value }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const size = (options = {}) => ({
|
|
64
|
+
name: 'size',
|
|
65
|
+
options,
|
|
66
|
+
applyFn: options.apply
|
|
67
|
+
})
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { useClickOutside } from "stimulus-use"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base Menu Controller
|
|
6
|
+
*
|
|
7
|
+
* A base controller for menu-like components (dropdown, context menu, select, etc.)
|
|
8
|
+
* that provides common functionality for:
|
|
9
|
+
* - Opening/closing menus
|
|
10
|
+
* - Keyboard navigation (arrow keys, home, end, enter, space, escape)
|
|
11
|
+
* - Focus management
|
|
12
|
+
* - Click outside to close (using stimulus-use)
|
|
13
|
+
* - Item selection
|
|
14
|
+
*
|
|
15
|
+
* Subclasses can override specific methods to customize behavior:
|
|
16
|
+
* - positionContent() - Custom positioning logic
|
|
17
|
+
* - showMenu() - Additional show behavior
|
|
18
|
+
* - hideMenu() - Additional hide behavior
|
|
19
|
+
* - shouldCloseOnClickOutside(event) - Custom click outside logic
|
|
20
|
+
*/
|
|
21
|
+
export default class extends Controller {
|
|
22
|
+
static targets = ["trigger", "content", "item"]
|
|
23
|
+
static values = {
|
|
24
|
+
open: { type: Boolean, default: false },
|
|
25
|
+
hideDelay: { type: Number, default: 150 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Lifecycle hooks
|
|
29
|
+
connect() {
|
|
30
|
+
this.focusedIndex = -1
|
|
31
|
+
this.hideTimeoutId = null
|
|
32
|
+
this.boundHandleKeydown = this.handleKeydown.bind(this)
|
|
33
|
+
|
|
34
|
+
// Use stimulus-use for click outside detection
|
|
35
|
+
useClickOutside(this)
|
|
36
|
+
|
|
37
|
+
if (this.openValue) {
|
|
38
|
+
this.show()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
disconnect() {
|
|
43
|
+
this.hide()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Public API
|
|
47
|
+
toggle(event) {
|
|
48
|
+
event?.preventDefault()
|
|
49
|
+
if (this.openValue) {
|
|
50
|
+
this.hide()
|
|
51
|
+
} else {
|
|
52
|
+
this.show()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
show(event) {
|
|
57
|
+
if (this.openValue) return
|
|
58
|
+
|
|
59
|
+
// Cancel any pending hide timeout
|
|
60
|
+
this.cancelHideTimeout()
|
|
61
|
+
|
|
62
|
+
this.openValue = true
|
|
63
|
+
|
|
64
|
+
if (this.hasContentTarget) {
|
|
65
|
+
this.contentTarget.hidden = false
|
|
66
|
+
this.contentTarget.dataset.state = "open"
|
|
67
|
+
this.positionContent(event)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.hasTriggerTarget) {
|
|
71
|
+
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Add event listeners
|
|
75
|
+
this.addEventListeners()
|
|
76
|
+
|
|
77
|
+
// Allow subclasses to add custom show behavior
|
|
78
|
+
this.showMenu(event)
|
|
79
|
+
|
|
80
|
+
// Focus first item
|
|
81
|
+
this.focusedIndex = -1
|
|
82
|
+
this.focusNextItem()
|
|
83
|
+
|
|
84
|
+
this.dispatch("opened")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
hide() {
|
|
88
|
+
if (!this.openValue) return
|
|
89
|
+
|
|
90
|
+
this.openValue = false
|
|
91
|
+
|
|
92
|
+
// Remove event listeners immediately to prevent double-triggering
|
|
93
|
+
this.removeEventListeners()
|
|
94
|
+
|
|
95
|
+
if (this.hasContentTarget) {
|
|
96
|
+
this.contentTarget.dataset.state = "closed"
|
|
97
|
+
// Hide after animation completes
|
|
98
|
+
this.hideTimeoutId = setTimeout(() => {
|
|
99
|
+
if (!this.openValue && this.hasContentTarget) {
|
|
100
|
+
this.contentTarget.hidden = true
|
|
101
|
+
}
|
|
102
|
+
this.hideTimeoutId = null
|
|
103
|
+
// Allow subclasses to add custom hide behavior
|
|
104
|
+
this.hideMenu()
|
|
105
|
+
}, this.hideDelayValue)
|
|
106
|
+
} else {
|
|
107
|
+
this.hideMenu()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (this.hasTriggerTarget) {
|
|
111
|
+
this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Reset focus index
|
|
115
|
+
this.focusedIndex = -1
|
|
116
|
+
|
|
117
|
+
this.dispatch("closed")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
close() {
|
|
121
|
+
this.hide()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
selectItem(event) {
|
|
125
|
+
const item = event.currentTarget
|
|
126
|
+
if (item.dataset.disabled !== undefined) return
|
|
127
|
+
|
|
128
|
+
this.dispatch("select", { detail: { item } })
|
|
129
|
+
this.hide()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Event handling - clickOutside is called by stimulus-use
|
|
133
|
+
clickOutside(event) {
|
|
134
|
+
// Only close if menu is open and shouldCloseOnClickOutside returns true
|
|
135
|
+
if (this.openValue && this.shouldCloseOnClickOutside(event)) {
|
|
136
|
+
this.hide()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
handleKeydown(event) {
|
|
141
|
+
switch (event.key) {
|
|
142
|
+
case "Escape":
|
|
143
|
+
this.hide()
|
|
144
|
+
this.triggerTarget?.focus()
|
|
145
|
+
break
|
|
146
|
+
case "ArrowDown":
|
|
147
|
+
event.preventDefault()
|
|
148
|
+
this.focusNextItem()
|
|
149
|
+
break
|
|
150
|
+
case "ArrowUp":
|
|
151
|
+
event.preventDefault()
|
|
152
|
+
this.focusPreviousItem()
|
|
153
|
+
break
|
|
154
|
+
case "Home":
|
|
155
|
+
event.preventDefault()
|
|
156
|
+
this.focusFirstItem()
|
|
157
|
+
break
|
|
158
|
+
case "End":
|
|
159
|
+
event.preventDefault()
|
|
160
|
+
this.focusLastItem()
|
|
161
|
+
break
|
|
162
|
+
case "Enter":
|
|
163
|
+
case " ":
|
|
164
|
+
event.preventDefault()
|
|
165
|
+
this.selectFocusedItem()
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Focus management
|
|
171
|
+
focusNextItem() {
|
|
172
|
+
const items = this.enabledItems
|
|
173
|
+
if (items.length === 0) return
|
|
174
|
+
|
|
175
|
+
this.focusedIndex = (this.focusedIndex + 1) % items.length
|
|
176
|
+
items[this.focusedIndex].focus()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
focusPreviousItem() {
|
|
180
|
+
const items = this.enabledItems
|
|
181
|
+
if (items.length === 0) return
|
|
182
|
+
|
|
183
|
+
this.focusedIndex = this.focusedIndex <= 0 ? items.length - 1 : this.focusedIndex - 1
|
|
184
|
+
items[this.focusedIndex].focus()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
focusFirstItem() {
|
|
188
|
+
const items = this.enabledItems
|
|
189
|
+
if (items.length === 0) return
|
|
190
|
+
|
|
191
|
+
this.focusedIndex = 0
|
|
192
|
+
items[0].focus()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
focusLastItem() {
|
|
196
|
+
const items = this.enabledItems
|
|
197
|
+
if (items.length === 0) return
|
|
198
|
+
|
|
199
|
+
this.focusedIndex = items.length - 1
|
|
200
|
+
items[this.focusedIndex].focus()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
selectFocusedItem() {
|
|
204
|
+
const items = this.enabledItems
|
|
205
|
+
if (this.focusedIndex >= 0 && this.focusedIndex < items.length) {
|
|
206
|
+
items[this.focusedIndex].click()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get enabledItems() {
|
|
211
|
+
return this.itemTargets.filter(item => item.dataset.disabled === undefined)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Protected methods that subclasses can override
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Position the content element. Override in subclasses for custom positioning.
|
|
218
|
+
* @param {Event} event - The event that triggered the show (optional)
|
|
219
|
+
*/
|
|
220
|
+
positionContent(event) {
|
|
221
|
+
// Default: no positioning (subclasses should override)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Called after showing the menu. Override in subclasses for additional behavior.
|
|
226
|
+
* @param {Event} event - The event that triggered the show (optional)
|
|
227
|
+
*/
|
|
228
|
+
showMenu(event) {
|
|
229
|
+
// Default: no-op (subclasses can override)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Called after hiding the menu. Override in subclasses for additional behavior.
|
|
234
|
+
*/
|
|
235
|
+
hideMenu() {
|
|
236
|
+
// Default: no-op (subclasses can override)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Determine if the menu should close on click outside.
|
|
241
|
+
* Override in subclasses for custom behavior (e.g., context menu).
|
|
242
|
+
* @param {Event} event - The click event
|
|
243
|
+
* @returns {boolean} - True if the menu should close
|
|
244
|
+
*/
|
|
245
|
+
shouldCloseOnClickOutside(event) {
|
|
246
|
+
// Default: close if clicking outside the entire element
|
|
247
|
+
return !this.element.contains(event.target)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Private helpers
|
|
251
|
+
// Note: click outside is handled by stimulus-use's useClickOutside
|
|
252
|
+
addEventListeners() {
|
|
253
|
+
document.addEventListener("keydown", this.boundHandleKeydown)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
removeEventListeners() {
|
|
257
|
+
document.removeEventListener("keydown", this.boundHandleKeydown)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
cancelHideTimeout() {
|
|
261
|
+
if (this.hideTimeoutId) {
|
|
262
|
+
clearTimeout(this.hideTimeoutId)
|
|
263
|
+
this.hideTimeoutId = null
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -1,23 +1,43 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { useClickOutside, useDebounce } from "stimulus-use"
|
|
3
|
+
import { positionFloating } from "../utils/floating"
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Combobox controller for searchable select dropdown
|
|
5
7
|
* Handles open/close, filtering, keyboard navigation, and item selection
|
|
8
|
+
* Uses Floating UI for smart positioning and stimulus-use for utilities
|
|
6
9
|
*/
|
|
7
10
|
export default class extends Controller {
|
|
8
11
|
static targets = ["trigger", "content", "input", "list", "item", "empty", "displayValue", "hiddenInput"]
|
|
9
12
|
static values = {
|
|
10
13
|
open: { type: Boolean, default: false },
|
|
11
14
|
value: { type: String, default: "" },
|
|
12
|
-
selectedIndex: { type: Number, default: -1 }
|
|
15
|
+
selectedIndex: { type: Number, default: -1 },
|
|
16
|
+
debounceWait: { type: Number, default: 150 },
|
|
17
|
+
placement: { type: String, default: "bottom-start" }
|
|
13
18
|
}
|
|
19
|
+
static debounces = ["filter"]
|
|
14
20
|
|
|
15
21
|
connect() {
|
|
16
22
|
this.boundHandleKeydown = this.handleKeydown.bind(this)
|
|
23
|
+
this.cleanupFloating = null
|
|
24
|
+
|
|
25
|
+
// Use stimulus-use for click outside detection
|
|
26
|
+
useClickOutside(this)
|
|
27
|
+
// Use stimulus-use for debounced filtering
|
|
28
|
+
useDebounce(this, { wait: this.debounceWaitValue })
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
disconnect() {
|
|
20
32
|
document.removeEventListener("keydown", this.boundHandleKeydown)
|
|
33
|
+
this.cleanupPositioning()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
cleanupPositioning() {
|
|
37
|
+
if (this.cleanupFloating) {
|
|
38
|
+
this.cleanupFloating()
|
|
39
|
+
this.cleanupFloating = null
|
|
40
|
+
}
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
toggle() {
|
|
@@ -36,6 +56,13 @@ export default class extends Controller {
|
|
|
36
56
|
this.contentTarget.dataset.state = "open"
|
|
37
57
|
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
38
58
|
|
|
59
|
+
// Use Floating UI for smart positioning
|
|
60
|
+
this.cleanupFloating = positionFloating(this.triggerTarget, this.contentTarget, {
|
|
61
|
+
placement: this.placementValue,
|
|
62
|
+
sameWidth: true,
|
|
63
|
+
maxHeight: 384 // max-h-96
|
|
64
|
+
})
|
|
65
|
+
|
|
39
66
|
// Focus the input
|
|
40
67
|
requestAnimationFrame(() => {
|
|
41
68
|
if (this.hasInputTarget) {
|
|
@@ -58,6 +85,9 @@ export default class extends Controller {
|
|
|
58
85
|
this.contentTarget.dataset.state = "closed"
|
|
59
86
|
this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
60
87
|
|
|
88
|
+
// Cleanup Floating UI
|
|
89
|
+
this.cleanupPositioning()
|
|
90
|
+
|
|
61
91
|
// Hide after animation completes, then reset filter state
|
|
62
92
|
const hideAndReset = () => {
|
|
63
93
|
this.contentTarget.hidden = true
|
|
@@ -221,13 +251,9 @@ export default class extends Controller {
|
|
|
221
251
|
return this.itemTargets.filter((item) => item.style.display !== "none")
|
|
222
252
|
}
|
|
223
253
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
handleClickOutside(event) {
|
|
228
|
-
if (!this.openValue) return
|
|
229
|
-
|
|
230
|
-
if (!this.element.contains(event.target)) {
|
|
254
|
+
// Called by stimulus-use when clicking outside the element
|
|
255
|
+
clickOutside(event) {
|
|
256
|
+
if (this.openValue) {
|
|
231
257
|
this.close()
|
|
232
258
|
}
|
|
233
259
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { useDebounce } from "stimulus-use"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Command controller for command palette functionality
|
|
@@ -7,10 +8,13 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
7
8
|
export default class extends Controller {
|
|
8
9
|
static targets = ["input", "list", "empty", "group", "item"]
|
|
9
10
|
static values = {
|
|
10
|
-
selectedIndex: { type: Number, default: -1 }
|
|
11
|
+
selectedIndex: { type: Number, default: -1 },
|
|
12
|
+
debounceWait: { type: Number, default: 150 }
|
|
11
13
|
}
|
|
14
|
+
static debounces = ["filter"]
|
|
12
15
|
|
|
13
16
|
connect() {
|
|
17
|
+
useDebounce(this, { wait: this.debounceWaitValue })
|
|
14
18
|
this.updateSelection()
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -1,43 +1,66 @@
|
|
|
1
|
-
import
|
|
1
|
+
import BaseMenuController from "./base_menu_controller"
|
|
2
|
+
import { positionAtPoint } from "../utils/floating"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Context Menu controller for right-click menus
|
|
5
|
-
*
|
|
6
|
+
* Extends BaseMenuController with Floating UI positioning at cursor location
|
|
6
7
|
*/
|
|
7
|
-
export default class extends
|
|
8
|
-
static targets = [
|
|
8
|
+
export default class extends BaseMenuController {
|
|
9
|
+
static targets = [...BaseMenuController.targets]
|
|
9
10
|
static values = {
|
|
10
|
-
|
|
11
|
+
...BaseMenuController.values,
|
|
12
|
+
hideDelay: { type: Number, default: 100 }
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
connect() {
|
|
14
|
-
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
16
|
+
super.connect()
|
|
17
|
+
this.boundHandleContextMenu = this.handleContextMenu.bind(this)
|
|
18
|
+
this.originalOverflow = null
|
|
19
|
+
this.mouseX = 0
|
|
20
|
+
this.mouseY = 0
|
|
21
|
+
this._ignoreClickOutside = false
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
// Override clickOutside to handle the deferred close behavior
|
|
25
|
+
// Context menus need to ignore clicks in the same frame as the right-click
|
|
26
|
+
clickOutside(event) {
|
|
27
|
+
if (this._ignoreClickOutside) return
|
|
28
|
+
super.clickOutside(event)
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
show(event) {
|
|
24
32
|
event?.preventDefault()
|
|
25
33
|
|
|
34
|
+
// Cancel any pending hide timeout from a previous close
|
|
35
|
+
this.cancelHideTimeout()
|
|
36
|
+
|
|
26
37
|
// Store mouse position for positioning
|
|
27
38
|
this.mouseX = event?.clientX || 0
|
|
28
39
|
this.mouseY = event?.clientY || 0
|
|
29
40
|
|
|
30
41
|
this.openValue = true
|
|
31
42
|
|
|
43
|
+
// Lock scroll (only if not already locked)
|
|
44
|
+
if (document.body.style.overflow !== "hidden") {
|
|
45
|
+
this.originalOverflow = document.body.style.overflow
|
|
46
|
+
document.body.style.overflow = "hidden"
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
if (this.hasContentTarget) {
|
|
33
50
|
this.contentTarget.hidden = false
|
|
34
51
|
this.contentTarget.dataset.state = "open"
|
|
35
52
|
this.positionContent()
|
|
36
53
|
}
|
|
37
54
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
// Defer click outside detection to prevent immediate close from right-click
|
|
56
|
+
// The contextmenu event can sometimes trigger a click in the same event cycle
|
|
57
|
+
this._ignoreClickOutside = true
|
|
58
|
+
requestAnimationFrame(() => {
|
|
59
|
+
this._ignoreClickOutside = false
|
|
60
|
+
if (this.openValue) {
|
|
61
|
+
document.addEventListener("contextmenu", this.boundHandleContextMenu)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
41
64
|
document.addEventListener("keydown", this.boundHandleKeydown)
|
|
42
65
|
|
|
43
66
|
// Focus first item
|
|
@@ -52,151 +75,57 @@ export default class extends Controller {
|
|
|
52
75
|
|
|
53
76
|
this.openValue = false
|
|
54
77
|
|
|
78
|
+
// Remove event listeners immediately to prevent double-triggering
|
|
79
|
+
document.removeEventListener("contextmenu", this.boundHandleContextMenu)
|
|
80
|
+
document.removeEventListener("keydown", this.boundHandleKeydown)
|
|
81
|
+
|
|
55
82
|
if (this.hasContentTarget) {
|
|
56
83
|
this.contentTarget.dataset.state = "closed"
|
|
57
|
-
//
|
|
58
|
-
|
|
84
|
+
// Wait for animation to complete before hiding and restoring scroll
|
|
85
|
+
// Animation duration is 100ms, add buffer for smooth transition
|
|
86
|
+
this.hideTimeoutId = setTimeout(() => {
|
|
59
87
|
if (!this.openValue) {
|
|
60
88
|
this.contentTarget.hidden = true
|
|
89
|
+
// Restore scroll only after menu is fully hidden
|
|
90
|
+
document.body.style.overflow = this.originalOverflow || ""
|
|
61
91
|
}
|
|
62
|
-
|
|
92
|
+
this.hideTimeoutId = null
|
|
93
|
+
}, this.hideDelayValue)
|
|
94
|
+
} else {
|
|
95
|
+
// No content target, restore scroll immediately
|
|
96
|
+
document.body.style.overflow = this.originalOverflow || ""
|
|
63
97
|
}
|
|
64
98
|
|
|
65
|
-
// Remove event listeners
|
|
66
|
-
document.removeEventListener("click", this.boundHandleClickOutside)
|
|
67
|
-
document.removeEventListener("contextmenu", this.boundHandleClickOutside)
|
|
68
|
-
document.removeEventListener("keydown", this.boundHandleKeydown)
|
|
69
|
-
|
|
70
99
|
// Reset focus index
|
|
71
100
|
this.focusedIndex = -1
|
|
72
101
|
|
|
73
102
|
this.dispatch("closed")
|
|
74
103
|
}
|
|
75
104
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
selectItem(event) {
|
|
81
|
-
const item = event.currentTarget
|
|
82
|
-
if (item.dataset.disabled !== undefined) return
|
|
83
|
-
|
|
84
|
-
this.dispatch("select", { detail: { item } })
|
|
85
|
-
this.hide()
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
handleClickOutside(event) {
|
|
89
|
-
// Don't close if clicking inside the content
|
|
90
|
-
if (this.hasContentTarget && this.contentTarget.contains(event.target)) {
|
|
105
|
+
handleContextMenu(event) {
|
|
106
|
+
// Don't close if right-clicking on the trigger element
|
|
107
|
+
// This allows show() to be called again to reposition the menu
|
|
108
|
+
if (this.hasTriggerTarget && this.triggerTarget.contains(event.target)) {
|
|
91
109
|
return
|
|
92
110
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
handleKeydown(event) {
|
|
97
|
-
switch (event.key) {
|
|
98
|
-
case "Escape":
|
|
99
|
-
this.hide()
|
|
100
|
-
break
|
|
101
|
-
case "ArrowDown":
|
|
102
|
-
event.preventDefault()
|
|
103
|
-
this.focusNextItem()
|
|
104
|
-
break
|
|
105
|
-
case "ArrowUp":
|
|
106
|
-
event.preventDefault()
|
|
107
|
-
this.focusPreviousItem()
|
|
108
|
-
break
|
|
109
|
-
case "Home":
|
|
110
|
-
event.preventDefault()
|
|
111
|
-
this.focusFirstItem()
|
|
112
|
-
break
|
|
113
|
-
case "End":
|
|
114
|
-
event.preventDefault()
|
|
115
|
-
this.focusLastItem()
|
|
116
|
-
break
|
|
117
|
-
case "Enter":
|
|
118
|
-
case " ":
|
|
119
|
-
event.preventDefault()
|
|
120
|
-
this.selectFocusedItem()
|
|
121
|
-
break
|
|
111
|
+
// Close if right-clicking outside the content
|
|
112
|
+
if (this.hasContentTarget && !this.contentTarget.contains(event.target)) {
|
|
113
|
+
this.hide()
|
|
122
114
|
}
|
|
123
115
|
}
|
|
124
116
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
this.focusedIndex = (this.focusedIndex + 1) % items.length
|
|
130
|
-
items[this.focusedIndex].focus()
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
focusPreviousItem() {
|
|
134
|
-
const items = this.enabledItems
|
|
135
|
-
if (items.length === 0) return
|
|
136
|
-
|
|
137
|
-
this.focusedIndex = this.focusedIndex <= 0 ? items.length - 1 : this.focusedIndex - 1
|
|
138
|
-
items[this.focusedIndex].focus()
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
focusFirstItem() {
|
|
142
|
-
const items = this.enabledItems
|
|
143
|
-
if (items.length === 0) return
|
|
144
|
-
|
|
145
|
-
this.focusedIndex = 0
|
|
146
|
-
items[0].focus()
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
focusLastItem() {
|
|
150
|
-
const items = this.enabledItems
|
|
151
|
-
if (items.length === 0) return
|
|
152
|
-
|
|
153
|
-
this.focusedIndex = items.length - 1
|
|
154
|
-
items[this.focusedIndex].focus()
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
selectFocusedItem() {
|
|
158
|
-
const items = this.enabledItems
|
|
159
|
-
if (this.focusedIndex >= 0 && this.focusedIndex < items.length) {
|
|
160
|
-
items[this.focusedIndex].click()
|
|
117
|
+
shouldCloseOnClickOutside(event) {
|
|
118
|
+
// Don't close if clicking inside the content
|
|
119
|
+
if (this.hasContentTarget && this.contentTarget.contains(event.target)) {
|
|
120
|
+
return false
|
|
161
121
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
get enabledItems() {
|
|
165
|
-
return this.itemTargets.filter(item => item.dataset.disabled === undefined)
|
|
122
|
+
return true
|
|
166
123
|
}
|
|
167
124
|
|
|
168
125
|
positionContent() {
|
|
169
126
|
if (!this.hasContentTarget) return
|
|
170
127
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const viewportHeight = window.innerHeight
|
|
174
|
-
|
|
175
|
-
// Reset position to measure actual size
|
|
176
|
-
content.style.left = "0"
|
|
177
|
-
content.style.top = "0"
|
|
178
|
-
|
|
179
|
-
const contentRect = content.getBoundingClientRect()
|
|
180
|
-
|
|
181
|
-
// Calculate position, keeping menu within viewport
|
|
182
|
-
let x = this.mouseX
|
|
183
|
-
let y = this.mouseY
|
|
184
|
-
|
|
185
|
-
// Adjust if menu would overflow right edge
|
|
186
|
-
if (x + contentRect.width > viewportWidth) {
|
|
187
|
-
x = viewportWidth - contentRect.width - 8
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Adjust if menu would overflow bottom edge
|
|
191
|
-
if (y + contentRect.height > viewportHeight) {
|
|
192
|
-
y = viewportHeight - contentRect.height - 8
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Ensure menu doesn't go off left or top edge
|
|
196
|
-
x = Math.max(8, x)
|
|
197
|
-
y = Math.max(8, y)
|
|
198
|
-
|
|
199
|
-
content.style.left = `${x}px`
|
|
200
|
-
content.style.top = `${y}px`
|
|
128
|
+
// Use Floating UI for smart positioning at cursor location
|
|
129
|
+
positionAtPoint(this.contentTarget, this.mouseX, this.mouseY)
|
|
201
130
|
}
|
|
202
131
|
}
|