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,907 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import TabsController from "../../app/assets/javascripts/shadcn/controllers/tabs_controller.js"
3
- import { setupController, cleanupController, click, wait, nextFrame, keydown, mockLocation, mockHistory, waitForEvent } from '../helpers/stimulus-test-helper.js'
4
-
5
- describe("TabsController", () => {
6
- let application
7
- let element
8
- let controller
9
-
10
- const createTabsHTML = (options = {}) => {
11
- const {
12
- defaultValue = "tab1",
13
- urlParam = null,
14
- tabCount = 3
15
- } = options
16
-
17
- const urlParamAttr = urlParam ? `data-shadcn--tabs-url-param-value="${urlParam}"` : ''
18
-
19
- const triggers = Array.from({ length: tabCount }, (_, i) => {
20
- const tabNum = i + 1
21
- return `<button data-shadcn--tabs-target="trigger" data-value="tab${tabNum}" role="tab" data-action="click->shadcn--tabs#selectTab keydown->shadcn--tabs#handleKeydown">Tab ${tabNum}</button>`
22
- }).join('\n')
23
-
24
- const contents = Array.from({ length: tabCount }, (_, i) => {
25
- const tabNum = i + 1
26
- const hidden = tabNum === 1 ? '' : 'hidden'
27
- return `<div data-shadcn--tabs-target="content" data-value="tab${tabNum}" role="tabpanel" ${hidden}>Content ${tabNum}</div>`
28
- }).join('\n')
29
-
30
- return `
31
- <div data-controller="shadcn--tabs"
32
- data-shadcn--tabs-default-value-value="${defaultValue}"
33
- ${urlParamAttr}>
34
- <div data-shadcn--tabs-target="list" role="tablist">
35
- ${triggers}
36
- </div>
37
- ${contents}
38
- </div>
39
- `
40
- }
41
-
42
- beforeEach(async () => {
43
- application = Application.start()
44
- application.register("shadcn--tabs", TabsController)
45
- document.body.innerHTML = createTabsHTML()
46
-
47
- await new Promise(resolve => requestAnimationFrame(resolve))
48
-
49
- element = document.querySelector('[data-controller="shadcn--tabs"]')
50
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--tabs")
51
- })
52
-
53
- afterEach(() => {
54
- if (application) {
55
- application.stop()
56
- }
57
- document.body.innerHTML = ""
58
- })
59
-
60
- describe("initialization", () => {
61
- test("connects successfully", () => {
62
- expect(controller).not.toBeNull()
63
- expect(controller).toBeDefined()
64
- })
65
-
66
- test("initializes with default value", () => {
67
- const trigger = element.querySelector('[data-value="tab1"]')
68
- expect(trigger.dataset.state).toBe("active")
69
- expect(trigger.getAttribute("aria-selected")).toBe("true")
70
- expect(trigger.tabIndex).toBe(0)
71
- })
72
-
73
- test("sets first tab as active when no default value", async () => {
74
- application.stop()
75
- document.body.innerHTML = createTabsHTML({ defaultValue: "" })
76
-
77
- application = Application.start()
78
- application.register("shadcn--tabs", TabsController)
79
-
80
- await new Promise(resolve => requestAnimationFrame(resolve))
81
-
82
- element = document.querySelector('[data-controller="shadcn--tabs"]')
83
- const trigger = element.querySelector('[data-value="tab1"]')
84
-
85
- expect(trigger.dataset.state).toBe("active")
86
- })
87
-
88
- test("initializes inactive tabs correctly", () => {
89
- const tab2 = element.querySelector('[data-value="tab2"]')
90
- const tab3 = element.querySelector('[data-value="tab3"]')
91
-
92
- expect(tab2.dataset.state).toBe("inactive")
93
- expect(tab2.getAttribute("aria-selected")).toBe("false")
94
- expect(tab2.tabIndex).toBe(-1)
95
-
96
- expect(tab3.dataset.state).toBe("inactive")
97
- expect(tab3.getAttribute("aria-selected")).toBe("false")
98
- expect(tab3.tabIndex).toBe(-1)
99
- })
100
-
101
- test("shows correct content panel on initialization", () => {
102
- const content1 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab1"]')
103
- const content2 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab2"]')
104
- const content3 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab3"]')
105
-
106
- expect(content1.dataset.state).toBe("active")
107
- expect(content1.hidden).toBe(false)
108
-
109
- expect(content2.dataset.state).toBe("inactive")
110
- expect(content2.hidden).toBe(true)
111
-
112
- expect(content3.dataset.state).toBe("inactive")
113
- expect(content3.hidden).toBe(true)
114
- })
115
-
116
- test("validates initial value and falls back to default if invalid", async () => {
117
- application.stop()
118
-
119
- const restoreLocation = mockLocation("http://localhost?tab=invalid")
120
-
121
- document.body.innerHTML = `
122
- <div data-controller="shadcn--tabs"
123
- data-shadcn--tabs-default-value-value="tab2"
124
- data-shadcn--tabs-url-param-value="tab">
125
- <div data-shadcn--tabs-target="list" role="tablist">
126
- <button data-shadcn--tabs-target="trigger" data-value="tab1" role="tab" data-action="click->shadcn--tabs#selectTab">Tab 1</button>
127
- <button data-shadcn--tabs-target="trigger" data-value="tab2" role="tab" data-action="click->shadcn--tabs#selectTab">Tab 2</button>
128
- </div>
129
- <div data-shadcn--tabs-target="content" data-value="tab1" role="tabpanel">Content 1</div>
130
- <div data-shadcn--tabs-target="content" data-value="tab2" role="tabpanel" hidden>Content 2</div>
131
- </div>
132
- `
133
-
134
- application = Application.start()
135
- application.register("shadcn--tabs", TabsController)
136
-
137
- await new Promise(resolve => requestAnimationFrame(resolve))
138
-
139
- element = document.querySelector('[data-controller="shadcn--tabs"]')
140
-
141
- // Should fall back to default value "tab2"
142
- const tab2 = element.querySelector('[data-value="tab2"]')
143
- expect(tab2.dataset.state).toBe("active")
144
-
145
- restoreLocation()
146
- })
147
- })
148
-
149
- describe("tab switching", () => {
150
- test("switches to clicked tab", () => {
151
- const tab2Trigger = element.querySelector('[data-value="tab2"]')
152
- click(tab2Trigger)
153
-
154
- expect(tab2Trigger.dataset.state).toBe("active")
155
- expect(tab2Trigger.getAttribute("aria-selected")).toBe("true")
156
- expect(tab2Trigger.tabIndex).toBe(0)
157
- })
158
-
159
- test("deactivates previously active tab", () => {
160
- const tab1 = element.querySelector('[data-value="tab1"]')
161
- const tab2 = element.querySelector('[data-value="tab2"]')
162
-
163
- expect(tab1.dataset.state).toBe("active")
164
-
165
- click(tab2)
166
-
167
- expect(tab1.dataset.state).toBe("inactive")
168
- expect(tab1.getAttribute("aria-selected")).toBe("false")
169
- expect(tab1.tabIndex).toBe(-1)
170
- })
171
-
172
- test("shows correct content panel when tab is clicked", () => {
173
- const tab2Trigger = element.querySelector('[data-value="tab2"]')
174
- const content1 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab1"]')
175
- const content2 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab2"]')
176
-
177
- click(tab2Trigger)
178
-
179
- expect(content1.dataset.state).toBe("inactive")
180
- expect(content1.hidden).toBe(true)
181
-
182
- expect(content2.dataset.state).toBe("active")
183
- expect(content2.hidden).toBe(false)
184
- })
185
-
186
- test("can switch between multiple tabs", () => {
187
- const tab2 = element.querySelector('[data-value="tab2"]')
188
- const tab3 = element.querySelector('[data-value="tab3"]')
189
- const tab1 = element.querySelector('[data-value="tab1"]')
190
-
191
- click(tab2)
192
- expect(tab2.dataset.state).toBe("active")
193
-
194
- click(tab3)
195
- expect(tab3.dataset.state).toBe("active")
196
- expect(tab2.dataset.state).toBe("inactive")
197
-
198
- click(tab1)
199
- expect(tab1.dataset.state).toBe("active")
200
- expect(tab3.dataset.state).toBe("inactive")
201
- })
202
-
203
- test("dispatches change event with correct value", async () => {
204
- const tab2 = element.querySelector('[data-value="tab2"]')
205
-
206
- const eventPromise = waitForEvent(element, "shadcn--tabs:change")
207
-
208
- click(tab2)
209
-
210
- const event = await eventPromise
211
- expect(event.detail.value).toBe("tab2")
212
- })
213
-
214
- test("dispatches change event when switching tabs", async () => {
215
- const tab2 = element.querySelector('[data-value="tab2"]')
216
-
217
- let changeEventFired = false
218
- element.addEventListener("shadcn--tabs:change", () => {
219
- changeEventFired = true
220
- })
221
-
222
- click(tab2)
223
-
224
- await nextFrame()
225
-
226
- expect(changeEventFired).toBe(true)
227
- })
228
- })
229
-
230
- describe("content visibility", () => {
231
- test("only selected tab content is visible", () => {
232
- const allContents = element.querySelectorAll('[data-shadcn--tabs-target="content"]')
233
-
234
- // Initially tab1 is active
235
- expect(allContents[0].hidden).toBe(false)
236
- expect(allContents[1].hidden).toBe(true)
237
- expect(allContents[2].hidden).toBe(true)
238
- })
239
-
240
- test("content visibility updates when switching tabs", () => {
241
- const tab3 = element.querySelector('[data-value="tab3"]')
242
- const allContents = element.querySelectorAll('[data-shadcn--tabs-target="content"]')
243
-
244
- click(tab3)
245
-
246
- expect(allContents[0].hidden).toBe(true)
247
- expect(allContents[1].hidden).toBe(true)
248
- expect(allContents[2].hidden).toBe(false)
249
- })
250
-
251
- test("content state attribute matches visibility", () => {
252
- const tab2 = element.querySelector('[data-value="tab2"]')
253
- const content2 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab2"]')
254
-
255
- click(tab2)
256
-
257
- expect(content2.dataset.state).toBe("active")
258
- expect(content2.hidden).toBe(false)
259
- })
260
- })
261
-
262
- describe("URL sync", () => {
263
- let historyMock
264
- let restoreLocation
265
-
266
- beforeEach(async () => {
267
- application.stop()
268
- document.body.innerHTML = ""
269
-
270
- // Mock window.location
271
- restoreLocation = mockLocation("http://localhost/")
272
-
273
- // Mock history methods
274
- historyMock = mockHistory()
275
-
276
- // Create tabs with URL param
277
- document.body.innerHTML = createTabsHTML({ urlParam: "tab" })
278
-
279
- application = Application.start()
280
- application.register("shadcn--tabs", TabsController)
281
-
282
- await new Promise(resolve => requestAnimationFrame(resolve))
283
-
284
- element = document.querySelector('[data-controller="shadcn--tabs"]')
285
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--tabs")
286
- })
287
-
288
- afterEach(() => {
289
- historyMock.restore()
290
- restoreLocation()
291
- })
292
-
293
- test("updates URL when tab is clicked", () => {
294
- const tab2 = element.querySelector('[data-value="tab2"]')
295
- click(tab2)
296
-
297
- expect(historyMock.calls.replaceState.length).toBe(1)
298
- expect(historyMock.calls.replaceState[0].url).toContain("tab=tab2")
299
- })
300
-
301
- test("does not update URL on initial load", () => {
302
- // Should not have called replaceState during initialization
303
- expect(historyMock.calls.replaceState.length).toBe(0)
304
- })
305
-
306
- test("reads initial tab from URL parameter", async () => {
307
- application.stop()
308
- historyMock.restore()
309
- restoreLocation()
310
-
311
- const newRestoreLocation = mockLocation("http://localhost?tab=tab3")
312
- const newHistoryMock = mockHistory()
313
-
314
- document.body.innerHTML = createTabsHTML({ urlParam: "tab" })
315
-
316
- application = Application.start()
317
- application.register("shadcn--tabs", TabsController)
318
-
319
- await new Promise(resolve => requestAnimationFrame(resolve))
320
-
321
- element = document.querySelector('[data-controller="shadcn--tabs"]')
322
-
323
- const tab3 = element.querySelector('[data-value="tab3"]')
324
- expect(tab3.dataset.state).toBe("active")
325
-
326
- newHistoryMock.restore()
327
- newRestoreLocation()
328
-
329
- // Re-initialize for next tests
330
- historyMock = mockHistory()
331
- restoreLocation = mockLocation("http://localhost/")
332
- })
333
-
334
- test("URL parameter takes precedence over default value", async () => {
335
- application.stop()
336
- historyMock.restore()
337
- restoreLocation()
338
-
339
- const newRestoreLocation = mockLocation("http://localhost?tab=tab2")
340
- const newHistoryMock = mockHistory()
341
-
342
- document.body.innerHTML = createTabsHTML({ defaultValue: "tab1", urlParam: "tab" })
343
-
344
- application = Application.start()
345
- application.register("shadcn--tabs", TabsController)
346
-
347
- await new Promise(resolve => requestAnimationFrame(resolve))
348
-
349
- element = document.querySelector('[data-controller="shadcn--tabs"]')
350
-
351
- const tab2 = element.querySelector('[data-value="tab2"]')
352
- expect(tab2.dataset.state).toBe("active")
353
-
354
- newHistoryMock.restore()
355
- newRestoreLocation()
356
-
357
- // Re-initialize for next tests
358
- historyMock = mockHistory()
359
- restoreLocation = mockLocation("http://localhost/")
360
- })
361
-
362
- test("does not update URL when urlParam is not set", async () => {
363
- application.stop()
364
- historyMock.restore()
365
- historyMock = mockHistory()
366
-
367
- document.body.innerHTML = createTabsHTML() // No urlParam
368
-
369
- application = Application.start()
370
- application.register("shadcn--tabs", TabsController)
371
-
372
- await new Promise(resolve => requestAnimationFrame(resolve))
373
-
374
- element = document.querySelector('[data-controller="shadcn--tabs"]')
375
-
376
- const tab2 = element.querySelector('[data-value="tab2"]')
377
- click(tab2)
378
-
379
- expect(historyMock.calls.replaceState.length).toBe(0)
380
- })
381
-
382
- test("handles popstate event for browser navigation", async () => {
383
- // Start with tab1 active
384
- const tab1 = element.querySelector('[data-value="tab1"]')
385
- const tab2 = element.querySelector('[data-value="tab2"]')
386
-
387
- expect(tab1.dataset.state).toBe("active")
388
-
389
- // Update the mocked location to have tab2 in the URL
390
- historyMock.restore()
391
- restoreLocation()
392
-
393
- const newRestoreLocation = mockLocation("http://localhost?tab=tab2")
394
- const newHistoryMock = mockHistory()
395
-
396
- // Re-get the element reference since we're in a new context
397
- element = document.querySelector('[data-controller="shadcn--tabs"]')
398
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--tabs")
399
-
400
- // Simulate browser back/forward by firing popstate
401
- window.dispatchEvent(new PopStateEvent('popstate'))
402
-
403
- await nextFrame()
404
-
405
- // Controller should react to popstate and switch tabs
406
- const newTab2 = element.querySelector('[data-value="tab2"]')
407
- const newTab1 = element.querySelector('[data-value="tab1"]')
408
-
409
- expect(newTab2.dataset.state).toBe("active")
410
- expect(newTab1.dataset.state).toBe("inactive")
411
-
412
- newHistoryMock.restore()
413
- newRestoreLocation()
414
-
415
- // Re-initialize for next tests
416
- historyMock = mockHistory()
417
- restoreLocation = mockLocation("http://localhost/")
418
- })
419
-
420
- test("cleans up popstate listener on disconnect", () => {
421
- let popstateRemoved = false
422
- const originalRemove = window.removeEventListener
423
-
424
- window.removeEventListener = function(event) {
425
- if (event === 'popstate') {
426
- popstateRemoved = true
427
- }
428
- return originalRemove.apply(this, arguments)
429
- }
430
-
431
- controller.disconnect()
432
-
433
- // Should have removed the popstate listener
434
- expect(popstateRemoved).toBe(true)
435
-
436
- window.removeEventListener = originalRemove
437
- })
438
-
439
- test("does not add popstate listener when urlParam is not set", async () => {
440
- application.stop()
441
- historyMock.restore()
442
- restoreLocation()
443
-
444
- let popstateAdded = false
445
- const originalAdd = window.addEventListener
446
-
447
- window.addEventListener = function(event) {
448
- if (event === 'popstate') {
449
- popstateAdded = true
450
- }
451
- return originalAdd.apply(this, arguments)
452
- }
453
-
454
- document.body.innerHTML = createTabsHTML() // No urlParam
455
-
456
- application = Application.start()
457
- application.register("shadcn--tabs", TabsController)
458
-
459
- await new Promise(resolve => requestAnimationFrame(resolve))
460
-
461
- // Should not have added popstate listener
462
- expect(popstateAdded).toBe(false)
463
-
464
- window.addEventListener = originalAdd
465
-
466
- // Re-initialize for potential next tests in URL sync suite
467
- historyMock = mockHistory()
468
- restoreLocation = mockLocation("http://localhost/")
469
- })
470
- })
471
-
472
- describe("keyboard navigation", () => {
473
- test("ArrowRight moves to next tab", () => {
474
- const tab1 = element.querySelector('[data-value="tab1"]')
475
- const tab2 = element.querySelector('[data-value="tab2"]')
476
-
477
- tab1.focus()
478
- keydown(tab1, 'ArrowRight')
479
-
480
- expect(document.activeElement).toBe(tab2)
481
- expect(tab2.dataset.state).toBe("active")
482
- })
483
-
484
- test("ArrowLeft moves to previous tab", () => {
485
- const tab2 = element.querySelector('[data-value="tab2"]')
486
- const tab1 = element.querySelector('[data-value="tab1"]')
487
-
488
- // First switch to tab2
489
- click(tab2)
490
- tab2.focus()
491
-
492
- keydown(tab2, 'ArrowLeft')
493
-
494
- expect(document.activeElement).toBe(tab1)
495
- expect(tab1.dataset.state).toBe("active")
496
- })
497
-
498
- test("ArrowDown moves to next tab", () => {
499
- const tab1 = element.querySelector('[data-value="tab1"]')
500
- const tab2 = element.querySelector('[data-value="tab2"]')
501
-
502
- tab1.focus()
503
- keydown(tab1, 'ArrowDown')
504
-
505
- expect(document.activeElement).toBe(tab2)
506
- expect(tab2.dataset.state).toBe("active")
507
- })
508
-
509
- test("ArrowUp moves to previous tab", () => {
510
- const tab2 = element.querySelector('[data-value="tab2"]')
511
- const tab1 = element.querySelector('[data-value="tab1"]')
512
-
513
- click(tab2)
514
- tab2.focus()
515
-
516
- keydown(tab2, 'ArrowUp')
517
-
518
- expect(document.activeElement).toBe(tab1)
519
- expect(tab1.dataset.state).toBe("active")
520
- })
521
-
522
- test("ArrowRight wraps from last to first tab", () => {
523
- const tab3 = element.querySelector('[data-value="tab3"]')
524
- const tab1 = element.querySelector('[data-value="tab1"]')
525
-
526
- click(tab3)
527
- tab3.focus()
528
-
529
- keydown(tab3, 'ArrowRight')
530
-
531
- expect(document.activeElement).toBe(tab1)
532
- expect(tab1.dataset.state).toBe("active")
533
- })
534
-
535
- test("ArrowLeft wraps from first to last tab", () => {
536
- const tab1 = element.querySelector('[data-value="tab1"]')
537
- const tab3 = element.querySelector('[data-value="tab3"]')
538
-
539
- tab1.focus()
540
-
541
- keydown(tab1, 'ArrowLeft')
542
-
543
- expect(document.activeElement).toBe(tab3)
544
- expect(tab3.dataset.state).toBe("active")
545
- })
546
-
547
- test("Home key moves to first tab", () => {
548
- const tab3 = element.querySelector('[data-value="tab3"]')
549
- const tab1 = element.querySelector('[data-value="tab1"]')
550
-
551
- click(tab3)
552
- tab3.focus()
553
-
554
- keydown(tab3, 'Home')
555
-
556
- expect(document.activeElement).toBe(tab1)
557
- expect(tab1.dataset.state).toBe("active")
558
- })
559
-
560
- test("End key moves to last tab", () => {
561
- const tab1 = element.querySelector('[data-value="tab1"]')
562
- const tab3 = element.querySelector('[data-value="tab3"]')
563
-
564
- tab1.focus()
565
-
566
- keydown(tab1, 'End')
567
-
568
- expect(document.activeElement).toBe(tab3)
569
- expect(tab3.dataset.state).toBe("active")
570
- })
571
-
572
- test("keyboard navigation triggers click to update content", () => {
573
- const tab1 = element.querySelector('[data-value="tab1"]')
574
- const content2 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab2"]')
575
-
576
- tab1.focus()
577
- keydown(tab1, 'ArrowRight')
578
-
579
- // Content should have switched
580
- expect(content2.hidden).toBe(false)
581
- expect(content2.dataset.state).toBe("active")
582
- })
583
-
584
- test("does not handle keyboard events when no tab is focused", () => {
585
- const tab1 = element.querySelector('[data-value="tab1"]')
586
- const tab2 = element.querySelector('[data-value="tab2"]')
587
-
588
- // Don't focus any tab
589
- document.body.focus()
590
-
591
- keydown(element, 'ArrowRight')
592
-
593
- // Should still be on tab1
594
- expect(tab1.dataset.state).toBe("active")
595
- expect(tab2.dataset.state).toBe("inactive")
596
- })
597
-
598
- test("ignores other keys", () => {
599
- const tab1 = element.querySelector('[data-value="tab1"]')
600
-
601
- tab1.focus()
602
-
603
- keydown(tab1, 'Enter')
604
- expect(tab1.dataset.state).toBe("active")
605
-
606
- keydown(tab1, ' ')
607
- expect(tab1.dataset.state).toBe("active")
608
-
609
- keydown(tab1, 'Tab')
610
- expect(tab1.dataset.state).toBe("active")
611
- })
612
-
613
- test("skips disabled tabs when navigating", async () => {
614
- // Create tabs with disabled trigger
615
- application.stop()
616
- document.body.innerHTML = `
617
- <div data-controller="shadcn--tabs"
618
- data-shadcn--tabs-default-value-value="tab1">
619
- <div data-shadcn--tabs-target="list" role="tablist">
620
- <button data-shadcn--tabs-target="trigger" data-value="tab1" role="tab" data-action="click->shadcn--tabs#selectTab keydown->shadcn--tabs#handleKeydown">Tab 1</button>
621
- <button data-shadcn--tabs-target="trigger" data-value="tab2" role="tab" disabled data-action="click->shadcn--tabs#selectTab keydown->shadcn--tabs#handleKeydown">Tab 2</button>
622
- <button data-shadcn--tabs-target="trigger" data-value="tab3" role="tab" data-action="click->shadcn--tabs#selectTab keydown->shadcn--tabs#handleKeydown">Tab 3</button>
623
- </div>
624
- <div data-shadcn--tabs-target="content" data-value="tab1" role="tabpanel">Content 1</div>
625
- <div data-shadcn--tabs-target="content" data-value="tab2" role="tabpanel" hidden>Content 2</div>
626
- <div data-shadcn--tabs-target="content" data-value="tab3" role="tabpanel" hidden>Content 3</div>
627
- </div>
628
- `
629
-
630
- application = Application.start()
631
- application.register("shadcn--tabs", TabsController)
632
-
633
- await new Promise(resolve => requestAnimationFrame(resolve))
634
-
635
- element = document.querySelector('[data-controller="shadcn--tabs"]')
636
-
637
- const tab1 = element.querySelector('[data-value="tab1"]')
638
- const tab3 = element.querySelector('[data-value="tab3"]')
639
-
640
- tab1.focus()
641
- keydown(tab1, 'ArrowRight')
642
-
643
- // Should skip disabled tab2 and go to tab3
644
- expect(document.activeElement).toBe(tab3)
645
- expect(tab3.dataset.state).toBe("active")
646
- })
647
-
648
- test("keyboard navigation prevents default behavior", () => {
649
- const tab1 = element.querySelector('[data-value="tab1"]')
650
- tab1.focus()
651
-
652
- let preventDefaultCalled = false
653
-
654
- const event = new KeyboardEvent('keydown', {
655
- key: 'ArrowRight',
656
- bubbles: true,
657
- cancelable: true
658
- })
659
-
660
- // Override preventDefault to track if it was called
661
- const originalPreventDefault = event.preventDefault
662
- event.preventDefault = function() {
663
- preventDefaultCalled = true
664
- return originalPreventDefault.apply(this, arguments)
665
- }
666
-
667
- tab1.dispatchEvent(event)
668
-
669
- expect(preventDefaultCalled).toBe(true)
670
- })
671
- })
672
-
673
- describe("ARIA attributes", () => {
674
- test("triggers have correct role", () => {
675
- const triggers = element.querySelectorAll('[data-shadcn--tabs-target="trigger"]')
676
-
677
- triggers.forEach(trigger => {
678
- expect(trigger.getAttribute("role")).toBe("tab")
679
- })
680
- })
681
-
682
- test("list has correct role", () => {
683
- const list = element.querySelector('[data-shadcn--tabs-target="list"]')
684
- expect(list.getAttribute("role")).toBe("tablist")
685
- })
686
-
687
- test("content panels have correct role", () => {
688
- const contents = element.querySelectorAll('[data-shadcn--tabs-target="content"]')
689
-
690
- contents.forEach(content => {
691
- expect(content.getAttribute("role")).toBe("tabpanel")
692
- })
693
- })
694
-
695
- test("active tab has aria-selected=true", () => {
696
- const tab1 = element.querySelector('[data-value="tab1"]')
697
- expect(tab1.getAttribute("aria-selected")).toBe("true")
698
- })
699
-
700
- test("inactive tabs have aria-selected=false", () => {
701
- const tab2 = element.querySelector('[data-value="tab2"]')
702
- const tab3 = element.querySelector('[data-value="tab3"]')
703
-
704
- expect(tab2.getAttribute("aria-selected")).toBe("false")
705
- expect(tab3.getAttribute("aria-selected")).toBe("false")
706
- })
707
-
708
- test("aria-selected updates when tab is clicked", () => {
709
- const tab1 = element.querySelector('[data-value="tab1"]')
710
- const tab2 = element.querySelector('[data-value="tab2"]')
711
-
712
- click(tab2)
713
-
714
- expect(tab1.getAttribute("aria-selected")).toBe("false")
715
- expect(tab2.getAttribute("aria-selected")).toBe("true")
716
- })
717
-
718
- test("active tab has tabIndex 0", () => {
719
- const tab1 = element.querySelector('[data-value="tab1"]')
720
- expect(tab1.tabIndex).toBe(0)
721
- })
722
-
723
- test("inactive tabs have tabIndex -1", () => {
724
- const tab2 = element.querySelector('[data-value="tab2"]')
725
- const tab3 = element.querySelector('[data-value="tab3"]')
726
-
727
- expect(tab2.tabIndex).toBe(-1)
728
- expect(tab3.tabIndex).toBe(-1)
729
- })
730
-
731
- test("tabIndex updates when switching tabs", () => {
732
- const tab1 = element.querySelector('[data-value="tab1"]')
733
- const tab2 = element.querySelector('[data-value="tab2"]')
734
-
735
- click(tab2)
736
-
737
- expect(tab1.tabIndex).toBe(-1)
738
- expect(tab2.tabIndex).toBe(0)
739
- })
740
- })
741
-
742
- describe("default value", () => {
743
- test("respects custom default value", async () => {
744
- application.stop()
745
- document.body.innerHTML = createTabsHTML({ defaultValue: "tab2" })
746
-
747
- application = Application.start()
748
- application.register("shadcn--tabs", TabsController)
749
-
750
- await new Promise(resolve => requestAnimationFrame(resolve))
751
-
752
- element = document.querySelector('[data-controller="shadcn--tabs"]')
753
-
754
- const tab2 = element.querySelector('[data-value="tab2"]')
755
- expect(tab2.dataset.state).toBe("active")
756
- })
757
-
758
- test("uses first tab when default value is empty", async () => {
759
- application.stop()
760
- document.body.innerHTML = createTabsHTML({ defaultValue: "" })
761
-
762
- application = Application.start()
763
- application.register("shadcn--tabs", TabsController)
764
-
765
- await new Promise(resolve => requestAnimationFrame(resolve))
766
-
767
- element = document.querySelector('[data-controller="shadcn--tabs"]')
768
-
769
- const tab1 = element.querySelector('[data-value="tab1"]')
770
- expect(tab1.dataset.state).toBe("active")
771
- })
772
-
773
- test("falls back to default or first tab if value is invalid", async () => {
774
- application.stop()
775
- document.body.innerHTML = createTabsHTML({ defaultValue: "tab2" })
776
-
777
- application = Application.start()
778
- application.register("shadcn--tabs", TabsController)
779
-
780
- await new Promise(resolve => requestAnimationFrame(resolve))
781
-
782
- element = document.querySelector('[data-controller="shadcn--tabs"]')
783
-
784
- // Should use the valid default value (tab2)
785
- const tab2 = element.querySelector('[data-value="tab2"]')
786
- expect(tab2.dataset.state).toBe("active")
787
- })
788
- })
789
-
790
- describe("edge cases", () => {
791
- test("handles single tab gracefully", async () => {
792
- application.stop()
793
- document.body.innerHTML = createTabsHTML({ tabCount: 1 })
794
-
795
- application = Application.start()
796
- application.register("shadcn--tabs", TabsController)
797
-
798
- await new Promise(resolve => requestAnimationFrame(resolve))
799
-
800
- element = document.querySelector('[data-controller="shadcn--tabs"]')
801
-
802
- const tab1 = element.querySelector('[data-value="tab1"]')
803
- expect(tab1.dataset.state).toBe("active")
804
-
805
- // Keyboard navigation should stay on same tab
806
- tab1.focus()
807
- keydown(tab1, 'ArrowRight')
808
- expect(document.activeElement).toBe(tab1)
809
-
810
- keydown(tab1, 'ArrowLeft')
811
- expect(document.activeElement).toBe(tab1)
812
- })
813
-
814
- test("handles many tabs", async () => {
815
- application.stop()
816
- document.body.innerHTML = createTabsHTML({ tabCount: 10 })
817
-
818
- application = Application.start()
819
- application.register("shadcn--tabs", TabsController)
820
-
821
- await new Promise(resolve => requestAnimationFrame(resolve))
822
-
823
- element = document.querySelector('[data-controller="shadcn--tabs"]')
824
-
825
- const tab10 = element.querySelector('[data-value="tab10"]')
826
- click(tab10)
827
-
828
- expect(tab10.dataset.state).toBe("active")
829
-
830
- // End key should work
831
- const tab1 = element.querySelector('[data-value="tab1"]')
832
- click(tab1)
833
- tab1.focus()
834
-
835
- keydown(tab1, 'End')
836
- expect(document.activeElement).toBe(tab10)
837
- })
838
-
839
- test("selectTabByValue method works correctly", () => {
840
- controller.selectTabByValue("tab3")
841
-
842
- const tab3 = element.querySelector('[data-value="tab3"]')
843
- const content3 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab3"]')
844
-
845
- expect(tab3.dataset.state).toBe("active")
846
- expect(content3.hidden).toBe(false)
847
- })
848
-
849
- test("selectTabByValue with updateUrl=false does not update URL", async () => {
850
- application.stop()
851
-
852
- // Set up mocks in correct order: location first, then history
853
- const localRestoreLocation = mockLocation("http://localhost/")
854
- const localHistoryMock = mockHistory()
855
-
856
- document.body.innerHTML = createTabsHTML({ urlParam: "tab" })
857
-
858
- application = Application.start()
859
- application.register("shadcn--tabs", TabsController)
860
-
861
- await new Promise(resolve => requestAnimationFrame(resolve))
862
-
863
- element = document.querySelector('[data-controller="shadcn--tabs"]')
864
- controller = application.getControllerForElementAndIdentifier(element, "shadcn--tabs")
865
-
866
- // Clear any calls from initialization
867
- localHistoryMock.calls.replaceState = []
868
-
869
- controller.selectTabByValue("tab2", false)
870
-
871
- expect(localHistoryMock.calls.replaceState.length).toBe(0)
872
-
873
- localHistoryMock.restore()
874
- localRestoreLocation()
875
- })
876
-
877
- test("handles rapid tab switching", async () => {
878
- const tab2 = element.querySelector('[data-value="tab2"]')
879
- const tab3 = element.querySelector('[data-value="tab3"]')
880
- const tab1 = element.querySelector('[data-value="tab1"]')
881
-
882
- click(tab2)
883
- click(tab3)
884
- click(tab1)
885
- click(tab2)
886
-
887
- await nextFrame()
888
-
889
- expect(tab2.dataset.state).toBe("active")
890
- const content2 = element.querySelector('[data-shadcn--tabs-target="content"][data-value="tab2"]')
891
- expect(content2.hidden).toBe(false)
892
- })
893
- })
894
-
895
- describe("snapshots", () => {
896
- test("renders default tabs correctly", () => {
897
- expect(element.innerHTML).toMatchSnapshot()
898
- })
899
-
900
- test("renders tabs with tab2 active", () => {
901
- const tab2 = element.querySelector('[data-value="tab2"]')
902
- click(tab2)
903
-
904
- expect(element.innerHTML).toMatchSnapshot()
905
- })
906
- })
907
- })