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,912 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import CarouselController from "../../app/assets/javascripts/shadcn/controllers/carousel_controller.js"
3
- import { setupController, cleanupController, click, wait, nextFrame, keydown, waitForEvent } from '../helpers/stimulus-test-helper.js'
4
-
5
- describe("CarouselController", () => {
6
- let application
7
- let element
8
- let controller
9
-
10
- const createCarouselHTML = ({
11
- orientation = "horizontal",
12
- loop = false,
13
- autoplay = false,
14
- autoplayInterval = 4000,
15
- align = "start",
16
- selectedIndex = 0,
17
- itemCount = 3
18
- } = {}) => {
19
- const items = Array.from({ length: itemCount }, (_, i) =>
20
- `<div data-shadcn--carousel-target="item" data-index="${i}">Slide ${i + 1}</div>`
21
- ).join('')
22
-
23
- return `
24
- <div data-controller="shadcn--carousel"
25
- data-shadcn--carousel-orientation-value="${orientation}"
26
- data-shadcn--carousel-loop-value="${loop}"
27
- data-shadcn--carousel-autoplay-value="${autoplay}"
28
- data-shadcn--carousel-autoplay-interval-value="${autoplayInterval}"
29
- data-shadcn--carousel-align-value="${align}"
30
- data-shadcn--carousel-selected-index-value="${selectedIndex}">
31
- <div data-shadcn--carousel-target="viewport">
32
- <div data-shadcn--carousel-target="content">
33
- ${items}
34
- </div>
35
- </div>
36
- <button data-shadcn--carousel-target="prevButton" data-action="click->shadcn--carousel#previous">Prev</button>
37
- <button data-shadcn--carousel-target="nextButton" data-action="click->shadcn--carousel#next">Next</button>
38
- </div>
39
- `
40
- }
41
-
42
- const setupCarousel = async (options = {}) => {
43
- application = Application.start()
44
- application.register("shadcn--carousel", CarouselController)
45
- document.body.innerHTML = createCarouselHTML(options)
46
-
47
- await nextFrame()
48
-
49
- element = document.querySelector('[data-controller="shadcn--carousel"]')
50
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--carousel")
51
-
52
- return { application, element, controller }
53
- }
54
-
55
- afterEach(() => {
56
- if (application) {
57
- application.stop()
58
- }
59
- document.body.innerHTML = ""
60
- })
61
-
62
- describe("Value Initialization", () => {
63
- test("initializes with default values", async () => {
64
- await setupCarousel()
65
-
66
- expect(controller.orientationValue).toBe("horizontal")
67
- expect(controller.loopValue).toBe(false)
68
- expect(controller.autoplayValue).toBe(false)
69
- expect(controller.autoplayIntervalValue).toBe(4000)
70
- expect(controller.alignValue).toBe("start")
71
- expect(controller.selectedIndexValue).toBe(0)
72
- })
73
-
74
- test("initializes with custom orientation value", async () => {
75
- await setupCarousel({ orientation: "vertical" })
76
-
77
- expect(controller.orientationValue).toBe("vertical")
78
- })
79
-
80
- test("initializes with loop enabled", async () => {
81
- await setupCarousel({ loop: true })
82
-
83
- expect(controller.loopValue).toBe(true)
84
- })
85
-
86
- test("initializes with autoplay enabled", async () => {
87
- await setupCarousel({ autoplay: true })
88
-
89
- expect(controller.autoplayValue).toBe(true)
90
- expect(controller.autoplayTimer).toBeDefined()
91
- })
92
-
93
- test("initializes with custom autoplay interval", async () => {
94
- await setupCarousel({ autoplayInterval: 2000 })
95
-
96
- expect(controller.autoplayIntervalValue).toBe(2000)
97
- })
98
-
99
- test("initializes with custom align value", async () => {
100
- await setupCarousel({ align: "center" })
101
-
102
- expect(controller.alignValue).toBe("center")
103
- })
104
-
105
- test("initializes with custom selected index", async () => {
106
- await setupCarousel({ selectedIndex: 1 })
107
-
108
- expect(controller.selectedIndexValue).toBe(1)
109
- })
110
- })
111
-
112
- describe("Navigation - next()", () => {
113
- test("advances to next slide", async () => {
114
- await setupCarousel()
115
-
116
- expect(controller.selectedIndexValue).toBe(0)
117
-
118
- controller.next()
119
- expect(controller.selectedIndexValue).toBe(1)
120
-
121
- controller.next()
122
- expect(controller.selectedIndexValue).toBe(2)
123
- })
124
-
125
- test("stops at last slide when loop is disabled", async () => {
126
- await setupCarousel({ loop: false })
127
-
128
- controller.selectedIndexValue = 2 // Last slide
129
-
130
- controller.next()
131
- expect(controller.selectedIndexValue).toBe(2) // Should stay at 2
132
- })
133
-
134
- test("wraps around to first slide when loop is enabled", async () => {
135
- await setupCarousel({ loop: true })
136
-
137
- controller.selectedIndexValue = 2 // Last slide
138
-
139
- controller.next()
140
- expect(controller.selectedIndexValue).toBe(0) // Should wrap to first
141
- })
142
-
143
- test("dispatches select event with correct index", async () => {
144
- await setupCarousel()
145
-
146
- const selectPromise = waitForEvent(element, "shadcn--carousel:select")
147
-
148
- controller.next()
149
-
150
- const event = await selectPromise
151
- expect(event.detail.index).toBe(1)
152
- })
153
-
154
- test("next button triggers next slide", async () => {
155
- await setupCarousel()
156
-
157
- const nextButton = element.querySelector('[data-shadcn--carousel-target="nextButton"]')
158
-
159
- click(nextButton)
160
- await nextFrame()
161
-
162
- expect(controller.selectedIndexValue).toBe(1)
163
- })
164
- })
165
-
166
- describe("Navigation - previous()", () => {
167
- test("goes back to previous slide", async () => {
168
- await setupCarousel({ selectedIndex: 2 })
169
-
170
- expect(controller.selectedIndexValue).toBe(2)
171
-
172
- controller.previous()
173
- expect(controller.selectedIndexValue).toBe(1)
174
-
175
- controller.previous()
176
- expect(controller.selectedIndexValue).toBe(0)
177
- })
178
-
179
- test("stops at first slide when loop is disabled", async () => {
180
- await setupCarousel({ loop: false, selectedIndex: 0 })
181
-
182
- controller.previous()
183
- expect(controller.selectedIndexValue).toBe(0) // Should stay at 0
184
- })
185
-
186
- test("wraps around to last slide when loop is enabled", async () => {
187
- await setupCarousel({ loop: true, selectedIndex: 0 })
188
-
189
- controller.previous()
190
- expect(controller.selectedIndexValue).toBe(2) // Should wrap to last (index 2)
191
- })
192
-
193
- test("dispatches select event with correct index", async () => {
194
- await setupCarousel({ selectedIndex: 1 })
195
-
196
- const selectPromise = waitForEvent(element, "shadcn--carousel:select")
197
-
198
- controller.previous()
199
-
200
- const event = await selectPromise
201
- expect(event.detail.index).toBe(0)
202
- })
203
-
204
- test("previous button triggers previous slide", async () => {
205
- await setupCarousel({ selectedIndex: 2 })
206
-
207
- const prevButton = element.querySelector('[data-shadcn--carousel-target="prevButton"]')
208
-
209
- click(prevButton)
210
- await nextFrame()
211
-
212
- expect(controller.selectedIndexValue).toBe(1)
213
- })
214
- })
215
-
216
- describe("Loop Behavior", () => {
217
- test("loop disabled - buttons disabled at boundaries", async () => {
218
- await setupCarousel({ loop: false, selectedIndex: 0 })
219
-
220
- const prevButton = element.querySelector('[data-shadcn--carousel-target="prevButton"]')
221
- const nextButton = element.querySelector('[data-shadcn--carousel-target="nextButton"]')
222
-
223
- // At first slide, prev should be disabled
224
- expect(prevButton.disabled).toBe(true)
225
- expect(nextButton.disabled).toBe(false)
226
-
227
- // Navigate to last slide
228
- controller.selectedIndexValue = 2
229
- controller.updateButtonStates()
230
-
231
- // At last slide, next should be disabled
232
- expect(prevButton.disabled).toBe(false)
233
- expect(nextButton.disabled).toBe(true)
234
- })
235
-
236
- test("loop enabled - buttons never disabled", async () => {
237
- await setupCarousel({ loop: true, selectedIndex: 0 })
238
-
239
- const prevButton = element.querySelector('[data-shadcn--carousel-target="prevButton"]')
240
- const nextButton = element.querySelector('[data-shadcn--carousel-target="nextButton"]')
241
-
242
- // At first slide
243
- expect(prevButton.disabled).toBe(false)
244
- expect(nextButton.disabled).toBe(false)
245
-
246
- // At last slide
247
- controller.selectedIndexValue = 2
248
- controller.updateButtonStates()
249
-
250
- expect(prevButton.disabled).toBe(false)
251
- expect(nextButton.disabled).toBe(false)
252
- })
253
-
254
- test("loop enabled - continuous navigation forward", async () => {
255
- await setupCarousel({ loop: true, itemCount: 3 })
256
-
257
- const indices = []
258
- for (let i = 0; i < 5; i++) {
259
- indices.push(controller.selectedIndexValue)
260
- controller.next()
261
- }
262
-
263
- expect(indices).toEqual([0, 1, 2, 0, 1])
264
- })
265
-
266
- test("loop enabled - continuous navigation backward", async () => {
267
- await setupCarousel({ loop: true, selectedIndex: 0, itemCount: 3 })
268
-
269
- const indices = []
270
- for (let i = 0; i < 5; i++) {
271
- indices.push(controller.selectedIndexValue)
272
- controller.previous()
273
- }
274
-
275
- expect(indices).toEqual([0, 2, 1, 0, 2])
276
- })
277
- })
278
-
279
- describe("Autoplay", () => {
280
- test("starts autoplay on connect when enabled", async () => {
281
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
282
-
283
- expect(controller.autoplayTimer).toBeDefined()
284
- expect(controller.selectedIndexValue).toBe(0)
285
-
286
- // Wait for autoplay to advance
287
- await wait(110)
288
- expect(controller.selectedIndexValue).toBe(1)
289
-
290
- await wait(100)
291
- expect(controller.selectedIndexValue).toBe(2)
292
- })
293
-
294
- test("does not start autoplay when disabled", async () => {
295
- await setupCarousel({ autoplay: false })
296
-
297
- expect(controller.autoplayTimer).toBeUndefined()
298
- })
299
-
300
- test("respects custom autoplay interval", async () => {
301
- await setupCarousel({ autoplay: true, autoplayInterval: 150 })
302
-
303
- expect(controller.selectedIndexValue).toBe(0)
304
-
305
- // Wait a bit less than the interval - should still be at 0
306
- await wait(100)
307
- expect(controller.selectedIndexValue).toBe(0) // Still at 0
308
-
309
- // Wait for the interval to complete
310
- await wait(60)
311
- expect(controller.selectedIndexValue).toBe(1) // Now at 1
312
- })
313
-
314
- test("pauses autoplay on mouse enter", async () => {
315
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
316
-
317
- expect(controller.autoplayTimer).toBeDefined()
318
-
319
- controller.mouseEnter()
320
- expect(controller.autoplayTimer).toBeNull()
321
-
322
- // Timer should not advance slides
323
- await wait(110)
324
- expect(controller.selectedIndexValue).toBe(0)
325
- })
326
-
327
- test("resumes autoplay on mouse leave", async () => {
328
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
329
-
330
- controller.mouseEnter()
331
- expect(controller.autoplayTimer).toBeNull()
332
-
333
- controller.mouseLeave()
334
- expect(controller.autoplayTimer).toBeDefined()
335
-
336
- await wait(110)
337
- expect(controller.selectedIndexValue).toBe(1)
338
- })
339
-
340
- test("pauses autoplay on touch start", async () => {
341
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
342
-
343
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
344
-
345
- const touchStartEvent = new TouchEvent('touchstart', {
346
- touches: [{ clientX: 100, clientY: 100 }]
347
- })
348
-
349
- viewport.dispatchEvent(touchStartEvent)
350
- expect(controller.autoplayTimer).toBeNull()
351
- })
352
-
353
- test("resumes autoplay after touch end", async () => {
354
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
355
-
356
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
357
-
358
- // Touch start
359
- controller.touchStartX = 100
360
- controller.touchStartY = 100
361
- controller.stopAutoplay()
362
-
363
- // Touch end (small swipe, below threshold)
364
- const touchEndEvent = new TouchEvent('touchend', {
365
- changedTouches: [{ clientX: 110, clientY: 100 }]
366
- })
367
-
368
- viewport.dispatchEvent(touchEndEvent)
369
-
370
- expect(controller.autoplayTimer).toBeDefined()
371
- })
372
-
373
- test("autoplay wraps around with loop enabled", async () => {
374
- await setupCarousel({ autoplay: true, loop: true, autoplayInterval: 100 })
375
-
376
- controller.selectedIndexValue = 2 // Last slide
377
-
378
- await wait(110)
379
- expect(controller.selectedIndexValue).toBe(0) // Should wrap to first
380
- })
381
-
382
- test("autoplay stops at end with loop disabled", async () => {
383
- await setupCarousel({ autoplay: true, loop: false, autoplayInterval: 100 })
384
-
385
- controller.selectedIndexValue = 2 // Last slide
386
-
387
- await wait(110)
388
- expect(controller.selectedIndexValue).toBe(2) // Should stay at last
389
- })
390
- })
391
-
392
- describe("Orientation", () => {
393
- test("horizontal orientation - ArrowLeft goes to previous", async () => {
394
- await setupCarousel({ orientation: "horizontal", selectedIndex: 1 })
395
-
396
- keydown(element, "ArrowLeft")
397
- expect(controller.selectedIndexValue).toBe(0)
398
- })
399
-
400
- test("horizontal orientation - ArrowRight goes to next", async () => {
401
- await setupCarousel({ orientation: "horizontal", selectedIndex: 0 })
402
-
403
- keydown(element, "ArrowRight")
404
- expect(controller.selectedIndexValue).toBe(1)
405
- })
406
-
407
- test("horizontal orientation - ArrowUp/Down do nothing", async () => {
408
- await setupCarousel({ orientation: "horizontal", selectedIndex: 1 })
409
-
410
- keydown(element, "ArrowUp")
411
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
412
-
413
- keydown(element, "ArrowDown")
414
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
415
- })
416
-
417
- test("vertical orientation - ArrowUp goes to previous", async () => {
418
- await setupCarousel({ orientation: "vertical", selectedIndex: 1 })
419
-
420
- keydown(element, "ArrowUp")
421
- expect(controller.selectedIndexValue).toBe(0)
422
- })
423
-
424
- test("vertical orientation - ArrowDown goes to next", async () => {
425
- await setupCarousel({ orientation: "vertical", selectedIndex: 0 })
426
-
427
- keydown(element, "ArrowDown")
428
- expect(controller.selectedIndexValue).toBe(1)
429
- })
430
-
431
- test("vertical orientation - ArrowLeft/Right do nothing", async () => {
432
- await setupCarousel({ orientation: "vertical", selectedIndex: 1 })
433
-
434
- keydown(element, "ArrowLeft")
435
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
436
-
437
- keydown(element, "ArrowRight")
438
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
439
- })
440
- })
441
-
442
- describe("Selected Index", () => {
443
- test("updates aria-hidden on slides when index changes", async () => {
444
- await setupCarousel()
445
-
446
- const items = element.querySelectorAll('[data-shadcn--carousel-target="item"]')
447
-
448
- // Initial state - first slide visible
449
- expect(items[0].getAttribute('aria-hidden')).toBe('false')
450
- expect(items[1].getAttribute('aria-hidden')).toBe('true')
451
- expect(items[2].getAttribute('aria-hidden')).toBe('true')
452
-
453
- // Navigate to second slide
454
- controller.selectedIndexValue = 1
455
- controller.scrollToIndex(1)
456
-
457
- expect(items[0].getAttribute('aria-hidden')).toBe('true')
458
- expect(items[1].getAttribute('aria-hidden')).toBe('false')
459
- expect(items[2].getAttribute('aria-hidden')).toBe('true')
460
- })
461
-
462
- test("updates inert property on slides when index changes", async () => {
463
- await setupCarousel()
464
-
465
- const items = element.querySelectorAll('[data-shadcn--carousel-target="item"]')
466
-
467
- // Initial state
468
- expect(items[0].inert).toBe(false)
469
- expect(items[1].inert).toBe(true)
470
- expect(items[2].inert).toBe(true)
471
-
472
- // Navigate to third slide
473
- controller.selectedIndexValue = 2
474
- controller.scrollToIndex(2)
475
-
476
- expect(items[0].inert).toBe(true)
477
- expect(items[1].inert).toBe(true)
478
- expect(items[2].inert).toBe(false)
479
- })
480
-
481
- test("selectedIndexValueChanged triggers scrollToIndex", async () => {
482
- await setupCarousel()
483
-
484
- // Track the current index before change
485
- const previousIndex = controller.selectedIndexValue
486
-
487
- controller.selectedIndexValue = 1
488
-
489
- // Verify index changed
490
- expect(controller.selectedIndexValue).toBe(1)
491
- expect(previousIndex).toBe(0)
492
- })
493
-
494
- test("persists selected index through interactions", async () => {
495
- await setupCarousel({ selectedIndex: 1 })
496
-
497
- expect(controller.selectedIndexValue).toBe(1)
498
-
499
- controller.next()
500
- expect(controller.selectedIndexValue).toBe(2)
501
-
502
- controller.previous()
503
- expect(controller.selectedIndexValue).toBe(1)
504
- })
505
- })
506
-
507
- describe("Timer Cleanup", () => {
508
- test("clears autoplay timer on disconnect", async () => {
509
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
510
-
511
- expect(controller.autoplayTimer).toBeDefined()
512
-
513
- // Stop autoplay manually before disconnecting
514
- controller.stopAutoplay()
515
-
516
- // Timer should be cleared
517
- expect(controller.autoplayTimer).toBeNull()
518
-
519
- // Wait - if timer wasn't cleared, it would cause errors
520
- await wait(110)
521
- })
522
-
523
- test("clears autoplay timer when stopAutoplay is called", async () => {
524
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
525
-
526
- expect(controller.autoplayTimer).toBeDefined()
527
-
528
- controller.stopAutoplay()
529
-
530
- expect(controller.autoplayTimer).toBeNull()
531
- })
532
-
533
- test("no memory leaks - multiple start/stop cycles", async () => {
534
- await setupCarousel({ autoplay: false })
535
-
536
- // Start and stop multiple times
537
- for (let i = 0; i < 5; i++) {
538
- controller.autoplayValue = true
539
- controller.startAutoplay()
540
- expect(controller.autoplayTimer).toBeDefined()
541
-
542
- controller.stopAutoplay()
543
- expect(controller.autoplayTimer).toBeNull()
544
- }
545
- })
546
-
547
- test("clears old timer when starting new autoplay", async () => {
548
- await setupCarousel({ autoplay: true, autoplayInterval: 100 })
549
-
550
- const firstTimerId = controller.autoplayTimer
551
-
552
- // Start autoplay again (should clear old timer)
553
- controller.startAutoplay()
554
-
555
- const secondTimerId = controller.autoplayTimer
556
-
557
- expect(secondTimerId).toBeDefined()
558
- expect(firstTimerId).not.toBe(secondTimerId)
559
- })
560
-
561
- test("removes event listeners on disconnect", async () => {
562
- await setupCarousel()
563
-
564
- // Verify bound event handlers exist
565
- expect(controller.boundHandleKeydown).toBeDefined()
566
- expect(controller.boundHandleTouchStart).toBeDefined()
567
- expect(controller.boundHandleTouchEnd).toBeDefined()
568
-
569
- // Test that disconnect actually calls stopAutoplay
570
- controller.autoplayValue = true
571
- controller.startAutoplay()
572
- expect(controller.autoplayTimer).toBeDefined()
573
-
574
- // Manually call disconnect to test cleanup
575
- controller.disconnect()
576
-
577
- // Timer should be cleared after disconnect
578
- expect(controller.autoplayTimer).toBeNull()
579
- })
580
- })
581
-
582
- describe("Keyboard Navigation", () => {
583
- test("prevents default on arrow key navigation", async () => {
584
- await setupCarousel({ orientation: "horizontal" })
585
-
586
- let defaultPrevented = false
587
-
588
- const event = new KeyboardEvent('keydown', {
589
- key: 'ArrowRight',
590
- bubbles: true,
591
- cancelable: true
592
- })
593
-
594
- // Override preventDefault to track if it was called
595
- const originalPreventDefault = event.preventDefault
596
- event.preventDefault = function() {
597
- defaultPrevented = true
598
- originalPreventDefault.call(this)
599
- }
600
-
601
- element.dispatchEvent(event)
602
-
603
- expect(defaultPrevented).toBe(true)
604
- })
605
-
606
- test("horizontal carousel ignores non-arrow keys", async () => {
607
- await setupCarousel({ orientation: "horizontal", selectedIndex: 1 })
608
-
609
- keydown(element, "Enter")
610
- expect(controller.selectedIndexValue).toBe(1)
611
-
612
- keydown(element, "Space")
613
- expect(controller.selectedIndexValue).toBe(1)
614
-
615
- keydown(element, "Tab")
616
- expect(controller.selectedIndexValue).toBe(1)
617
- })
618
-
619
- test("vertical carousel ignores non-arrow keys", async () => {
620
- await setupCarousel({ orientation: "vertical", selectedIndex: 1 })
621
-
622
- keydown(element, "Enter")
623
- expect(controller.selectedIndexValue).toBe(1)
624
-
625
- keydown(element, "Space")
626
- expect(controller.selectedIndexValue).toBe(1)
627
- })
628
-
629
- test("keyboard navigation respects loop setting", async () => {
630
- await setupCarousel({ orientation: "horizontal", loop: false, selectedIndex: 0 })
631
-
632
- keydown(element, "ArrowLeft")
633
- expect(controller.selectedIndexValue).toBe(0) // Should not wrap
634
-
635
- controller.loopValue = true
636
- keydown(element, "ArrowLeft")
637
- expect(controller.selectedIndexValue).toBe(2) // Should wrap to last
638
- })
639
- })
640
-
641
- describe("goToSlide", () => {
642
- test("navigates to specific slide by index", async () => {
643
- await setupCarousel()
644
-
645
- const mockEvent = {
646
- currentTarget: { dataset: { index: "2" } }
647
- }
648
-
649
- controller.goToSlide(mockEvent)
650
-
651
- expect(controller.selectedIndexValue).toBe(2)
652
- })
653
-
654
- test("dispatches select event when going to slide", async () => {
655
- await setupCarousel()
656
-
657
- const selectPromise = waitForEvent(element, "shadcn--carousel:select")
658
-
659
- const mockEvent = {
660
- currentTarget: { dataset: { index: "1" } }
661
- }
662
-
663
- controller.goToSlide(mockEvent)
664
-
665
- const event = await selectPromise
666
- expect(event.detail.index).toBe(1)
667
- })
668
-
669
- test("ignores invalid index values", async () => {
670
- await setupCarousel({ selectedIndex: 1 })
671
-
672
- const mockEvent = {
673
- currentTarget: { dataset: { index: "invalid" } }
674
- }
675
-
676
- controller.goToSlide(mockEvent)
677
-
678
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
679
- })
680
-
681
- test("ignores out of range index values", async () => {
682
- await setupCarousel({ selectedIndex: 1 })
683
-
684
- // Index too high
685
- let mockEvent = {
686
- currentTarget: { dataset: { index: "10" } }
687
- }
688
-
689
- controller.goToSlide(mockEvent)
690
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
691
-
692
- // Index negative
693
- mockEvent = {
694
- currentTarget: { dataset: { index: "-1" } }
695
- }
696
-
697
- controller.goToSlide(mockEvent)
698
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
699
- })
700
- })
701
-
702
- describe("Touch/Swipe Support", () => {
703
- test("horizontal swipe left triggers next", async () => {
704
- await setupCarousel({ orientation: "horizontal", selectedIndex: 0 })
705
-
706
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
707
-
708
- // Swipe left (large enough to trigger - threshold is 50)
709
- const touchStartEvent = new TouchEvent('touchstart', {
710
- touches: [{ clientX: 200, clientY: 100 }]
711
- })
712
- viewport.dispatchEvent(touchStartEvent)
713
-
714
- const touchEndEvent = new TouchEvent('touchend', {
715
- changedTouches: [{ clientX: 100, clientY: 100 }]
716
- })
717
- viewport.dispatchEvent(touchEndEvent)
718
-
719
- expect(controller.selectedIndexValue).toBe(1)
720
- })
721
-
722
- test("horizontal swipe right triggers previous", async () => {
723
- await setupCarousel({ orientation: "horizontal", selectedIndex: 1 })
724
-
725
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
726
-
727
- // Swipe right
728
- const touchStartEvent = new TouchEvent('touchstart', {
729
- touches: [{ clientX: 100, clientY: 100 }]
730
- })
731
- viewport.dispatchEvent(touchStartEvent)
732
-
733
- const touchEndEvent = new TouchEvent('touchend', {
734
- changedTouches: [{ clientX: 200, clientY: 100 }]
735
- })
736
- viewport.dispatchEvent(touchEndEvent)
737
-
738
- expect(controller.selectedIndexValue).toBe(0)
739
- })
740
-
741
- test("vertical swipe down triggers previous", async () => {
742
- await setupCarousel({ orientation: "vertical", selectedIndex: 1 })
743
-
744
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
745
-
746
- // Swipe down (positive deltaY)
747
- const touchStartEvent = new TouchEvent('touchstart', {
748
- touches: [{ clientX: 100, clientY: 100 }]
749
- })
750
- viewport.dispatchEvent(touchStartEvent)
751
-
752
- const touchEndEvent = new TouchEvent('touchend', {
753
- changedTouches: [{ clientX: 100, clientY: 200 }]
754
- })
755
- viewport.dispatchEvent(touchEndEvent)
756
-
757
- expect(controller.selectedIndexValue).toBe(0)
758
- })
759
-
760
- test("vertical swipe up triggers next", async () => {
761
- await setupCarousel({ orientation: "vertical", selectedIndex: 0 })
762
-
763
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
764
-
765
- // Swipe up (negative deltaY)
766
- const touchStartEvent = new TouchEvent('touchstart', {
767
- touches: [{ clientX: 100, clientY: 200 }]
768
- })
769
- viewport.dispatchEvent(touchStartEvent)
770
-
771
- const touchEndEvent = new TouchEvent('touchend', {
772
- changedTouches: [{ clientX: 100, clientY: 100 }]
773
- })
774
- viewport.dispatchEvent(touchEndEvent)
775
-
776
- expect(controller.selectedIndexValue).toBe(1)
777
- })
778
-
779
- test("small swipe below threshold does not trigger navigation", async () => {
780
- await setupCarousel({ orientation: "horizontal", selectedIndex: 1 })
781
-
782
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
783
-
784
- // Small swipe (threshold is 50)
785
- const touchStartEvent = new TouchEvent('touchstart', {
786
- touches: [{ clientX: 100, clientY: 100 }]
787
- })
788
- viewport.dispatchEvent(touchStartEvent)
789
-
790
- const touchEndEvent = new TouchEvent('touchend', {
791
- changedTouches: [{ clientX: 130, clientY: 100 }]
792
- })
793
- viewport.dispatchEvent(touchEndEvent)
794
-
795
- expect(controller.selectedIndexValue).toBe(1) // Unchanged
796
- })
797
-
798
- test("diagonal swipe with stronger horizontal component triggers horizontal navigation", async () => {
799
- await setupCarousel({ orientation: "horizontal", selectedIndex: 1 })
800
-
801
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
802
-
803
- // Diagonal swipe with stronger horizontal
804
- const touchStartEvent = new TouchEvent('touchstart', {
805
- touches: [{ clientX: 200, clientY: 100 }]
806
- })
807
- viewport.dispatchEvent(touchStartEvent)
808
-
809
- const touchEndEvent = new TouchEvent('touchend', {
810
- changedTouches: [{ clientX: 100, clientY: 120 }]
811
- })
812
- viewport.dispatchEvent(touchEndEvent)
813
-
814
- expect(controller.selectedIndexValue).toBe(2) // Next slide
815
- })
816
- })
817
-
818
- describe("Align Offset Calculation", () => {
819
- test("start alignment returns 0 offset", async () => {
820
- await setupCarousel({ align: "start" })
821
-
822
- const mockItem = { offsetWidth: 100, offsetHeight: 100 }
823
- const offset = controller.getAlignOffset(mockItem, "width")
824
-
825
- expect(offset).toBe(0)
826
- })
827
-
828
- test("center alignment calculates correct offset", async () => {
829
- await setupCarousel({ align: "center" })
830
-
831
- // Mock viewport size
832
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
833
- Object.defineProperty(viewport, 'offsetWidth', { value: 500, configurable: true })
834
-
835
- const mockItem = { offsetWidth: 100 }
836
- const offset = controller.getAlignOffset(mockItem, "width")
837
-
838
- expect(offset).toBe(200) // (500 - 100) / 2
839
- })
840
-
841
- test("end alignment calculates correct offset", async () => {
842
- await setupCarousel({ align: "end" })
843
-
844
- const viewport = element.querySelector('[data-shadcn--carousel-target="viewport"]')
845
- Object.defineProperty(viewport, 'offsetWidth', { value: 500, configurable: true })
846
-
847
- const mockItem = { offsetWidth: 100 }
848
- const offset = controller.getAlignOffset(mockItem, "width")
849
-
850
- expect(offset).toBe(400) // 500 - 100
851
- })
852
- })
853
-
854
- describe("Edge Cases", () => {
855
- test("handles carousel with single item", async () => {
856
- await setupCarousel({ itemCount: 1 })
857
-
858
- controller.next()
859
- expect(controller.selectedIndexValue).toBe(0) // Stays at 0
860
-
861
- controller.previous()
862
- expect(controller.selectedIndexValue).toBe(0) // Stays at 0
863
- })
864
-
865
- test("handles carousel with no items gracefully", async () => {
866
- await setupCarousel({ itemCount: 0 })
867
-
868
- expect(() => {
869
- controller.next()
870
- controller.previous()
871
- controller.scrollToIndex(0)
872
- }).not.toThrow()
873
- })
874
-
875
- test("updateButtonStates handles missing button targets", async () => {
876
- application = Application.start()
877
- application.register("shadcn--carousel", CarouselController)
878
-
879
- // Create carousel without button targets
880
- document.body.innerHTML = `
881
- <div data-controller="shadcn--carousel">
882
- <div data-shadcn--carousel-target="viewport">
883
- <div data-shadcn--carousel-target="content">
884
- <div data-shadcn--carousel-target="item">Slide 1</div>
885
- </div>
886
- </div>
887
- </div>
888
- `
889
-
890
- await nextFrame()
891
-
892
- element = document.querySelector('[data-controller="shadcn--carousel"]')
893
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--carousel")
894
-
895
- expect(() => {
896
- controller.updateButtonStates()
897
- }).not.toThrow()
898
- })
899
-
900
- test("scrollToIndex handles missing content target", async () => {
901
- await setupCarousel()
902
-
903
- // Remove content target
904
- const content = element.querySelector('[data-shadcn--carousel-target="content"]')
905
- content.removeAttribute('data-shadcn--carousel-target')
906
-
907
- expect(() => {
908
- controller.scrollToIndex(1)
909
- }).not.toThrow()
910
- })
911
- })
912
- })