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,878 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import DialogController from '../../app/assets/javascripts/shadcn/controllers/dialog_controller.js'
3
- import {
4
- setupController,
5
- cleanupController,
6
- click,
7
- wait,
8
- nextFrame,
9
- keydown,
10
- waitForPortal,
11
- getFocusableElements,
12
- waitForEvent
13
- } from '../helpers/stimulus-test-helper.js'
14
-
15
- describe('DialogController', () => {
16
- let application
17
- let element
18
- let controller
19
-
20
- const html = `
21
- <div data-controller="shadcn--dialog" data-shadcn--dialog-open-value="false" data-shadcn--dialog-modal-value="true">
22
- <button data-shadcn--dialog-target="trigger" data-action="click->shadcn--dialog#toggle">Open Dialog</button>
23
- <template data-shadcn--dialog-target="template">
24
- <div data-shadcn--dialog-target="overlay" class="fixed inset-0 bg-black/50" hidden></div>
25
- <div data-shadcn--dialog-target="content" role="dialog" aria-modal="true" hidden>
26
- <h2>Dialog Title</h2>
27
- <p>Dialog content goes here.</p>
28
- <button data-action="click->shadcn--dialog#close">Close</button>
29
- <input type="text" placeholder="First input">
30
- <input type="text" placeholder="Second input">
31
- <button>Submit</button>
32
- </div>
33
- </template>
34
- </div>
35
- `
36
-
37
- beforeEach(async () => {
38
- // Reset body overflow before each test
39
- document.body.style.overflow = ''
40
-
41
- const setup = await setupController(DialogController, html, 'shadcn--dialog')
42
- application = setup.application
43
- element = setup.element
44
- controller = setup.controller
45
- })
46
-
47
- afterEach(() => {
48
- cleanupController(application)
49
- // Clean up any remaining portals
50
- document.querySelectorAll('.shadcn-dialog-portal').forEach(portal => portal.remove())
51
- // Reset body overflow
52
- document.body.style.overflow = ''
53
- })
54
-
55
- describe('Value Initialization', () => {
56
- test('initializes with default open value as false', () => {
57
- expect(controller.openValue).toBe(false)
58
- })
59
-
60
- test('initializes with default modal value as true', () => {
61
- expect(controller.modalValue).toBe(true)
62
- })
63
-
64
- test('respects open value during initialization', async () => {
65
- const openHtml = html.replace('data-shadcn--dialog-open-value="false"', 'data-shadcn--dialog-open-value="true"')
66
- cleanupController(application)
67
-
68
- // When initialized with open=true, the connect() method calls open()
69
- // However, there's a check at the start of open() that returns if openValue is already true
70
- // This means the dialog won't actually open during initialization with this implementation
71
- // The test verifies the value is set correctly, even if the dialog doesn't render
72
- const setup = await setupController(DialogController, openHtml, 'shadcn--dialog')
73
- application = setup.application
74
- element = setup.element
75
- controller = setup.controller
76
-
77
- expect(controller.openValue).toBe(true)
78
-
79
- // Due to the guard in open(), initialization with open=true doesn't create the portal
80
- // To actually open it, we need to call toggle() or close then open
81
- controller.toggle() // This closes it
82
- expect(controller.openValue).toBe(false)
83
-
84
- controller.toggle() // This opens it
85
- await nextFrame()
86
- expect(controller.openValue).toBe(true)
87
-
88
- const portal = await waitForPortal('.shadcn-dialog-portal')
89
- expect(portal).toBeTruthy()
90
- })
91
-
92
- test('can be initialized with modal value as false', async () => {
93
- const nonModalHtml = html.replace('data-shadcn--dialog-modal-value="true"', 'data-shadcn--dialog-modal-value="false"')
94
- cleanupController(application)
95
-
96
- const setup = await setupController(DialogController, nonModalHtml, 'shadcn--dialog')
97
- application = setup.application
98
- controller = setup.controller
99
-
100
- expect(controller.modalValue).toBe(false)
101
- })
102
- })
103
-
104
- describe('Portal Rendering', () => {
105
- test('creates portal in body when dialog opens', async () => {
106
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
107
- click(trigger)
108
- await nextFrame()
109
-
110
- const portal = await waitForPortal('.shadcn-dialog-portal')
111
- expect(portal).toBeTruthy()
112
- expect(portal.parentElement).toBe(document.body)
113
- })
114
-
115
- test('portal contains overlay element', async () => {
116
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
117
- click(trigger)
118
- await nextFrame()
119
-
120
- const portal = await waitForPortal('.shadcn-dialog-portal')
121
- const overlay = portal.querySelector('[data-shadcn--dialog-target="overlay"]')
122
- expect(overlay).toBeTruthy()
123
- expect(overlay.classList.contains('fixed')).toBe(true)
124
- })
125
-
126
- test('portal contains content element', async () => {
127
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
128
- click(trigger)
129
- await nextFrame()
130
-
131
- const portal = await waitForPortal('.shadcn-dialog-portal')
132
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
133
- expect(content).toBeTruthy()
134
- expect(content.getAttribute('role')).toBe('dialog')
135
- expect(content.getAttribute('aria-modal')).toBe('true')
136
- })
137
-
138
- test('removes hidden attribute from overlay when opened', async () => {
139
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
140
- click(trigger)
141
- await nextFrame()
142
-
143
- const portal = await waitForPortal('.shadcn-dialog-portal')
144
- const overlay = portal.querySelector('[data-shadcn--dialog-target="overlay"]')
145
- expect(overlay.hasAttribute('hidden')).toBe(false)
146
- })
147
-
148
- test('removes hidden attribute from content when opened', async () => {
149
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
150
- click(trigger)
151
- await nextFrame()
152
-
153
- const portal = await waitForPortal('.shadcn-dialog-portal')
154
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
155
- expect(content.hasAttribute('hidden')).toBe(false)
156
- })
157
-
158
- test('sets data-state="open" on overlay', async () => {
159
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
160
- click(trigger)
161
- await nextFrame()
162
-
163
- const portal = await waitForPortal('.shadcn-dialog-portal')
164
- const overlay = portal.querySelector('[data-shadcn--dialog-target="overlay"]')
165
- expect(overlay.dataset.state).toBe('open')
166
- })
167
-
168
- test('sets data-state="open" on content', async () => {
169
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
170
- click(trigger)
171
- await nextFrame()
172
-
173
- const portal = await waitForPortal('.shadcn-dialog-portal')
174
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
175
- expect(content.dataset.state).toBe('open')
176
- })
177
-
178
- test('removes portal from DOM when dialog closes', async () => {
179
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
180
- click(trigger)
181
- await nextFrame()
182
-
183
- let portal = await waitForPortal('.shadcn-dialog-portal')
184
- expect(portal).toBeTruthy()
185
-
186
- controller.close()
187
- await wait(250) // Wait for animation timeout (200ms + buffer)
188
-
189
- portal = document.querySelector('.shadcn-dialog-portal')
190
- expect(portal).toBeNull()
191
- })
192
-
193
- test('sets data-state="closed" when closing', async () => {
194
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
195
- click(trigger)
196
- await nextFrame()
197
-
198
- const portal = await waitForPortal('.shadcn-dialog-portal')
199
- const overlay = portal.querySelector('[data-shadcn--dialog-target="overlay"]')
200
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
201
-
202
- controller.close()
203
-
204
- expect(overlay.dataset.state).toBe('closed')
205
- expect(content.dataset.state).toBe('closed')
206
- })
207
-
208
- test('only creates portal once on multiple opens', async () => {
209
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
210
-
211
- click(trigger)
212
- await nextFrame()
213
- let portals = document.querySelectorAll('.shadcn-dialog-portal')
214
- expect(portals.length).toBe(1)
215
-
216
- controller.close()
217
- await wait(250)
218
-
219
- click(trigger)
220
- await nextFrame()
221
- portals = document.querySelectorAll('.shadcn-dialog-portal')
222
- expect(portals.length).toBe(1)
223
- })
224
- })
225
-
226
- describe('Open/Close/Toggle', () => {
227
- test('opens dialog when toggle is called on closed dialog', async () => {
228
- expect(controller.openValue).toBe(false)
229
-
230
- controller.toggle()
231
- await nextFrame()
232
-
233
- expect(controller.openValue).toBe(true)
234
- const portal = await waitForPortal('.shadcn-dialog-portal')
235
- expect(portal).toBeTruthy()
236
- })
237
-
238
- test('closes dialog when toggle is called on open dialog', async () => {
239
- controller.open()
240
- await nextFrame()
241
- expect(controller.openValue).toBe(true)
242
-
243
- controller.toggle()
244
- expect(controller.openValue).toBe(false)
245
- })
246
-
247
- test('open method sets openValue to true', async () => {
248
- controller.open()
249
- await nextFrame()
250
- expect(controller.openValue).toBe(true)
251
- })
252
-
253
- test('close method sets openValue to false', async () => {
254
- controller.open()
255
- await nextFrame()
256
- expect(controller.openValue).toBe(true)
257
-
258
- controller.close()
259
- expect(controller.openValue).toBe(false)
260
- })
261
-
262
- test('trigger button click toggles dialog', async () => {
263
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
264
-
265
- click(trigger)
266
- await nextFrame()
267
- expect(controller.openValue).toBe(true)
268
-
269
- click(trigger)
270
- expect(controller.openValue).toBe(false)
271
- })
272
-
273
- test('does not open again if already open', async () => {
274
- controller.open()
275
- await nextFrame()
276
- const firstPortal = controller.portal
277
-
278
- controller.open()
279
- await nextFrame()
280
-
281
- expect(controller.portal).toBe(firstPortal)
282
- })
283
-
284
- test('does not close again if already closed', () => {
285
- expect(controller.openValue).toBe(false)
286
- controller.close()
287
- expect(controller.openValue).toBe(false)
288
- })
289
- })
290
-
291
- describe('Focus Management', () => {
292
- test('focuses first focusable element when dialog opens', async () => {
293
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
294
- click(trigger)
295
- await nextFrame()
296
-
297
- const portal = await waitForPortal('.shadcn-dialog-portal')
298
- const focusableElements = getFocusableElements(portal)
299
-
300
- expect(document.activeElement).toBe(focusableElements[0])
301
- })
302
-
303
- test('stores previous active element before opening', async () => {
304
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
305
- trigger.focus()
306
- expect(document.activeElement).toBe(trigger)
307
-
308
- click(trigger)
309
- await nextFrame()
310
-
311
- expect(controller.previousActiveElement).toBe(trigger)
312
- })
313
-
314
- test('returns focus to previous element when dialog closes', async () => {
315
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
316
- trigger.focus()
317
-
318
- click(trigger)
319
- await nextFrame()
320
-
321
- controller.close()
322
- await nextFrame()
323
-
324
- expect(document.activeElement).toBe(trigger)
325
- })
326
-
327
- test('focuses content element if no focusable elements exist', async () => {
328
- const noFocusHtml = `
329
- <div data-controller="shadcn--dialog">
330
- <button data-shadcn--dialog-target="trigger" data-action="click->shadcn--dialog#toggle">Open</button>
331
- <template data-shadcn--dialog-target="template">
332
- <div data-shadcn--dialog-target="overlay" class="fixed inset-0 bg-black/50"></div>
333
- <div data-shadcn--dialog-target="content" role="dialog" aria-modal="true" tabindex="-1">
334
- <p>No focusable elements</p>
335
- </div>
336
- </template>
337
- </div>
338
- `
339
-
340
- cleanupController(application)
341
- const setup = await setupController(DialogController, noFocusHtml, 'shadcn--dialog')
342
- application = setup.application
343
- element = setup.element
344
- controller = setup.controller
345
-
346
- const trigger = element.querySelector('[data-shadcn--dialog-target="trigger"]')
347
- click(trigger)
348
- await nextFrame()
349
-
350
- const portal = await waitForPortal('.shadcn-dialog-portal')
351
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
352
-
353
- // Content should be focused
354
- expect(document.activeElement).toBe(content)
355
- })
356
- })
357
-
358
- describe('Focus Trap', () => {
359
- test('traps Tab key to cycle through focusable elements', async () => {
360
- controller.open()
361
- await nextFrame()
362
-
363
- const portal = await waitForPortal('.shadcn-dialog-portal')
364
- const focusableElements = Array.from(getFocusableElements(portal))
365
-
366
- // First element should be focused initially
367
- expect(document.activeElement).toBe(focusableElements[0])
368
-
369
- // Tab to second element
370
- keydown(document, 'Tab')
371
- focusableElements[1].focus()
372
- expect(document.activeElement).toBe(focusableElements[1])
373
-
374
- // Tab to third element
375
- keydown(document, 'Tab')
376
- focusableElements[2].focus()
377
- expect(document.activeElement).toBe(focusableElements[2])
378
- })
379
-
380
- test('cycles focus to first element when Tab on last element', async () => {
381
- controller.open()
382
- await nextFrame()
383
-
384
- const portal = await waitForPortal('.shadcn-dialog-portal')
385
- const focusableElements = Array.from(getFocusableElements(portal))
386
- const lastElement = focusableElements[focusableElements.length - 1]
387
- const firstElement = focusableElements[0]
388
-
389
- // Focus last element
390
- lastElement.focus()
391
- expect(document.activeElement).toBe(lastElement)
392
-
393
- // Tab should cycle to first
394
- const event = new KeyboardEvent('keydown', {
395
- key: 'Tab',
396
- bubbles: true,
397
- cancelable: true
398
- })
399
- document.dispatchEvent(event)
400
-
401
- if (event.defaultPrevented) {
402
- firstElement.focus()
403
- }
404
-
405
- expect(document.activeElement).toBe(firstElement)
406
- })
407
-
408
- test('cycles focus to last element when Shift+Tab on first element', async () => {
409
- controller.open()
410
- await nextFrame()
411
-
412
- const portal = await waitForPortal('.shadcn-dialog-portal')
413
- const focusableElements = Array.from(getFocusableElements(portal))
414
- const firstElement = focusableElements[0]
415
- const lastElement = focusableElements[focusableElements.length - 1]
416
-
417
- // First element should be focused initially
418
- expect(document.activeElement).toBe(firstElement)
419
-
420
- // Shift+Tab should cycle to last
421
- const event = new KeyboardEvent('keydown', {
422
- key: 'Tab',
423
- shiftKey: true,
424
- bubbles: true,
425
- cancelable: true
426
- })
427
- document.dispatchEvent(event)
428
-
429
- if (event.defaultPrevented) {
430
- lastElement.focus()
431
- }
432
-
433
- expect(document.activeElement).toBe(lastElement)
434
- })
435
-
436
- test('does not trap focus when modal is false', async () => {
437
- const nonModalHtml = html.replace('data-shadcn--dialog-modal-value="true"', 'data-shadcn--dialog-modal-value="false"')
438
- cleanupController(application)
439
-
440
- const setup = await setupController(DialogController, nonModalHtml, 'shadcn--dialog')
441
- application = setup.application
442
- controller = setup.controller
443
-
444
- controller.open()
445
- await nextFrame()
446
-
447
- // Tab event should not be trapped
448
- const event = new KeyboardEvent('keydown', {
449
- key: 'Tab',
450
- bubbles: true,
451
- cancelable: true
452
- })
453
- document.dispatchEvent(event)
454
-
455
- expect(event.defaultPrevented).toBe(false)
456
- })
457
- })
458
-
459
- describe('Keyboard Navigation', () => {
460
- test('closes dialog when Escape key is pressed', async () => {
461
- controller.open()
462
- await nextFrame()
463
- expect(controller.openValue).toBe(true)
464
-
465
- keydown(document, 'Escape')
466
- expect(controller.openValue).toBe(false)
467
- })
468
-
469
- test('does not close when other keys are pressed', async () => {
470
- controller.open()
471
- await nextFrame()
472
- expect(controller.openValue).toBe(true)
473
-
474
- keydown(document, 'Enter')
475
- expect(controller.openValue).toBe(true)
476
-
477
- keydown(document, 'ArrowDown')
478
- expect(controller.openValue).toBe(true)
479
-
480
- keydown(document, 'Space')
481
- expect(controller.openValue).toBe(true)
482
- })
483
-
484
- test('only responds to Escape when dialog is open', () => {
485
- expect(controller.openValue).toBe(false)
486
-
487
- keydown(document, 'Escape')
488
-
489
- // Should not throw error or cause issues
490
- expect(controller.openValue).toBe(false)
491
- })
492
- })
493
-
494
- describe('Overlay Click', () => {
495
- test('closes dialog when overlay is clicked', async () => {
496
- controller.open()
497
- await nextFrame()
498
-
499
- const portal = await waitForPortal('.shadcn-dialog-portal')
500
- const overlay = portal.querySelector('[data-shadcn--dialog-target="overlay"]')
501
-
502
- expect(controller.openValue).toBe(true)
503
- click(overlay)
504
- expect(controller.openValue).toBe(false)
505
- })
506
-
507
- test('does not close when content is clicked', async () => {
508
- controller.open()
509
- await nextFrame()
510
-
511
- const portal = await waitForPortal('.shadcn-dialog-portal')
512
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
513
-
514
- expect(controller.openValue).toBe(true)
515
- click(content)
516
- expect(controller.openValue).toBe(true)
517
- })
518
- })
519
-
520
- describe('Body Scroll Lock', () => {
521
- test('sets body overflow to hidden when modal dialog opens', async () => {
522
- expect(document.body.style.overflow).toBe('')
523
-
524
- controller.open()
525
- await nextFrame()
526
-
527
- expect(document.body.style.overflow).toBe('hidden')
528
- })
529
-
530
- test('restores body overflow when modal dialog closes', async () => {
531
- controller.open()
532
- await nextFrame()
533
- expect(document.body.style.overflow).toBe('hidden')
534
-
535
- controller.close()
536
- expect(document.body.style.overflow).toBe('')
537
- })
538
-
539
- test('does not set body overflow when modal is false', async () => {
540
- const nonModalHtml = html.replace('data-shadcn--dialog-modal-value="true"', 'data-shadcn--dialog-modal-value="false"')
541
- cleanupController(application)
542
-
543
- const setup = await setupController(DialogController, nonModalHtml, 'shadcn--dialog')
544
- application = setup.application
545
- controller = setup.controller
546
-
547
- controller.open()
548
- await nextFrame()
549
-
550
- expect(document.body.style.overflow).toBe('')
551
- })
552
-
553
- test('restores body overflow even if closed early', async () => {
554
- controller.open()
555
- await nextFrame()
556
- expect(document.body.style.overflow).toBe('hidden')
557
-
558
- // Close immediately without waiting
559
- controller.close()
560
- expect(document.body.style.overflow).toBe('')
561
- })
562
- })
563
-
564
- describe('ARIA Attributes', () => {
565
- test('content has role="dialog"', async () => {
566
- controller.open()
567
- await nextFrame()
568
-
569
- const portal = await waitForPortal('.shadcn-dialog-portal')
570
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
571
-
572
- expect(content.getAttribute('role')).toBe('dialog')
573
- })
574
-
575
- test('content has aria-modal="true"', async () => {
576
- controller.open()
577
- await nextFrame()
578
-
579
- const portal = await waitForPortal('.shadcn-dialog-portal')
580
- const content = portal.querySelector('[data-shadcn--dialog-target="content"]')
581
-
582
- expect(content.getAttribute('aria-modal')).toBe('true')
583
- })
584
- })
585
-
586
- describe('Event Dispatching', () => {
587
- test('dispatches opened event when dialog opens', async () => {
588
- const eventPromise = waitForEvent(element, 'shadcn--dialog:opened')
589
-
590
- controller.open()
591
- await nextFrame()
592
-
593
- const event = await eventPromise
594
- expect(event).toBeTruthy()
595
- expect(event.type).toBe('shadcn--dialog:opened')
596
- })
597
-
598
- test('dispatches closed event when dialog closes', async () => {
599
- controller.open()
600
- await nextFrame()
601
-
602
- const eventPromise = waitForEvent(element, 'shadcn--dialog:closed')
603
- controller.close()
604
-
605
- const event = await eventPromise
606
- expect(event).toBeTruthy()
607
- expect(event.type).toBe('shadcn--dialog:closed')
608
- })
609
-
610
- test('does not dispatch opened event if already open', async () => {
611
- controller.open()
612
- await nextFrame()
613
-
614
- let eventCount = 0
615
- element.addEventListener('shadcn--dialog:opened', () => eventCount++)
616
-
617
- controller.open()
618
- await nextFrame()
619
-
620
- expect(eventCount).toBe(0)
621
- })
622
-
623
- test('does not dispatch closed event if already closed', () => {
624
- let eventCount = 0
625
- element.addEventListener('shadcn--dialog:closed', () => eventCount++)
626
-
627
- controller.close()
628
-
629
- expect(eventCount).toBe(0)
630
- })
631
- })
632
-
633
- describe('Close Actions', () => {
634
- test('closes dialog when close button is clicked', async () => {
635
- controller.open()
636
- await nextFrame()
637
-
638
- const portal = await waitForPortal('.shadcn-dialog-portal')
639
- const closeButton = portal.querySelector('[data-action*="shadcn--dialog#close"]')
640
-
641
- expect(controller.openValue).toBe(true)
642
- click(closeButton)
643
- expect(controller.openValue).toBe(false)
644
- })
645
-
646
- test('wires up close actions on portal elements', async () => {
647
- const closeHtml = `
648
- <div data-controller="shadcn--dialog">
649
- <button data-shadcn--dialog-target="trigger" data-action="click->shadcn--dialog#toggle">Open</button>
650
- <template data-shadcn--dialog-target="template">
651
- <div data-shadcn--dialog-target="overlay"></div>
652
- <div data-shadcn--dialog-target="content" role="dialog">
653
- <button data-action="click->shadcn--dialog#close">Close 1</button>
654
- <button data-action="click->shadcn--dialog#close">Close 2</button>
655
- </div>
656
- </template>
657
- </div>
658
- `
659
-
660
- cleanupController(application)
661
- const setup = await setupController(DialogController, closeHtml, 'shadcn--dialog')
662
- application = setup.application
663
- controller = setup.controller
664
-
665
- controller.open()
666
- await nextFrame()
667
-
668
- const portal = await waitForPortal('.shadcn-dialog-portal')
669
- const closeButtons = portal.querySelectorAll('[data-action*="shadcn--dialog#close"]')
670
-
671
- expect(closeButtons.length).toBe(2)
672
-
673
- click(closeButtons[1])
674
- expect(controller.openValue).toBe(false)
675
- })
676
- })
677
-
678
- describe('Cleanup and Disconnect', () => {
679
- test('removes portal when controller disconnects', async () => {
680
- controller.open()
681
- await nextFrame()
682
-
683
- let portal = await waitForPortal('.shadcn-dialog-portal')
684
- expect(portal).toBeTruthy()
685
-
686
- controller.disconnect()
687
-
688
- portal = document.querySelector('.shadcn-dialog-portal')
689
- expect(portal).toBeNull()
690
- })
691
-
692
- test('restores body overflow when controller disconnects', async () => {
693
- controller.open()
694
- await nextFrame()
695
- expect(document.body.style.overflow).toBe('hidden')
696
-
697
- controller.disconnect()
698
- expect(document.body.style.overflow).toBe('')
699
- })
700
-
701
- test('removes event listeners when controller disconnects', async () => {
702
- controller.open()
703
- await nextFrame()
704
-
705
- controller.disconnect()
706
-
707
- // Try to trigger events - should not cause errors
708
- keydown(document, 'Escape')
709
- keydown(document, 'Tab')
710
-
711
- // Should not throw errors
712
- expect(true).toBe(true)
713
- })
714
-
715
- test('closes dialog and cleans up when disconnecting', async () => {
716
- controller.open()
717
- await nextFrame()
718
- expect(controller.openValue).toBe(true)
719
-
720
- controller.disconnect()
721
-
722
- expect(document.body.style.overflow).toBe('')
723
- const portal = document.querySelector('.shadcn-dialog-portal')
724
- expect(portal).toBeNull()
725
- })
726
- })
727
-
728
- describe('openValueChanged Callback', () => {
729
- test('triggers when open() is called', async () => {
730
- expect(controller.openValue).toBe(false)
731
-
732
- // open() sets openValue to true, which triggers openValueChanged
733
- controller.open()
734
- await nextFrame()
735
-
736
- expect(controller.openValue).toBe(true)
737
- const portal = await waitForPortal('.shadcn-dialog-portal')
738
- expect(portal).toBeTruthy()
739
- })
740
-
741
- test('triggers when close() is called', async () => {
742
- controller.open()
743
- await nextFrame()
744
- expect(controller.openValue).toBe(true)
745
-
746
- // close() sets openValue to false, which triggers openValueChanged
747
- controller.close()
748
- expect(controller.openValue).toBe(false)
749
-
750
- // Body overflow should be restored
751
- expect(document.body.style.overflow).toBe('')
752
- })
753
-
754
- test('value can be changed programmatically from closed to open', async () => {
755
- expect(controller.openValue).toBe(false)
756
-
757
- // The openValueChanged callback will call open() when value changes to true
758
- // But open() checks if already open and returns early
759
- // So we need to use the public API: toggle() or open()
760
- controller.open()
761
- await nextFrame()
762
-
763
- expect(controller.openValue).toBe(true)
764
- const portal = await waitForPortal('.shadcn-dialog-portal')
765
- expect(portal).toBeTruthy()
766
- })
767
- })
768
-
769
- describe('Edge Cases', () => {
770
- test('handles rapid open/close calls', async () => {
771
- controller.open()
772
- controller.close()
773
- controller.open()
774
- controller.close()
775
- await nextFrame()
776
-
777
- expect(controller.openValue).toBe(false)
778
- })
779
-
780
- test('handles missing template target gracefully', async () => {
781
- const noTemplateHtml = `
782
- <div data-controller="shadcn--dialog">
783
- <button data-action="click->shadcn--dialog#toggle">Open</button>
784
- </div>
785
- `
786
-
787
- cleanupController(application)
788
- const setup = await setupController(DialogController, noTemplateHtml, 'shadcn--dialog')
789
- application = setup.application
790
- controller = setup.controller
791
-
792
- // Should not throw error
793
- controller.open()
794
- await nextFrame()
795
-
796
- const portal = document.querySelector('.shadcn-dialog-portal')
797
- expect(portal).toBeNull()
798
- })
799
-
800
- test('handles missing overlay target', async () => {
801
- const noOverlayHtml = `
802
- <div data-controller="shadcn--dialog">
803
- <button data-shadcn--dialog-target="trigger" data-action="click->shadcn--dialog#toggle">Open</button>
804
- <template data-shadcn--dialog-target="template">
805
- <div data-shadcn--dialog-target="content" role="dialog">
806
- <p>Content</p>
807
- </div>
808
- </template>
809
- </div>
810
- `
811
-
812
- cleanupController(application)
813
- const setup = await setupController(DialogController, noOverlayHtml, 'shadcn--dialog')
814
- application = setup.application
815
- controller = setup.controller
816
-
817
- controller.open()
818
- await nextFrame()
819
-
820
- // Should not throw error
821
- expect(controller.openValue).toBe(true)
822
- })
823
-
824
- test('handles multiple dialogs on same page', async () => {
825
- const multiDialogHtml = `
826
- <div>
827
- <div data-controller="shadcn--dialog">
828
- <button data-shadcn--dialog-target="trigger" data-action="click->shadcn--dialog#toggle">Open 1</button>
829
- <template data-shadcn--dialog-target="template">
830
- <div data-shadcn--dialog-target="overlay" class="overlay-1"></div>
831
- <div data-shadcn--dialog-target="content" role="dialog" class="content-1">
832
- <button data-action="click->shadcn--dialog#close">Close 1</button>
833
- </div>
834
- </template>
835
- </div>
836
- <div data-controller="shadcn--dialog">
837
- <button data-shadcn--dialog-target="trigger" data-action="click->shadcn--dialog#toggle">Open 2</button>
838
- <template data-shadcn--dialog-target="template">
839
- <div data-shadcn--dialog-target="overlay" class="overlay-2"></div>
840
- <div data-shadcn--dialog-target="content" role="dialog" class="content-2">
841
- <button data-action="click->shadcn--dialog#close">Close 2</button>
842
- </div>
843
- </template>
844
- </div>
845
- </div>
846
- `
847
-
848
- cleanupController(application)
849
- document.body.innerHTML = multiDialogHtml
850
-
851
- const app = Application.start()
852
- app.register('shadcn--dialog', DialogController)
853
-
854
- await nextFrame()
855
-
856
- const dialogs = document.querySelectorAll('[data-controller="shadcn--dialog"]')
857
- const trigger1 = dialogs[0].querySelector('[data-shadcn--dialog-target="trigger"]')
858
- const trigger2 = dialogs[1].querySelector('[data-shadcn--dialog-target="trigger"]')
859
-
860
- click(trigger1)
861
- await nextFrame()
862
-
863
- let portals = document.querySelectorAll('.shadcn-dialog-portal')
864
- expect(portals.length).toBe(1)
865
- expect(portals[0].querySelector('.content-1')).toBeTruthy()
866
-
867
- click(trigger2)
868
- await nextFrame()
869
-
870
- portals = document.querySelectorAll('.shadcn-dialog-portal')
871
- expect(portals.length).toBe(2)
872
- expect(portals[1].querySelector('.content-2')).toBeTruthy()
873
-
874
- app.stop()
875
- document.querySelectorAll('.shadcn-dialog-portal').forEach(p => p.remove())
876
- })
877
- })
878
- })