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,971 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import ComboboxController from "../../app/assets/javascripts/shadcn/controllers/combobox_controller.js"
3
- import {
4
- setupController,
5
- cleanupController,
6
- click,
7
- wait,
8
- nextFrame,
9
- keydown,
10
- waitForEvent,
11
- dispatchEvent
12
- } from "../helpers/stimulus-test-helper.js"
13
-
14
- describe("ComboboxController", () => {
15
- let application, element, controller
16
-
17
- afterEach(() => {
18
- cleanupController(application)
19
- })
20
-
21
- /**
22
- * Helper to get the HTML template for combobox tests
23
- */
24
- function getComboboxHTML(options = {}) {
25
- const {
26
- open = false,
27
- value = "",
28
- selectedIndex = -1,
29
- debounceWait = 0, // Default to 0 for tests (no debounce delay)
30
- items = [
31
- { value: "react", label: "React" },
32
- { value: "vue", label: "Vue" },
33
- { value: "angular", label: "Angular" },
34
- { value: "svelte", label: "Svelte" }
35
- ],
36
- includeEmpty = true,
37
- includeDisplayValue = true,
38
- includeHiddenInput = true
39
- } = options
40
-
41
- const itemsHTML = items.map(item => `
42
- <div
43
- data-shadcn--combobox-target="item"
44
- data-value="${item.value}"
45
- data-label="${item.label}"
46
- data-action="click->shadcn--combobox#select"
47
- data-selected="false"
48
- class="cursor-pointer"
49
- >
50
- <svg class="opacity-0"></svg>
51
- ${item.label}
52
- </div>
53
- `).join("")
54
-
55
- return `
56
- <div
57
- data-controller="shadcn--combobox"
58
- data-shadcn--combobox-open-value="${open}"
59
- data-shadcn--combobox-value-value="${value}"
60
- data-shadcn--combobox-selected-index-value="${selectedIndex}"
61
- data-shadcn--combobox-debounce-wait-value="${debounceWait}"
62
- >
63
- <button
64
- data-shadcn--combobox-target="trigger"
65
- data-action="click->shadcn--combobox#toggle"
66
- aria-expanded="${open}"
67
- >
68
- ${includeDisplayValue ? `<span data-shadcn--combobox-target="displayValue" class="text-muted-foreground">Select framework...</span>` : 'Select framework...'}
69
- </button>
70
- ${includeHiddenInput ? '<input type="hidden" data-shadcn--combobox-target="hiddenInput" name="framework">' : ''}
71
- <div
72
- data-shadcn--combobox-target="content"
73
- data-state="closed"
74
- ${!open ? 'hidden' : ''}
75
- >
76
- <input
77
- data-shadcn--combobox-target="input"
78
- type="text"
79
- placeholder="Search..."
80
- data-action="input->shadcn--combobox#filter"
81
- >
82
- <div data-shadcn--combobox-target="list">
83
- ${itemsHTML}
84
- </div>
85
- ${includeEmpty ? '<div data-shadcn--combobox-target="empty" hidden>No results found.</div>' : ''}
86
- </div>
87
- </div>
88
- `
89
- }
90
-
91
- describe("Value Initialization", () => {
92
- it("initializes with default values", async () => {
93
- const html = getComboboxHTML()
94
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
95
- application = setup.application
96
- element = setup.element
97
- controller = setup.controller
98
-
99
- expect(controller.openValue).toBe(false)
100
- expect(controller.valueValue).toBe("")
101
- expect(controller.selectedIndexValue).toBe(-1)
102
- })
103
-
104
- it("initializes with custom open value", async () => {
105
- const html = getComboboxHTML({ open: true })
106
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
107
- application = setup.application
108
- element = setup.element
109
- controller = setup.controller
110
-
111
- expect(controller.openValue).toBe(true)
112
- })
113
-
114
- it("initializes with custom value", async () => {
115
- const html = getComboboxHTML({ value: "react" })
116
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
117
- application = setup.application
118
- element = setup.element
119
- controller = setup.controller
120
-
121
- expect(controller.valueValue).toBe("react")
122
- })
123
-
124
- it("initializes with custom selected index", async () => {
125
- const html = getComboboxHTML({ selectedIndex: 2 })
126
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
127
- application = setup.application
128
- element = setup.element
129
- controller = setup.controller
130
-
131
- expect(controller.selectedIndexValue).toBe(2)
132
- })
133
- })
134
-
135
- describe("Open/Close Behavior", () => {
136
- beforeEach(async () => {
137
- const html = getComboboxHTML()
138
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
139
- application = setup.application
140
- element = setup.element
141
- controller = setup.controller
142
- })
143
-
144
- it("opens the combobox when toggle is called on closed state", async () => {
145
- expect(controller.openValue).toBe(false)
146
-
147
- controller.toggle()
148
- await nextFrame()
149
-
150
- expect(controller.openValue).toBe(true)
151
- expect(controller.contentTarget.hidden).toBe(false)
152
- expect(controller.contentTarget.dataset.state).toBe("open")
153
- expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
154
- })
155
-
156
- it("closes the combobox when toggle is called on open state", async () => {
157
- controller.open()
158
- await nextFrame()
159
- expect(controller.openValue).toBe(true)
160
-
161
- controller.toggle()
162
- await wait(250) // Wait for animation and cleanup
163
-
164
- expect(controller.openValue).toBe(false)
165
- expect(controller.contentTarget.dataset.state).toBe("closed")
166
- expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
167
- })
168
-
169
- it("focuses the input when opened", async () => {
170
- controller.open()
171
- await nextFrame()
172
- await nextFrame() // requestAnimationFrame in open()
173
-
174
- expect(document.activeElement).toBe(controller.inputTarget)
175
- })
176
-
177
- it("does not open if already open", async () => {
178
- controller.open()
179
- await nextFrame()
180
- const initialState = controller.openValue
181
-
182
- controller.open()
183
- await nextFrame()
184
-
185
- expect(controller.openValue).toBe(initialState)
186
- expect(controller.openValue).toBe(true)
187
- })
188
-
189
- it("does not close if already closed", async () => {
190
- expect(controller.openValue).toBe(false)
191
-
192
- controller.close()
193
- await wait(250)
194
-
195
- expect(controller.openValue).toBe(false)
196
- })
197
-
198
- it("resets selected index when opened", async () => {
199
- controller.selectedIndexValue = 2
200
-
201
- controller.open()
202
- await nextFrame()
203
-
204
- expect(controller.selectedIndexValue).toBe(-1)
205
- })
206
-
207
- it("closes on Escape key", async () => {
208
- controller.open()
209
- await nextFrame()
210
-
211
- keydown(document, "Escape")
212
- await wait(250)
213
-
214
- expect(controller.openValue).toBe(false)
215
- })
216
-
217
- it("hides content after close animation completes", async () => {
218
- controller.open()
219
- await nextFrame()
220
- expect(controller.contentTarget.hidden).toBe(false)
221
-
222
- controller.close()
223
- await wait(250) // Wait for animation and fallback timeout
224
-
225
- expect(controller.contentTarget.hidden).toBe(true)
226
- })
227
-
228
- it("resets input value when closed", async () => {
229
- controller.open()
230
- await nextFrame()
231
-
232
- controller.inputTarget.value = "test search"
233
- controller.close()
234
- await wait(250)
235
-
236
- expect(controller.inputTarget.value).toBe("")
237
- })
238
-
239
- it("resets item visibility when closed", async () => {
240
- controller.open()
241
- await nextFrame()
242
-
243
- // Hide some items
244
- controller.itemTargets[0].style.display = "none"
245
- controller.itemTargets[1].style.display = "none"
246
-
247
- controller.close()
248
- await wait(250)
249
-
250
- controller.itemTargets.forEach(item => {
251
- expect(item.style.display).toBe("")
252
- })
253
- })
254
-
255
- it("hides empty state when closed", async () => {
256
- controller.open()
257
- await nextFrame()
258
-
259
- controller.emptyTarget.hidden = false
260
- controller.close()
261
- await wait(250)
262
-
263
- expect(controller.emptyTarget.hidden).toBe(true)
264
- })
265
-
266
- it("adds keyboard listener when opened", async () => {
267
- const spy = jest.spyOn(document, "addEventListener")
268
-
269
- controller.open()
270
- await nextFrame()
271
-
272
- expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
273
- spy.mockRestore()
274
- })
275
-
276
- it("removes keyboard listener when closed", async () => {
277
- controller.open()
278
- await nextFrame()
279
-
280
- const spy = jest.spyOn(document, "removeEventListener")
281
- controller.close()
282
- await wait(250)
283
-
284
- expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
285
- spy.mockRestore()
286
- })
287
- })
288
-
289
- describe("Filtering", () => {
290
- beforeEach(async () => {
291
- const html = getComboboxHTML()
292
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
293
- application = setup.application
294
- element = setup.element
295
- controller = setup.controller
296
- })
297
-
298
- // Helper to run filter and wait for debounce (debounceWait=0 still uses setTimeout)
299
- async function filterAndWait() {
300
- controller.filter()
301
- await new Promise(resolve => setTimeout(resolve, 0))
302
- }
303
-
304
- it("filters items based on input value", async () => {
305
- controller.inputTarget.value = "react"
306
- await filterAndWait()
307
-
308
- expect(controller.itemTargets[0].style.display).toBe("") // React - visible
309
- expect(controller.itemTargets[1].style.display).toBe("none") // Vue - hidden
310
- expect(controller.itemTargets[2].style.display).toBe("none") // Angular - hidden
311
- expect(controller.itemTargets[3].style.display).toBe("none") // Svelte - hidden
312
- })
313
-
314
- it("is case insensitive when filtering", async () => {
315
- controller.inputTarget.value = "REACT"
316
- await filterAndWait()
317
-
318
- expect(controller.itemTargets[0].style.display).toBe("") // React matches
319
- })
320
-
321
- it("filters by label attribute", async () => {
322
- controller.inputTarget.value = "Vue"
323
- await filterAndWait()
324
-
325
- expect(controller.itemTargets[0].style.display).toBe("none")
326
- expect(controller.itemTargets[1].style.display).toBe("") // Vue visible
327
- expect(controller.itemTargets[2].style.display).toBe("none")
328
- expect(controller.itemTargets[3].style.display).toBe("none")
329
- })
330
-
331
- it("filters by value attribute", async () => {
332
- controller.inputTarget.value = "angular"
333
- await filterAndWait()
334
-
335
- expect(controller.itemTargets[0].style.display).toBe("none")
336
- expect(controller.itemTargets[1].style.display).toBe("none")
337
- expect(controller.itemTargets[2].style.display).toBe("") // Angular visible
338
- expect(controller.itemTargets[3].style.display).toBe("none")
339
- })
340
-
341
- it("shows all items when input is empty", async () => {
342
- controller.inputTarget.value = "react"
343
- await filterAndWait()
344
-
345
- controller.inputTarget.value = ""
346
- await filterAndWait()
347
-
348
- controller.itemTargets.forEach(item => {
349
- expect(item.style.display).toBe("")
350
- })
351
- })
352
-
353
- it("shows empty state when no results match query", async () => {
354
- controller.inputTarget.value = "nonexistent"
355
- await filterAndWait()
356
-
357
- expect(controller.emptyTarget.hidden).toBe(false)
358
- })
359
-
360
- it("hides empty state when results exist", async () => {
361
- controller.emptyTarget.hidden = false
362
-
363
- controller.inputTarget.value = "react"
364
- await filterAndWait()
365
-
366
- expect(controller.emptyTarget.hidden).toBe(true)
367
- })
368
-
369
- it("hides empty state when query is empty", async () => {
370
- controller.emptyTarget.hidden = false
371
-
372
- controller.inputTarget.value = ""
373
- await filterAndWait()
374
-
375
- expect(controller.emptyTarget.hidden).toBe(true)
376
- })
377
-
378
- it("resets selected index after filtering", async () => {
379
- controller.selectedIndexValue = 2
380
-
381
- controller.inputTarget.value = "react"
382
- await filterAndWait()
383
-
384
- expect(controller.selectedIndexValue).toBe(-1)
385
- })
386
-
387
- it("handles partial matches", async () => {
388
- controller.inputTarget.value = "vue"
389
- await filterAndWait()
390
-
391
- expect(controller.itemTargets[1].style.display).toBe("") // Vue
392
- expect(controller.itemTargets[3].style.display).toBe("none") // Svelte (contains 'v' but not 'vue')
393
- })
394
-
395
- it("trims whitespace from query", async () => {
396
- controller.inputTarget.value = " react "
397
- await filterAndWait()
398
-
399
- expect(controller.itemTargets[0].style.display).toBe("") // React visible
400
- })
401
- })
402
-
403
- describe("Selection", () => {
404
- beforeEach(async () => {
405
- const html = getComboboxHTML()
406
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
407
- application = setup.application
408
- element = setup.element
409
- controller = setup.controller
410
- })
411
-
412
- it("selects item on click", () => {
413
- const item = controller.itemTargets[0]
414
-
415
- click(item)
416
-
417
- expect(controller.valueValue).toBe("react")
418
- })
419
-
420
- it("updates hidden input value when item selected", () => {
421
- const item = controller.itemTargets[1]
422
-
423
- click(item)
424
-
425
- expect(controller.hiddenInputTarget.value).toBe("vue")
426
- })
427
-
428
- it("updates display value text when item selected", () => {
429
- const item = controller.itemTargets[2]
430
-
431
- click(item)
432
-
433
- expect(controller.displayValueTarget.textContent).toBe("Angular")
434
- })
435
-
436
- it("removes muted foreground class from display value", () => {
437
- controller.displayValueTarget.classList.add("text-muted-foreground")
438
- const item = controller.itemTargets[0]
439
-
440
- click(item)
441
-
442
- expect(controller.displayValueTarget.classList.contains("text-muted-foreground")).toBe(false)
443
- })
444
-
445
- it("updates selected state on items", () => {
446
- const item = controller.itemTargets[1]
447
-
448
- click(item)
449
-
450
- expect(controller.itemTargets[0].dataset.selected).toBe("false")
451
- expect(controller.itemTargets[1].dataset.selected).toBe("true")
452
- expect(controller.itemTargets[2].dataset.selected).toBe("false")
453
- expect(controller.itemTargets[3].dataset.selected).toBe("false")
454
- })
455
-
456
- it("updates check icon visibility for selected item", () => {
457
- const item = controller.itemTargets[0]
458
- const checkIcon = item.querySelector("svg")
459
-
460
- click(item)
461
-
462
- expect(checkIcon.classList.contains("opacity-100")).toBe(true)
463
- expect(checkIcon.classList.contains("opacity-0")).toBe(false)
464
- })
465
-
466
- it("hides check icon for unselected items", () => {
467
- click(controller.itemTargets[0])
468
-
469
- // Select different item
470
- click(controller.itemTargets[1])
471
-
472
- const firstCheckIcon = controller.itemTargets[0].querySelector("svg")
473
- expect(firstCheckIcon.classList.contains("opacity-0")).toBe(true)
474
- expect(firstCheckIcon.classList.contains("opacity-100")).toBe(false)
475
- })
476
-
477
- it("dispatches change event with value and label", async () => {
478
- const item = controller.itemTargets[2]
479
- const eventPromise = waitForEvent(element, "shadcn--combobox:change", 1000)
480
-
481
- click(item)
482
- const event = await eventPromise
483
-
484
- expect(event.detail.value).toBe("angular")
485
- expect(event.detail.label).toBe("Angular")
486
- })
487
-
488
- it("closes combobox after selection", async () => {
489
- controller.open()
490
- await nextFrame()
491
- expect(controller.openValue).toBe(true)
492
-
493
- click(controller.itemTargets[0])
494
- await wait(250)
495
-
496
- expect(controller.openValue).toBe(false)
497
- })
498
-
499
- it("selects item on Enter key when item is highlighted", async () => {
500
- controller.open()
501
- await nextFrame()
502
-
503
- controller.selectedIndexValue = 1
504
- controller.updateSelection()
505
-
506
- keydown(document, "Enter")
507
- await wait(250)
508
-
509
- expect(controller.valueValue).toBe("vue")
510
- })
511
-
512
- it("does nothing on Enter if no item is highlighted", () => {
513
- controller.open()
514
-
515
- const initialValue = controller.valueValue
516
- keydown(document, "Enter")
517
-
518
- expect(controller.valueValue).toBe(initialValue)
519
- })
520
- })
521
-
522
- describe("Value Persistence", () => {
523
- beforeEach(async () => {
524
- const html = getComboboxHTML()
525
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
526
- application = setup.application
527
- element = setup.element
528
- controller = setup.controller
529
- })
530
-
531
- it("maintains selected value after filtering", async () => {
532
- click(controller.itemTargets[0]) // Select React
533
-
534
- controller.inputTarget.value = "vue"
535
- controller.filter()
536
- await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
537
-
538
- expect(controller.valueValue).toBe("react")
539
- })
540
-
541
- it("maintains selected value after closing and reopening", async () => {
542
- controller.open()
543
- await nextFrame()
544
-
545
- click(controller.itemTargets[1]) // Select Vue
546
- await wait(250)
547
-
548
- controller.open()
549
- await nextFrame()
550
-
551
- expect(controller.valueValue).toBe("vue")
552
- expect(controller.itemTargets[1].dataset.selected).toBe("true")
553
- })
554
-
555
- it("maintains display value after reopening", async () => {
556
- controller.open()
557
- await nextFrame()
558
-
559
- click(controller.itemTargets[2]) // Select Angular
560
- await wait(250)
561
-
562
- controller.open()
563
- await nextFrame()
564
-
565
- expect(controller.displayValueTarget.textContent).toBe("Angular")
566
- })
567
- })
568
-
569
- describe("Animation Timing", () => {
570
- beforeEach(async () => {
571
- const html = getComboboxHTML()
572
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
573
- application = setup.application
574
- element = setup.element
575
- controller = setup.controller
576
- })
577
-
578
- it("sets state to open immediately when opening", () => {
579
- controller.open()
580
-
581
- expect(controller.contentTarget.dataset.state).toBe("open")
582
- })
583
-
584
- it("sets state to closed immediately when closing", () => {
585
- controller.open()
586
- controller.close()
587
-
588
- expect(controller.contentTarget.dataset.state).toBe("closed")
589
- })
590
-
591
- it("hides content after close animation with fallback timeout", async () => {
592
- controller.open()
593
- await nextFrame()
594
-
595
- controller.close()
596
-
597
- // Content should still be visible during animation
598
- expect(controller.contentTarget.hidden).toBe(false)
599
-
600
- // Wait for fallback timeout (200ms)
601
- await wait(250)
602
-
603
- // Content should now be hidden
604
- expect(controller.contentTarget.hidden).toBe(true)
605
- })
606
-
607
- it("listens for animationend event on close", async () => {
608
- controller.open()
609
- await nextFrame()
610
-
611
- const spy = jest.spyOn(controller.contentTarget, "addEventListener")
612
- controller.close()
613
-
614
- expect(spy).toHaveBeenCalledWith("animationend", expect.any(Function))
615
- spy.mockRestore()
616
- await wait(250)
617
- })
618
- })
619
-
620
- describe("Keyboard Navigation", () => {
621
- beforeEach(async () => {
622
- const html = getComboboxHTML()
623
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
624
- application = setup.application
625
- element = setup.element
626
- controller = setup.controller
627
- controller.open()
628
- await nextFrame()
629
- })
630
-
631
- it("navigates down with ArrowDown", () => {
632
- expect(controller.selectedIndexValue).toBe(-1)
633
-
634
- keydown(document, "ArrowDown")
635
- expect(controller.selectedIndexValue).toBe(0)
636
-
637
- keydown(document, "ArrowDown")
638
- expect(controller.selectedIndexValue).toBe(1)
639
- })
640
-
641
- it("navigates up with ArrowUp", () => {
642
- controller.selectedIndexValue = 2
643
-
644
- keydown(document, "ArrowUp")
645
- expect(controller.selectedIndexValue).toBe(1)
646
-
647
- keydown(document, "ArrowUp")
648
- expect(controller.selectedIndexValue).toBe(0)
649
- })
650
-
651
- it("does not go below 0 with ArrowUp", () => {
652
- controller.selectedIndexValue = 0
653
-
654
- keydown(document, "ArrowUp")
655
- expect(controller.selectedIndexValue).toBe(0)
656
- })
657
-
658
- it("does not go beyond last item with ArrowDown", () => {
659
- const lastIndex = controller.itemTargets.length - 1
660
- controller.selectedIndexValue = lastIndex
661
-
662
- keydown(document, "ArrowDown")
663
- expect(controller.selectedIndexValue).toBe(lastIndex)
664
- })
665
-
666
- it("applies highlight classes to selected item", () => {
667
- controller.selectedIndexValue = 1
668
- controller.updateSelection()
669
-
670
- expect(controller.itemTargets[1].classList.contains("bg-accent")).toBe(true)
671
- expect(controller.itemTargets[1].classList.contains("text-accent-foreground")).toBe(true)
672
- })
673
-
674
- it("removes highlight classes from unselected items", () => {
675
- controller.selectedIndexValue = 1
676
- controller.updateSelection()
677
-
678
- expect(controller.itemTargets[0].classList.contains("bg-accent")).toBe(false)
679
- expect(controller.itemTargets[2].classList.contains("bg-accent")).toBe(false)
680
- })
681
-
682
- it("scrolls selected item into view", () => {
683
- const spy = jest.spyOn(controller.itemTargets[2], "scrollIntoView")
684
-
685
- controller.selectedIndexValue = 2
686
- controller.updateSelection()
687
-
688
- expect(spy).toHaveBeenCalledWith({ block: "nearest" })
689
- spy.mockRestore()
690
- })
691
-
692
- it("navigates only through visible items after filtering", async () => {
693
- controller.inputTarget.value = "react"
694
- controller.filter()
695
- await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
696
-
697
- keydown(document, "ArrowDown")
698
-
699
- // Should select first (and only) visible item
700
- expect(controller.selectedIndexValue).toBe(0)
701
-
702
- keydown(document, "ArrowDown")
703
-
704
- // Should stay at 0 since it's the last visible item
705
- expect(controller.selectedIndexValue).toBe(0)
706
- })
707
-
708
- it("prevents default on navigation keys", () => {
709
- const event = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true })
710
- const spy = jest.spyOn(event, "preventDefault")
711
-
712
- document.dispatchEvent(event)
713
-
714
- expect(spy).toHaveBeenCalled()
715
- })
716
-
717
- it("prevents default on Escape key", () => {
718
- const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })
719
- const spy = jest.spyOn(event, "preventDefault")
720
-
721
- document.dispatchEvent(event)
722
-
723
- expect(spy).toHaveBeenCalled()
724
- })
725
-
726
- it("prevents default on Enter key", () => {
727
- controller.selectedIndexValue = 0
728
- const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })
729
- const spy = jest.spyOn(event, "preventDefault")
730
-
731
- document.dispatchEvent(event)
732
-
733
- expect(spy).toHaveBeenCalled()
734
- })
735
- })
736
-
737
- describe("ARIA Attributes", () => {
738
- beforeEach(async () => {
739
- const html = getComboboxHTML()
740
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
741
- application = setup.application
742
- element = setup.element
743
- controller = setup.controller
744
- })
745
-
746
- it("sets aria-expanded to false when closed", () => {
747
- expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
748
- })
749
-
750
- it("sets aria-expanded to true when opened", async () => {
751
- controller.open()
752
- await nextFrame()
753
-
754
- expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
755
- })
756
-
757
- it("updates aria-expanded when toggling", async () => {
758
- controller.toggle()
759
- await nextFrame()
760
- expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
761
-
762
- controller.toggle()
763
- await wait(250)
764
- expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
765
- })
766
- })
767
-
768
- describe("Helper Methods", () => {
769
- beforeEach(async () => {
770
- const html = getComboboxHTML()
771
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
772
- application = setup.application
773
- element = setup.element
774
- controller = setup.controller
775
- })
776
-
777
- it("getVisibleItems returns all items when none are filtered", () => {
778
- const visibleItems = controller.getVisibleItems()
779
- expect(visibleItems.length).toBe(4)
780
- })
781
-
782
- it("getVisibleItems returns only visible items after filtering", () => {
783
- controller.itemTargets[0].style.display = "none"
784
- controller.itemTargets[2].style.display = "none"
785
-
786
- const visibleItems = controller.getVisibleItems()
787
-
788
- expect(visibleItems.length).toBe(2)
789
- expect(visibleItems[0]).toBe(controller.itemTargets[1])
790
- expect(visibleItems[1]).toBe(controller.itemTargets[3])
791
- })
792
- })
793
-
794
- describe("Click Outside Handling", () => {
795
- beforeEach(async () => {
796
- const html = getComboboxHTML()
797
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
798
- application = setup.application
799
- element = setup.element
800
- controller = setup.controller
801
- })
802
-
803
- it("closes when clicking outside the element", async () => {
804
- controller.open()
805
- await nextFrame()
806
-
807
- const outsideElement = document.createElement("div")
808
- document.body.appendChild(outsideElement)
809
-
810
- // Use clickOutside directly since stimulus-use doesn't trigger via DOM events in jsdom
811
- controller.clickOutside({ target: outsideElement })
812
- await wait(250)
813
-
814
- expect(controller.openValue).toBe(false)
815
- outsideElement.remove()
816
- })
817
-
818
- it("does not close when clicking inside the element", async () => {
819
- controller.open()
820
- await nextFrame()
821
-
822
- // Clicking inside the controller element should not close via clickOutside
823
- // The clickOutside method from stimulus-use only fires for clicks outside the element
824
- // So we verify the combobox stays open
825
- expect(controller.openValue).toBe(true)
826
- })
827
-
828
- it("does nothing when already closed", () => {
829
- expect(controller.openValue).toBe(false)
830
-
831
- const outsideElement = document.createElement("div")
832
- document.body.appendChild(outsideElement)
833
-
834
- // Calling clickOutside on closed combobox should have no effect
835
- controller.clickOutside({ target: outsideElement })
836
-
837
- expect(controller.openValue).toBe(false)
838
- outsideElement.remove()
839
- })
840
- })
841
-
842
- describe("Disconnect", () => {
843
- beforeEach(async () => {
844
- const html = getComboboxHTML()
845
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
846
- application = setup.application
847
- element = setup.element
848
- controller = setup.controller
849
- })
850
-
851
- it("removes keyboard event listener on disconnect", () => {
852
- controller.open()
853
-
854
- const spy = jest.spyOn(document, "removeEventListener")
855
- controller.disconnect()
856
-
857
- expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
858
- spy.mockRestore()
859
- })
860
- })
861
-
862
- describe("Edge Cases", () => {
863
- it("handles combobox without empty target", async () => {
864
- const html = getComboboxHTML({ includeEmpty: false })
865
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
866
- application = setup.application
867
- element = setup.element
868
- controller = setup.controller
869
-
870
- expect(controller.hasEmptyTarget).toBe(false)
871
-
872
- controller.inputTarget.value = "nonexistent"
873
-
874
- // Should not throw error (filter is debounced, but should still not throw)
875
- expect(() => controller.filter()).not.toThrow()
876
- await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
877
- })
878
-
879
- it("handles combobox without display value target", async () => {
880
- const html = getComboboxHTML({ includeDisplayValue: false })
881
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
882
- application = setup.application
883
- element = setup.element
884
- controller = setup.controller
885
-
886
- expect(controller.hasDisplayValueTarget).toBe(false)
887
-
888
- // Should not throw error when selecting
889
- expect(() => click(controller.itemTargets[0])).not.toThrow()
890
- })
891
-
892
- it("handles combobox without hidden input target", async () => {
893
- const html = getComboboxHTML({ includeHiddenInput: false })
894
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
895
- application = setup.application
896
- element = setup.element
897
- controller = setup.controller
898
-
899
- expect(controller.hasHiddenInputTarget).toBe(false)
900
-
901
- // Should not throw error when selecting
902
- expect(() => click(controller.itemTargets[0])).not.toThrow()
903
- })
904
-
905
- it("handles items without check icons", () => {
906
- const html = `
907
- <div data-controller="shadcn--combobox">
908
- <button data-shadcn--combobox-target="trigger" data-action="click->shadcn--combobox#toggle">
909
- Select
910
- </button>
911
- <div data-shadcn--combobox-target="content" hidden>
912
- <input data-shadcn--combobox-target="input" type="text" data-action="input->shadcn--combobox#filter">
913
- <div data-shadcn--combobox-target="item" data-value="item1" data-label="Item 1" data-action="click->shadcn--combobox#select">
914
- Item 1
915
- </div>
916
- </div>
917
- </div>
918
- `
919
-
920
- return setupController(ComboboxController, html, "shadcn--combobox").then(setup => {
921
- application = setup.application
922
- element = setup.element
923
- controller = setup.controller
924
-
925
- // Should not throw error when selecting item without icon
926
- expect(() => click(controller.itemTargets[0])).not.toThrow()
927
- })
928
- })
929
-
930
- it("handles empty item list", async () => {
931
- const html = getComboboxHTML({ items: [] })
932
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
933
- application = setup.application
934
- element = setup.element
935
- controller = setup.controller
936
-
937
- expect(controller.itemTargets.length).toBe(0)
938
-
939
- // Should not throw errors (filter is debounced)
940
- expect(() => controller.filter()).not.toThrow()
941
- await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
942
- expect(() => keydown(document, "ArrowDown")).not.toThrow()
943
- expect(() => controller.updateSelection()).not.toThrow()
944
- })
945
-
946
- it("handles item without label attribute falling back to textContent", async () => {
947
- const html = `
948
- <div data-controller="shadcn--combobox" data-shadcn--combobox-debounce-wait-value="0">
949
- <button data-shadcn--combobox-target="trigger"></button>
950
- <div data-shadcn--combobox-target="content" hidden>
951
- <input data-shadcn--combobox-target="input" type="text" data-action="input->shadcn--combobox#filter">
952
- <div data-shadcn--combobox-target="item" data-value="test" data-action="click->shadcn--combobox#select">
953
- Text Content Only
954
- </div>
955
- </div>
956
- </div>
957
- `
958
-
959
- const setup = await setupController(ComboboxController, html, "shadcn--combobox")
960
- application = setup.application
961
- element = setup.element
962
- controller = setup.controller
963
-
964
- controller.inputTarget.value = "text"
965
- controller.filter()
966
- await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
967
-
968
- expect(controller.itemTargets[0].style.display).toBe("")
969
- })
970
- })
971
- })