shadcn-rails 0.1.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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -2
  3. data/README.md +102 -1398
  4. data/__mocks__/@floating-ui/dom.js +67 -0
  5. data/app/assets/javascripts/shadcn/controllers/base_menu_controller.js +266 -0
  6. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +34 -8
  7. data/app/assets/javascripts/shadcn/controllers/command_controller.js +5 -1
  8. data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +64 -135
  9. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +56 -186
  10. data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +29 -55
  11. data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +10 -7
  12. data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +10 -6
  13. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +35 -60
  14. data/app/assets/javascripts/shadcn/controllers/select_controller.js +37 -17
  15. data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +24 -14
  16. data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +28 -59
  17. data/app/assets/javascripts/shadcn/index.js +9 -1
  18. data/app/assets/javascripts/shadcn/utils/floating.js +179 -0
  19. data/app/assets/stylesheets/shadcn/base.css +32 -0
  20. data/app/assets/stylesheets/shadcn/components.css +12 -0
  21. data/app/components/shadcn/accordion_component.html.erb +8 -0
  22. data/app/components/shadcn/accordion_component.rb +6 -15
  23. data/app/components/shadcn/alert_component.html.erb +6 -0
  24. data/app/components/shadcn/alert_component.rb +0 -18
  25. data/app/components/shadcn/alert_dialog_component.html.erb +12 -0
  26. data/app/components/shadcn/alert_dialog_component.rb +7 -27
  27. data/app/components/shadcn/aspect_ratio_component.html.erb +7 -0
  28. data/app/components/shadcn/aspect_ratio_component.rb +4 -19
  29. data/app/components/shadcn/avatar_component.html.erb +20 -0
  30. data/app/components/shadcn/avatar_component.rb +8 -36
  31. data/app/components/shadcn/badge_component.html.erb +1 -0
  32. data/app/components/shadcn/badge_component.rb +0 -11
  33. data/app/components/shadcn/base_component.rb +15 -2
  34. data/app/components/shadcn/breadcrumb_component.html.erb +5 -0
  35. data/app/components/shadcn/breadcrumb_component.rb +6 -16
  36. data/app/components/shadcn/button_component.html.erb +18 -0
  37. data/app/components/shadcn/button_component.rb +1 -41
  38. data/app/components/shadcn/card_component.html.erb +8 -0
  39. data/app/components/shadcn/card_component.rb +2 -6
  40. data/app/components/shadcn/checkbox_component.html.erb +32 -0
  41. data/app/components/shadcn/checkbox_component.rb +4 -43
  42. data/app/components/shadcn/collapsible_component.html.erb +8 -0
  43. data/app/components/shadcn/collapsible_component.rb +6 -15
  44. data/app/components/shadcn/command_list_component.rb +29 -14
  45. data/app/components/shadcn/context_menu_checkbox_item_component.rb +76 -0
  46. data/app/components/shadcn/context_menu_component.html.erb +11 -0
  47. data/app/components/shadcn/context_menu_component.rb +6 -26
  48. data/app/components/shadcn/context_menu_content_component.rb +37 -14
  49. data/app/components/shadcn/context_menu_item_component.rb +3 -2
  50. data/app/components/shadcn/context_menu_radio_group_component.rb +42 -0
  51. data/app/components/shadcn/context_menu_radio_item_component.rb +76 -0
  52. data/app/components/shadcn/dialog_component.html.erb +14 -0
  53. data/app/components/shadcn/dialog_component.rb +8 -29
  54. data/app/components/shadcn/drawer_component.html.erb +12 -0
  55. data/app/components/shadcn/drawer_component.rb +7 -27
  56. data/app/components/shadcn/dropdown_menu_checkbox_item_component.rb +76 -0
  57. data/app/components/shadcn/dropdown_menu_component.html.erb +14 -0
  58. data/app/components/shadcn/dropdown_menu_component.rb +9 -29
  59. data/app/components/shadcn/dropdown_menu_content_component.rb +45 -16
  60. data/app/components/shadcn/dropdown_menu_radio_group_component.rb +42 -0
  61. data/app/components/shadcn/dropdown_menu_radio_item_component.rb +76 -0
  62. data/app/components/shadcn/field_component.rb +7 -8
  63. data/app/components/shadcn/hover_card_component.html.erb +12 -0
  64. data/app/components/shadcn/hover_card_component.rb +7 -26
  65. data/app/components/shadcn/input_component.html.erb +18 -0
  66. data/app/components/shadcn/input_component.rb +2 -27
  67. data/app/components/shadcn/input_otp_component.rb +3 -3
  68. data/app/components/shadcn/kbd_component.html.erb +1 -0
  69. data/app/components/shadcn/kbd_component.rb +3 -10
  70. data/app/components/shadcn/label_component.html.erb +3 -0
  71. data/app/components/shadcn/label_component.rb +2 -18
  72. data/app/components/shadcn/menubar_component.html.erb +6 -0
  73. data/app/components/shadcn/menubar_component.rb +4 -15
  74. data/app/components/shadcn/menubar_content_component.rb +45 -20
  75. data/app/components/shadcn/menubar_sub_content_component.rb +21 -8
  76. data/app/components/shadcn/native_select_component.html.erb +22 -0
  77. data/app/components/shadcn/native_select_component.rb +9 -39
  78. data/app/components/shadcn/navigation_menu_component.html.erb +6 -0
  79. data/app/components/shadcn/navigation_menu_component.rb +4 -15
  80. data/app/components/shadcn/pagination_component.html.erb +5 -0
  81. data/app/components/shadcn/pagination_component.rb +11 -15
  82. data/app/components/shadcn/popover_component.html.erb +15 -0
  83. data/app/components/shadcn/popover_component.rb +10 -30
  84. data/app/components/shadcn/progress_component.html.erb +13 -0
  85. data/app/components/shadcn/progress_component.rb +6 -26
  86. data/app/components/shadcn/radio_group_component.html.erb +8 -0
  87. data/app/components/shadcn/radio_group_component.rb +12 -26
  88. data/app/components/shadcn/radio_group_item_component.rb +32 -6
  89. data/app/components/shadcn/resizable_panel_group_component.rb +27 -16
  90. data/app/components/shadcn/scroll_area_component.html.erb +7 -0
  91. data/app/components/shadcn/scroll_area_component.rb +4 -16
  92. data/app/components/shadcn/select_component.html.erb +46 -0
  93. data/app/components/shadcn/select_component.rb +29 -86
  94. data/app/components/shadcn/separator_component.html.erb +5 -0
  95. data/app/components/shadcn/separator_component.rb +6 -14
  96. data/app/components/shadcn/sheet_component.html.erb +12 -0
  97. data/app/components/shadcn/sheet_component.rb +7 -27
  98. data/app/components/shadcn/sidebar_component.rb +2 -2
  99. data/app/components/shadcn/skeleton_component.html.erb +1 -0
  100. data/app/components/shadcn/skeleton_component.rb +4 -2
  101. data/app/components/shadcn/slider_component.html.erb +12 -0
  102. data/app/components/shadcn/slider_component.rb +2 -21
  103. data/app/components/shadcn/spinner_component.html.erb +18 -0
  104. data/app/components/shadcn/spinner_component.rb +2 -30
  105. data/app/components/shadcn/switch_component.html.erb +72 -0
  106. data/app/components/shadcn/switch_component.rb +4 -82
  107. data/app/components/shadcn/table_component.html.erb +9 -0
  108. data/app/components/shadcn/table_component.rb +2 -10
  109. data/app/components/shadcn/tabs_component.html.erb +8 -0
  110. data/app/components/shadcn/tabs_component.rb +4 -17
  111. data/app/components/shadcn/textarea_component.html.erb +13 -0
  112. data/app/components/shadcn/textarea_component.rb +6 -22
  113. data/app/components/shadcn/toast_component.html.erb +36 -0
  114. data/app/components/shadcn/toast_component.rb +6 -54
  115. data/app/components/shadcn/toggle_component.html.erb +12 -0
  116. data/app/components/shadcn/toggle_component.rb +6 -21
  117. data/app/components/shadcn/toggle_group_component.html.erb +14 -0
  118. data/app/components/shadcn/toggle_group_component.rb +6 -29
  119. data/app/components/shadcn/tooltip_component.html.erb +20 -0
  120. data/app/components/shadcn/tooltip_component.rb +13 -38
  121. data/lib/generators/shadcn/add/USAGE +24 -0
  122. data/lib/generators/shadcn/add/add_generator.rb +279 -0
  123. data/lib/generators/shadcn/install/USAGE +22 -0
  124. data/lib/generators/shadcn/install/install_generator.rb +8 -3
  125. data/lib/generators/shadcn/install/templates/initializer.rb.tt +7 -27
  126. data/lib/generators/shadcn/install/templates/shadcn.yml.tt +15 -31
  127. data/lib/shadcn/rails/version.rb +1 -1
  128. metadata +54 -42
  129. data/.dockerignore +0 -40
  130. data/CLAUDE.md +0 -463
  131. data/PROGRESS.md +0 -485
  132. data/Rakefile +0 -29
  133. data/__tests__/controllers/__snapshots__/calendar_controller.test.js.snap +0 -13
  134. data/__tests__/controllers/__snapshots__/popover_controller.test.js.snap +0 -46
  135. data/__tests__/controllers/__snapshots__/sheet_controller.test.js.snap +0 -111
  136. data/__tests__/controllers/__snapshots__/tabs_controller.test.js.snap +0 -27
  137. data/__tests__/controllers/accordion_controller.test.js +0 -904
  138. data/__tests__/controllers/calendar_controller.test.js +0 -1370
  139. data/__tests__/controllers/carousel_controller.test.js +0 -912
  140. data/__tests__/controllers/checkbox_controller.test.js +0 -454
  141. data/__tests__/controllers/collapsible_controller.test.js +0 -407
  142. data/__tests__/controllers/combobox_controller.test.js +0 -966
  143. data/__tests__/controllers/context_menu_controller.test.js +0 -627
  144. data/__tests__/controllers/date_picker_controller.test.js +0 -636
  145. data/__tests__/controllers/dialog_controller.test.js +0 -878
  146. data/__tests__/controllers/drawer_controller.test.js +0 -995
  147. data/__tests__/controllers/menubar_controller.test.js +0 -736
  148. data/__tests__/controllers/navigation_menu_controller.test.js +0 -598
  149. data/__tests__/controllers/popover_controller.test.js +0 -1007
  150. data/__tests__/controllers/radio_group_controller.test.js +0 -640
  151. data/__tests__/controllers/resizable_controller.test.js +0 -680
  152. data/__tests__/controllers/select_controller.test.js +0 -674
  153. data/__tests__/controllers/sheet_controller.test.js +0 -986
  154. data/__tests__/controllers/slider_controller.test.js +0 -1036
  155. data/__tests__/controllers/switch_controller.test.js +0 -424
  156. data/__tests__/controllers/tabs_controller.test.js +0 -907
  157. data/__tests__/controllers/toggle_group_controller.test.js +0 -839
  158. data/__tests__/controllers/tooltip_controller.test.js +0 -808
  159. data/__tests__/helpers/stimulus-test-helper.js +0 -203
  160. data/babel.config.cjs +0 -5
  161. data/bin/console +0 -11
  162. data/bin/setup +0 -8
  163. data/jest.config.js +0 -19
  164. data/jest.setup.js +0 -8
  165. data/lib/generators/shadcn/component/component_generator.rb +0 -188
  166. data/lib/generators/shadcn/theme/theme_generator.rb +0 -128
  167. data/package-lock.json +0 -7415
  168. data/package.json +0 -68
  169. data/rollup.config.js +0 -29
@@ -1,640 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import RadioGroupController from "../../app/assets/javascripts/shadcn/controllers/radio_group_controller.js"
3
- import { setupController, cleanupController, click, nextFrame, keydown } from '../helpers/stimulus-test-helper.js'
4
-
5
- describe("RadioGroupController", () => {
6
- let application
7
- let element
8
- let controller
9
-
10
- afterEach(() => {
11
- cleanupController(application)
12
- })
13
-
14
- describe("basic rendering and initialization", () => {
15
- const basicHTML = `
16
- <div data-controller="shadcn--radio-group"
17
- data-shadcn--radio-group-name-value="size"
18
- data-shadcn--radio-group-value-value=""
19
- role="radiogroup">
20
- <button data-shadcn--radio-group-target="item"
21
- data-value="small"
22
- role="radio"
23
- type="button"
24
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
25
- Small
26
- <span data-shadcn--radio-group-target="indicator" class="opacity-0"></span>
27
- </button>
28
- <button data-shadcn--radio-group-target="item"
29
- data-value="medium"
30
- role="radio"
31
- type="button"
32
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
33
- Medium
34
- <span data-shadcn--radio-group-target="indicator" class="opacity-0"></span>
35
- </button>
36
- <button data-shadcn--radio-group-target="item"
37
- data-value="large"
38
- role="radio"
39
- type="button"
40
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
41
- Large
42
- <span data-shadcn--radio-group-target="indicator" class="opacity-0"></span>
43
- </button>
44
- </div>
45
- `
46
-
47
- beforeEach(async () => {
48
- const setup = await setupController(RadioGroupController, basicHTML, 'shadcn--radio-group')
49
- application = setup.application
50
- element = setup.element
51
- controller = setup.controller
52
- })
53
-
54
- test("initializes with empty value", () => {
55
- expect(controller.valueValue).toBe("")
56
- })
57
-
58
- test("initializes with name value", () => {
59
- expect(controller.nameValue).toBe("size")
60
- })
61
-
62
- test("initializes all items with aria-checked false", () => {
63
- controller.itemTargets.forEach(item => {
64
- expect(item.getAttribute("aria-checked")).toBe("false")
65
- })
66
- })
67
-
68
- test("initializes all items with data-state unchecked", () => {
69
- controller.itemTargets.forEach(item => {
70
- expect(item.dataset.state).toBe("unchecked")
71
- })
72
- })
73
-
74
- test("first enabled item is focusable when no value selected", () => {
75
- const firstItem = controller.itemTargets[0]
76
- expect(firstItem.getAttribute("tabindex")).toBe("0")
77
- })
78
-
79
- test("other items are not focusable initially", () => {
80
- const secondItem = controller.itemTargets[1]
81
- const thirdItem = controller.itemTargets[2]
82
- expect(secondItem.getAttribute("tabindex")).toBe("-1")
83
- expect(thirdItem.getAttribute("tabindex")).toBe("-1")
84
- })
85
- })
86
-
87
- describe("selection", () => {
88
- const selectionHTML = `
89
- <div data-controller="shadcn--radio-group"
90
- data-shadcn--radio-group-name-value="option"
91
- data-shadcn--radio-group-value-value="">
92
- <button data-shadcn--radio-group-target="item"
93
- data-value="one"
94
- role="radio"
95
- data-action="click->shadcn--radio-group#select">
96
- One
97
- <span data-shadcn--radio-group-target="indicator" class="opacity-0"></span>
98
- </button>
99
- <button data-shadcn--radio-group-target="item"
100
- data-value="two"
101
- role="radio"
102
- data-action="click->shadcn--radio-group#select">
103
- Two
104
- <span data-shadcn--radio-group-target="indicator" class="opacity-0"></span>
105
- </button>
106
- </div>
107
- `
108
-
109
- beforeEach(async () => {
110
- const setup = await setupController(RadioGroupController, selectionHTML, 'shadcn--radio-group')
111
- application = setup.application
112
- element = setup.element
113
- controller = setup.controller
114
- })
115
-
116
- test("selects item when clicked", async () => {
117
- const firstItem = controller.itemTargets[0]
118
- click(firstItem)
119
- await nextFrame()
120
-
121
- expect(controller.valueValue).toBe("one")
122
- })
123
-
124
- test("updates aria-checked on selected item", async () => {
125
- const firstItem = controller.itemTargets[0]
126
- click(firstItem)
127
- await nextFrame()
128
-
129
- expect(firstItem.getAttribute("aria-checked")).toBe("true")
130
- })
131
-
132
- test("updates data-state on selected item", async () => {
133
- const firstItem = controller.itemTargets[0]
134
- click(firstItem)
135
- await nextFrame()
136
-
137
- expect(firstItem.dataset.state).toBe("checked")
138
- })
139
-
140
- test("makes selected item focusable", async () => {
141
- const secondItem = controller.itemTargets[1]
142
- click(secondItem)
143
- await nextFrame()
144
-
145
- expect(secondItem.getAttribute("tabindex")).toBe("0")
146
- })
147
-
148
- test("makes non-selected items non-focusable", async () => {
149
- const firstItem = controller.itemTargets[0]
150
- const secondItem = controller.itemTargets[1]
151
- click(secondItem)
152
- await nextFrame()
153
-
154
- expect(firstItem.getAttribute("tabindex")).toBe("-1")
155
- })
156
-
157
- test("deselects previous selection when new item selected", async () => {
158
- const firstItem = controller.itemTargets[0]
159
- const secondItem = controller.itemTargets[1]
160
-
161
- click(firstItem)
162
- await nextFrame()
163
- expect(firstItem.getAttribute("aria-checked")).toBe("true")
164
-
165
- click(secondItem)
166
- await nextFrame()
167
- expect(firstItem.getAttribute("aria-checked")).toBe("false")
168
- expect(secondItem.getAttribute("aria-checked")).toBe("true")
169
- })
170
-
171
- test("dispatches change event on selection", async () => {
172
- let eventDetail = null
173
- element.addEventListener("shadcn--radio-group:change", (e) => {
174
- eventDetail = e.detail
175
- })
176
-
177
- const firstItem = controller.itemTargets[0]
178
- click(firstItem)
179
- await nextFrame()
180
-
181
- expect(eventDetail).not.toBeNull()
182
- expect(eventDetail.value).toBe("one")
183
- expect(eventDetail.name).toBe("option")
184
- })
185
-
186
- test("dispatches native input event on selection", async () => {
187
- let inputEventFired = false
188
- element.addEventListener("input", () => {
189
- inputEventFired = true
190
- })
191
-
192
- const firstItem = controller.itemTargets[0]
193
- click(firstItem)
194
- await nextFrame()
195
-
196
- expect(inputEventFired).toBe(true)
197
- })
198
- })
199
-
200
- describe("indicator visibility", () => {
201
- const indicatorHTML = `
202
- <div data-controller="shadcn--radio-group"
203
- data-shadcn--radio-group-value-value="">
204
- <button data-shadcn--radio-group-target="item"
205
- data-value="a"
206
- data-action="click->shadcn--radio-group#select">
207
- A
208
- <span data-shadcn--radio-group-target="indicator" class="opacity-0"></span>
209
- </button>
210
- <button data-shadcn--radio-group-target="item"
211
- data-value="b"
212
- data-action="click->shadcn--radio-group#select">
213
- B
214
- <span data-shadcn--radio-group-target="indicator" class="opacity-0"></span>
215
- </button>
216
- </div>
217
- `
218
-
219
- beforeEach(async () => {
220
- const setup = await setupController(RadioGroupController, indicatorHTML, 'shadcn--radio-group')
221
- application = setup.application
222
- element = setup.element
223
- controller = setup.controller
224
- })
225
-
226
- test("shows indicator for selected item", async () => {
227
- const firstItem = controller.itemTargets[0]
228
- click(firstItem)
229
- await nextFrame()
230
-
231
- const indicator = firstItem.querySelector('[data-shadcn--radio-group-target="indicator"]')
232
- expect(indicator.classList.contains("opacity-0")).toBe(false)
233
- })
234
-
235
- test("hides indicator for non-selected items", async () => {
236
- const firstItem = controller.itemTargets[0]
237
- const secondItem = controller.itemTargets[1]
238
- click(firstItem)
239
- await nextFrame()
240
-
241
- const indicator = secondItem.querySelector('[data-shadcn--radio-group-target="indicator"]')
242
- expect(indicator.classList.contains("opacity-0")).toBe(true)
243
- })
244
- })
245
-
246
- describe("keyboard navigation", () => {
247
- const keyboardHTML = `
248
- <div data-controller="shadcn--radio-group"
249
- data-shadcn--radio-group-value-value="">
250
- <button data-shadcn--radio-group-target="item"
251
- data-value="first"
252
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
253
- First
254
- </button>
255
- <button data-shadcn--radio-group-target="item"
256
- data-value="second"
257
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
258
- Second
259
- </button>
260
- <button data-shadcn--radio-group-target="item"
261
- data-value="third"
262
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
263
- Third
264
- </button>
265
- </div>
266
- `
267
-
268
- beforeEach(async () => {
269
- const setup = await setupController(RadioGroupController, keyboardHTML, 'shadcn--radio-group')
270
- application = setup.application
271
- element = setup.element
272
- controller = setup.controller
273
- })
274
-
275
- test("navigates forward with ArrowDown", async () => {
276
- const firstItem = controller.itemTargets[0]
277
- const secondItem = controller.itemTargets[1]
278
- const focusSpy = jest.spyOn(secondItem, 'focus')
279
-
280
- controller.handleKeydown({
281
- key: "ArrowDown",
282
- preventDefault: jest.fn(),
283
- currentTarget: firstItem
284
- })
285
- await nextFrame()
286
-
287
- expect(focusSpy).toHaveBeenCalled()
288
- expect(controller.valueValue).toBe("second")
289
- })
290
-
291
- test("navigates forward with ArrowRight", async () => {
292
- const firstItem = controller.itemTargets[0]
293
- const secondItem = controller.itemTargets[1]
294
- const focusSpy = jest.spyOn(secondItem, 'focus')
295
-
296
- controller.handleKeydown({
297
- key: "ArrowRight",
298
- preventDefault: jest.fn(),
299
- currentTarget: firstItem
300
- })
301
- await nextFrame()
302
-
303
- expect(focusSpy).toHaveBeenCalled()
304
- })
305
-
306
- test("navigates backward with ArrowUp", async () => {
307
- const firstItem = controller.itemTargets[0]
308
- const secondItem = controller.itemTargets[1]
309
- const focusSpy = jest.spyOn(firstItem, 'focus')
310
-
311
- controller.handleKeydown({
312
- key: "ArrowUp",
313
- preventDefault: jest.fn(),
314
- currentTarget: secondItem
315
- })
316
- await nextFrame()
317
-
318
- expect(focusSpy).toHaveBeenCalled()
319
- expect(controller.valueValue).toBe("first")
320
- })
321
-
322
- test("navigates backward with ArrowLeft", async () => {
323
- const firstItem = controller.itemTargets[0]
324
- const secondItem = controller.itemTargets[1]
325
- const focusSpy = jest.spyOn(firstItem, 'focus')
326
-
327
- controller.handleKeydown({
328
- key: "ArrowLeft",
329
- preventDefault: jest.fn(),
330
- currentTarget: secondItem
331
- })
332
- await nextFrame()
333
-
334
- expect(focusSpy).toHaveBeenCalled()
335
- })
336
-
337
- test("wraps from last to first with ArrowDown", async () => {
338
- const firstItem = controller.itemTargets[0]
339
- const thirdItem = controller.itemTargets[2]
340
- const focusSpy = jest.spyOn(firstItem, 'focus')
341
-
342
- controller.handleKeydown({
343
- key: "ArrowDown",
344
- preventDefault: jest.fn(),
345
- currentTarget: thirdItem
346
- })
347
- await nextFrame()
348
-
349
- expect(focusSpy).toHaveBeenCalled()
350
- })
351
-
352
- test("wraps from first to last with ArrowUp", async () => {
353
- const firstItem = controller.itemTargets[0]
354
- const thirdItem = controller.itemTargets[2]
355
- const focusSpy = jest.spyOn(thirdItem, 'focus')
356
-
357
- controller.handleKeydown({
358
- key: "ArrowUp",
359
- preventDefault: jest.fn(),
360
- currentTarget: firstItem
361
- })
362
- await nextFrame()
363
-
364
- expect(focusSpy).toHaveBeenCalled()
365
- })
366
-
367
- test("selects current item with Space", async () => {
368
- const secondItem = controller.itemTargets[1]
369
-
370
- controller.handleKeydown({
371
- key: " ",
372
- preventDefault: jest.fn(),
373
- currentTarget: secondItem
374
- })
375
- await nextFrame()
376
-
377
- expect(controller.valueValue).toBe("second")
378
- })
379
-
380
- test("selects current item with Enter", async () => {
381
- const secondItem = controller.itemTargets[1]
382
-
383
- controller.handleKeydown({
384
- key: "Enter",
385
- preventDefault: jest.fn(),
386
- currentTarget: secondItem
387
- })
388
- await nextFrame()
389
-
390
- expect(controller.valueValue).toBe("second")
391
- })
392
-
393
- test("dispatches change event on keyboard navigation", async () => {
394
- let eventDetail = null
395
- element.addEventListener("shadcn--radio-group:change", (e) => {
396
- eventDetail = e.detail
397
- })
398
-
399
- const firstItem = controller.itemTargets[0]
400
- controller.handleKeydown({
401
- key: "ArrowDown",
402
- preventDefault: jest.fn(),
403
- currentTarget: firstItem
404
- })
405
- await nextFrame()
406
-
407
- expect(eventDetail).not.toBeNull()
408
- expect(eventDetail.value).toBe("second")
409
- })
410
- })
411
-
412
- describe("disabled items", () => {
413
- const disabledHTML = `
414
- <div data-controller="shadcn--radio-group"
415
- data-shadcn--radio-group-value-value="">
416
- <button data-shadcn--radio-group-target="item"
417
- data-value="enabled"
418
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
419
- Enabled
420
- </button>
421
- <button data-shadcn--radio-group-target="item"
422
- data-value="disabled"
423
- disabled
424
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
425
- Disabled
426
- </button>
427
- <button data-shadcn--radio-group-target="item"
428
- data-value="also-enabled"
429
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">
430
- Also Enabled
431
- </button>
432
- </div>
433
- `
434
-
435
- beforeEach(async () => {
436
- const setup = await setupController(RadioGroupController, disabledHTML, 'shadcn--radio-group')
437
- application = setup.application
438
- element = setup.element
439
- controller = setup.controller
440
- })
441
-
442
- test("does not select disabled item on click", async () => {
443
- const disabledItem = controller.itemTargets[1]
444
- click(disabledItem)
445
- await nextFrame()
446
-
447
- expect(controller.valueValue).toBe("")
448
- })
449
-
450
- test("enabledItems excludes disabled items", () => {
451
- const enabled = controller.enabledItems
452
- expect(enabled.length).toBe(2)
453
- expect(enabled.map(item => item.dataset.value)).toEqual(["enabled", "also-enabled"])
454
- })
455
-
456
- test("keyboard navigation skips disabled items", async () => {
457
- const firstItem = controller.itemTargets[0]
458
- const thirdItem = controller.itemTargets[2]
459
- const focusSpy = jest.spyOn(thirdItem, 'focus')
460
-
461
- controller.handleKeydown({
462
- key: "ArrowDown",
463
- preventDefault: jest.fn(),
464
- currentTarget: firstItem
465
- })
466
- await nextFrame()
467
-
468
- expect(focusSpy).toHaveBeenCalled()
469
- expect(controller.valueValue).toBe("also-enabled")
470
- })
471
- })
472
-
473
- describe("initial value", () => {
474
- const initialValueHTML = `
475
- <div data-controller="shadcn--radio-group"
476
- data-shadcn--radio-group-value-value="medium">
477
- <button data-shadcn--radio-group-target="item"
478
- data-value="small">Small</button>
479
- <button data-shadcn--radio-group-target="item"
480
- data-value="medium">Medium</button>
481
- <button data-shadcn--radio-group-target="item"
482
- data-value="large">Large</button>
483
- </div>
484
- `
485
-
486
- beforeEach(async () => {
487
- const setup = await setupController(RadioGroupController, initialValueHTML, 'shadcn--radio-group')
488
- application = setup.application
489
- element = setup.element
490
- controller = setup.controller
491
- })
492
-
493
- test("initializes with pre-set value", () => {
494
- expect(controller.valueValue).toBe("medium")
495
- })
496
-
497
- test("marks correct item as checked on init", () => {
498
- const mediumItem = controller.itemTargets[1]
499
- expect(mediumItem.getAttribute("aria-checked")).toBe("true")
500
- expect(mediumItem.dataset.state).toBe("checked")
501
- })
502
-
503
- test("selected item is focusable on init", () => {
504
- const mediumItem = controller.itemTargets[1]
505
- expect(mediumItem.getAttribute("tabindex")).toBe("0")
506
- })
507
-
508
- test("non-selected items are not focusable on init", () => {
509
- const smallItem = controller.itemTargets[0]
510
- const largeItem = controller.itemTargets[2]
511
- expect(smallItem.getAttribute("tabindex")).toBe("-1")
512
- expect(largeItem.getAttribute("tabindex")).toBe("-1")
513
- })
514
- })
515
-
516
- describe("programmatic value change", () => {
517
- const programmaticHTML = `
518
- <div data-controller="shadcn--radio-group"
519
- data-shadcn--radio-group-value-value="">
520
- <button data-shadcn--radio-group-target="item" data-value="x">X</button>
521
- <button data-shadcn--radio-group-target="item" data-value="y">Y</button>
522
- </div>
523
- `
524
-
525
- beforeEach(async () => {
526
- const setup = await setupController(RadioGroupController, programmaticHTML, 'shadcn--radio-group')
527
- application = setup.application
528
- element = setup.element
529
- controller = setup.controller
530
- })
531
-
532
- test("updates selection when valueValue changes", async () => {
533
- controller.valueValue = "y"
534
- await nextFrame()
535
-
536
- const yItem = controller.itemTargets[1]
537
- expect(yItem.getAttribute("aria-checked")).toBe("true")
538
- })
539
-
540
- test("valueValueChanged callback updates UI", async () => {
541
- controller.valueValue = "x"
542
- await nextFrame()
543
-
544
- const xItem = controller.itemTargets[0]
545
- const yItem = controller.itemTargets[1]
546
- expect(xItem.getAttribute("aria-checked")).toBe("true")
547
- expect(yItem.getAttribute("aria-checked")).toBe("false")
548
- })
549
- })
550
-
551
- describe("edge cases", () => {
552
- const edgeCaseHTML = `
553
- <div data-controller="shadcn--radio-group"
554
- data-shadcn--radio-group-value-value="">
555
- <button data-shadcn--radio-group-target="item"
556
- data-value="only"
557
- data-action="click->shadcn--radio-group#select keydown->shadcn--radio-group#handleKeydown">Only Option</button>
558
- </div>
559
- `
560
-
561
- beforeEach(async () => {
562
- const setup = await setupController(RadioGroupController, edgeCaseHTML, 'shadcn--radio-group')
563
- application = setup.application
564
- element = setup.element
565
- controller = setup.controller
566
- })
567
-
568
- test("handles single item gracefully", async () => {
569
- const onlyItem = controller.itemTargets[0]
570
- click(onlyItem)
571
- await nextFrame()
572
-
573
- expect(controller.valueValue).toBe("only")
574
- })
575
-
576
- test("keyboard navigation with single item stays on that item", async () => {
577
- const onlyItem = controller.itemTargets[0]
578
- const focusSpy = jest.spyOn(onlyItem, 'focus')
579
-
580
- controller.handleKeydown({
581
- key: "ArrowDown",
582
- preventDefault: jest.fn(),
583
- currentTarget: onlyItem
584
- })
585
- await nextFrame()
586
-
587
- expect(focusSpy).toHaveBeenCalled()
588
- })
589
- })
590
-
591
- describe("non-matching key handling", () => {
592
- const keyHandlingHTML = `
593
- <div data-controller="shadcn--radio-group"
594
- data-shadcn--radio-group-value-value="">
595
- <button data-shadcn--radio-group-target="item"
596
- data-value="a"
597
- data-action="keydown->shadcn--radio-group#handleKeydown">A</button>
598
- <button data-shadcn--radio-group-target="item"
599
- data-value="b"
600
- data-action="keydown->shadcn--radio-group#handleKeydown">B</button>
601
- </div>
602
- `
603
-
604
- beforeEach(async () => {
605
- const setup = await setupController(RadioGroupController, keyHandlingHTML, 'shadcn--radio-group')
606
- application = setup.application
607
- element = setup.element
608
- controller = setup.controller
609
- })
610
-
611
- test("ignores non-navigation keys", async () => {
612
- const firstItem = controller.itemTargets[0]
613
- const preventDefault = jest.fn()
614
-
615
- controller.handleKeydown({
616
- key: "Tab",
617
- preventDefault,
618
- currentTarget: firstItem
619
- })
620
- await nextFrame()
621
-
622
- expect(preventDefault).not.toHaveBeenCalled()
623
- expect(controller.valueValue).toBe("")
624
- })
625
-
626
- test("ignores letter keys", async () => {
627
- const firstItem = controller.itemTargets[0]
628
- const preventDefault = jest.fn()
629
-
630
- controller.handleKeydown({
631
- key: "a",
632
- preventDefault,
633
- currentTarget: firstItem
634
- })
635
- await nextFrame()
636
-
637
- expect(preventDefault).not.toHaveBeenCalled()
638
- })
639
- })
640
- })