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,986 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import SheetController from "../../app/assets/javascripts/shadcn/controllers/sheet_controller.js"
3
- import { setupController, cleanupController, click, wait, nextFrame, keydown, waitForPortal, getFocusableElements, waitForEvent } from '../helpers/stimulus-test-helper.js'
4
-
5
- describe("SheetController", () => {
6
- let application
7
- let element
8
- let controller
9
-
10
- const createSheetHTML = (options = {}) => {
11
- const {
12
- open = false,
13
- side = "right"
14
- } = options
15
-
16
- const openAttr = open ? 'data-shadcn--sheet-open-value="true"' : ''
17
-
18
- return `
19
- <div data-controller="shadcn--sheet"
20
- ${openAttr}
21
- data-shadcn--sheet-side-value="${side}">
22
- <button data-shadcn--sheet-target="trigger" data-action="click->shadcn--sheet#toggle">
23
- Open Sheet
24
- </button>
25
-
26
- <template data-shadcn--sheet-target="template">
27
- <div data-shadcn--sheet-target="overlay" data-state="closed" hidden
28
- data-action="click->shadcn--sheet#close"
29
- class="fixed inset-0 z-50 bg-black/80">
30
- </div>
31
- <div data-shadcn--sheet-target="content" data-state="closed" hidden
32
- data-side="${side}"
33
- class="fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out">
34
- <button data-action="click->shadcn--sheet#close" class="close-button">
35
- Close
36
- </button>
37
- <input type="text" class="first-input" placeholder="First input" />
38
- <button class="action-button">Action</button>
39
- <a href="#" class="link">Link</a>
40
- <input type="text" class="last-input" placeholder="Last input" />
41
- </div>
42
- </template>
43
- </div>
44
- `
45
- }
46
-
47
- beforeEach(async () => {
48
- application = Application.start()
49
- application.register("shadcn--sheet", SheetController)
50
- document.body.innerHTML = createSheetHTML()
51
-
52
- await new Promise(resolve => requestAnimationFrame(resolve))
53
-
54
- element = document.querySelector('[data-controller="shadcn--sheet"]')
55
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
56
- })
57
-
58
- afterEach(() => {
59
- if (application) {
60
- application.stop()
61
- }
62
- document.body.innerHTML = ""
63
- // Restore body overflow
64
- document.body.style.overflow = ""
65
- })
66
-
67
- describe("initialization", () => {
68
- test("connects successfully", () => {
69
- expect(controller).not.toBeNull()
70
- expect(controller).toBeDefined()
71
- })
72
-
73
- test("initializes with default values", () => {
74
- expect(controller.openValue).toBe(false)
75
- expect(controller.sideValue).toBe("right")
76
- })
77
-
78
- test("initializes with custom side value", async () => {
79
- application.stop()
80
- document.body.innerHTML = createSheetHTML({ side: "left" })
81
-
82
- application = Application.start()
83
- application.register("shadcn--sheet", SheetController)
84
-
85
- await new Promise(resolve => requestAnimationFrame(resolve))
86
-
87
- element = document.querySelector('[data-controller="shadcn--sheet"]')
88
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
89
-
90
- expect(controller.sideValue).toBe("left")
91
- })
92
-
93
- test("can be controlled via openValue", async () => {
94
- // Verify that setting openValue directly works
95
- expect(controller.openValue).toBe(false)
96
-
97
- // Manually trigger open by calling the method
98
- controller.open()
99
-
100
- await nextFrame()
101
- await nextFrame()
102
-
103
- expect(controller.openValue).toBe(true)
104
- const portal = document.querySelector(".shadcn-sheet-portal")
105
- expect(portal).toBeTruthy()
106
- })
107
-
108
- test("does not create portal on initialization when closed", () => {
109
- const portal = document.querySelector(".shadcn-sheet-portal")
110
- expect(portal).toBeNull()
111
- })
112
- })
113
-
114
- describe("opening and closing", () => {
115
- test("opens sheet when toggle is called", async () => {
116
- const trigger = element.querySelector('[data-shadcn--sheet-target="trigger"]')
117
- click(trigger)
118
-
119
- await nextFrame()
120
-
121
- expect(controller.openValue).toBe(true)
122
-
123
- const portal = await waitForPortal(".shadcn-sheet-portal")
124
- expect(portal).toBeTruthy()
125
- })
126
-
127
- test("closes sheet when toggle is called again", async () => {
128
- const trigger = element.querySelector('[data-shadcn--sheet-target="trigger"]')
129
-
130
- // Open
131
- click(trigger)
132
- await nextFrame()
133
- expect(controller.openValue).toBe(true)
134
-
135
- // Close
136
- click(trigger)
137
- await nextFrame()
138
- expect(controller.openValue).toBe(false)
139
- })
140
-
141
- test("open() does nothing if already open", async () => {
142
- const trigger = element.querySelector('[data-shadcn--sheet-target="trigger"]')
143
-
144
- click(trigger)
145
- await nextFrame()
146
-
147
- const portal = document.querySelector(".shadcn-sheet-portal")
148
- const portalHTML = portal.innerHTML
149
-
150
- // Try to open again
151
- controller.open()
152
- await nextFrame()
153
-
154
- // Portal should be the same
155
- const samePortal = document.querySelector(".shadcn-sheet-portal")
156
- expect(samePortal.innerHTML).toBe(portalHTML)
157
- })
158
-
159
- test("close() does nothing if already closed", async () => {
160
- expect(controller.openValue).toBe(false)
161
-
162
- controller.close()
163
- await nextFrame()
164
-
165
- expect(controller.openValue).toBe(false)
166
- })
167
-
168
- test("opens and closes multiple times", async () => {
169
- const trigger = element.querySelector('[data-shadcn--sheet-target="trigger"]')
170
-
171
- for (let i = 0; i < 3; i++) {
172
- // Open
173
- click(trigger)
174
- await nextFrame()
175
- expect(controller.openValue).toBe(true)
176
-
177
- // Close
178
- click(trigger)
179
- await nextFrame()
180
- expect(controller.openValue).toBe(false)
181
- }
182
- })
183
- })
184
-
185
- describe("portal rendering", () => {
186
- test("creates portal element when opening", async () => {
187
- controller.open()
188
- await nextFrame()
189
-
190
- const portal = document.querySelector(".shadcn-sheet-portal")
191
- expect(portal).toBeTruthy()
192
- expect(portal.parentElement).toBe(document.body)
193
- })
194
-
195
- test("portal contains overlay and content", async () => {
196
- controller.open()
197
- await nextFrame()
198
-
199
- const portal = await waitForPortal(".shadcn-sheet-portal")
200
- const overlay = portal.querySelector('[data-shadcn--sheet-target="overlay"]')
201
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
202
-
203
- expect(overlay).toBeTruthy()
204
- expect(content).toBeTruthy()
205
- })
206
-
207
- test("portal content includes template elements", async () => {
208
- controller.open()
209
- await nextFrame()
210
-
211
- const portal = await waitForPortal(".shadcn-sheet-portal")
212
-
213
- // Check that key elements from template are present in portal
214
- expect(portal.querySelector('.close-button')).toBeTruthy()
215
- expect(portal.querySelector('.first-input')).toBeTruthy()
216
- expect(portal.querySelector('.action-button')).toBeTruthy()
217
- expect(portal.querySelector('.link')).toBeTruthy()
218
- expect(portal.querySelector('.last-input')).toBeTruthy()
219
- })
220
-
221
- test("removes portal after closing with delay", async () => {
222
- controller.open()
223
- await nextFrame()
224
-
225
- const portal = await waitForPortal(".shadcn-sheet-portal")
226
- expect(portal).toBeTruthy()
227
-
228
- controller.close()
229
-
230
- // Portal should still exist immediately after close
231
- const portalAfterClose = document.querySelector(".shadcn-sheet-portal")
232
- expect(portalAfterClose).toBeTruthy()
233
-
234
- // Wait for the 300ms delay
235
- await wait(350)
236
-
237
- const portalAfterDelay = document.querySelector(".shadcn-sheet-portal")
238
- expect(portalAfterDelay).toBeNull()
239
- })
240
-
241
- test("reuses portal if it exists", async () => {
242
- controller.open()
243
- await nextFrame()
244
-
245
- const portal = await waitForPortal(".shadcn-sheet-portal")
246
- const portalReference = portal
247
-
248
- controller.close()
249
- await wait(50) // Close but don't wait for removal
250
-
251
- controller.open()
252
- await nextFrame()
253
-
254
- // Portal reference should be different since it was removed
255
- // But the class name should be the same
256
- const newPortal = document.querySelector(".shadcn-sheet-portal")
257
- expect(newPortal.className).toBe(portalReference.className)
258
- })
259
- })
260
-
261
- describe("side positioning", () => {
262
- const sides = ["top", "right", "bottom", "left"]
263
-
264
- sides.forEach(side => {
265
- test(`renders with side="${side}"`, async () => {
266
- application.stop()
267
- document.body.innerHTML = createSheetHTML({ side })
268
-
269
- application = Application.start()
270
- application.register("shadcn--sheet", SheetController)
271
-
272
- await nextFrame()
273
-
274
- element = document.querySelector('[data-controller="shadcn--sheet"]')
275
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
276
-
277
- controller.open()
278
- await nextFrame()
279
-
280
- const portal = await waitForPortal(".shadcn-sheet-portal")
281
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
282
-
283
- expect(content.dataset.side).toBe(side)
284
- })
285
- })
286
-
287
- test("defaults to right side when not specified", () => {
288
- expect(controller.sideValue).toBe("right")
289
- })
290
- })
291
-
292
- describe("overlay and content state", () => {
293
- test("sets overlay state to open when opening", async () => {
294
- controller.open()
295
- await nextFrame()
296
-
297
- const portal = await waitForPortal(".shadcn-sheet-portal")
298
- const overlay = portal.querySelector('[data-shadcn--sheet-target="overlay"]')
299
-
300
- expect(overlay.dataset.state).toBe("open")
301
- expect(overlay.hidden).toBe(false)
302
- })
303
-
304
- test("sets content state to open when opening", async () => {
305
- controller.open()
306
- await nextFrame()
307
-
308
- const portal = await waitForPortal(".shadcn-sheet-portal")
309
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
310
-
311
- expect(content.dataset.state).toBe("open")
312
- expect(content.hidden).toBe(false)
313
- })
314
-
315
- test("sets overlay state to closed when closing", async () => {
316
- controller.open()
317
- await nextFrame()
318
-
319
- const portal = await waitForPortal(".shadcn-sheet-portal")
320
- const overlay = portal.querySelector('[data-shadcn--sheet-target="overlay"]')
321
-
322
- controller.close()
323
-
324
- expect(overlay.dataset.state).toBe("closed")
325
- })
326
-
327
- test("sets content state to closed when closing", async () => {
328
- controller.open()
329
- await nextFrame()
330
-
331
- const portal = await waitForPortal(".shadcn-sheet-portal")
332
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
333
-
334
- controller.close()
335
-
336
- expect(content.dataset.state).toBe("closed")
337
- })
338
- })
339
-
340
- describe("focus management", () => {
341
- test("focuses first focusable element when opening", async () => {
342
- controller.open()
343
- await nextFrame()
344
- await nextFrame() // Wait for focus
345
-
346
- const portal = await waitForPortal(".shadcn-sheet-portal")
347
- const closeButton = portal.querySelector('.close-button')
348
-
349
- expect(document.activeElement).toBe(closeButton)
350
- })
351
-
352
- test("focuses content if no focusable elements", async () => {
353
- application.stop()
354
-
355
- const htmlWithNoFocusable = `
356
- <div data-controller="shadcn--sheet">
357
- <button data-shadcn--sheet-target="trigger" data-action="click->shadcn--sheet#toggle">
358
- Open
359
- </button>
360
- <template data-shadcn--sheet-target="template">
361
- <div data-shadcn--sheet-target="overlay" data-state="closed" hidden></div>
362
- <div data-shadcn--sheet-target="content" data-state="closed" hidden tabindex="-1">
363
- <div>No focusable elements</div>
364
- </div>
365
- </template>
366
- </div>
367
- `
368
-
369
- document.body.innerHTML = htmlWithNoFocusable
370
-
371
- application = Application.start()
372
- application.register("shadcn--sheet", SheetController)
373
-
374
- await nextFrame()
375
-
376
- element = document.querySelector('[data-controller="shadcn--sheet"]')
377
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
378
-
379
- controller.open()
380
- await nextFrame()
381
- await nextFrame()
382
-
383
- const portal = await waitForPortal(".shadcn-sheet-portal")
384
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
385
-
386
- expect(document.activeElement).toBe(content)
387
- })
388
-
389
- test("stores and restores previous active element", async () => {
390
- const trigger = element.querySelector('[data-shadcn--sheet-target="trigger"]')
391
- trigger.focus()
392
-
393
- expect(document.activeElement).toBe(trigger)
394
-
395
- controller.open()
396
- await nextFrame()
397
- await nextFrame()
398
-
399
- // Focus should have moved
400
- expect(document.activeElement).not.toBe(trigger)
401
-
402
- controller.close()
403
- await nextFrame()
404
-
405
- // Focus should be restored
406
- expect(document.activeElement).toBe(trigger)
407
- })
408
-
409
- test("traps focus with Tab key", async () => {
410
- controller.open()
411
- await nextFrame()
412
-
413
- const portal = await waitForPortal(".shadcn-sheet-portal")
414
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
415
- const focusableElements = getFocusableElements(content)
416
- const firstElement = focusableElements[0]
417
- const lastElement = focusableElements[focusableElements.length - 1]
418
-
419
- // Focus last element
420
- lastElement.focus()
421
- expect(document.activeElement).toBe(lastElement)
422
-
423
- // Tab forward should wrap to first
424
- keydown(document, 'Tab')
425
- await nextFrame()
426
-
427
- expect(document.activeElement).toBe(firstElement)
428
- })
429
-
430
- test("traps focus with Shift+Tab key", async () => {
431
- controller.open()
432
- await nextFrame()
433
-
434
- const portal = await waitForPortal(".shadcn-sheet-portal")
435
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
436
- const focusableElements = getFocusableElements(content)
437
- const firstElement = focusableElements[0]
438
- const lastElement = focusableElements[focusableElements.length - 1]
439
-
440
- // Focus first element
441
- firstElement.focus()
442
- expect(document.activeElement).toBe(firstElement)
443
-
444
- // Shift+Tab backward should wrap to last
445
- keydown(document, 'Tab', { shiftKey: true })
446
- await nextFrame()
447
-
448
- expect(document.activeElement).toBe(lastElement)
449
- })
450
-
451
- test("does not trap focus in middle of focusable elements", async () => {
452
- controller.open()
453
- await nextFrame()
454
-
455
- const portal = await waitForPortal(".shadcn-sheet-portal")
456
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
457
- const focusableElements = getFocusableElements(content)
458
- const firstElement = focusableElements[0]
459
- const secondElement = focusableElements[1]
460
-
461
- // Focus first element
462
- firstElement.focus()
463
-
464
- // Tab should move to second element naturally
465
- keydown(document, 'Tab')
466
-
467
- // The actual focus move is handled by browser, we just check preventDefault wasn't called
468
- // In a real scenario, focus would move to secondElement
469
- expect(document.activeElement).toBe(firstElement) // Focus hasn't moved yet in jsdom
470
- })
471
- })
472
-
473
- describe("escape key", () => {
474
- test("closes sheet when Escape is pressed", async () => {
475
- controller.open()
476
- await nextFrame()
477
-
478
- expect(controller.openValue).toBe(true)
479
-
480
- keydown(document, 'Escape')
481
- await nextFrame()
482
-
483
- expect(controller.openValue).toBe(false)
484
- })
485
-
486
- test("Escape key handler is added when opening", async () => {
487
- let eventListenerAdded = false
488
- const originalAdd = document.addEventListener
489
-
490
- document.addEventListener = function(event, handler) {
491
- if (event === 'keydown') {
492
- eventListenerAdded = true
493
- }
494
- return originalAdd.apply(this, arguments)
495
- }
496
-
497
- controller.open()
498
- await nextFrame()
499
-
500
- expect(eventListenerAdded).toBe(true)
501
-
502
- document.addEventListener = originalAdd
503
- })
504
-
505
- test("Escape key handler is removed when closing", async () => {
506
- controller.open()
507
- await nextFrame()
508
-
509
- let eventListenerRemoved = false
510
- const originalRemove = document.removeEventListener
511
-
512
- document.removeEventListener = function(event) {
513
- if (event === 'keydown') {
514
- eventListenerRemoved = true
515
- }
516
- return originalRemove.apply(this, arguments)
517
- }
518
-
519
- controller.close()
520
-
521
- expect(eventListenerRemoved).toBe(true)
522
-
523
- document.removeEventListener = originalRemove
524
- })
525
-
526
- test("other keys do not close sheet", async () => {
527
- controller.open()
528
- await nextFrame()
529
-
530
- expect(controller.openValue).toBe(true)
531
-
532
- keydown(document, 'Enter')
533
- expect(controller.openValue).toBe(true)
534
-
535
- keydown(document, 'Space')
536
- expect(controller.openValue).toBe(true)
537
-
538
- keydown(document, 'a')
539
- expect(controller.openValue).toBe(true)
540
- })
541
- })
542
-
543
- describe("overlay click", () => {
544
- test("closes sheet when overlay is clicked", async () => {
545
- controller.open()
546
- await nextFrame()
547
-
548
- const portal = await waitForPortal(".shadcn-sheet-portal")
549
- const overlay = portal.querySelector('[data-shadcn--sheet-target="overlay"]')
550
-
551
- expect(controller.openValue).toBe(true)
552
-
553
- click(overlay)
554
- await nextFrame()
555
-
556
- expect(controller.openValue).toBe(false)
557
- })
558
-
559
- test("re-attaches close event listeners to buttons in portal", async () => {
560
- controller.open()
561
- await nextFrame()
562
-
563
- const portal = await waitForPortal(".shadcn-sheet-portal")
564
- const closeButton = portal.querySelector('.close-button')
565
-
566
- expect(controller.openValue).toBe(true)
567
-
568
- click(closeButton)
569
- await nextFrame()
570
-
571
- expect(controller.openValue).toBe(false)
572
- })
573
-
574
- test("multiple close buttons all work", async () => {
575
- application.stop()
576
-
577
- const htmlWithMultipleClose = `
578
- <div data-controller="shadcn--sheet">
579
- <button data-shadcn--sheet-target="trigger" data-action="click->shadcn--sheet#toggle">
580
- Open
581
- </button>
582
- <template data-shadcn--sheet-target="template">
583
- <div data-shadcn--sheet-target="overlay" data-state="closed" hidden></div>
584
- <div data-shadcn--sheet-target="content" data-state="closed" hidden>
585
- <button data-action="click->shadcn--sheet#close" class="close-1">Close 1</button>
586
- <button data-action="click->shadcn--sheet#close" class="close-2">Close 2</button>
587
- <button data-action="click->shadcn--sheet#close" class="close-3">Close 3</button>
588
- </div>
589
- </template>
590
- </div>
591
- `
592
-
593
- document.body.innerHTML = htmlWithMultipleClose
594
-
595
- application = Application.start()
596
- application.register("shadcn--sheet", SheetController)
597
-
598
- await nextFrame()
599
-
600
- element = document.querySelector('[data-controller="shadcn--sheet"]')
601
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
602
-
603
- // Test each close button
604
- const closeButtons = ['.close-1', '.close-2', '.close-3']
605
-
606
- for (const selector of closeButtons) {
607
- controller.open()
608
- await nextFrame()
609
-
610
- const portal = await waitForPortal(".shadcn-sheet-portal")
611
- const closeButton = portal.querySelector(selector)
612
-
613
- click(closeButton)
614
- await nextFrame()
615
-
616
- expect(controller.openValue).toBe(false)
617
- }
618
- })
619
- })
620
-
621
- describe("body scroll lock", () => {
622
- test("locks body scroll when opening", async () => {
623
- expect(document.body.style.overflow).toBe("")
624
-
625
- controller.open()
626
- await nextFrame()
627
-
628
- expect(document.body.style.overflow).toBe("hidden")
629
- })
630
-
631
- test("unlocks body scroll when closing", async () => {
632
- controller.open()
633
- await nextFrame()
634
-
635
- expect(document.body.style.overflow).toBe("hidden")
636
-
637
- controller.close()
638
- await nextFrame()
639
-
640
- expect(document.body.style.overflow).toBe("")
641
- })
642
-
643
- test("restores body scroll even if closed multiple times", async () => {
644
- controller.open()
645
- await nextFrame()
646
-
647
- controller.close()
648
- controller.close()
649
- controller.close()
650
-
651
- expect(document.body.style.overflow).toBe("")
652
- })
653
- })
654
-
655
- describe("event dispatch", () => {
656
- test("dispatches opened event when opening", async () => {
657
- const eventPromise = waitForEvent(element, "shadcn--sheet:opened")
658
-
659
- controller.open()
660
-
661
- const event = await eventPromise
662
- expect(event).toBeDefined()
663
- })
664
-
665
- test("dispatches closed event when closing", async () => {
666
- controller.open()
667
- await nextFrame()
668
-
669
- const eventPromise = waitForEvent(element, "shadcn--sheet:closed")
670
-
671
- controller.close()
672
-
673
- const event = await eventPromise
674
- expect(event).toBeDefined()
675
- })
676
-
677
- test("opened event is dispatched after portal is created", async () => {
678
- let portalExists = false
679
-
680
- element.addEventListener("shadcn--sheet:opened", () => {
681
- portalExists = document.querySelector(".shadcn-sheet-portal") !== null
682
- })
683
-
684
- controller.open()
685
- await nextFrame()
686
-
687
- expect(portalExists).toBe(true)
688
- })
689
-
690
- test("closed event is dispatched immediately on close", async () => {
691
- controller.open()
692
- await nextFrame()
693
-
694
- let openValueOnEvent = null
695
-
696
- element.addEventListener("shadcn--sheet:closed", () => {
697
- openValueOnEvent = controller.openValue
698
- })
699
-
700
- controller.close()
701
- await nextFrame()
702
-
703
- expect(openValueOnEvent).toBe(false)
704
- })
705
- })
706
-
707
- describe("cleanup on disconnect", () => {
708
- test("closes sheet when controller disconnects", async () => {
709
- controller.open()
710
- await nextFrame()
711
-
712
- expect(controller.openValue).toBe(true)
713
-
714
- controller.disconnect()
715
-
716
- expect(controller.openValue).toBe(false)
717
- })
718
-
719
- test("removes portal when controller disconnects", async () => {
720
- controller.open()
721
- await nextFrame()
722
-
723
- const portal = await waitForPortal(".shadcn-sheet-portal")
724
- expect(portal).toBeTruthy()
725
-
726
- controller.disconnect()
727
-
728
- const portalAfterDisconnect = document.querySelector(".shadcn-sheet-portal")
729
- expect(portalAfterDisconnect).toBeNull()
730
- })
731
-
732
- test("removes keydown listener when controller disconnects", async () => {
733
- controller.open()
734
- await nextFrame()
735
-
736
- let listenerRemoved = false
737
- const originalRemove = document.removeEventListener
738
-
739
- document.removeEventListener = function(event) {
740
- if (event === 'keydown') {
741
- listenerRemoved = true
742
- }
743
- return originalRemove.apply(this, arguments)
744
- }
745
-
746
- controller.disconnect()
747
-
748
- expect(listenerRemoved).toBe(true)
749
-
750
- document.removeEventListener = originalRemove
751
- })
752
-
753
- test("unlocks body scroll when controller disconnects", async () => {
754
- controller.open()
755
- await nextFrame()
756
-
757
- expect(document.body.style.overflow).toBe("hidden")
758
-
759
- controller.disconnect()
760
-
761
- expect(document.body.style.overflow).toBe("")
762
- })
763
-
764
- test("disconnect while closed does not cause errors", () => {
765
- expect(controller.openValue).toBe(false)
766
-
767
- expect(() => {
768
- controller.disconnect()
769
- }).not.toThrow()
770
- })
771
- })
772
-
773
- describe("edge cases", () => {
774
- test("handles rapid open/close calls", async () => {
775
- controller.open()
776
- controller.close()
777
- controller.open()
778
- controller.close()
779
- controller.open()
780
-
781
- await nextFrame()
782
-
783
- expect(controller.openValue).toBe(true)
784
-
785
- const portal = await waitForPortal(".shadcn-sheet-portal")
786
- expect(portal).toBeTruthy()
787
- })
788
-
789
- test("handles missing template target gracefully", async () => {
790
- application.stop()
791
-
792
- const htmlWithoutTemplate = `
793
- <div data-controller="shadcn--sheet">
794
- <button data-shadcn--sheet-target="trigger" data-action="click->shadcn--sheet#toggle">
795
- Open
796
- </button>
797
- </div>
798
- `
799
-
800
- document.body.innerHTML = htmlWithoutTemplate
801
-
802
- application = Application.start()
803
- application.register("shadcn--sheet", SheetController)
804
-
805
- await nextFrame()
806
-
807
- element = document.querySelector('[data-controller="shadcn--sheet"]')
808
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
809
-
810
- expect(() => {
811
- controller.open()
812
- }).not.toThrow()
813
-
814
- // Should not create portal without template
815
- await nextFrame()
816
- const portal = document.querySelector(".shadcn-sheet-portal")
817
- expect(portal).toBeNull()
818
- })
819
-
820
- test("handles empty template content", async () => {
821
- application.stop()
822
-
823
- const htmlWithEmptyTemplate = `
824
- <div data-controller="shadcn--sheet">
825
- <button data-shadcn--sheet-target="trigger" data-action="click->shadcn--sheet#toggle">
826
- Open
827
- </button>
828
- <template data-shadcn--sheet-target="template"></template>
829
- </div>
830
- `
831
-
832
- document.body.innerHTML = htmlWithEmptyTemplate
833
-
834
- application = Application.start()
835
- application.register("shadcn--sheet", SheetController)
836
-
837
- await nextFrame()
838
-
839
- element = document.querySelector('[data-controller="shadcn--sheet"]')
840
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
841
-
842
- controller.open()
843
- await nextFrame()
844
-
845
- const portal = await waitForPortal(".shadcn-sheet-portal")
846
- expect(portal).toBeTruthy()
847
- expect(portal.innerHTML).toBe("")
848
- })
849
-
850
- test("handles focus trap when no focusable elements exist", async () => {
851
- application.stop()
852
-
853
- const htmlNoFocusable = `
854
- <div data-controller="shadcn--sheet">
855
- <button data-shadcn--sheet-target="trigger" data-action="click->shadcn--sheet#toggle">
856
- Open
857
- </button>
858
- <template data-shadcn--sheet-target="template">
859
- <div data-shadcn--sheet-target="overlay" data-state="closed" hidden></div>
860
- <div data-shadcn--sheet-target="content" data-state="closed" hidden tabindex="-1">
861
- <div>No focusable content</div>
862
- </div>
863
- </template>
864
- </div>
865
- `
866
-
867
- document.body.innerHTML = htmlNoFocusable
868
-
869
- application = Application.start()
870
- application.register("shadcn--sheet", SheetController)
871
-
872
- await nextFrame()
873
-
874
- element = document.querySelector('[data-controller="shadcn--sheet"]')
875
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
876
-
877
- controller.open()
878
- await nextFrame()
879
-
880
- // Should not throw when trying to trap focus
881
- expect(() => {
882
- keydown(document, 'Tab')
883
- }).not.toThrow()
884
- })
885
-
886
- test("portal removal timeout is cleared properly", async () => {
887
- controller.open()
888
- await nextFrame()
889
-
890
- controller.close()
891
-
892
- // Open again before timeout completes
893
- await wait(150) // Half of the 300ms timeout
894
- controller.open()
895
- await nextFrame()
896
-
897
- // Portal should exist
898
- const portal = await waitForPortal(".shadcn-sheet-portal")
899
- expect(portal).toBeTruthy()
900
- })
901
-
902
- test("previousActiveElement is null-safe", async () => {
903
- // Don't focus anything
904
- document.activeElement?.blur()
905
-
906
- controller.open()
907
- await nextFrame()
908
-
909
- controller.close()
910
- await nextFrame()
911
-
912
- // Should not throw even without previous active element
913
- expect(controller.openValue).toBe(false)
914
- })
915
- })
916
-
917
- describe("accessibility", () => {
918
- test("overlay has appropriate class for backdrop", async () => {
919
- controller.open()
920
- await nextFrame()
921
-
922
- const portal = await waitForPortal(".shadcn-sheet-portal")
923
- const overlay = portal.querySelector('[data-shadcn--sheet-target="overlay"]')
924
-
925
- expect(overlay.className).toContain("bg-black/80")
926
- expect(overlay.className).toContain("fixed")
927
- expect(overlay.className).toContain("inset-0")
928
- })
929
-
930
- test("content has appropriate positioning classes", async () => {
931
- controller.open()
932
- await nextFrame()
933
-
934
- const portal = await waitForPortal(".shadcn-sheet-portal")
935
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
936
-
937
- expect(content.className).toContain("fixed")
938
- expect(content.className).toContain("z-50")
939
- })
940
-
941
- test("content maintains data-side attribute", async () => {
942
- application.stop()
943
- document.body.innerHTML = createSheetHTML({ side: "left" })
944
-
945
- application = Application.start()
946
- application.register("shadcn--sheet", SheetController)
947
-
948
- await nextFrame()
949
-
950
- element = document.querySelector('[data-controller="shadcn--sheet"]')
951
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--sheet")
952
-
953
- controller.open()
954
- await nextFrame()
955
-
956
- const portal = await waitForPortal(".shadcn-sheet-portal")
957
- const content = portal.querySelector('[data-shadcn--sheet-target="content"]')
958
-
959
- expect(content.dataset.side).toBe("left")
960
- })
961
- })
962
-
963
- describe("snapshots", () => {
964
- test("renders closed sheet correctly", () => {
965
- expect(element.innerHTML).toMatchSnapshot()
966
- })
967
-
968
- test("renders with different sides", async () => {
969
- const sides = ["top", "right", "bottom", "left"]
970
-
971
- for (const side of sides) {
972
- application.stop()
973
- document.body.innerHTML = createSheetHTML({ side })
974
-
975
- application = Application.start()
976
- application.register("shadcn--sheet", SheetController)
977
-
978
- await nextFrame()
979
-
980
- element = document.querySelector('[data-controller="shadcn--sheet"]')
981
-
982
- expect(element.innerHTML).toMatchSnapshot(`side-${side}`)
983
- }
984
- })
985
- })
986
- })