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.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -2
  3. data/README.md +102 -1398
  4. data/__mocks__/@floating-ui/dom.js +67 -0
  5. data/app/assets/javascripts/shadcn/controllers/base_menu_controller.js +266 -0
  6. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +34 -8
  7. data/app/assets/javascripts/shadcn/controllers/command_controller.js +5 -1
  8. data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +64 -135
  9. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +56 -186
  10. data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +29 -55
  11. data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +10 -7
  12. data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +10 -6
  13. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +35 -60
  14. data/app/assets/javascripts/shadcn/controllers/select_controller.js +37 -17
  15. data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +24 -14
  16. data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +28 -59
  17. data/app/assets/javascripts/shadcn/index.js +9 -1
  18. data/app/assets/javascripts/shadcn/utils/floating.js +179 -0
  19. data/app/assets/stylesheets/shadcn/base.css +32 -0
  20. data/app/assets/stylesheets/shadcn/components.css +12 -0
  21. data/app/components/shadcn/accordion_component.html.erb +8 -0
  22. data/app/components/shadcn/accordion_component.rb +6 -15
  23. data/app/components/shadcn/alert_component.html.erb +6 -0
  24. data/app/components/shadcn/alert_component.rb +0 -18
  25. data/app/components/shadcn/alert_dialog_component.html.erb +12 -0
  26. data/app/components/shadcn/alert_dialog_component.rb +7 -27
  27. data/app/components/shadcn/aspect_ratio_component.html.erb +7 -0
  28. data/app/components/shadcn/aspect_ratio_component.rb +4 -19
  29. data/app/components/shadcn/avatar_component.html.erb +20 -0
  30. data/app/components/shadcn/avatar_component.rb +8 -36
  31. data/app/components/shadcn/badge_component.html.erb +1 -0
  32. data/app/components/shadcn/badge_component.rb +0 -11
  33. data/app/components/shadcn/base_component.rb +15 -2
  34. data/app/components/shadcn/breadcrumb_component.html.erb +5 -0
  35. data/app/components/shadcn/breadcrumb_component.rb +6 -16
  36. data/app/components/shadcn/button_component.html.erb +18 -0
  37. data/app/components/shadcn/button_component.rb +1 -41
  38. data/app/components/shadcn/card_component.html.erb +8 -0
  39. data/app/components/shadcn/card_component.rb +2 -6
  40. data/app/components/shadcn/checkbox_component.html.erb +32 -0
  41. data/app/components/shadcn/checkbox_component.rb +4 -43
  42. data/app/components/shadcn/collapsible_component.html.erb +8 -0
  43. data/app/components/shadcn/collapsible_component.rb +6 -15
  44. data/app/components/shadcn/command_list_component.rb +29 -14
  45. data/app/components/shadcn/context_menu_checkbox_item_component.rb +76 -0
  46. data/app/components/shadcn/context_menu_component.html.erb +11 -0
  47. data/app/components/shadcn/context_menu_component.rb +6 -26
  48. data/app/components/shadcn/context_menu_content_component.rb +37 -14
  49. data/app/components/shadcn/context_menu_item_component.rb +3 -2
  50. data/app/components/shadcn/context_menu_radio_group_component.rb +42 -0
  51. data/app/components/shadcn/context_menu_radio_item_component.rb +76 -0
  52. data/app/components/shadcn/dialog_component.html.erb +14 -0
  53. data/app/components/shadcn/dialog_component.rb +8 -29
  54. data/app/components/shadcn/drawer_component.html.erb +12 -0
  55. data/app/components/shadcn/drawer_component.rb +7 -27
  56. data/app/components/shadcn/dropdown_menu_checkbox_item_component.rb +76 -0
  57. data/app/components/shadcn/dropdown_menu_component.html.erb +14 -0
  58. data/app/components/shadcn/dropdown_menu_component.rb +9 -29
  59. data/app/components/shadcn/dropdown_menu_content_component.rb +45 -16
  60. data/app/components/shadcn/dropdown_menu_radio_group_component.rb +42 -0
  61. data/app/components/shadcn/dropdown_menu_radio_item_component.rb +76 -0
  62. data/app/components/shadcn/field_component.rb +7 -8
  63. data/app/components/shadcn/hover_card_component.html.erb +12 -0
  64. data/app/components/shadcn/hover_card_component.rb +7 -26
  65. data/app/components/shadcn/input_component.html.erb +18 -0
  66. data/app/components/shadcn/input_component.rb +2 -27
  67. data/app/components/shadcn/input_otp_component.rb +3 -3
  68. data/app/components/shadcn/kbd_component.html.erb +1 -0
  69. data/app/components/shadcn/kbd_component.rb +3 -10
  70. data/app/components/shadcn/label_component.html.erb +3 -0
  71. data/app/components/shadcn/label_component.rb +2 -18
  72. data/app/components/shadcn/menubar_component.html.erb +6 -0
  73. data/app/components/shadcn/menubar_component.rb +4 -15
  74. data/app/components/shadcn/menubar_content_component.rb +45 -20
  75. data/app/components/shadcn/menubar_sub_content_component.rb +21 -8
  76. data/app/components/shadcn/native_select_component.html.erb +22 -0
  77. data/app/components/shadcn/native_select_component.rb +9 -39
  78. data/app/components/shadcn/navigation_menu_component.html.erb +6 -0
  79. data/app/components/shadcn/navigation_menu_component.rb +4 -15
  80. data/app/components/shadcn/pagination_component.html.erb +5 -0
  81. data/app/components/shadcn/pagination_component.rb +11 -15
  82. data/app/components/shadcn/popover_component.html.erb +15 -0
  83. data/app/components/shadcn/popover_component.rb +10 -30
  84. data/app/components/shadcn/progress_component.html.erb +13 -0
  85. data/app/components/shadcn/progress_component.rb +6 -26
  86. data/app/components/shadcn/radio_group_component.html.erb +8 -0
  87. data/app/components/shadcn/radio_group_component.rb +12 -26
  88. data/app/components/shadcn/radio_group_item_component.rb +32 -6
  89. data/app/components/shadcn/resizable_panel_group_component.rb +27 -16
  90. data/app/components/shadcn/scroll_area_component.html.erb +7 -0
  91. data/app/components/shadcn/scroll_area_component.rb +4 -16
  92. data/app/components/shadcn/select_component.html.erb +46 -0
  93. data/app/components/shadcn/select_component.rb +29 -86
  94. data/app/components/shadcn/separator_component.html.erb +5 -0
  95. data/app/components/shadcn/separator_component.rb +6 -14
  96. data/app/components/shadcn/sheet_component.html.erb +12 -0
  97. data/app/components/shadcn/sheet_component.rb +7 -27
  98. data/app/components/shadcn/sidebar_component.rb +2 -2
  99. data/app/components/shadcn/skeleton_component.html.erb +1 -0
  100. data/app/components/shadcn/skeleton_component.rb +4 -2
  101. data/app/components/shadcn/slider_component.html.erb +12 -0
  102. data/app/components/shadcn/slider_component.rb +2 -21
  103. data/app/components/shadcn/spinner_component.html.erb +18 -0
  104. data/app/components/shadcn/spinner_component.rb +2 -30
  105. data/app/components/shadcn/switch_component.html.erb +72 -0
  106. data/app/components/shadcn/switch_component.rb +4 -82
  107. data/app/components/shadcn/table_component.html.erb +9 -0
  108. data/app/components/shadcn/table_component.rb +2 -10
  109. data/app/components/shadcn/tabs_component.html.erb +8 -0
  110. data/app/components/shadcn/tabs_component.rb +4 -17
  111. data/app/components/shadcn/textarea_component.html.erb +13 -0
  112. data/app/components/shadcn/textarea_component.rb +6 -22
  113. data/app/components/shadcn/toast_component.html.erb +36 -0
  114. data/app/components/shadcn/toast_component.rb +6 -54
  115. data/app/components/shadcn/toggle_component.html.erb +12 -0
  116. data/app/components/shadcn/toggle_component.rb +6 -21
  117. data/app/components/shadcn/toggle_group_component.html.erb +14 -0
  118. data/app/components/shadcn/toggle_group_component.rb +6 -29
  119. data/app/components/shadcn/tooltip_component.html.erb +20 -0
  120. data/app/components/shadcn/tooltip_component.rb +13 -38
  121. data/lib/generators/shadcn/add/USAGE +24 -0
  122. data/lib/generators/shadcn/add/add_generator.rb +279 -0
  123. data/lib/generators/shadcn/install/USAGE +22 -0
  124. data/lib/generators/shadcn/install/install_generator.rb +8 -3
  125. data/lib/generators/shadcn/install/templates/initializer.rb.tt +7 -27
  126. data/lib/generators/shadcn/install/templates/shadcn.yml.tt +15 -31
  127. data/lib/shadcn/rails/version.rb +1 -1
  128. metadata +54 -42
  129. data/.dockerignore +0 -40
  130. data/CLAUDE.md +0 -463
  131. data/PROGRESS.md +0 -485
  132. data/Rakefile +0 -29
  133. data/__tests__/controllers/__snapshots__/calendar_controller.test.js.snap +0 -13
  134. data/__tests__/controllers/__snapshots__/popover_controller.test.js.snap +0 -46
  135. data/__tests__/controllers/__snapshots__/sheet_controller.test.js.snap +0 -111
  136. data/__tests__/controllers/__snapshots__/tabs_controller.test.js.snap +0 -27
  137. data/__tests__/controllers/accordion_controller.test.js +0 -904
  138. data/__tests__/controllers/calendar_controller.test.js +0 -1370
  139. data/__tests__/controllers/carousel_controller.test.js +0 -912
  140. data/__tests__/controllers/checkbox_controller.test.js +0 -454
  141. data/__tests__/controllers/collapsible_controller.test.js +0 -407
  142. data/__tests__/controllers/combobox_controller.test.js +0 -966
  143. data/__tests__/controllers/context_menu_controller.test.js +0 -627
  144. data/__tests__/controllers/date_picker_controller.test.js +0 -636
  145. data/__tests__/controllers/dialog_controller.test.js +0 -878
  146. data/__tests__/controllers/drawer_controller.test.js +0 -995
  147. data/__tests__/controllers/menubar_controller.test.js +0 -736
  148. data/__tests__/controllers/navigation_menu_controller.test.js +0 -598
  149. data/__tests__/controllers/popover_controller.test.js +0 -1007
  150. data/__tests__/controllers/radio_group_controller.test.js +0 -640
  151. data/__tests__/controllers/resizable_controller.test.js +0 -680
  152. data/__tests__/controllers/select_controller.test.js +0 -674
  153. data/__tests__/controllers/sheet_controller.test.js +0 -986
  154. data/__tests__/controllers/slider_controller.test.js +0 -1036
  155. data/__tests__/controllers/switch_controller.test.js +0 -424
  156. data/__tests__/controllers/tabs_controller.test.js +0 -907
  157. data/__tests__/controllers/toggle_group_controller.test.js +0 -839
  158. data/__tests__/controllers/tooltip_controller.test.js +0 -808
  159. data/__tests__/helpers/stimulus-test-helper.js +0 -203
  160. data/babel.config.cjs +0 -5
  161. data/bin/console +0 -11
  162. data/bin/setup +0 -8
  163. data/jest.config.js +0 -19
  164. data/jest.setup.js +0 -8
  165. data/lib/generators/shadcn/component/component_generator.rb +0 -188
  166. data/lib/generators/shadcn/theme/theme_generator.rb +0 -128
  167. data/package-lock.json +0 -7415
  168. data/package.json +0 -68
  169. data/rollup.config.js +0 -29
@@ -1,1007 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import PopoverController from "../../app/assets/javascripts/shadcn/controllers/popover_controller.js"
3
- import { setupController, cleanupController, click, wait, nextFrame, keydown, waitForEvent } from '../helpers/stimulus-test-helper.js'
4
-
5
- describe("PopoverController", () => {
6
- let application
7
- let element
8
- let controller
9
-
10
- const createPopoverHTML = (options = {}) => {
11
- const {
12
- open = false,
13
- side = "bottom",
14
- align = "center",
15
- modal = false,
16
- includeContent = true,
17
- includeTrigger = true
18
- } = options
19
-
20
- const openAttr = open ? 'data-shadcn--popover-open-value="true"' : ''
21
- const sideAttr = side !== "bottom" ? `data-shadcn--popover-side-value="${side}"` : ''
22
- const alignAttr = align !== "center" ? `data-shadcn--popover-align-value="${align}"` : ''
23
- const modalAttr = modal ? 'data-shadcn--popover-modal-value="true"' : ''
24
-
25
- const triggerHTML = includeTrigger ? `
26
- <button data-shadcn--popover-target="trigger" data-action="click->shadcn--popover#toggle">
27
- Open
28
- </button>
29
- ` : ''
30
-
31
- const contentHTML = includeContent ? `
32
- <div data-shadcn--popover-target="content" hidden>
33
- Popover content
34
- </div>
35
- ` : ''
36
-
37
- return `
38
- <div data-controller="shadcn--popover"
39
- ${openAttr}
40
- ${sideAttr}
41
- ${alignAttr}
42
- ${modalAttr}>
43
- ${triggerHTML}
44
- ${contentHTML}
45
- </div>
46
- `
47
- }
48
-
49
- beforeEach(async () => {
50
- application = Application.start()
51
- application.register("shadcn--popover", PopoverController)
52
- document.body.innerHTML = createPopoverHTML()
53
-
54
- await new Promise(resolve => requestAnimationFrame(resolve))
55
-
56
- element = document.querySelector('[data-controller="shadcn--popover"]')
57
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
58
- })
59
-
60
- afterEach(() => {
61
- if (application) {
62
- application.stop()
63
- }
64
- document.body.innerHTML = ""
65
- // Reset body styles
66
- document.body.style.pointerEvents = ""
67
- })
68
-
69
- describe("initialization", () => {
70
- test("connects successfully", () => {
71
- expect(controller).not.toBeNull()
72
- expect(controller).toBeDefined()
73
- })
74
-
75
- test("initializes with default values", () => {
76
- expect(controller.openValue).toBe(false)
77
- expect(controller.sideValue).toBe("bottom")
78
- expect(controller.alignValue).toBe("center")
79
- expect(controller.modalValue).toBe(false)
80
- })
81
-
82
- test("initializes with custom values", async () => {
83
- application.stop()
84
- document.body.innerHTML = createPopoverHTML({
85
- open: true,
86
- side: "top",
87
- align: "start",
88
- modal: true
89
- })
90
-
91
- application = Application.start()
92
- application.register("shadcn--popover", PopoverController)
93
-
94
- await new Promise(resolve => requestAnimationFrame(resolve))
95
-
96
- element = document.querySelector('[data-controller="shadcn--popover"]')
97
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
98
-
99
- expect(controller.openValue).toBe(true)
100
- expect(controller.sideValue).toBe("top")
101
- expect(controller.alignValue).toBe("start")
102
- expect(controller.modalValue).toBe(true)
103
- })
104
-
105
- test("respects open=true value on initialization", async () => {
106
- application.stop()
107
- document.body.innerHTML = createPopoverHTML({ open: true })
108
-
109
- application = Application.start()
110
- application.register("shadcn--popover", PopoverController)
111
-
112
- await new Promise(resolve => requestAnimationFrame(resolve))
113
-
114
- element = document.querySelector('[data-controller="shadcn--popover"]')
115
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
116
-
117
- // The openValue should be set to true from the data attribute
118
- expect(controller.openValue).toBe(true)
119
-
120
- // Note: Due to the guard clause in show(), when openValue is already true,
121
- // the connect() method calls show() but it returns early, so the content
122
- // state is not set. This is actual controller behavior.
123
- // To properly open on init, the value would need to be set after connect.
124
- })
125
-
126
- test("keeps popover hidden when initialized with open=false", () => {
127
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
128
- expect(content.hidden).toBe(true)
129
- expect(content.dataset.state).toBeUndefined()
130
- })
131
-
132
- test("handles missing trigger target gracefully", async () => {
133
- application.stop()
134
- document.body.innerHTML = createPopoverHTML({ includeTrigger: false })
135
-
136
- application = Application.start()
137
- application.register("shadcn--popover", PopoverController)
138
-
139
- await new Promise(resolve => requestAnimationFrame(resolve))
140
-
141
- element = document.querySelector('[data-controller="shadcn--popover"]')
142
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
143
-
144
- expect(controller).toBeDefined()
145
- expect(controller.hasTriggerTarget).toBe(false)
146
- })
147
-
148
- test("handles missing content target gracefully", async () => {
149
- application.stop()
150
- document.body.innerHTML = createPopoverHTML({ includeContent: false })
151
-
152
- application = Application.start()
153
- application.register("shadcn--popover", PopoverController)
154
-
155
- await new Promise(resolve => requestAnimationFrame(resolve))
156
-
157
- element = document.querySelector('[data-controller="shadcn--popover"]')
158
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
159
-
160
- expect(controller).toBeDefined()
161
- expect(controller.hasContentTarget).toBe(false)
162
- })
163
- })
164
-
165
- describe("toggle behavior", () => {
166
- test("toggle opens closed popover", () => {
167
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
168
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
169
-
170
- expect(controller.openValue).toBe(false)
171
-
172
- click(trigger)
173
-
174
- expect(controller.openValue).toBe(true)
175
- expect(content.hidden).toBe(false)
176
- expect(content.dataset.state).toBe("open")
177
- })
178
-
179
- test("toggle closes open popover", async () => {
180
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
181
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
182
-
183
- // Open first
184
- click(trigger)
185
- expect(controller.openValue).toBe(true)
186
-
187
- // Close
188
- click(trigger)
189
- expect(controller.openValue).toBe(false)
190
- expect(content.dataset.state).toBe("closed")
191
-
192
- // Content should be hidden after animation delay
193
- await wait(200)
194
- expect(content.hidden).toBe(true)
195
- })
196
-
197
- test("toggle prevents default event behavior", () => {
198
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
199
-
200
- let defaultPrevented = false
201
- const event = new MouseEvent('click', {
202
- bubbles: true,
203
- cancelable: true,
204
- view: window
205
- })
206
-
207
- const originalPreventDefault = event.preventDefault
208
- event.preventDefault = function() {
209
- defaultPrevented = true
210
- return originalPreventDefault.apply(this, arguments)
211
- }
212
-
213
- trigger.dispatchEvent(event)
214
-
215
- expect(defaultPrevented).toBe(true)
216
- })
217
-
218
- test("multiple toggles work correctly", async () => {
219
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
220
-
221
- click(trigger) // Open
222
- expect(controller.openValue).toBe(true)
223
-
224
- click(trigger) // Close
225
- expect(controller.openValue).toBe(false)
226
-
227
- await wait(200)
228
-
229
- click(trigger) // Open again
230
- expect(controller.openValue).toBe(true)
231
-
232
- click(trigger) // Close again
233
- expect(controller.openValue).toBe(false)
234
- })
235
- })
236
-
237
- describe("show method", () => {
238
- test("show opens popover", () => {
239
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
240
-
241
- controller.show()
242
-
243
- expect(controller.openValue).toBe(true)
244
- expect(content.hidden).toBe(false)
245
- expect(content.dataset.state).toBe("open")
246
- })
247
-
248
- test("show is idempotent when already open", () => {
249
- controller.show()
250
- expect(controller.openValue).toBe(true)
251
-
252
- controller.show()
253
- expect(controller.openValue).toBe(true)
254
- })
255
-
256
- test("show adds click outside listener", () => {
257
- let clickListenerAdded = false
258
- const originalAddEventListener = document.addEventListener
259
-
260
- document.addEventListener = function(event) {
261
- if (event === 'click') {
262
- clickListenerAdded = true
263
- }
264
- return originalAddEventListener.apply(this, arguments)
265
- }
266
-
267
- controller.show()
268
-
269
- expect(clickListenerAdded).toBe(true)
270
-
271
- document.addEventListener = originalAddEventListener
272
- })
273
-
274
- test("show dispatches opened event", async () => {
275
- const eventPromise = waitForEvent(element, "shadcn--popover:opened")
276
-
277
- controller.show()
278
-
279
- const event = await eventPromise
280
- expect(event).toBeDefined()
281
- })
282
-
283
- test("show sets side data attribute on content", () => {
284
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
285
-
286
- controller.show()
287
-
288
- expect(content.dataset.side).toBe("bottom")
289
- })
290
-
291
- test("show calls positionContent", () => {
292
- let positionContentCalled = false
293
- const originalPositionContent = controller.positionContent.bind(controller)
294
-
295
- controller.positionContent = function() {
296
- positionContentCalled = true
297
- return originalPositionContent()
298
- }
299
-
300
- controller.show()
301
-
302
- expect(positionContentCalled).toBe(true)
303
- })
304
- })
305
-
306
- describe("hide method", () => {
307
- test("hide closes open popover", async () => {
308
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
309
-
310
- controller.show()
311
- expect(controller.openValue).toBe(true)
312
-
313
- controller.hide()
314
-
315
- expect(controller.openValue).toBe(false)
316
- expect(content.dataset.state).toBe("closed")
317
-
318
- await wait(200)
319
- expect(content.hidden).toBe(true)
320
- })
321
-
322
- test("hide is idempotent when already closed", () => {
323
- expect(controller.openValue).toBe(false)
324
-
325
- controller.hide()
326
-
327
- expect(controller.openValue).toBe(false)
328
- })
329
-
330
- test("hide removes click outside listener", () => {
331
- let clickListenerRemoved = false
332
- const originalRemoveEventListener = document.removeEventListener
333
-
334
- document.removeEventListener = function(event) {
335
- if (event === 'click') {
336
- clickListenerRemoved = true
337
- }
338
- return originalRemoveEventListener.apply(this, arguments)
339
- }
340
-
341
- controller.show()
342
- controller.hide()
343
-
344
- expect(clickListenerRemoved).toBe(true)
345
-
346
- document.removeEventListener = originalRemoveEventListener
347
- })
348
-
349
- test("hide dispatches closed event", async () => {
350
- controller.show()
351
-
352
- const eventPromise = waitForEvent(element, "shadcn--popover:closed")
353
-
354
- controller.hide()
355
-
356
- const event = await eventPromise
357
- expect(event).toBeDefined()
358
- })
359
-
360
- test("hide delays hiding content for animation", async () => {
361
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
362
-
363
- controller.show()
364
- controller.hide()
365
-
366
- // Should be marked as closed but not hidden yet
367
- expect(content.dataset.state).toBe("closed")
368
- expect(content.hidden).toBe(false)
369
-
370
- // After delay, should be hidden
371
- await wait(200)
372
- expect(content.hidden).toBe(true)
373
- })
374
-
375
- test("hide animation is cancelled if reopened", async () => {
376
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
377
-
378
- controller.show()
379
- controller.hide()
380
-
381
- // Reopen before animation completes
382
- await wait(50)
383
- controller.show()
384
-
385
- await wait(150)
386
-
387
- // Should still be visible
388
- expect(content.hidden).toBe(false)
389
- expect(content.dataset.state).toBe("open")
390
- })
391
- })
392
-
393
- describe("close method", () => {
394
- test("close is an alias for hide", () => {
395
- controller.show()
396
- expect(controller.openValue).toBe(true)
397
-
398
- controller.close()
399
-
400
- expect(controller.openValue).toBe(false)
401
- })
402
- })
403
-
404
- describe("click outside behavior", () => {
405
- test("clicking outside closes popover", async () => {
406
- controller.show()
407
- expect(controller.openValue).toBe(true)
408
-
409
- // Click outside
410
- await nextFrame()
411
- click(document.body)
412
-
413
- expect(controller.openValue).toBe(false)
414
- })
415
-
416
- test("clicking inside popover does not close it", async () => {
417
- controller.show()
418
- expect(controller.openValue).toBe(true)
419
-
420
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
421
-
422
- await nextFrame()
423
- click(content)
424
-
425
- expect(controller.openValue).toBe(true)
426
- })
427
-
428
- test("clicking trigger does not trigger click outside", async () => {
429
- controller.show()
430
- expect(controller.openValue).toBe(true)
431
-
432
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
433
-
434
- await nextFrame()
435
- click(trigger)
436
-
437
- // Should toggle to closed via toggle action, not click outside
438
- expect(controller.openValue).toBe(false)
439
- })
440
-
441
- test("click outside listener is not added when closed", () => {
442
- let clickListenerAdded = false
443
- const originalAddEventListener = document.addEventListener
444
-
445
- document.addEventListener = function(event) {
446
- if (event === 'click') {
447
- clickListenerAdded = true
448
- }
449
- return originalAddEventListener.apply(this, arguments)
450
- }
451
-
452
- // Don't call show
453
- expect(clickListenerAdded).toBe(false)
454
-
455
- document.addEventListener = originalAddEventListener
456
- })
457
- })
458
-
459
- describe("modal behavior", () => {
460
- test("modal=true disables pointer events on body when open", async () => {
461
- application.stop()
462
- document.body.innerHTML = createPopoverHTML({ modal: true })
463
-
464
- application = Application.start()
465
- application.register("shadcn--popover", PopoverController)
466
-
467
- await new Promise(resolve => requestAnimationFrame(resolve))
468
-
469
- element = document.querySelector('[data-controller="shadcn--popover"]')
470
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
471
-
472
- controller.show()
473
-
474
- expect(document.body.style.pointerEvents).toBe("none")
475
- })
476
-
477
- test("modal=true enables pointer events on content when open", async () => {
478
- application.stop()
479
- document.body.innerHTML = createPopoverHTML({ modal: true })
480
-
481
- application = Application.start()
482
- application.register("shadcn--popover", PopoverController)
483
-
484
- await new Promise(resolve => requestAnimationFrame(resolve))
485
-
486
- element = document.querySelector('[data-controller="shadcn--popover"]')
487
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
488
-
489
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
490
-
491
- controller.show()
492
-
493
- expect(content.style.pointerEvents).toBe("auto")
494
- })
495
-
496
- test("modal=true restores pointer events on body when closed", async () => {
497
- application.stop()
498
- document.body.innerHTML = createPopoverHTML({ modal: true })
499
-
500
- application = Application.start()
501
- application.register("shadcn--popover", PopoverController)
502
-
503
- await new Promise(resolve => requestAnimationFrame(resolve))
504
-
505
- element = document.querySelector('[data-controller="shadcn--popover"]')
506
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
507
-
508
- controller.show()
509
- expect(document.body.style.pointerEvents).toBe("none")
510
-
511
- controller.hide()
512
- expect(document.body.style.pointerEvents).toBe("")
513
- })
514
-
515
- test("modal=false does not affect pointer events", () => {
516
- controller.show()
517
-
518
- expect(document.body.style.pointerEvents).toBe("")
519
- })
520
- })
521
-
522
- describe("positioning - side", () => {
523
- test("positions content on bottom by default", () => {
524
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
525
-
526
- controller.show()
527
-
528
- expect(content.style.position).toBe("absolute")
529
- expect(content.style.top).toBe("100%")
530
- expect(content.style.bottom).toBe("auto")
531
- expect(content.style.marginTop).toBe("8px")
532
- })
533
-
534
- test("positions content on top", async () => {
535
- application.stop()
536
- document.body.innerHTML = createPopoverHTML({ side: "top" })
537
-
538
- application = Application.start()
539
- application.register("shadcn--popover", PopoverController)
540
-
541
- await new Promise(resolve => requestAnimationFrame(resolve))
542
-
543
- element = document.querySelector('[data-controller="shadcn--popover"]')
544
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
545
-
546
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
547
-
548
- controller.show()
549
-
550
- expect(content.style.position).toBe("absolute")
551
- expect(content.style.bottom).toBe("100%")
552
- expect(content.style.top).toBe("auto")
553
- expect(content.style.marginBottom).toBe("8px")
554
- })
555
-
556
- test("positions content on left", async () => {
557
- application.stop()
558
- document.body.innerHTML = createPopoverHTML({ side: "left" })
559
-
560
- application = Application.start()
561
- application.register("shadcn--popover", PopoverController)
562
-
563
- await new Promise(resolve => requestAnimationFrame(resolve))
564
-
565
- element = document.querySelector('[data-controller="shadcn--popover"]')
566
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
567
-
568
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
569
-
570
- controller.show()
571
-
572
- expect(content.style.position).toBe("absolute")
573
- expect(content.style.right).toBe("100%")
574
- expect(content.style.left).toBe("auto")
575
- expect(content.style.marginRight).toBe("8px")
576
- })
577
-
578
- test("positions content on right", async () => {
579
- application.stop()
580
- document.body.innerHTML = createPopoverHTML({ side: "right" })
581
-
582
- application = Application.start()
583
- application.register("shadcn--popover", PopoverController)
584
-
585
- await new Promise(resolve => requestAnimationFrame(resolve))
586
-
587
- element = document.querySelector('[data-controller="shadcn--popover"]')
588
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
589
-
590
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
591
-
592
- controller.show()
593
-
594
- expect(content.style.position).toBe("absolute")
595
- expect(content.style.left).toBe("100%")
596
- expect(content.style.right).toBe("auto")
597
- expect(content.style.marginLeft).toBe("8px")
598
- })
599
-
600
- test("sets data-side attribute on content", async () => {
601
- application.stop()
602
- document.body.innerHTML = createPopoverHTML({ side: "right" })
603
-
604
- application = Application.start()
605
- application.register("shadcn--popover", PopoverController)
606
-
607
- await new Promise(resolve => requestAnimationFrame(resolve))
608
-
609
- element = document.querySelector('[data-controller="shadcn--popover"]')
610
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
611
-
612
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
613
-
614
- controller.show()
615
-
616
- expect(content.dataset.side).toBe("right")
617
- })
618
- })
619
-
620
- describe("positioning - align", () => {
621
- test("aligns content to center by default on bottom side", () => {
622
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
623
-
624
- controller.show()
625
-
626
- expect(content.style.left).toBe("50%")
627
- expect(content.style.transform).toBe("translateX(-50%)")
628
- })
629
-
630
- test("aligns content to start", async () => {
631
- application.stop()
632
- document.body.innerHTML = createPopoverHTML({ align: "start" })
633
-
634
- application = Application.start()
635
- application.register("shadcn--popover", PopoverController)
636
-
637
- await new Promise(resolve => requestAnimationFrame(resolve))
638
-
639
- element = document.querySelector('[data-controller="shadcn--popover"]')
640
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
641
-
642
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
643
-
644
- controller.show()
645
-
646
- expect(content.style.left).toBe("0px")
647
- expect(content.style.right).toBe("auto")
648
- })
649
-
650
- test("aligns content to end", async () => {
651
- application.stop()
652
- document.body.innerHTML = createPopoverHTML({ align: "end" })
653
-
654
- application = Application.start()
655
- application.register("shadcn--popover", PopoverController)
656
-
657
- await new Promise(resolve => requestAnimationFrame(resolve))
658
-
659
- element = document.querySelector('[data-controller="shadcn--popover"]')
660
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
661
-
662
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
663
-
664
- controller.show()
665
-
666
- expect(content.style.right).toBe("0px")
667
- expect(content.style.left).toBe("auto")
668
- })
669
-
670
- test("center alignment only applies transform on top/bottom sides", async () => {
671
- application.stop()
672
- document.body.innerHTML = createPopoverHTML({ side: "left", align: "center" })
673
-
674
- application = Application.start()
675
- application.register("shadcn--popover", PopoverController)
676
-
677
- await new Promise(resolve => requestAnimationFrame(resolve))
678
-
679
- element = document.querySelector('[data-controller="shadcn--popover"]')
680
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
681
-
682
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
683
-
684
- controller.show()
685
-
686
- // Should not have transform for left/right sides
687
- expect(content.style.transform).toBe("")
688
- })
689
- })
690
-
691
- describe("positionContent edge cases", () => {
692
- test("does not position if content target missing", async () => {
693
- application.stop()
694
- document.body.innerHTML = createPopoverHTML({ includeContent: false })
695
-
696
- application = Application.start()
697
- application.register("shadcn--popover", PopoverController)
698
-
699
- await new Promise(resolve => requestAnimationFrame(resolve))
700
-
701
- element = document.querySelector('[data-controller="shadcn--popover"]')
702
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
703
-
704
- // Should not throw
705
- expect(() => controller.positionContent()).not.toThrow()
706
- })
707
-
708
- test("does not position if trigger target missing", async () => {
709
- application.stop()
710
- document.body.innerHTML = createPopoverHTML({ includeTrigger: false })
711
-
712
- application = Application.start()
713
- application.register("shadcn--popover", PopoverController)
714
-
715
- await new Promise(resolve => requestAnimationFrame(resolve))
716
-
717
- element = document.querySelector('[data-controller="shadcn--popover"]')
718
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
719
-
720
- // Should not throw
721
- expect(() => controller.positionContent()).not.toThrow()
722
- })
723
- })
724
-
725
- describe("event dispatching", () => {
726
- test("dispatches opened event when opening", async () => {
727
- const eventPromise = waitForEvent(element, "shadcn--popover:opened")
728
-
729
- controller.show()
730
-
731
- const event = await eventPromise
732
- expect(event.type).toBe("shadcn--popover:opened")
733
- })
734
-
735
- test("dispatches closed event when closing", async () => {
736
- controller.show()
737
-
738
- const eventPromise = waitForEvent(element, "shadcn--popover:closed")
739
-
740
- controller.hide()
741
-
742
- const event = await eventPromise
743
- expect(event.type).toBe("shadcn--popover:closed")
744
- })
745
-
746
- test("opened event bubbles", async () => {
747
- let eventCaught = false
748
-
749
- document.body.addEventListener("shadcn--popover:opened", () => {
750
- eventCaught = true
751
- })
752
-
753
- controller.show()
754
-
755
- await nextFrame()
756
-
757
- expect(eventCaught).toBe(true)
758
- })
759
-
760
- test("closed event bubbles", async () => {
761
- controller.show()
762
-
763
- let eventCaught = false
764
-
765
- document.body.addEventListener("shadcn--popover:closed", () => {
766
- eventCaught = true
767
- })
768
-
769
- controller.hide()
770
-
771
- await nextFrame()
772
-
773
- expect(eventCaught).toBe(true)
774
- })
775
- })
776
-
777
- describe("disconnect cleanup", () => {
778
- test("closes popover on disconnect", () => {
779
- controller.show()
780
- expect(controller.openValue).toBe(true)
781
-
782
- controller.disconnect()
783
-
784
- expect(controller.openValue).toBe(false)
785
- })
786
-
787
- test("removes click outside listener on disconnect", () => {
788
- let clickListenerRemoved = false
789
- const originalRemoveEventListener = document.removeEventListener
790
-
791
- document.removeEventListener = function(event) {
792
- if (event === 'click') {
793
- clickListenerRemoved = true
794
- }
795
- return originalRemoveEventListener.apply(this, arguments)
796
- }
797
-
798
- controller.show()
799
- controller.disconnect()
800
-
801
- expect(clickListenerRemoved).toBe(true)
802
-
803
- document.removeEventListener = originalRemoveEventListener
804
- })
805
-
806
- test("restores body pointer events on disconnect when modal", async () => {
807
- application.stop()
808
- document.body.innerHTML = createPopoverHTML({ modal: true })
809
-
810
- application = Application.start()
811
- application.register("shadcn--popover", PopoverController)
812
-
813
- await new Promise(resolve => requestAnimationFrame(resolve))
814
-
815
- element = document.querySelector('[data-controller="shadcn--popover"]')
816
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
817
-
818
- controller.show()
819
- expect(document.body.style.pointerEvents).toBe("none")
820
-
821
- controller.disconnect()
822
-
823
- expect(document.body.style.pointerEvents).toBe("")
824
- })
825
- })
826
-
827
- describe("ARIA attributes", () => {
828
- test("trigger can have aria-haspopup", () => {
829
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
830
-
831
- // Set manually as this would typically be in the HTML
832
- trigger.setAttribute('aria-haspopup', 'dialog')
833
-
834
- expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
835
- })
836
-
837
- test("trigger can have aria-expanded", () => {
838
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
839
-
840
- // Set manually as this would typically be managed in the HTML/component
841
- trigger.setAttribute('aria-expanded', 'false')
842
- expect(trigger.getAttribute('aria-expanded')).toBe('false')
843
-
844
- controller.show()
845
- trigger.setAttribute('aria-expanded', 'true')
846
- expect(trigger.getAttribute('aria-expanded')).toBe('true')
847
- })
848
-
849
- test("content can have role dialog", () => {
850
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
851
-
852
- // Set manually as this would typically be in the HTML
853
- content.setAttribute('role', 'dialog')
854
-
855
- expect(content.getAttribute('role')).toBe('dialog')
856
- })
857
- })
858
-
859
- describe("edge cases", () => {
860
- test("rapid open/close transitions", async () => {
861
- controller.show()
862
- controller.hide()
863
- controller.show()
864
- controller.hide()
865
- controller.show()
866
-
867
- expect(controller.openValue).toBe(true)
868
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
869
- expect(content.hidden).toBe(false)
870
- })
871
-
872
- test("handles getBoundingClientRect on trigger", () => {
873
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
874
-
875
- let getBoundingClientRectCalled = false
876
-
877
- // Mock getBoundingClientRect
878
- const originalGetBoundingClientRect = trigger.getBoundingClientRect.bind(trigger)
879
- trigger.getBoundingClientRect = function() {
880
- getBoundingClientRectCalled = true
881
- return originalGetBoundingClientRect()
882
- }
883
-
884
- controller.show()
885
-
886
- expect(getBoundingClientRectCalled).toBe(true)
887
- })
888
-
889
- test("handles null event in toggle", () => {
890
- // Should not throw when called without event
891
- expect(() => controller.toggle()).not.toThrow()
892
- })
893
-
894
- test("handles undefined event in toggle", () => {
895
- expect(() => controller.toggle(undefined)).not.toThrow()
896
- })
897
- })
898
-
899
- describe("integration scenarios", () => {
900
- test("complete interaction flow: open, click outside, reopen", async () => {
901
- const trigger = element.querySelector('[data-shadcn--popover-target="trigger"]')
902
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
903
-
904
- // Open
905
- click(trigger)
906
- expect(controller.openValue).toBe(true)
907
- expect(content.hidden).toBe(false)
908
-
909
- // Click outside
910
- await nextFrame()
911
- click(document.body)
912
- expect(controller.openValue).toBe(false)
913
-
914
- await wait(200)
915
- expect(content.hidden).toBe(true)
916
-
917
- // Reopen
918
- click(trigger)
919
- expect(controller.openValue).toBe(true)
920
- expect(content.hidden).toBe(false)
921
- })
922
-
923
- test("modal popover prevents background interaction", async () => {
924
- application.stop()
925
- document.body.innerHTML = `
926
- <div>
927
- <button id="background-button">Background</button>
928
- ${createPopoverHTML({ modal: true })}
929
- </div>
930
- `
931
-
932
- application = Application.start()
933
- application.register("shadcn--popover", PopoverController)
934
-
935
- await new Promise(resolve => requestAnimationFrame(resolve))
936
-
937
- element = document.querySelector('[data-controller="shadcn--popover"]')
938
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--popover")
939
-
940
- controller.show()
941
-
942
- // Body should block pointer events
943
- expect(document.body.style.pointerEvents).toBe("none")
944
-
945
- // Content should allow pointer events
946
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
947
- expect(content.style.pointerEvents).toBe("auto")
948
- })
949
-
950
- test("changing side value while open repositions content", async () => {
951
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
952
-
953
- controller.show()
954
- expect(content.style.top).toBe("100%")
955
- expect(content.dataset.side).toBe("bottom")
956
-
957
- // Change side value
958
- controller.sideValue = "top"
959
- // Need to update data attribute manually since positionContent doesn't do it
960
- content.dataset.side = controller.sideValue
961
- controller.positionContent()
962
-
963
- expect(content.style.bottom).toBe("100%")
964
- expect(content.dataset.side).toBe("top")
965
- })
966
-
967
- test("changing align value while open repositions content", () => {
968
- const content = element.querySelector('[data-shadcn--popover-target="content"]')
969
-
970
- controller.show()
971
- expect(content.style.left).toBe("50%")
972
- expect(content.style.transform).toBe("translateX(-50%)")
973
-
974
- // Change align value
975
- controller.alignValue = "start"
976
- controller.positionContent()
977
-
978
- expect(content.style.left).toBe("0px")
979
- expect(content.style.right).toBe("auto")
980
- })
981
- })
982
-
983
- describe("snapshots", () => {
984
- test("renders closed popover correctly", () => {
985
- expect(element.innerHTML).toMatchSnapshot()
986
- })
987
-
988
- test("renders open popover correctly", () => {
989
- controller.show()
990
- expect(element.innerHTML).toMatchSnapshot()
991
- })
992
-
993
- test("renders modal popover correctly", async () => {
994
- application.stop()
995
- document.body.innerHTML = createPopoverHTML({ modal: true, open: true })
996
-
997
- application = Application.start()
998
- application.register("shadcn--popover", PopoverController)
999
-
1000
- await new Promise(resolve => requestAnimationFrame(resolve))
1001
-
1002
- element = document.querySelector('[data-controller="shadcn--popover"]')
1003
-
1004
- expect(element.innerHTML).toMatchSnapshot()
1005
- })
1006
- })
1007
- })