shadcn-rails 0.2.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -2
  3. data/README.md +21 -8
  4. data/__mocks__/@floating-ui/dom.js +67 -0
  5. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +23 -2
  6. data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +4 -31
  7. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +32 -41
  8. data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +29 -55
  9. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +29 -54
  10. data/app/assets/javascripts/shadcn/controllers/select_controller.js +26 -8
  11. data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +28 -59
  12. data/app/assets/javascripts/shadcn/index.js +7 -1
  13. data/app/assets/javascripts/shadcn/utils/floating.js +179 -0
  14. data/app/assets/stylesheets/shadcn/base.css +32 -0
  15. data/app/components/shadcn/accordion_component.html.erb +8 -0
  16. data/app/components/shadcn/accordion_component.rb +6 -15
  17. data/app/components/shadcn/alert_component.html.erb +6 -0
  18. data/app/components/shadcn/alert_component.rb +0 -18
  19. data/app/components/shadcn/alert_dialog_component.html.erb +12 -0
  20. data/app/components/shadcn/alert_dialog_component.rb +7 -27
  21. data/app/components/shadcn/aspect_ratio_component.html.erb +7 -0
  22. data/app/components/shadcn/aspect_ratio_component.rb +4 -19
  23. data/app/components/shadcn/avatar_component.html.erb +20 -0
  24. data/app/components/shadcn/avatar_component.rb +8 -36
  25. data/app/components/shadcn/badge_component.html.erb +1 -0
  26. data/app/components/shadcn/badge_component.rb +0 -11
  27. data/app/components/shadcn/base_component.rb +15 -2
  28. data/app/components/shadcn/breadcrumb_component.html.erb +5 -0
  29. data/app/components/shadcn/breadcrumb_component.rb +6 -16
  30. data/app/components/shadcn/button_component.html.erb +18 -0
  31. data/app/components/shadcn/button_component.rb +1 -41
  32. data/app/components/shadcn/card_component.html.erb +8 -0
  33. data/app/components/shadcn/card_component.rb +2 -6
  34. data/app/components/shadcn/checkbox_component.html.erb +32 -0
  35. data/app/components/shadcn/checkbox_component.rb +4 -43
  36. data/app/components/shadcn/collapsible_component.html.erb +8 -0
  37. data/app/components/shadcn/collapsible_component.rb +6 -15
  38. data/app/components/shadcn/context_menu_component.html.erb +11 -0
  39. data/app/components/shadcn/context_menu_component.rb +6 -26
  40. data/app/components/shadcn/dialog_component.html.erb +14 -0
  41. data/app/components/shadcn/dialog_component.rb +8 -29
  42. data/app/components/shadcn/drawer_component.html.erb +12 -0
  43. data/app/components/shadcn/drawer_component.rb +7 -27
  44. data/app/components/shadcn/dropdown_menu_component.html.erb +14 -0
  45. data/app/components/shadcn/dropdown_menu_component.rb +9 -29
  46. data/app/components/shadcn/field_component.rb +7 -8
  47. data/app/components/shadcn/hover_card_component.html.erb +12 -0
  48. data/app/components/shadcn/hover_card_component.rb +7 -26
  49. data/app/components/shadcn/input_component.html.erb +18 -0
  50. data/app/components/shadcn/input_component.rb +2 -27
  51. data/app/components/shadcn/input_otp_component.rb +3 -3
  52. data/app/components/shadcn/kbd_component.html.erb +1 -0
  53. data/app/components/shadcn/kbd_component.rb +3 -10
  54. data/app/components/shadcn/label_component.html.erb +3 -0
  55. data/app/components/shadcn/label_component.rb +2 -18
  56. data/app/components/shadcn/menubar_component.html.erb +6 -0
  57. data/app/components/shadcn/menubar_component.rb +4 -15
  58. data/app/components/shadcn/native_select_component.html.erb +22 -0
  59. data/app/components/shadcn/native_select_component.rb +9 -39
  60. data/app/components/shadcn/navigation_menu_component.html.erb +6 -0
  61. data/app/components/shadcn/navigation_menu_component.rb +4 -15
  62. data/app/components/shadcn/pagination_component.html.erb +5 -0
  63. data/app/components/shadcn/pagination_component.rb +11 -15
  64. data/app/components/shadcn/popover_component.html.erb +15 -0
  65. data/app/components/shadcn/popover_component.rb +10 -30
  66. data/app/components/shadcn/progress_component.html.erb +13 -0
  67. data/app/components/shadcn/progress_component.rb +6 -26
  68. data/app/components/shadcn/radio_group_component.html.erb +8 -0
  69. data/app/components/shadcn/radio_group_component.rb +12 -26
  70. data/app/components/shadcn/scroll_area_component.html.erb +7 -0
  71. data/app/components/shadcn/scroll_area_component.rb +4 -16
  72. data/app/components/shadcn/select_component.html.erb +46 -0
  73. data/app/components/shadcn/select_component.rb +6 -80
  74. data/app/components/shadcn/separator_component.html.erb +5 -0
  75. data/app/components/shadcn/separator_component.rb +6 -14
  76. data/app/components/shadcn/sheet_component.html.erb +12 -0
  77. data/app/components/shadcn/sheet_component.rb +7 -27
  78. data/app/components/shadcn/sidebar_component.rb +2 -2
  79. data/app/components/shadcn/skeleton_component.html.erb +1 -0
  80. data/app/components/shadcn/skeleton_component.rb +4 -2
  81. data/app/components/shadcn/slider_component.html.erb +12 -0
  82. data/app/components/shadcn/slider_component.rb +2 -21
  83. data/app/components/shadcn/spinner_component.html.erb +18 -0
  84. data/app/components/shadcn/spinner_component.rb +2 -30
  85. data/app/components/shadcn/switch_component.html.erb +72 -0
  86. data/app/components/shadcn/switch_component.rb +4 -82
  87. data/app/components/shadcn/table_component.html.erb +9 -0
  88. data/app/components/shadcn/table_component.rb +2 -10
  89. data/app/components/shadcn/tabs_component.html.erb +8 -0
  90. data/app/components/shadcn/tabs_component.rb +4 -17
  91. data/app/components/shadcn/textarea_component.html.erb +13 -0
  92. data/app/components/shadcn/textarea_component.rb +6 -22
  93. data/app/components/shadcn/toast_component.html.erb +36 -0
  94. data/app/components/shadcn/toast_component.rb +6 -54
  95. data/app/components/shadcn/toggle_component.html.erb +12 -0
  96. data/app/components/shadcn/toggle_component.rb +6 -21
  97. data/app/components/shadcn/toggle_group_component.html.erb +14 -0
  98. data/app/components/shadcn/toggle_group_component.rb +6 -29
  99. data/app/components/shadcn/tooltip_component.html.erb +20 -0
  100. data/app/components/shadcn/tooltip_component.rb +13 -38
  101. data/lib/generators/shadcn/add/USAGE +24 -0
  102. data/lib/generators/shadcn/add/add_generator.rb +279 -0
  103. data/lib/generators/shadcn/install/USAGE +22 -0
  104. data/lib/generators/shadcn/install/install_generator.rb +8 -3
  105. data/lib/generators/shadcn/install/templates/initializer.rb.tt +7 -27
  106. data/lib/generators/shadcn/install/templates/shadcn.yml.tt +15 -31
  107. data/lib/shadcn/rails/version.rb +1 -1
  108. metadata +47 -45
  109. data/.dockerignore +0 -40
  110. data/CLAUDE.md +0 -612
  111. data/PROGRESS.md +0 -495
  112. data/Rakefile +0 -95
  113. data/__tests__/controllers/__snapshots__/calendar_controller.test.js.snap +0 -13
  114. data/__tests__/controllers/__snapshots__/popover_controller.test.js.snap +0 -46
  115. data/__tests__/controllers/__snapshots__/sheet_controller.test.js.snap +0 -111
  116. data/__tests__/controllers/__snapshots__/tabs_controller.test.js.snap +0 -27
  117. data/__tests__/controllers/accordion_controller.test.js +0 -904
  118. data/__tests__/controllers/calendar_controller.test.js +0 -1370
  119. data/__tests__/controllers/carousel_controller.test.js +0 -912
  120. data/__tests__/controllers/checkbox_controller.test.js +0 -454
  121. data/__tests__/controllers/collapsible_controller.test.js +0 -407
  122. data/__tests__/controllers/combobox_controller.test.js +0 -971
  123. data/__tests__/controllers/context_menu_controller.test.js +0 -905
  124. data/__tests__/controllers/date_picker_controller.test.js +0 -636
  125. data/__tests__/controllers/dialog_controller.test.js +0 -878
  126. data/__tests__/controllers/drawer_controller.test.js +0 -995
  127. data/__tests__/controllers/menubar_controller.test.js +0 -737
  128. data/__tests__/controllers/navigation_menu_controller.test.js +0 -599
  129. data/__tests__/controllers/popover_controller.test.js +0 -982
  130. data/__tests__/controllers/radio_group_controller.test.js +0 -640
  131. data/__tests__/controllers/resizable_controller.test.js +0 -680
  132. data/__tests__/controllers/select_controller.test.js +0 -678
  133. data/__tests__/controllers/sheet_controller.test.js +0 -986
  134. data/__tests__/controllers/slider_controller.test.js +0 -1036
  135. data/__tests__/controllers/switch_controller.test.js +0 -424
  136. data/__tests__/controllers/tabs_controller.test.js +0 -907
  137. data/__tests__/controllers/toggle_group_controller.test.js +0 -839
  138. data/__tests__/controllers/tooltip_controller.test.js +0 -808
  139. data/__tests__/helpers/stimulus-test-helper.js +0 -203
  140. data/babel.config.cjs +0 -5
  141. data/bin/bump +0 -321
  142. data/bin/console +0 -11
  143. data/bin/release +0 -205
  144. data/bin/setup +0 -8
  145. data/bin/test +0 -75
  146. data/jest.config.js +0 -19
  147. data/jest.setup.js +0 -8
  148. data/lib/generators/shadcn/component/component_generator.rb +0 -188
  149. data/lib/generators/shadcn/theme/theme_generator.rb +0 -128
  150. data/package-lock.json +0 -7438
  151. data/package.json +0 -71
  152. data/rollup.config.js +0 -29
@@ -1,905 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import ContextMenuController from "../../app/assets/javascripts/shadcn/controllers/context_menu_controller.js"
3
- import { setupController, cleanupController, click, nextFrame, wait } from '../helpers/stimulus-test-helper.js'
4
-
5
- describe("ContextMenuController", () => {
6
- let application
7
- let element
8
- let controller
9
-
10
- afterEach(() => {
11
- cleanupController(application)
12
- })
13
-
14
- describe("basic rendering and initialization", () => {
15
- const basicHTML = `
16
- <div data-controller="shadcn--context-menu"
17
- data-shadcn--context-menu-open-value="false">
18
- <div data-shadcn--context-menu-target="trigger"
19
- data-action="contextmenu->shadcn--context-menu#show">
20
- Right click here
21
- </div>
22
- <div data-shadcn--context-menu-target="content" hidden>
23
- <button data-shadcn--context-menu-target="item"
24
- data-action="click->shadcn--context-menu#selectItem">Item 1</button>
25
- <button data-shadcn--context-menu-target="item"
26
- data-action="click->shadcn--context-menu#selectItem">Item 2</button>
27
- </div>
28
- </div>
29
- `
30
-
31
- beforeEach(async () => {
32
- const setup = await setupController(ContextMenuController, basicHTML, 'shadcn--context-menu')
33
- application = setup.application
34
- element = setup.element
35
- controller = setup.controller
36
- })
37
-
38
- test("initializes with closed state", () => {
39
- expect(controller.openValue).toBe(false)
40
- })
41
-
42
- test("initializes focusedIndex to -1", () => {
43
- expect(controller.focusedIndex).toBe(-1)
44
- })
45
-
46
- test("content is hidden initially", () => {
47
- expect(controller.contentTarget.hidden).toBe(true)
48
- })
49
-
50
- test("has trigger target", () => {
51
- expect(controller.hasTriggerTarget).toBe(true)
52
- })
53
-
54
- test("has content target", () => {
55
- expect(controller.hasContentTarget).toBe(true)
56
- })
57
-
58
- test("has item targets", () => {
59
- expect(controller.itemTargets.length).toBe(2)
60
- })
61
- })
62
-
63
- describe("show functionality", () => {
64
- const showHTML = `
65
- <div data-controller="shadcn--context-menu"
66
- data-shadcn--context-menu-open-value="false">
67
- <div data-shadcn--context-menu-target="trigger"
68
- data-action="contextmenu->shadcn--context-menu#show">
69
- Right click here
70
- </div>
71
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
72
- <button data-shadcn--context-menu-target="item"
73
- data-action="click->shadcn--context-menu#selectItem">Item 1</button>
74
- <button data-shadcn--context-menu-target="item"
75
- data-action="click->shadcn--context-menu#selectItem">Item 2</button>
76
- </div>
77
- </div>
78
- `
79
-
80
- beforeEach(async () => {
81
- const setup = await setupController(ContextMenuController, showHTML, 'shadcn--context-menu')
82
- application = setup.application
83
- element = setup.element
84
- controller = setup.controller
85
- })
86
-
87
- test("sets openValue to true", async () => {
88
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
89
- controller.show(event)
90
- await nextFrame()
91
-
92
- expect(controller.openValue).toBe(true)
93
- })
94
-
95
- test("prevents default on event", async () => {
96
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
97
- controller.show(event)
98
-
99
- expect(event.preventDefault).toHaveBeenCalled()
100
- })
101
-
102
- test("stores mouse position", async () => {
103
- const event = { preventDefault: jest.fn(), clientX: 150, clientY: 200 }
104
- controller.show(event)
105
-
106
- expect(controller.mouseX).toBe(150)
107
- expect(controller.mouseY).toBe(200)
108
- })
109
-
110
- test("shows content", async () => {
111
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
112
- controller.show(event)
113
- await nextFrame()
114
-
115
- expect(controller.contentTarget.hidden).toBe(false)
116
- })
117
-
118
- test("sets content data-state to open", async () => {
119
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
120
- controller.show(event)
121
- await nextFrame()
122
-
123
- expect(controller.contentTarget.dataset.state).toBe("open")
124
- })
125
-
126
- test("dispatches opened event", async () => {
127
- let eventFired = false
128
- element.addEventListener("shadcn--context-menu:opened", () => {
129
- eventFired = true
130
- })
131
-
132
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
133
- controller.show(event)
134
- await nextFrame()
135
-
136
- expect(eventFired).toBe(true)
137
- })
138
-
139
- test("focuses first item on show", async () => {
140
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
141
- controller.show(event)
142
- await nextFrame()
143
-
144
- expect(controller.focusedIndex).toBe(0)
145
- })
146
- })
147
-
148
- describe("hide functionality", () => {
149
- const hideHTML = `
150
- <div data-controller="shadcn--context-menu"
151
- data-shadcn--context-menu-open-value="false">
152
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
153
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
154
- <button data-shadcn--context-menu-target="item">Item 1</button>
155
- </div>
156
- </div>
157
- `
158
-
159
- beforeEach(async () => {
160
- const setup = await setupController(ContextMenuController, hideHTML, 'shadcn--context-menu')
161
- application = setup.application
162
- element = setup.element
163
- controller = setup.controller
164
- })
165
-
166
- test("sets openValue to false", async () => {
167
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
168
- controller.show(event)
169
- await nextFrame()
170
-
171
- controller.hide()
172
- await nextFrame()
173
-
174
- expect(controller.openValue).toBe(false)
175
- })
176
-
177
- test("sets content data-state to closed", async () => {
178
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
179
- controller.show(event)
180
- await nextFrame()
181
-
182
- controller.hide()
183
- await nextFrame()
184
-
185
- expect(controller.contentTarget.dataset.state).toBe("closed")
186
- })
187
-
188
- test("dispatches closed event", async () => {
189
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
190
- controller.show(event)
191
- await nextFrame()
192
-
193
- let eventFired = false
194
- element.addEventListener("shadcn--context-menu:closed", () => {
195
- eventFired = true
196
- })
197
-
198
- controller.hide()
199
- await nextFrame()
200
-
201
- expect(eventFired).toBe(true)
202
- })
203
-
204
- test("resets focusedIndex to -1", async () => {
205
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
206
- controller.show(event)
207
- await nextFrame()
208
-
209
- controller.hide()
210
- await nextFrame()
211
-
212
- expect(controller.focusedIndex).toBe(-1)
213
- })
214
-
215
- test("does nothing if already closed", async () => {
216
- let eventFired = false
217
- element.addEventListener("shadcn--context-menu:closed", () => {
218
- eventFired = true
219
- })
220
-
221
- controller.hide()
222
- await nextFrame()
223
-
224
- expect(eventFired).toBe(false)
225
- })
226
-
227
- test("close() is an alias for hide()", async () => {
228
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
229
- controller.show(event)
230
- await nextFrame()
231
-
232
- controller.close()
233
- await nextFrame()
234
-
235
- expect(controller.openValue).toBe(false)
236
- })
237
- })
238
-
239
- describe("item selection", () => {
240
- const selectHTML = `
241
- <div data-controller="shadcn--context-menu"
242
- data-shadcn--context-menu-open-value="false">
243
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
244
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
245
- <button data-shadcn--context-menu-target="item"
246
- data-action="click->shadcn--context-menu#selectItem">Item 1</button>
247
- <button data-shadcn--context-menu-target="item"
248
- data-action="click->shadcn--context-menu#selectItem"
249
- data-disabled>Disabled Item</button>
250
- <button data-shadcn--context-menu-target="item"
251
- data-action="click->shadcn--context-menu#selectItem">Item 3</button>
252
- </div>
253
- </div>
254
- `
255
-
256
- beforeEach(async () => {
257
- const setup = await setupController(ContextMenuController, selectHTML, 'shadcn--context-menu')
258
- application = setup.application
259
- element = setup.element
260
- controller = setup.controller
261
- })
262
-
263
- test("dispatches select event with item", async () => {
264
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
265
- controller.show(event)
266
- await nextFrame()
267
-
268
- let selectedItem = null
269
- element.addEventListener("shadcn--context-menu:select", (e) => {
270
- selectedItem = e.detail.item
271
- })
272
-
273
- const item = controller.itemTargets[0]
274
- controller.selectItem({ currentTarget: item })
275
- await nextFrame()
276
-
277
- expect(selectedItem).toBe(item)
278
- })
279
-
280
- test("closes menu after selection", async () => {
281
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
282
- controller.show(event)
283
- await nextFrame()
284
-
285
- const item = controller.itemTargets[0]
286
- controller.selectItem({ currentTarget: item })
287
- await nextFrame()
288
-
289
- expect(controller.openValue).toBe(false)
290
- })
291
-
292
- test("does not select disabled items", async () => {
293
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
294
- controller.show(event)
295
- await nextFrame()
296
-
297
- let selectFired = false
298
- element.addEventListener("shadcn--context-menu:select", () => {
299
- selectFired = true
300
- })
301
-
302
- const disabledItem = controller.itemTargets[1]
303
- controller.selectItem({ currentTarget: disabledItem })
304
- await nextFrame()
305
-
306
- expect(selectFired).toBe(false)
307
- })
308
-
309
- test("enabled items getter filters disabled items", () => {
310
- const enabledItems = controller.enabledItems
311
- expect(enabledItems.length).toBe(2)
312
- })
313
- })
314
-
315
- describe("keyboard navigation", () => {
316
- const keyboardHTML = `
317
- <div data-controller="shadcn--context-menu"
318
- data-shadcn--context-menu-open-value="false">
319
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
320
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
321
- <button data-shadcn--context-menu-target="item">Item 1</button>
322
- <button data-shadcn--context-menu-target="item" data-disabled>Disabled</button>
323
- <button data-shadcn--context-menu-target="item">Item 3</button>
324
- <button data-shadcn--context-menu-target="item">Item 4</button>
325
- </div>
326
- </div>
327
- `
328
-
329
- beforeEach(async () => {
330
- const setup = await setupController(ContextMenuController, keyboardHTML, 'shadcn--context-menu')
331
- application = setup.application
332
- element = setup.element
333
- controller = setup.controller
334
-
335
- // Open the menu first
336
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
337
- controller.show(event)
338
- await nextFrame()
339
- })
340
-
341
- test("ArrowDown moves to next item", async () => {
342
- // Already at first item (index 0) from show()
343
- controller.handleKeydown({ key: "ArrowDown", preventDefault: jest.fn() })
344
- await nextFrame()
345
-
346
- expect(controller.focusedIndex).toBe(1)
347
- })
348
-
349
- test("ArrowDown wraps to first item", async () => {
350
- // Move to last enabled item
351
- controller.focusedIndex = 2 // Last enabled item (index 2 in enabledItems)
352
- controller.handleKeydown({ key: "ArrowDown", preventDefault: jest.fn() })
353
- await nextFrame()
354
-
355
- expect(controller.focusedIndex).toBe(0)
356
- })
357
-
358
- test("ArrowUp moves to previous item", async () => {
359
- controller.focusedIndex = 1
360
- controller.handleKeydown({ key: "ArrowUp", preventDefault: jest.fn() })
361
- await nextFrame()
362
-
363
- expect(controller.focusedIndex).toBe(0)
364
- })
365
-
366
- test("ArrowUp wraps to last item from first", async () => {
367
- controller.focusedIndex = 0
368
- controller.handleKeydown({ key: "ArrowUp", preventDefault: jest.fn() })
369
- await nextFrame()
370
-
371
- expect(controller.focusedIndex).toBe(2) // Last enabled item
372
- })
373
-
374
- test("Home moves to first item", async () => {
375
- controller.focusedIndex = 2
376
- controller.handleKeydown({ key: "Home", preventDefault: jest.fn() })
377
- await nextFrame()
378
-
379
- expect(controller.focusedIndex).toBe(0)
380
- })
381
-
382
- test("End moves to last item", async () => {
383
- controller.focusedIndex = 0
384
- controller.handleKeydown({ key: "End", preventDefault: jest.fn() })
385
- await nextFrame()
386
-
387
- expect(controller.focusedIndex).toBe(2) // Last enabled item
388
- })
389
-
390
- test("Escape closes the menu", async () => {
391
- controller.handleKeydown({ key: "Escape", preventDefault: jest.fn() })
392
- await nextFrame()
393
-
394
- expect(controller.openValue).toBe(false)
395
- })
396
-
397
- test("Enter triggers click on focused item", async () => {
398
- const enabledItems = controller.enabledItems
399
- const clickSpy = jest.spyOn(enabledItems[0], 'click')
400
-
401
- controller.focusedIndex = 0
402
- controller.handleKeydown({ key: "Enter", preventDefault: jest.fn() })
403
- await nextFrame()
404
-
405
- expect(clickSpy).toHaveBeenCalled()
406
- })
407
-
408
- test("Space triggers click on focused item", async () => {
409
- const enabledItems = controller.enabledItems
410
- const clickSpy = jest.spyOn(enabledItems[0], 'click')
411
-
412
- controller.focusedIndex = 0
413
- controller.handleKeydown({ key: " ", preventDefault: jest.fn() })
414
- await nextFrame()
415
-
416
- expect(clickSpy).toHaveBeenCalled()
417
- })
418
-
419
- test("prevents default on navigation keys", () => {
420
- const preventDefault = jest.fn()
421
-
422
- controller.handleKeydown({ key: "ArrowDown", preventDefault })
423
- expect(preventDefault).toHaveBeenCalled()
424
-
425
- preventDefault.mockClear()
426
- controller.handleKeydown({ key: "ArrowUp", preventDefault })
427
- expect(preventDefault).toHaveBeenCalled()
428
-
429
- preventDefault.mockClear()
430
- controller.handleKeydown({ key: "Home", preventDefault })
431
- expect(preventDefault).toHaveBeenCalled()
432
-
433
- preventDefault.mockClear()
434
- controller.handleKeydown({ key: "End", preventDefault })
435
- expect(preventDefault).toHaveBeenCalled()
436
- })
437
- })
438
-
439
- describe("click outside handling", () => {
440
- const clickOutsideHTML = `
441
- <div data-controller="shadcn--context-menu"
442
- data-shadcn--context-menu-open-value="false">
443
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
444
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
445
- <button data-shadcn--context-menu-target="item">Item 1</button>
446
- </div>
447
- </div>
448
- `
449
-
450
- beforeEach(async () => {
451
- const setup = await setupController(ContextMenuController, clickOutsideHTML, 'shadcn--context-menu')
452
- application = setup.application
453
- element = setup.element
454
- controller = setup.controller
455
- })
456
-
457
- test("closes on click outside", async () => {
458
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
459
- controller.show(event)
460
- await nextFrame()
461
-
462
- // Simulate click outside
463
- const outsideElement = document.createElement("div")
464
- document.body.appendChild(outsideElement)
465
- controller.clickOutside({ target: outsideElement })
466
- await nextFrame()
467
-
468
- expect(controller.openValue).toBe(false)
469
-
470
- document.body.removeChild(outsideElement)
471
- })
472
-
473
- test("does not close on click inside content", async () => {
474
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
475
- controller.show(event)
476
- await nextFrame()
477
-
478
- // Simulate click inside content
479
- controller.clickOutside({ target: controller.contentTarget })
480
- await nextFrame()
481
-
482
- expect(controller.openValue).toBe(true)
483
- })
484
- })
485
-
486
- describe("positioning", () => {
487
- const positionHTML = `
488
- <div data-controller="shadcn--context-menu"
489
- data-shadcn--context-menu-open-value="false">
490
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
491
- <div data-shadcn--context-menu-target="content" hidden
492
- style="position: fixed; width: 200px; height: 150px;">
493
- <button data-shadcn--context-menu-target="item">Item 1</button>
494
- </div>
495
- </div>
496
- `
497
-
498
- beforeEach(async () => {
499
- const setup = await setupController(ContextMenuController, positionHTML, 'shadcn--context-menu')
500
- application = setup.application
501
- element = setup.element
502
- controller = setup.controller
503
- })
504
-
505
- test("positions content at mouse location", async () => {
506
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 150 }
507
- controller.show(event)
508
- await nextFrame()
509
-
510
- const content = controller.contentTarget
511
- expect(content.style.left).toBe("100px")
512
- expect(content.style.top).toBe("150px")
513
- })
514
-
515
- test("positions content with minimum offset from edges", async () => {
516
- const event = { preventDefault: jest.fn(), clientX: 5, clientY: 5 }
517
- controller.show(event)
518
- await nextFrame()
519
-
520
- const content = controller.contentTarget
521
- // Should be at least 8px from edge
522
- expect(parseInt(content.style.left)).toBeGreaterThanOrEqual(8)
523
- expect(parseInt(content.style.top)).toBeGreaterThanOrEqual(8)
524
- })
525
- })
526
-
527
- describe("disconnect cleanup", () => {
528
- const disconnectHTML = `
529
- <div data-controller="shadcn--context-menu"
530
- data-shadcn--context-menu-open-value="false">
531
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
532
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
533
- <button data-shadcn--context-menu-target="item">Item 1</button>
534
- </div>
535
- </div>
536
- `
537
-
538
- beforeEach(async () => {
539
- const setup = await setupController(ContextMenuController, disconnectHTML, 'shadcn--context-menu')
540
- application = setup.application
541
- element = setup.element
542
- controller = setup.controller
543
- })
544
-
545
- test("hides menu on disconnect", async () => {
546
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
547
- controller.show(event)
548
- await nextFrame()
549
-
550
- controller.disconnect()
551
- await nextFrame()
552
-
553
- expect(controller.openValue).toBe(false)
554
- })
555
- })
556
-
557
- describe("without items", () => {
558
- const noItemsHTML = `
559
- <div data-controller="shadcn--context-menu"
560
- data-shadcn--context-menu-open-value="false">
561
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
562
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
563
- <p>No items here</p>
564
- </div>
565
- </div>
566
- `
567
-
568
- beforeEach(async () => {
569
- const setup = await setupController(ContextMenuController, noItemsHTML, 'shadcn--context-menu')
570
- application = setup.application
571
- element = setup.element
572
- controller = setup.controller
573
- })
574
-
575
- test("handles empty items gracefully", async () => {
576
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
577
-
578
- expect(() => {
579
- controller.show(event)
580
- }).not.toThrow()
581
-
582
- expect(controller.openValue).toBe(true)
583
- })
584
-
585
- test("navigation does nothing with no items", async () => {
586
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
587
- controller.show(event)
588
- await nextFrame()
589
-
590
- expect(() => {
591
- controller.focusNextItem()
592
- controller.focusPreviousItem()
593
- controller.focusFirstItem()
594
- controller.focusLastItem()
595
- }).not.toThrow()
596
- })
597
- })
598
-
599
- describe("show without event", () => {
600
- const noEventHTML = `
601
- <div data-controller="shadcn--context-menu"
602
- data-shadcn--context-menu-open-value="false">
603
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
604
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
605
- <button data-shadcn--context-menu-target="item">Item 1</button>
606
- </div>
607
- </div>
608
- `
609
-
610
- beforeEach(async () => {
611
- const setup = await setupController(ContextMenuController, noEventHTML, 'shadcn--context-menu')
612
- application = setup.application
613
- element = setup.element
614
- controller = setup.controller
615
- })
616
-
617
- test("handles show called without event", async () => {
618
- expect(() => {
619
- controller.show()
620
- }).not.toThrow()
621
-
622
- expect(controller.openValue).toBe(true)
623
- expect(controller.mouseX).toBe(0)
624
- expect(controller.mouseY).toBe(0)
625
- })
626
- })
627
-
628
- describe("scroll lock behavior", () => {
629
- const scrollLockHTML = `
630
- <div data-controller="shadcn--context-menu"
631
- data-shadcn--context-menu-open-value="false">
632
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
633
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
634
- <button data-shadcn--context-menu-target="item">Item 1</button>
635
- </div>
636
- </div>
637
- `
638
-
639
- beforeEach(async () => {
640
- const setup = await setupController(ContextMenuController, scrollLockHTML, 'shadcn--context-menu')
641
- application = setup.application
642
- element = setup.element
643
- controller = setup.controller
644
- // Reset body overflow before each test
645
- document.body.style.overflow = ""
646
- })
647
-
648
- afterEach(() => {
649
- // Clean up body overflow after each test
650
- document.body.style.overflow = ""
651
- })
652
-
653
- test("locks body scroll when menu opens", async () => {
654
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
655
- controller.show(event)
656
- await nextFrame()
657
-
658
- expect(document.body.style.overflow).toBe("hidden")
659
- })
660
-
661
- test("stores original overflow value", async () => {
662
- document.body.style.overflow = "auto"
663
-
664
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
665
- controller.show(event)
666
- await nextFrame()
667
-
668
- expect(controller.originalOverflow).toBe("auto")
669
- })
670
-
671
- test("restores original overflow after hide animation", async () => {
672
- document.body.style.overflow = "auto"
673
-
674
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
675
- controller.show(event)
676
- await nextFrame()
677
-
678
- controller.hide()
679
- // Wait for animation timeout (100ms + buffer)
680
- await wait(150)
681
-
682
- expect(document.body.style.overflow).toBe("auto")
683
- })
684
-
685
- test("does not lock scroll if already locked", async () => {
686
- document.body.style.overflow = "hidden"
687
-
688
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
689
- controller.show(event)
690
- await nextFrame()
691
-
692
- // originalOverflow should be null because it was already hidden
693
- expect(controller.originalOverflow).toBe(null)
694
- })
695
- })
696
-
697
- describe("double right-click handling", () => {
698
- const doubleClickHTML = `
699
- <div data-controller="shadcn--context-menu"
700
- data-shadcn--context-menu-open-value="false">
701
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
702
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
703
- <button data-shadcn--context-menu-target="item">Item 1</button>
704
- </div>
705
- </div>
706
- `
707
-
708
- beforeEach(async () => {
709
- const setup = await setupController(ContextMenuController, doubleClickHTML, 'shadcn--context-menu')
710
- application = setup.application
711
- element = setup.element
712
- controller = setup.controller
713
- document.body.style.overflow = ""
714
- })
715
-
716
- afterEach(() => {
717
- document.body.style.overflow = ""
718
- if (controller.hideTimeoutId) {
719
- clearTimeout(controller.hideTimeoutId)
720
- }
721
- })
722
-
723
- test("calling show() while menu is already open repositions instead of closing", async () => {
724
- // First right-click to open menu
725
- const event1 = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
726
- controller.show(event1)
727
- await nextFrame()
728
-
729
- expect(controller.openValue).toBe(true)
730
- expect(controller.mouseX).toBe(100)
731
- expect(controller.mouseY).toBe(100)
732
-
733
- // Second right-click at different position while menu is open
734
- // This simulates what happens when the contextmenu event is triggered again
735
- const event2 = { preventDefault: jest.fn(), clientX: 250, clientY: 300 }
736
- controller.show(event2)
737
- await nextFrame()
738
-
739
- // Menu should still be open at the NEW position
740
- expect(controller.openValue).toBe(true)
741
- expect(controller.mouseX).toBe(250)
742
- expect(controller.mouseY).toBe(300)
743
- expect(controller.contentTarget.hidden).toBe(false)
744
- expect(controller.contentTarget.dataset.state).toBe("open")
745
- })
746
-
747
- test("handleContextMenu should NOT close menu when contextmenu event triggers on trigger element", async () => {
748
- // Open the menu first
749
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
750
- controller.show(event)
751
- await nextFrame()
752
- await nextFrame() // Extra frame to ensure event listeners are attached
753
-
754
- expect(controller.openValue).toBe(true)
755
-
756
- // Simulate a contextmenu event on the trigger element
757
- // This is what happens when the user right-clicks again on the trigger
758
- // In the refactored code, contextmenu events are handled by handleContextMenu, not handleClickOutside
759
- controller.handleContextMenu({ type: "contextmenu", target: controller.triggerTarget })
760
- await nextFrame()
761
-
762
- // Menu should still be open because it was a contextmenu event on the trigger
763
- expect(controller.openValue).toBe(true)
764
- })
765
-
766
- test("clickOutside SHOULD close menu when regular click triggers on trigger element", async () => {
767
- // Open the menu first
768
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
769
- controller.show(event)
770
- await nextFrame()
771
- await nextFrame() // Extra frame to ensure event listeners are attached
772
-
773
- expect(controller.openValue).toBe(true)
774
-
775
- // Simulate a regular click event on the trigger element
776
- controller.clickOutside({ type: "click", target: controller.triggerTarget })
777
- await nextFrame()
778
-
779
- // Menu should close because it was a regular click (not a contextmenu event)
780
- expect(controller.openValue).toBe(false)
781
- })
782
-
783
- test("cancels pending hide timeout when showing again", async () => {
784
- const event1 = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
785
- controller.show(event1)
786
- await nextFrame()
787
-
788
- // Start hiding (this sets hideTimeoutId)
789
- controller.hide()
790
- await nextFrame()
791
-
792
- expect(controller.hideTimeoutId).not.toBe(null)
793
-
794
- // Immediately show again (should cancel the pending hide)
795
- const event2 = { preventDefault: jest.fn(), clientX: 200, clientY: 200 }
796
- controller.show(event2)
797
- await nextFrame()
798
-
799
- // The menu should be open at the new position
800
- expect(controller.openValue).toBe(true)
801
- expect(controller.mouseX).toBe(200)
802
- expect(controller.mouseY).toBe(200)
803
- expect(controller.contentTarget.hidden).toBe(false)
804
- })
805
-
806
- test("menu stays open after rapid open/close/open", async () => {
807
- // First open
808
- const event1 = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
809
- controller.show(event1)
810
- await nextFrame()
811
-
812
- // Quickly close
813
- controller.hide()
814
- await nextFrame()
815
-
816
- // Immediately open again
817
- const event2 = { preventDefault: jest.fn(), clientX: 150, clientY: 150 }
818
- controller.show(event2)
819
- await nextFrame()
820
-
821
- // Wait longer than the animation timeout
822
- await wait(150)
823
-
824
- // Menu should still be open
825
- expect(controller.openValue).toBe(true)
826
- expect(controller.contentTarget.hidden).toBe(false)
827
- })
828
-
829
- test("hideTimeoutId is cleared after timeout completes", async () => {
830
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
831
- controller.show(event)
832
- await nextFrame()
833
-
834
- controller.hide()
835
-
836
- // Wait for timeout to complete
837
- await wait(150)
838
-
839
- expect(controller.hideTimeoutId).toBe(null)
840
- })
841
- })
842
-
843
- describe("animation delay on close", () => {
844
- const animationHTML = `
845
- <div data-controller="shadcn--context-menu"
846
- data-shadcn--context-menu-open-value="false">
847
- <div data-shadcn--context-menu-target="trigger">Trigger</div>
848
- <div data-shadcn--context-menu-target="content" hidden style="position: fixed;">
849
- <button data-shadcn--context-menu-target="item">Item 1</button>
850
- </div>
851
- </div>
852
- `
853
-
854
- beforeEach(async () => {
855
- const setup = await setupController(ContextMenuController, animationHTML, 'shadcn--context-menu')
856
- application = setup.application
857
- element = setup.element
858
- controller = setup.controller
859
- })
860
-
861
- afterEach(() => {
862
- if (controller.hideTimeoutId) {
863
- clearTimeout(controller.hideTimeoutId)
864
- }
865
- })
866
-
867
- test("sets data-state to closed immediately", async () => {
868
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
869
- controller.show(event)
870
- await nextFrame()
871
-
872
- controller.hide()
873
- await nextFrame()
874
-
875
- // data-state should be set to closed immediately for CSS animation
876
- expect(controller.contentTarget.dataset.state).toBe("closed")
877
- })
878
-
879
- test("content remains visible during animation", async () => {
880
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
881
- controller.show(event)
882
- await nextFrame()
883
-
884
- controller.hide()
885
- await nextFrame()
886
-
887
- // Content should still be visible immediately after hide() is called
888
- // (hidden is set after the 100ms timeout)
889
- expect(controller.contentTarget.hidden).toBe(false)
890
- })
891
-
892
- test("content is hidden after animation completes", async () => {
893
- const event = { preventDefault: jest.fn(), clientX: 100, clientY: 100 }
894
- controller.show(event)
895
- await nextFrame()
896
-
897
- controller.hide()
898
-
899
- // Wait for animation to complete (100ms + buffer)
900
- await wait(150)
901
-
902
- expect(controller.contentTarget.hidden).toBe(true)
903
- })
904
- })
905
- })