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,1036 +0,0 @@
1
- import { Application } from "@hotwired/stimulus"
2
- import SliderController from "../../app/assets/javascripts/shadcn/controllers/slider_controller.js"
3
- import { setupController, cleanupController, click, nextFrame, keydown } from '../helpers/stimulus-test-helper.js'
4
-
5
- describe("SliderController", () => {
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--slider"
17
- data-shadcn--slider-min-value="0"
18
- data-shadcn--slider-max-value="100"
19
- data-shadcn--slider-step-value="1"
20
- data-shadcn--slider-value-value="50"
21
- role="slider"
22
- aria-valuemin="0"
23
- aria-valuemax="100"
24
- aria-valuenow="50">
25
- <div data-shadcn--slider-target="track" style="width: 200px; height: 8px;">
26
- <div data-shadcn--slider-target="range" style="width: 50%;"></div>
27
- </div>
28
- <div data-shadcn--slider-target="thumb"
29
- tabindex="0"
30
- data-action="keydown->shadcn--slider#handleKeydown"
31
- style="left: calc(50% - 8px);"></div>
32
- <input type="hidden" data-shadcn--slider-target="input" name="volume">
33
- </div>
34
- `
35
-
36
- beforeEach(async () => {
37
- const setup = await setupController(SliderController, basicHTML, 'shadcn--slider')
38
- application = setup.application
39
- element = setup.element
40
- controller = setup.controller
41
- })
42
-
43
- test("initializes with default values", () => {
44
- expect(controller.minValue).toBe(0)
45
- expect(controller.maxValue).toBe(100)
46
- expect(controller.stepValue).toBe(1)
47
- expect(controller.valueValue).toBe(50)
48
- })
49
-
50
- test("initializes with disabled false by default", () => {
51
- expect(controller.disabledValue).toBe(false)
52
- })
53
-
54
- test("calculates percentage correctly", () => {
55
- expect(controller.percentage).toBe(50)
56
- })
57
-
58
- test("updates hidden input value on init", () => {
59
- expect(controller.inputTarget.value).toBe("50")
60
- })
61
- })
62
-
63
- describe("percentage calculation", () => {
64
- const percentHTML = `
65
- <div data-controller="shadcn--slider"
66
- data-shadcn--slider-min-value="0"
67
- data-shadcn--slider-max-value="100"
68
- data-shadcn--slider-value-value="25">
69
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
70
- <div data-shadcn--slider-target="range"></div>
71
- <div data-shadcn--slider-target="thumb"></div>
72
- </div>
73
- `
74
-
75
- beforeEach(async () => {
76
- const setup = await setupController(SliderController, percentHTML, 'shadcn--slider')
77
- application = setup.application
78
- element = setup.element
79
- controller = setup.controller
80
- })
81
-
82
- test("calculates percentage at 25%", () => {
83
- expect(controller.percentage).toBe(25)
84
- })
85
-
86
- test("calculates percentage at 0%", async () => {
87
- controller.valueValue = 0
88
- await nextFrame()
89
- expect(controller.percentage).toBe(0)
90
- })
91
-
92
- test("calculates percentage at 100%", async () => {
93
- controller.valueValue = 100
94
- await nextFrame()
95
- expect(controller.percentage).toBe(100)
96
- })
97
-
98
- test("handles equal min and max gracefully", async () => {
99
- controller.minValue = 50
100
- controller.maxValue = 50
101
- await nextFrame()
102
- expect(controller.percentage).toBe(0)
103
- })
104
- })
105
-
106
- describe("custom range", () => {
107
- const customRangeHTML = `
108
- <div data-controller="shadcn--slider"
109
- data-shadcn--slider-min-value="10"
110
- data-shadcn--slider-max-value="50"
111
- data-shadcn--slider-value-value="30">
112
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
113
- <div data-shadcn--slider-target="range"></div>
114
- <div data-shadcn--slider-target="thumb"></div>
115
- </div>
116
- `
117
-
118
- beforeEach(async () => {
119
- const setup = await setupController(SliderController, customRangeHTML, 'shadcn--slider')
120
- application = setup.application
121
- element = setup.element
122
- controller = setup.controller
123
- })
124
-
125
- test("calculates percentage for custom range", () => {
126
- // 30 is halfway between 10 and 50
127
- expect(controller.percentage).toBe(50)
128
- })
129
- })
130
-
131
- describe("step snapping", () => {
132
- const stepHTML = `
133
- <div data-controller="shadcn--slider"
134
- data-shadcn--slider-min-value="0"
135
- data-shadcn--slider-max-value="100"
136
- data-shadcn--slider-step-value="10"
137
- data-shadcn--slider-value-value="0">
138
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
139
- <div data-shadcn--slider-target="range"></div>
140
- <div data-shadcn--slider-target="thumb"></div>
141
- </div>
142
- `
143
-
144
- beforeEach(async () => {
145
- const setup = await setupController(SliderController, stepHTML, 'shadcn--slider')
146
- application = setup.application
147
- element = setup.element
148
- controller = setup.controller
149
- })
150
-
151
- test("snaps to nearest step", () => {
152
- expect(controller.snapToStep(23)).toBe(20)
153
- expect(controller.snapToStep(27)).toBe(30)
154
- expect(controller.snapToStep(25)).toBe(30)
155
- })
156
-
157
- test("snaps to min when below range", () => {
158
- expect(controller.snapToStep(-5)).toBe(0)
159
- })
160
-
161
- test("snaps to max when above range", () => {
162
- expect(controller.snapToStep(105)).toBe(100)
163
- })
164
- })
165
-
166
- describe("decimal step snapping", () => {
167
- const decimalStepHTML = `
168
- <div data-controller="shadcn--slider"
169
- data-shadcn--slider-min-value="0"
170
- data-shadcn--slider-max-value="1"
171
- data-shadcn--slider-step-value="0.1"
172
- data-shadcn--slider-value-value="0.5">
173
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
174
- <div data-shadcn--slider-target="range"></div>
175
- <div data-shadcn--slider-target="thumb"></div>
176
- </div>
177
- `
178
-
179
- beforeEach(async () => {
180
- const setup = await setupController(SliderController, decimalStepHTML, 'shadcn--slider')
181
- application = setup.application
182
- element = setup.element
183
- controller = setup.controller
184
- })
185
-
186
- test("handles decimal values correctly", () => {
187
- expect(controller.valueValue).toBe(0.5)
188
- expect(controller.percentage).toBe(50)
189
- })
190
-
191
- test("snaps to decimal steps", () => {
192
- expect(controller.snapToStep(0.23)).toBeCloseTo(0.2, 5)
193
- expect(controller.snapToStep(0.27)).toBeCloseTo(0.3, 5)
194
- })
195
- })
196
-
197
- describe("keyboard navigation", () => {
198
- const keyboardHTML = `
199
- <div data-controller="shadcn--slider"
200
- data-shadcn--slider-min-value="0"
201
- data-shadcn--slider-max-value="100"
202
- data-shadcn--slider-step-value="1"
203
- data-shadcn--slider-value-value="50">
204
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
205
- <div data-shadcn--slider-target="range"></div>
206
- <div data-shadcn--slider-target="thumb"
207
- tabindex="0"
208
- data-action="keydown->shadcn--slider#handleKeydown"></div>
209
- <input type="hidden" data-shadcn--slider-target="input">
210
- </div>
211
- `
212
-
213
- beforeEach(async () => {
214
- const setup = await setupController(SliderController, keyboardHTML, 'shadcn--slider')
215
- application = setup.application
216
- element = setup.element
217
- controller = setup.controller
218
- })
219
-
220
- test("increases value with ArrowRight", () => {
221
- controller.handleKeydown({ key: "ArrowRight", preventDefault: jest.fn() })
222
- expect(controller.valueValue).toBe(51)
223
- })
224
-
225
- test("increases value with ArrowUp", () => {
226
- controller.handleKeydown({ key: "ArrowUp", preventDefault: jest.fn() })
227
- expect(controller.valueValue).toBe(51)
228
- })
229
-
230
- test("decreases value with ArrowLeft", () => {
231
- controller.handleKeydown({ key: "ArrowLeft", preventDefault: jest.fn() })
232
- expect(controller.valueValue).toBe(49)
233
- })
234
-
235
- test("decreases value with ArrowDown", () => {
236
- controller.handleKeydown({ key: "ArrowDown", preventDefault: jest.fn() })
237
- expect(controller.valueValue).toBe(49)
238
- })
239
-
240
- test("jumps by 10% with PageUp", () => {
241
- controller.handleKeydown({ key: "PageUp", preventDefault: jest.fn() })
242
- expect(controller.valueValue).toBe(60)
243
- })
244
-
245
- test("jumps by 10% with PageDown", () => {
246
- controller.handleKeydown({ key: "PageDown", preventDefault: jest.fn() })
247
- expect(controller.valueValue).toBe(40)
248
- })
249
-
250
- test("jumps to min with Home", () => {
251
- controller.handleKeydown({ key: "Home", preventDefault: jest.fn() })
252
- expect(controller.valueValue).toBe(0)
253
- })
254
-
255
- test("jumps to max with End", () => {
256
- controller.handleKeydown({ key: "End", preventDefault: jest.fn() })
257
- expect(controller.valueValue).toBe(100)
258
- })
259
-
260
- test("does not exceed max value", () => {
261
- controller.valueValue = 100
262
- controller.handleKeydown({ key: "ArrowRight", preventDefault: jest.fn() })
263
- expect(controller.valueValue).toBe(100)
264
- })
265
-
266
- test("does not go below min value", () => {
267
- controller.valueValue = 0
268
- controller.handleKeydown({ key: "ArrowLeft", preventDefault: jest.fn() })
269
- expect(controller.valueValue).toBe(0)
270
- })
271
-
272
- test("dispatches change event on keyboard navigation", () => {
273
- let eventDetail = null
274
- element.addEventListener("shadcn--slider:change", (e) => {
275
- eventDetail = e.detail
276
- })
277
-
278
- controller.handleKeydown({ key: "ArrowRight", preventDefault: jest.fn() })
279
-
280
- expect(eventDetail).not.toBeNull()
281
- expect(eventDetail.value).toBe(51)
282
- })
283
-
284
- test("ignores unrelated keys", () => {
285
- const preventDefault = jest.fn()
286
- controller.handleKeydown({ key: "Tab", preventDefault })
287
- expect(preventDefault).not.toHaveBeenCalled()
288
- expect(controller.valueValue).toBe(50)
289
- })
290
- })
291
-
292
- describe("disabled state", () => {
293
- const disabledHTML = `
294
- <div data-controller="shadcn--slider"
295
- data-shadcn--slider-min-value="0"
296
- data-shadcn--slider-max-value="100"
297
- data-shadcn--slider-value-value="50"
298
- data-shadcn--slider-disabled-value="true">
299
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
300
- <div data-shadcn--slider-target="range"></div>
301
- <div data-shadcn--slider-target="thumb"></div>
302
- </div>
303
- `
304
-
305
- beforeEach(async () => {
306
- const setup = await setupController(SliderController, disabledHTML, 'shadcn--slider')
307
- application = setup.application
308
- element = setup.element
309
- controller = setup.controller
310
- })
311
-
312
- test("ignores keyboard when disabled", () => {
313
- controller.handleKeydown({ key: "ArrowRight", preventDefault: jest.fn() })
314
- expect(controller.valueValue).toBe(50)
315
- })
316
-
317
- test("ignores drag when disabled", () => {
318
- const event = { preventDefault: jest.fn(), type: "mousedown" }
319
- controller.startDrag(event)
320
- expect(controller.isDragging).toBeFalsy()
321
- })
322
- })
323
-
324
- describe("visual updates", () => {
325
- const visualHTML = `
326
- <div data-controller="shadcn--slider"
327
- data-shadcn--slider-min-value="0"
328
- data-shadcn--slider-max-value="100"
329
- data-shadcn--slider-value-value="50">
330
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
331
- <div data-shadcn--slider-target="range" style="width: 0%;"></div>
332
- <div data-shadcn--slider-target="thumb" style="left: 0;"></div>
333
- <input type="hidden" data-shadcn--slider-target="input">
334
- </div>
335
- `
336
-
337
- beforeEach(async () => {
338
- const setup = await setupController(SliderController, visualHTML, 'shadcn--slider')
339
- application = setup.application
340
- element = setup.element
341
- controller = setup.controller
342
- })
343
-
344
- test("updates range width on value change", async () => {
345
- controller.valueValue = 75
346
- controller.updateVisuals()
347
- await nextFrame()
348
-
349
- expect(controller.rangeTarget.style.width).toBe("75%")
350
- })
351
-
352
- test("updates thumb position on value change", async () => {
353
- controller.valueValue = 75
354
- controller.updateVisuals()
355
- await nextFrame()
356
-
357
- expect(controller.thumbTarget.style.left).toBe("calc(75% - 8px)")
358
- })
359
-
360
- test("updates aria-valuenow on value change", async () => {
361
- controller.valueValue = 75
362
- controller.updateVisuals()
363
- await nextFrame()
364
-
365
- expect(element.getAttribute("aria-valuenow")).toBe("75")
366
- })
367
-
368
- test("updates hidden input on value change", async () => {
369
- controller.valueValue = 75
370
- controller.updateVisuals()
371
- await nextFrame()
372
-
373
- expect(controller.inputTarget.value).toBe("75")
374
- })
375
- })
376
-
377
- describe("output formatting", () => {
378
- const outputHTML = `
379
- <div data-controller="shadcn--slider"
380
- data-shadcn--slider-min-value="0"
381
- data-shadcn--slider-max-value="100"
382
- data-shadcn--slider-value-value="50"
383
- data-shadcn--slider-output-format-value="{value}%">
384
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
385
- <div data-shadcn--slider-target="range"></div>
386
- <div data-shadcn--slider-target="thumb"></div>
387
- <span data-shadcn--slider-target="output"></span>
388
- </div>
389
- `
390
-
391
- beforeEach(async () => {
392
- const setup = await setupController(SliderController, outputHTML, 'shadcn--slider')
393
- application = setup.application
394
- element = setup.element
395
- controller = setup.controller
396
- })
397
-
398
- test("formats output with value", async () => {
399
- controller.updateVisuals()
400
- await nextFrame()
401
-
402
- expect(controller.outputTarget.textContent).toBe("50%")
403
- })
404
-
405
- test("updates output on value change", async () => {
406
- controller.valueValue = 75
407
- controller.updateVisuals()
408
- await nextFrame()
409
-
410
- expect(controller.outputTarget.textContent).toBe("75%")
411
- })
412
- })
413
-
414
- describe("output formatting with percent", () => {
415
- const percentOutputHTML = `
416
- <div data-controller="shadcn--slider"
417
- data-shadcn--slider-min-value="0"
418
- data-shadcn--slider-max-value="200"
419
- data-shadcn--slider-value-value="100"
420
- data-shadcn--slider-output-format-value="Value: {value}, Progress: {percent}%">
421
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
422
- <div data-shadcn--slider-target="range"></div>
423
- <div data-shadcn--slider-target="thumb"></div>
424
- <span data-shadcn--slider-target="output"></span>
425
- </div>
426
- `
427
-
428
- beforeEach(async () => {
429
- const setup = await setupController(SliderController, percentOutputHTML, 'shadcn--slider')
430
- application = setup.application
431
- element = setup.element
432
- controller = setup.controller
433
- })
434
-
435
- test("formats output with both value and percent", async () => {
436
- controller.updateVisuals()
437
- await nextFrame()
438
-
439
- expect(controller.outputTarget.textContent).toBe("Value: 100, Progress: 50%")
440
- })
441
- })
442
-
443
- describe("drag functionality", () => {
444
- const dragHTML = `
445
- <div data-controller="shadcn--slider"
446
- data-shadcn--slider-min-value="0"
447
- data-shadcn--slider-max-value="100"
448
- data-shadcn--slider-step-value="1"
449
- data-shadcn--slider-value-value="50">
450
- <div data-shadcn--slider-target="track" style="width: 200px; height: 8px;"></div>
451
- <div data-shadcn--slider-target="range"></div>
452
- <div data-shadcn--slider-target="thumb"
453
- data-action="mousedown->shadcn--slider#startDrag"></div>
454
- <input type="hidden" data-shadcn--slider-target="input">
455
- </div>
456
- `
457
-
458
- beforeEach(async () => {
459
- const setup = await setupController(SliderController, dragHTML, 'shadcn--slider')
460
- application = setup.application
461
- element = setup.element
462
- controller = setup.controller
463
-
464
- // Mock getBoundingClientRect for track
465
- controller.trackTarget.getBoundingClientRect = jest.fn().mockReturnValue({
466
- left: 0,
467
- right: 200,
468
- width: 200,
469
- top: 0,
470
- bottom: 8,
471
- height: 8
472
- })
473
- })
474
-
475
- test("starts drag on mousedown", () => {
476
- const event = {
477
- preventDefault: jest.fn(),
478
- type: "mousedown",
479
- clientX: 100
480
- }
481
-
482
- controller.startDrag(event)
483
-
484
- expect(controller.isDragging).toBe(true)
485
- })
486
-
487
- test("updates value during drag", () => {
488
- controller.isDragging = true
489
-
490
- const event = {
491
- type: "mousemove",
492
- clientX: 150
493
- }
494
-
495
- controller.handleDrag(event)
496
-
497
- expect(controller.valueValue).toBe(75)
498
- })
499
-
500
- test("clamps value to track bounds", () => {
501
- controller.isDragging = true
502
-
503
- // Beyond right edge
504
- controller.handleDrag({ type: "mousemove", clientX: 300 })
505
- expect(controller.valueValue).toBe(100)
506
-
507
- // Beyond left edge
508
- controller.handleDrag({ type: "mousemove", clientX: -50 })
509
- expect(controller.valueValue).toBe(0)
510
- })
511
-
512
- test("stops drag and removes listeners", () => {
513
- controller.isDragging = true
514
- controller.boundHandleDrag = jest.fn()
515
- controller.boundStopDrag = jest.fn()
516
-
517
- const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener')
518
-
519
- controller.stopDrag()
520
-
521
- expect(controller.isDragging).toBe(false)
522
- expect(removeEventListenerSpy).toHaveBeenCalledWith("mousemove", controller.boundHandleDrag)
523
- expect(removeEventListenerSpy).toHaveBeenCalledWith("mouseup", controller.boundStopDrag)
524
- })
525
- })
526
-
527
- describe("touch events", () => {
528
- const touchHTML = `
529
- <div data-controller="shadcn--slider"
530
- data-shadcn--slider-min-value="0"
531
- data-shadcn--slider-max-value="100"
532
- data-shadcn--slider-value-value="50">
533
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
534
- <div data-shadcn--slider-target="range"></div>
535
- <div data-shadcn--slider-target="thumb"></div>
536
- </div>
537
- `
538
-
539
- beforeEach(async () => {
540
- const setup = await setupController(SliderController, touchHTML, 'shadcn--slider')
541
- application = setup.application
542
- element = setup.element
543
- controller = setup.controller
544
-
545
- controller.trackTarget.getBoundingClientRect = jest.fn().mockReturnValue({
546
- left: 0,
547
- right: 200,
548
- width: 200,
549
- top: 0,
550
- bottom: 8,
551
- height: 8
552
- })
553
- })
554
-
555
- test("handles touch events", () => {
556
- controller.isDragging = true
557
-
558
- const event = {
559
- type: "touchmove",
560
- touches: [{ clientX: 100 }]
561
- }
562
-
563
- controller.handleDrag(event)
564
-
565
- expect(controller.valueValue).toBe(50)
566
- })
567
- })
568
-
569
- describe("native input range support (updateStyle)", () => {
570
- const nativeInputHTML = `
571
- <div data-controller="shadcn--slider"
572
- data-shadcn--slider-value-value="50">
573
- <input type="range"
574
- min="0"
575
- max="100"
576
- value="50"
577
- data-action="input->shadcn--slider#updateStyle">
578
- <span data-shadcn--slider-target="output"></span>
579
- </div>
580
- `
581
-
582
- beforeEach(async () => {
583
- const setup = await setupController(SliderController, nativeInputHTML, 'shadcn--slider')
584
- application = setup.application
585
- element = setup.element
586
- controller = setup.controller
587
- })
588
-
589
- test("updates CSS custom property on input", () => {
590
- const input = element.querySelector('input[type="range"]')
591
- input.value = "75"
592
-
593
- const setPropertySpy = jest.spyOn(input.style, 'setProperty')
594
-
595
- controller.updateStyle({ target: input })
596
-
597
- expect(setPropertySpy).toHaveBeenCalledWith("--slider-fill", "75%")
598
- })
599
-
600
- test("dispatches change event on native input", () => {
601
- let eventDetail = null
602
- element.addEventListener("shadcn--slider:change", (e) => {
603
- eventDetail = e.detail
604
- })
605
-
606
- const input = element.querySelector('input[type="range"]')
607
- input.value = "75"
608
- controller.updateStyle({ target: input })
609
-
610
- expect(eventDetail).not.toBeNull()
611
- expect(eventDetail.value).toBe(75)
612
- expect(eventDetail.percentage).toBe(75)
613
- })
614
- })
615
-
616
- describe("ID-based output targeting (data-output-target)", () => {
617
- let outputDisplay
618
-
619
- beforeEach(async () => {
620
- const idOutputHTML = `
621
- <div data-controller="shadcn--slider"
622
- data-shadcn--slider-value-value="50">
623
- <input type="range"
624
- min="0"
625
- max="100"
626
- value="50"
627
- data-output-target="slider-value-display"
628
- data-output-format="{value}%"
629
- data-action="input->shadcn--slider#updateStyle">
630
- </div>
631
- `
632
-
633
- const setup = await setupController(SliderController, idOutputHTML, 'shadcn--slider')
634
- application = setup.application
635
- element = setup.element
636
- controller = setup.controller
637
-
638
- // Create output element AFTER setupController (which clears body.innerHTML)
639
- outputDisplay = document.createElement('span')
640
- outputDisplay.id = "slider-value-display"
641
- outputDisplay.textContent = "50"
642
- document.body.appendChild(outputDisplay)
643
- })
644
-
645
- afterEach(() => {
646
- if (outputDisplay && outputDisplay.parentNode) {
647
- outputDisplay.parentNode.removeChild(outputDisplay)
648
- }
649
- })
650
-
651
- test("updates external element by ID on input", () => {
652
- const input = element.querySelector('input[type="range"]')
653
-
654
- input.value = "75"
655
- controller.updateStyle({ target: input })
656
-
657
- expect(outputDisplay.textContent).toBe("75%")
658
- })
659
-
660
- test("uses format string with {value} placeholder", () => {
661
- const input = element.querySelector('input[type="range"]')
662
-
663
- input.value = "30"
664
- controller.updateStyle({ target: input })
665
-
666
- expect(outputDisplay.textContent).toBe("30%")
667
- })
668
-
669
- test("supports {percent} placeholder in format string", () => {
670
- const input = element.querySelector('input[type="range"]')
671
- input.dataset.outputFormat = "{percent}% complete"
672
-
673
- input.value = "50"
674
- controller.updateStyle({ target: input })
675
-
676
- expect(outputDisplay.textContent).toBe("50% complete")
677
- })
678
-
679
- test("handles missing output element gracefully", () => {
680
- const input = element.querySelector('input[type="range"]')
681
- input.dataset.outputTarget = "non-existent-id"
682
-
683
- expect(() => {
684
- input.value = "75"
685
- controller.updateStyle({ target: input })
686
- }).not.toThrow()
687
- })
688
-
689
- test("defaults to {value} format when not specified", () => {
690
- const input = element.querySelector('input[type="range"]')
691
- delete input.dataset.outputFormat
692
-
693
- input.value = "42"
694
- controller.updateStyle({ target: input })
695
-
696
- expect(outputDisplay.textContent).toBe("42")
697
- })
698
- })
699
-
700
- describe("valueValueChanged callback", () => {
701
- const callbackHTML = `
702
- <div data-controller="shadcn--slider"
703
- data-shadcn--slider-value-value="50">
704
- <div data-shadcn--slider-target="track" style="width: 200px;"></div>
705
- <div data-shadcn--slider-target="range"></div>
706
- <div data-shadcn--slider-target="thumb"></div>
707
- </div>
708
- `
709
-
710
- beforeEach(async () => {
711
- const setup = await setupController(SliderController, callbackHTML, 'shadcn--slider')
712
- application = setup.application
713
- element = setup.element
714
- controller = setup.controller
715
- })
716
-
717
- test("updates visuals when value changes programmatically", async () => {
718
- const updateVisualsSpy = jest.spyOn(controller, 'updateVisuals')
719
-
720
- controller.valueValue = 75
721
- await nextFrame()
722
-
723
- expect(updateVisualsSpy).toHaveBeenCalled()
724
- })
725
- })
726
-
727
- describe("two-way input binding (data-input-target)", () => {
728
- let linkedInput
729
-
730
- beforeEach(async () => {
731
- const twoWayHTML = `
732
- <div data-controller="shadcn--slider"
733
- data-shadcn--slider-value-value="50">
734
- <input type="range"
735
- id="volume-slider"
736
- min="0"
737
- max="100"
738
- step="1"
739
- value="50"
740
- data-input-target="volume-input"
741
- data-action="input->shadcn--slider#updateStyle">
742
- </div>
743
- `
744
-
745
- const setup = await setupController(SliderController, twoWayHTML, 'shadcn--slider')
746
- application = setup.application
747
- element = setup.element
748
- controller = setup.controller
749
-
750
- // Create linked input element AFTER setupController (which clears body.innerHTML)
751
- linkedInput = document.createElement('input')
752
- linkedInput.type = "number"
753
- linkedInput.id = "volume-input"
754
- linkedInput.value = "50"
755
- linkedInput.min = "0"
756
- linkedInput.max = "100"
757
- document.body.appendChild(linkedInput)
758
-
759
- // Re-run setup to bind the new input
760
- controller.setupTwoWayBindings()
761
- })
762
-
763
- afterEach(() => {
764
- if (linkedInput && linkedInput.parentNode) {
765
- linkedInput.parentNode.removeChild(linkedInput)
766
- }
767
- })
768
-
769
- test("syncs slider value to linked input (slider → input)", () => {
770
- const rangeInput = element.querySelector('input[type="range"]')
771
-
772
- rangeInput.value = "75"
773
- controller.updateStyle({ target: rangeInput })
774
-
775
- expect(linkedInput.value).toBe("75")
776
- })
777
-
778
- test("syncs linked input value to slider (input → slider)", () => {
779
- const rangeInput = element.querySelector('input[type="range"]')
780
-
781
- linkedInput.value = "25"
782
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
783
-
784
- expect(rangeInput.value).toBe("25")
785
- })
786
-
787
- test("clamps linked input value to max", () => {
788
- const rangeInput = element.querySelector('input[type="range"]')
789
-
790
- linkedInput.value = "150"
791
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
792
-
793
- expect(rangeInput.value).toBe("100")
794
- expect(linkedInput.value).toBe("100")
795
- })
796
-
797
- test("clamps linked input value to min", () => {
798
- const rangeInput = element.querySelector('input[type="range"]')
799
-
800
- linkedInput.value = "-10"
801
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
802
-
803
- expect(rangeInput.value).toBe("0")
804
- expect(linkedInput.value).toBe("0")
805
- })
806
-
807
- test("snaps linked input value to step", () => {
808
- const rangeInput = element.querySelector('input[type="range"]')
809
- rangeInput.step = "10"
810
-
811
- linkedInput.value = "27"
812
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
813
-
814
- expect(rangeInput.value).toBe("30")
815
- expect(linkedInput.value).toBe("30")
816
- })
817
-
818
- test("handles invalid linked input value", () => {
819
- const rangeInput = element.querySelector('input[type="range"]')
820
-
821
- linkedInput.value = "invalid"
822
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
823
-
824
- expect(rangeInput.value).toBe("0")
825
- expect(linkedInput.value).toBe("0")
826
- })
827
-
828
- test("updates CSS fill when syncing from linked input", () => {
829
- const rangeInput = element.querySelector('input[type="range"]')
830
- const setPropertySpy = jest.spyOn(rangeInput.style, 'setProperty')
831
-
832
- linkedInput.value = "75"
833
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
834
-
835
- expect(setPropertySpy).toHaveBeenCalledWith("--slider-fill", "75%")
836
- })
837
-
838
- test("dispatches change event when syncing from linked input", () => {
839
- let eventDetail = null
840
- element.addEventListener("shadcn--slider:change", (e) => {
841
- eventDetail = e.detail
842
- })
843
-
844
- linkedInput.value = "60"
845
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
846
-
847
- expect(eventDetail).not.toBeNull()
848
- expect(eventDetail.value).toBe(60)
849
- expect(eventDetail.percentage).toBe(60)
850
- })
851
-
852
- test("handles missing linked input gracefully", () => {
853
- const rangeInput = element.querySelector('input[type="range"]')
854
- rangeInput.dataset.inputTarget = "non-existent-id"
855
-
856
- expect(() => {
857
- rangeInput.value = "75"
858
- controller.updateStyle({ target: rangeInput })
859
- }).not.toThrow()
860
- })
861
-
862
- test("cleans up event listeners on disconnect", () => {
863
- const removeEventListenerSpy = jest.spyOn(linkedInput, 'removeEventListener')
864
-
865
- controller.teardownTwoWayBindings()
866
-
867
- expect(removeEventListenerSpy).toHaveBeenCalledWith('input', expect.any(Function))
868
- expect(removeEventListenerSpy).toHaveBeenCalledWith('change', expect.any(Function))
869
- })
870
- })
871
-
872
- describe("two-way binding with output sync", () => {
873
- let linkedInput
874
- let outputDisplay
875
-
876
- beforeEach(async () => {
877
- const combinedHTML = `
878
- <div data-controller="shadcn--slider"
879
- data-shadcn--slider-value-value="50">
880
- <input type="range"
881
- id="combined-slider"
882
- min="0"
883
- max="100"
884
- value="50"
885
- data-input-target="combined-input"
886
- data-output-target="combined-output"
887
- data-output-format="{value}%"
888
- data-action="input->shadcn--slider#updateStyle">
889
- </div>
890
- `
891
-
892
- const setup = await setupController(SliderController, combinedHTML, 'shadcn--slider')
893
- application = setup.application
894
- element = setup.element
895
- controller = setup.controller
896
-
897
- // Create linked input and output elements
898
- linkedInput = document.createElement('input')
899
- linkedInput.type = "number"
900
- linkedInput.id = "combined-input"
901
- linkedInput.value = "50"
902
- document.body.appendChild(linkedInput)
903
-
904
- outputDisplay = document.createElement('span')
905
- outputDisplay.id = "combined-output"
906
- outputDisplay.textContent = "50%"
907
- document.body.appendChild(outputDisplay)
908
-
909
- controller.setupTwoWayBindings()
910
- })
911
-
912
- afterEach(() => {
913
- if (linkedInput && linkedInput.parentNode) {
914
- linkedInput.parentNode.removeChild(linkedInput)
915
- }
916
- if (outputDisplay && outputDisplay.parentNode) {
917
- outputDisplay.parentNode.removeChild(outputDisplay)
918
- }
919
- })
920
-
921
- test("updates both linked input and output when slider changes", () => {
922
- const rangeInput = element.querySelector('input[type="range"]')
923
-
924
- rangeInput.value = "80"
925
- controller.updateStyle({ target: rangeInput })
926
-
927
- expect(linkedInput.value).toBe("80")
928
- expect(outputDisplay.textContent).toBe("80%")
929
- })
930
-
931
- test("updates both slider and output when linked input changes", () => {
932
- const rangeInput = element.querySelector('input[type="range"]')
933
-
934
- linkedInput.value = "30"
935
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
936
-
937
- expect(rangeInput.value).toBe("30")
938
- expect(outputDisplay.textContent).toBe("30%")
939
- })
940
- })
941
-
942
- describe("two-way binding when controller IS the input element (regression test)", () => {
943
- // This tests the case where data-controller is on the <input type="range"> itself,
944
- // not on a wrapper element. This is how SliderComponent actually renders.
945
- let linkedInput
946
-
947
- beforeEach(async () => {
948
- // Simulate how SliderComponent renders: controller on the input element itself
949
- const directInputHTML = `
950
- <input type="range"
951
- data-controller="shadcn--slider"
952
- id="direct-slider"
953
- min="0"
954
- max="100"
955
- step="1"
956
- value="50"
957
- data-input-target="direct-linked-input"
958
- data-action="input->shadcn--slider#updateStyle">
959
- `
960
-
961
- const setup = await setupController(SliderController, directInputHTML, 'shadcn--slider')
962
- application = setup.application
963
- element = setup.element
964
- controller = setup.controller
965
-
966
- // Create linked input element AFTER setupController
967
- linkedInput = document.createElement('input')
968
- linkedInput.type = "number"
969
- linkedInput.id = "direct-linked-input"
970
- linkedInput.value = "50"
971
- linkedInput.min = "0"
972
- linkedInput.max = "100"
973
- document.body.appendChild(linkedInput)
974
-
975
- // Re-run setup to bind the new input
976
- controller.setupTwoWayBindings()
977
- })
978
-
979
- afterEach(() => {
980
- if (linkedInput && linkedInput.parentNode) {
981
- linkedInput.parentNode.removeChild(linkedInput)
982
- }
983
- })
984
-
985
- test("detects that controller element itself is a range input with data-input-target", () => {
986
- // The element should match the selector for range inputs with data-input-target
987
- expect(element.matches('input[type="range"][data-input-target]')).toBe(true)
988
- })
989
-
990
- test("sets up binding when controller element is the range input", () => {
991
- // Should have one binding
992
- expect(controller.inputBindings.length).toBe(1)
993
- expect(controller.inputBindings[0].rangeInput).toBe(element)
994
- expect(controller.inputBindings[0].linkedInput).toBe(linkedInput)
995
- })
996
-
997
- test("syncs slider value to linked input (slider → input)", () => {
998
- element.value = "75"
999
- controller.updateStyle({ target: element })
1000
-
1001
- expect(linkedInput.value).toBe("75")
1002
- })
1003
-
1004
- test("syncs linked input value to slider (input → slider)", () => {
1005
- linkedInput.value = "25"
1006
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
1007
-
1008
- expect(element.value).toBe("25")
1009
- })
1010
-
1011
- test("updates CSS fill when linked input changes", () => {
1012
- const setPropertySpy = jest.spyOn(element.style, 'setProperty')
1013
-
1014
- linkedInput.value = "60"
1015
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
1016
-
1017
- expect(setPropertySpy).toHaveBeenCalledWith("--slider-fill", "60%")
1018
- })
1019
-
1020
- test("clamps value when linked input exceeds max", () => {
1021
- linkedInput.value = "150"
1022
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
1023
-
1024
- expect(element.value).toBe("100")
1025
- expect(linkedInput.value).toBe("100")
1026
- })
1027
-
1028
- test("clamps value when linked input is below min", () => {
1029
- linkedInput.value = "-10"
1030
- linkedInput.dispatchEvent(new Event('input', { bubbles: true }))
1031
-
1032
- expect(element.value).toBe("0")
1033
- expect(linkedInput.value).toBe("0")
1034
- })
1035
- })
1036
- })