shadcn-rails 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -2
- data/README.md +21 -8
- data/__mocks__/@floating-ui/dom.js +67 -0
- data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +23 -2
- data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +4 -31
- data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +32 -41
- data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +29 -55
- data/app/assets/javascripts/shadcn/controllers/popover_controller.js +29 -54
- data/app/assets/javascripts/shadcn/controllers/select_controller.js +26 -8
- data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +28 -59
- data/app/assets/javascripts/shadcn/index.js +7 -1
- data/app/assets/javascripts/shadcn/utils/floating.js +179 -0
- data/app/assets/stylesheets/shadcn/base.css +32 -0
- data/app/components/shadcn/accordion_component.html.erb +8 -0
- data/app/components/shadcn/accordion_component.rb +6 -15
- data/app/components/shadcn/alert_component.html.erb +6 -0
- data/app/components/shadcn/alert_component.rb +0 -18
- data/app/components/shadcn/alert_dialog_component.html.erb +12 -0
- data/app/components/shadcn/alert_dialog_component.rb +7 -27
- data/app/components/shadcn/aspect_ratio_component.html.erb +7 -0
- data/app/components/shadcn/aspect_ratio_component.rb +4 -19
- data/app/components/shadcn/avatar_component.html.erb +20 -0
- data/app/components/shadcn/avatar_component.rb +8 -36
- data/app/components/shadcn/badge_component.html.erb +1 -0
- data/app/components/shadcn/badge_component.rb +0 -11
- data/app/components/shadcn/base_component.rb +15 -2
- data/app/components/shadcn/breadcrumb_component.html.erb +5 -0
- data/app/components/shadcn/breadcrumb_component.rb +6 -16
- data/app/components/shadcn/button_component.html.erb +18 -0
- data/app/components/shadcn/button_component.rb +1 -41
- data/app/components/shadcn/card_component.html.erb +8 -0
- data/app/components/shadcn/card_component.rb +2 -6
- data/app/components/shadcn/checkbox_component.html.erb +32 -0
- data/app/components/shadcn/checkbox_component.rb +4 -43
- data/app/components/shadcn/collapsible_component.html.erb +8 -0
- data/app/components/shadcn/collapsible_component.rb +6 -15
- data/app/components/shadcn/context_menu_component.html.erb +11 -0
- data/app/components/shadcn/context_menu_component.rb +6 -26
- data/app/components/shadcn/dialog_component.html.erb +14 -0
- data/app/components/shadcn/dialog_component.rb +8 -29
- data/app/components/shadcn/drawer_component.html.erb +12 -0
- data/app/components/shadcn/drawer_component.rb +7 -27
- data/app/components/shadcn/dropdown_menu_component.html.erb +14 -0
- data/app/components/shadcn/dropdown_menu_component.rb +9 -29
- data/app/components/shadcn/field_component.rb +7 -8
- data/app/components/shadcn/hover_card_component.html.erb +12 -0
- data/app/components/shadcn/hover_card_component.rb +7 -26
- data/app/components/shadcn/input_component.html.erb +18 -0
- data/app/components/shadcn/input_component.rb +2 -27
- data/app/components/shadcn/input_otp_component.rb +3 -3
- data/app/components/shadcn/kbd_component.html.erb +1 -0
- data/app/components/shadcn/kbd_component.rb +3 -10
- data/app/components/shadcn/label_component.html.erb +3 -0
- data/app/components/shadcn/label_component.rb +2 -18
- data/app/components/shadcn/menubar_component.html.erb +6 -0
- data/app/components/shadcn/menubar_component.rb +4 -15
- data/app/components/shadcn/native_select_component.html.erb +22 -0
- data/app/components/shadcn/native_select_component.rb +9 -39
- data/app/components/shadcn/navigation_menu_component.html.erb +6 -0
- data/app/components/shadcn/navigation_menu_component.rb +4 -15
- data/app/components/shadcn/pagination_component.html.erb +5 -0
- data/app/components/shadcn/pagination_component.rb +11 -15
- data/app/components/shadcn/popover_component.html.erb +15 -0
- data/app/components/shadcn/popover_component.rb +10 -30
- data/app/components/shadcn/progress_component.html.erb +13 -0
- data/app/components/shadcn/progress_component.rb +6 -26
- data/app/components/shadcn/radio_group_component.html.erb +8 -0
- data/app/components/shadcn/radio_group_component.rb +12 -26
- data/app/components/shadcn/scroll_area_component.html.erb +7 -0
- data/app/components/shadcn/scroll_area_component.rb +4 -16
- data/app/components/shadcn/select_component.html.erb +46 -0
- data/app/components/shadcn/select_component.rb +6 -80
- data/app/components/shadcn/separator_component.html.erb +5 -0
- data/app/components/shadcn/separator_component.rb +6 -14
- data/app/components/shadcn/sheet_component.html.erb +12 -0
- data/app/components/shadcn/sheet_component.rb +7 -27
- data/app/components/shadcn/sidebar_component.rb +2 -2
- data/app/components/shadcn/skeleton_component.html.erb +1 -0
- data/app/components/shadcn/skeleton_component.rb +4 -2
- data/app/components/shadcn/slider_component.html.erb +12 -0
- data/app/components/shadcn/slider_component.rb +2 -21
- data/app/components/shadcn/spinner_component.html.erb +18 -0
- data/app/components/shadcn/spinner_component.rb +2 -30
- data/app/components/shadcn/switch_component.html.erb +72 -0
- data/app/components/shadcn/switch_component.rb +4 -82
- data/app/components/shadcn/table_component.html.erb +9 -0
- data/app/components/shadcn/table_component.rb +2 -10
- data/app/components/shadcn/tabs_component.html.erb +8 -0
- data/app/components/shadcn/tabs_component.rb +4 -17
- data/app/components/shadcn/textarea_component.html.erb +13 -0
- data/app/components/shadcn/textarea_component.rb +6 -22
- data/app/components/shadcn/toast_component.html.erb +36 -0
- data/app/components/shadcn/toast_component.rb +6 -54
- data/app/components/shadcn/toggle_component.html.erb +12 -0
- data/app/components/shadcn/toggle_component.rb +6 -21
- data/app/components/shadcn/toggle_group_component.html.erb +14 -0
- data/app/components/shadcn/toggle_group_component.rb +6 -29
- data/app/components/shadcn/tooltip_component.html.erb +20 -0
- data/app/components/shadcn/tooltip_component.rb +13 -38
- data/lib/generators/shadcn/add/USAGE +24 -0
- data/lib/generators/shadcn/add/add_generator.rb +279 -0
- data/lib/generators/shadcn/install/USAGE +22 -0
- data/lib/generators/shadcn/install/install_generator.rb +8 -3
- data/lib/generators/shadcn/install/templates/initializer.rb.tt +7 -27
- data/lib/generators/shadcn/install/templates/shadcn.yml.tt +15 -31
- data/lib/shadcn/rails/version.rb +1 -1
- metadata +47 -45
- data/.dockerignore +0 -40
- data/CLAUDE.md +0 -612
- data/PROGRESS.md +0 -495
- data/Rakefile +0 -95
- data/__tests__/controllers/__snapshots__/calendar_controller.test.js.snap +0 -13
- data/__tests__/controllers/__snapshots__/popover_controller.test.js.snap +0 -46
- data/__tests__/controllers/__snapshots__/sheet_controller.test.js.snap +0 -111
- data/__tests__/controllers/__snapshots__/tabs_controller.test.js.snap +0 -27
- data/__tests__/controllers/accordion_controller.test.js +0 -904
- data/__tests__/controllers/calendar_controller.test.js +0 -1370
- data/__tests__/controllers/carousel_controller.test.js +0 -912
- data/__tests__/controllers/checkbox_controller.test.js +0 -454
- data/__tests__/controllers/collapsible_controller.test.js +0 -407
- data/__tests__/controllers/combobox_controller.test.js +0 -971
- data/__tests__/controllers/context_menu_controller.test.js +0 -905
- data/__tests__/controllers/date_picker_controller.test.js +0 -636
- data/__tests__/controllers/dialog_controller.test.js +0 -878
- data/__tests__/controllers/drawer_controller.test.js +0 -995
- data/__tests__/controllers/menubar_controller.test.js +0 -737
- data/__tests__/controllers/navigation_menu_controller.test.js +0 -599
- data/__tests__/controllers/popover_controller.test.js +0 -982
- data/__tests__/controllers/radio_group_controller.test.js +0 -640
- data/__tests__/controllers/resizable_controller.test.js +0 -680
- data/__tests__/controllers/select_controller.test.js +0 -678
- data/__tests__/controllers/sheet_controller.test.js +0 -986
- data/__tests__/controllers/slider_controller.test.js +0 -1036
- data/__tests__/controllers/switch_controller.test.js +0 -424
- data/__tests__/controllers/tabs_controller.test.js +0 -907
- data/__tests__/controllers/toggle_group_controller.test.js +0 -839
- data/__tests__/controllers/tooltip_controller.test.js +0 -808
- data/__tests__/helpers/stimulus-test-helper.js +0 -203
- data/babel.config.cjs +0 -5
- data/bin/bump +0 -321
- data/bin/console +0 -11
- data/bin/release +0 -205
- data/bin/setup +0 -8
- data/bin/test +0 -75
- data/jest.config.js +0 -19
- data/jest.setup.js +0 -8
- data/lib/generators/shadcn/component/component_generator.rb +0 -188
- data/lib/generators/shadcn/theme/theme_generator.rb +0 -128
- data/package-lock.json +0 -7438
- data/package.json +0 -71
- data/rollup.config.js +0 -29
|
@@ -1,971 +0,0 @@
|
|
|
1
|
-
import { Application } from "@hotwired/stimulus"
|
|
2
|
-
import ComboboxController from "../../app/assets/javascripts/shadcn/controllers/combobox_controller.js"
|
|
3
|
-
import {
|
|
4
|
-
setupController,
|
|
5
|
-
cleanupController,
|
|
6
|
-
click,
|
|
7
|
-
wait,
|
|
8
|
-
nextFrame,
|
|
9
|
-
keydown,
|
|
10
|
-
waitForEvent,
|
|
11
|
-
dispatchEvent
|
|
12
|
-
} from "../helpers/stimulus-test-helper.js"
|
|
13
|
-
|
|
14
|
-
describe("ComboboxController", () => {
|
|
15
|
-
let application, element, controller
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
cleanupController(application)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Helper to get the HTML template for combobox tests
|
|
23
|
-
*/
|
|
24
|
-
function getComboboxHTML(options = {}) {
|
|
25
|
-
const {
|
|
26
|
-
open = false,
|
|
27
|
-
value = "",
|
|
28
|
-
selectedIndex = -1,
|
|
29
|
-
debounceWait = 0, // Default to 0 for tests (no debounce delay)
|
|
30
|
-
items = [
|
|
31
|
-
{ value: "react", label: "React" },
|
|
32
|
-
{ value: "vue", label: "Vue" },
|
|
33
|
-
{ value: "angular", label: "Angular" },
|
|
34
|
-
{ value: "svelte", label: "Svelte" }
|
|
35
|
-
],
|
|
36
|
-
includeEmpty = true,
|
|
37
|
-
includeDisplayValue = true,
|
|
38
|
-
includeHiddenInput = true
|
|
39
|
-
} = options
|
|
40
|
-
|
|
41
|
-
const itemsHTML = items.map(item => `
|
|
42
|
-
<div
|
|
43
|
-
data-shadcn--combobox-target="item"
|
|
44
|
-
data-value="${item.value}"
|
|
45
|
-
data-label="${item.label}"
|
|
46
|
-
data-action="click->shadcn--combobox#select"
|
|
47
|
-
data-selected="false"
|
|
48
|
-
class="cursor-pointer"
|
|
49
|
-
>
|
|
50
|
-
<svg class="opacity-0"></svg>
|
|
51
|
-
${item.label}
|
|
52
|
-
</div>
|
|
53
|
-
`).join("")
|
|
54
|
-
|
|
55
|
-
return `
|
|
56
|
-
<div
|
|
57
|
-
data-controller="shadcn--combobox"
|
|
58
|
-
data-shadcn--combobox-open-value="${open}"
|
|
59
|
-
data-shadcn--combobox-value-value="${value}"
|
|
60
|
-
data-shadcn--combobox-selected-index-value="${selectedIndex}"
|
|
61
|
-
data-shadcn--combobox-debounce-wait-value="${debounceWait}"
|
|
62
|
-
>
|
|
63
|
-
<button
|
|
64
|
-
data-shadcn--combobox-target="trigger"
|
|
65
|
-
data-action="click->shadcn--combobox#toggle"
|
|
66
|
-
aria-expanded="${open}"
|
|
67
|
-
>
|
|
68
|
-
${includeDisplayValue ? `<span data-shadcn--combobox-target="displayValue" class="text-muted-foreground">Select framework...</span>` : 'Select framework...'}
|
|
69
|
-
</button>
|
|
70
|
-
${includeHiddenInput ? '<input type="hidden" data-shadcn--combobox-target="hiddenInput" name="framework">' : ''}
|
|
71
|
-
<div
|
|
72
|
-
data-shadcn--combobox-target="content"
|
|
73
|
-
data-state="closed"
|
|
74
|
-
${!open ? 'hidden' : ''}
|
|
75
|
-
>
|
|
76
|
-
<input
|
|
77
|
-
data-shadcn--combobox-target="input"
|
|
78
|
-
type="text"
|
|
79
|
-
placeholder="Search..."
|
|
80
|
-
data-action="input->shadcn--combobox#filter"
|
|
81
|
-
>
|
|
82
|
-
<div data-shadcn--combobox-target="list">
|
|
83
|
-
${itemsHTML}
|
|
84
|
-
</div>
|
|
85
|
-
${includeEmpty ? '<div data-shadcn--combobox-target="empty" hidden>No results found.</div>' : ''}
|
|
86
|
-
</div>
|
|
87
|
-
</div>
|
|
88
|
-
`
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
describe("Value Initialization", () => {
|
|
92
|
-
it("initializes with default values", async () => {
|
|
93
|
-
const html = getComboboxHTML()
|
|
94
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
95
|
-
application = setup.application
|
|
96
|
-
element = setup.element
|
|
97
|
-
controller = setup.controller
|
|
98
|
-
|
|
99
|
-
expect(controller.openValue).toBe(false)
|
|
100
|
-
expect(controller.valueValue).toBe("")
|
|
101
|
-
expect(controller.selectedIndexValue).toBe(-1)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it("initializes with custom open value", async () => {
|
|
105
|
-
const html = getComboboxHTML({ open: true })
|
|
106
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
107
|
-
application = setup.application
|
|
108
|
-
element = setup.element
|
|
109
|
-
controller = setup.controller
|
|
110
|
-
|
|
111
|
-
expect(controller.openValue).toBe(true)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it("initializes with custom value", async () => {
|
|
115
|
-
const html = getComboboxHTML({ value: "react" })
|
|
116
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
117
|
-
application = setup.application
|
|
118
|
-
element = setup.element
|
|
119
|
-
controller = setup.controller
|
|
120
|
-
|
|
121
|
-
expect(controller.valueValue).toBe("react")
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it("initializes with custom selected index", async () => {
|
|
125
|
-
const html = getComboboxHTML({ selectedIndex: 2 })
|
|
126
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
127
|
-
application = setup.application
|
|
128
|
-
element = setup.element
|
|
129
|
-
controller = setup.controller
|
|
130
|
-
|
|
131
|
-
expect(controller.selectedIndexValue).toBe(2)
|
|
132
|
-
})
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
describe("Open/Close Behavior", () => {
|
|
136
|
-
beforeEach(async () => {
|
|
137
|
-
const html = getComboboxHTML()
|
|
138
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
139
|
-
application = setup.application
|
|
140
|
-
element = setup.element
|
|
141
|
-
controller = setup.controller
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
it("opens the combobox when toggle is called on closed state", async () => {
|
|
145
|
-
expect(controller.openValue).toBe(false)
|
|
146
|
-
|
|
147
|
-
controller.toggle()
|
|
148
|
-
await nextFrame()
|
|
149
|
-
|
|
150
|
-
expect(controller.openValue).toBe(true)
|
|
151
|
-
expect(controller.contentTarget.hidden).toBe(false)
|
|
152
|
-
expect(controller.contentTarget.dataset.state).toBe("open")
|
|
153
|
-
expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it("closes the combobox when toggle is called on open state", async () => {
|
|
157
|
-
controller.open()
|
|
158
|
-
await nextFrame()
|
|
159
|
-
expect(controller.openValue).toBe(true)
|
|
160
|
-
|
|
161
|
-
controller.toggle()
|
|
162
|
-
await wait(250) // Wait for animation and cleanup
|
|
163
|
-
|
|
164
|
-
expect(controller.openValue).toBe(false)
|
|
165
|
-
expect(controller.contentTarget.dataset.state).toBe("closed")
|
|
166
|
-
expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
it("focuses the input when opened", async () => {
|
|
170
|
-
controller.open()
|
|
171
|
-
await nextFrame()
|
|
172
|
-
await nextFrame() // requestAnimationFrame in open()
|
|
173
|
-
|
|
174
|
-
expect(document.activeElement).toBe(controller.inputTarget)
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it("does not open if already open", async () => {
|
|
178
|
-
controller.open()
|
|
179
|
-
await nextFrame()
|
|
180
|
-
const initialState = controller.openValue
|
|
181
|
-
|
|
182
|
-
controller.open()
|
|
183
|
-
await nextFrame()
|
|
184
|
-
|
|
185
|
-
expect(controller.openValue).toBe(initialState)
|
|
186
|
-
expect(controller.openValue).toBe(true)
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
it("does not close if already closed", async () => {
|
|
190
|
-
expect(controller.openValue).toBe(false)
|
|
191
|
-
|
|
192
|
-
controller.close()
|
|
193
|
-
await wait(250)
|
|
194
|
-
|
|
195
|
-
expect(controller.openValue).toBe(false)
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it("resets selected index when opened", async () => {
|
|
199
|
-
controller.selectedIndexValue = 2
|
|
200
|
-
|
|
201
|
-
controller.open()
|
|
202
|
-
await nextFrame()
|
|
203
|
-
|
|
204
|
-
expect(controller.selectedIndexValue).toBe(-1)
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it("closes on Escape key", async () => {
|
|
208
|
-
controller.open()
|
|
209
|
-
await nextFrame()
|
|
210
|
-
|
|
211
|
-
keydown(document, "Escape")
|
|
212
|
-
await wait(250)
|
|
213
|
-
|
|
214
|
-
expect(controller.openValue).toBe(false)
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
it("hides content after close animation completes", async () => {
|
|
218
|
-
controller.open()
|
|
219
|
-
await nextFrame()
|
|
220
|
-
expect(controller.contentTarget.hidden).toBe(false)
|
|
221
|
-
|
|
222
|
-
controller.close()
|
|
223
|
-
await wait(250) // Wait for animation and fallback timeout
|
|
224
|
-
|
|
225
|
-
expect(controller.contentTarget.hidden).toBe(true)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
it("resets input value when closed", async () => {
|
|
229
|
-
controller.open()
|
|
230
|
-
await nextFrame()
|
|
231
|
-
|
|
232
|
-
controller.inputTarget.value = "test search"
|
|
233
|
-
controller.close()
|
|
234
|
-
await wait(250)
|
|
235
|
-
|
|
236
|
-
expect(controller.inputTarget.value).toBe("")
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
it("resets item visibility when closed", async () => {
|
|
240
|
-
controller.open()
|
|
241
|
-
await nextFrame()
|
|
242
|
-
|
|
243
|
-
// Hide some items
|
|
244
|
-
controller.itemTargets[0].style.display = "none"
|
|
245
|
-
controller.itemTargets[1].style.display = "none"
|
|
246
|
-
|
|
247
|
-
controller.close()
|
|
248
|
-
await wait(250)
|
|
249
|
-
|
|
250
|
-
controller.itemTargets.forEach(item => {
|
|
251
|
-
expect(item.style.display).toBe("")
|
|
252
|
-
})
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
it("hides empty state when closed", async () => {
|
|
256
|
-
controller.open()
|
|
257
|
-
await nextFrame()
|
|
258
|
-
|
|
259
|
-
controller.emptyTarget.hidden = false
|
|
260
|
-
controller.close()
|
|
261
|
-
await wait(250)
|
|
262
|
-
|
|
263
|
-
expect(controller.emptyTarget.hidden).toBe(true)
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it("adds keyboard listener when opened", async () => {
|
|
267
|
-
const spy = jest.spyOn(document, "addEventListener")
|
|
268
|
-
|
|
269
|
-
controller.open()
|
|
270
|
-
await nextFrame()
|
|
271
|
-
|
|
272
|
-
expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
|
|
273
|
-
spy.mockRestore()
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
it("removes keyboard listener when closed", async () => {
|
|
277
|
-
controller.open()
|
|
278
|
-
await nextFrame()
|
|
279
|
-
|
|
280
|
-
const spy = jest.spyOn(document, "removeEventListener")
|
|
281
|
-
controller.close()
|
|
282
|
-
await wait(250)
|
|
283
|
-
|
|
284
|
-
expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
|
|
285
|
-
spy.mockRestore()
|
|
286
|
-
})
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
describe("Filtering", () => {
|
|
290
|
-
beforeEach(async () => {
|
|
291
|
-
const html = getComboboxHTML()
|
|
292
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
293
|
-
application = setup.application
|
|
294
|
-
element = setup.element
|
|
295
|
-
controller = setup.controller
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
// Helper to run filter and wait for debounce (debounceWait=0 still uses setTimeout)
|
|
299
|
-
async function filterAndWait() {
|
|
300
|
-
controller.filter()
|
|
301
|
-
await new Promise(resolve => setTimeout(resolve, 0))
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
it("filters items based on input value", async () => {
|
|
305
|
-
controller.inputTarget.value = "react"
|
|
306
|
-
await filterAndWait()
|
|
307
|
-
|
|
308
|
-
expect(controller.itemTargets[0].style.display).toBe("") // React - visible
|
|
309
|
-
expect(controller.itemTargets[1].style.display).toBe("none") // Vue - hidden
|
|
310
|
-
expect(controller.itemTargets[2].style.display).toBe("none") // Angular - hidden
|
|
311
|
-
expect(controller.itemTargets[3].style.display).toBe("none") // Svelte - hidden
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
it("is case insensitive when filtering", async () => {
|
|
315
|
-
controller.inputTarget.value = "REACT"
|
|
316
|
-
await filterAndWait()
|
|
317
|
-
|
|
318
|
-
expect(controller.itemTargets[0].style.display).toBe("") // React matches
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
it("filters by label attribute", async () => {
|
|
322
|
-
controller.inputTarget.value = "Vue"
|
|
323
|
-
await filterAndWait()
|
|
324
|
-
|
|
325
|
-
expect(controller.itemTargets[0].style.display).toBe("none")
|
|
326
|
-
expect(controller.itemTargets[1].style.display).toBe("") // Vue visible
|
|
327
|
-
expect(controller.itemTargets[2].style.display).toBe("none")
|
|
328
|
-
expect(controller.itemTargets[3].style.display).toBe("none")
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
it("filters by value attribute", async () => {
|
|
332
|
-
controller.inputTarget.value = "angular"
|
|
333
|
-
await filterAndWait()
|
|
334
|
-
|
|
335
|
-
expect(controller.itemTargets[0].style.display).toBe("none")
|
|
336
|
-
expect(controller.itemTargets[1].style.display).toBe("none")
|
|
337
|
-
expect(controller.itemTargets[2].style.display).toBe("") // Angular visible
|
|
338
|
-
expect(controller.itemTargets[3].style.display).toBe("none")
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
it("shows all items when input is empty", async () => {
|
|
342
|
-
controller.inputTarget.value = "react"
|
|
343
|
-
await filterAndWait()
|
|
344
|
-
|
|
345
|
-
controller.inputTarget.value = ""
|
|
346
|
-
await filterAndWait()
|
|
347
|
-
|
|
348
|
-
controller.itemTargets.forEach(item => {
|
|
349
|
-
expect(item.style.display).toBe("")
|
|
350
|
-
})
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
it("shows empty state when no results match query", async () => {
|
|
354
|
-
controller.inputTarget.value = "nonexistent"
|
|
355
|
-
await filterAndWait()
|
|
356
|
-
|
|
357
|
-
expect(controller.emptyTarget.hidden).toBe(false)
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
it("hides empty state when results exist", async () => {
|
|
361
|
-
controller.emptyTarget.hidden = false
|
|
362
|
-
|
|
363
|
-
controller.inputTarget.value = "react"
|
|
364
|
-
await filterAndWait()
|
|
365
|
-
|
|
366
|
-
expect(controller.emptyTarget.hidden).toBe(true)
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
it("hides empty state when query is empty", async () => {
|
|
370
|
-
controller.emptyTarget.hidden = false
|
|
371
|
-
|
|
372
|
-
controller.inputTarget.value = ""
|
|
373
|
-
await filterAndWait()
|
|
374
|
-
|
|
375
|
-
expect(controller.emptyTarget.hidden).toBe(true)
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
it("resets selected index after filtering", async () => {
|
|
379
|
-
controller.selectedIndexValue = 2
|
|
380
|
-
|
|
381
|
-
controller.inputTarget.value = "react"
|
|
382
|
-
await filterAndWait()
|
|
383
|
-
|
|
384
|
-
expect(controller.selectedIndexValue).toBe(-1)
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
it("handles partial matches", async () => {
|
|
388
|
-
controller.inputTarget.value = "vue"
|
|
389
|
-
await filterAndWait()
|
|
390
|
-
|
|
391
|
-
expect(controller.itemTargets[1].style.display).toBe("") // Vue
|
|
392
|
-
expect(controller.itemTargets[3].style.display).toBe("none") // Svelte (contains 'v' but not 'vue')
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
it("trims whitespace from query", async () => {
|
|
396
|
-
controller.inputTarget.value = " react "
|
|
397
|
-
await filterAndWait()
|
|
398
|
-
|
|
399
|
-
expect(controller.itemTargets[0].style.display).toBe("") // React visible
|
|
400
|
-
})
|
|
401
|
-
})
|
|
402
|
-
|
|
403
|
-
describe("Selection", () => {
|
|
404
|
-
beforeEach(async () => {
|
|
405
|
-
const html = getComboboxHTML()
|
|
406
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
407
|
-
application = setup.application
|
|
408
|
-
element = setup.element
|
|
409
|
-
controller = setup.controller
|
|
410
|
-
})
|
|
411
|
-
|
|
412
|
-
it("selects item on click", () => {
|
|
413
|
-
const item = controller.itemTargets[0]
|
|
414
|
-
|
|
415
|
-
click(item)
|
|
416
|
-
|
|
417
|
-
expect(controller.valueValue).toBe("react")
|
|
418
|
-
})
|
|
419
|
-
|
|
420
|
-
it("updates hidden input value when item selected", () => {
|
|
421
|
-
const item = controller.itemTargets[1]
|
|
422
|
-
|
|
423
|
-
click(item)
|
|
424
|
-
|
|
425
|
-
expect(controller.hiddenInputTarget.value).toBe("vue")
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
it("updates display value text when item selected", () => {
|
|
429
|
-
const item = controller.itemTargets[2]
|
|
430
|
-
|
|
431
|
-
click(item)
|
|
432
|
-
|
|
433
|
-
expect(controller.displayValueTarget.textContent).toBe("Angular")
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
it("removes muted foreground class from display value", () => {
|
|
437
|
-
controller.displayValueTarget.classList.add("text-muted-foreground")
|
|
438
|
-
const item = controller.itemTargets[0]
|
|
439
|
-
|
|
440
|
-
click(item)
|
|
441
|
-
|
|
442
|
-
expect(controller.displayValueTarget.classList.contains("text-muted-foreground")).toBe(false)
|
|
443
|
-
})
|
|
444
|
-
|
|
445
|
-
it("updates selected state on items", () => {
|
|
446
|
-
const item = controller.itemTargets[1]
|
|
447
|
-
|
|
448
|
-
click(item)
|
|
449
|
-
|
|
450
|
-
expect(controller.itemTargets[0].dataset.selected).toBe("false")
|
|
451
|
-
expect(controller.itemTargets[1].dataset.selected).toBe("true")
|
|
452
|
-
expect(controller.itemTargets[2].dataset.selected).toBe("false")
|
|
453
|
-
expect(controller.itemTargets[3].dataset.selected).toBe("false")
|
|
454
|
-
})
|
|
455
|
-
|
|
456
|
-
it("updates check icon visibility for selected item", () => {
|
|
457
|
-
const item = controller.itemTargets[0]
|
|
458
|
-
const checkIcon = item.querySelector("svg")
|
|
459
|
-
|
|
460
|
-
click(item)
|
|
461
|
-
|
|
462
|
-
expect(checkIcon.classList.contains("opacity-100")).toBe(true)
|
|
463
|
-
expect(checkIcon.classList.contains("opacity-0")).toBe(false)
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
it("hides check icon for unselected items", () => {
|
|
467
|
-
click(controller.itemTargets[0])
|
|
468
|
-
|
|
469
|
-
// Select different item
|
|
470
|
-
click(controller.itemTargets[1])
|
|
471
|
-
|
|
472
|
-
const firstCheckIcon = controller.itemTargets[0].querySelector("svg")
|
|
473
|
-
expect(firstCheckIcon.classList.contains("opacity-0")).toBe(true)
|
|
474
|
-
expect(firstCheckIcon.classList.contains("opacity-100")).toBe(false)
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
it("dispatches change event with value and label", async () => {
|
|
478
|
-
const item = controller.itemTargets[2]
|
|
479
|
-
const eventPromise = waitForEvent(element, "shadcn--combobox:change", 1000)
|
|
480
|
-
|
|
481
|
-
click(item)
|
|
482
|
-
const event = await eventPromise
|
|
483
|
-
|
|
484
|
-
expect(event.detail.value).toBe("angular")
|
|
485
|
-
expect(event.detail.label).toBe("Angular")
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
it("closes combobox after selection", async () => {
|
|
489
|
-
controller.open()
|
|
490
|
-
await nextFrame()
|
|
491
|
-
expect(controller.openValue).toBe(true)
|
|
492
|
-
|
|
493
|
-
click(controller.itemTargets[0])
|
|
494
|
-
await wait(250)
|
|
495
|
-
|
|
496
|
-
expect(controller.openValue).toBe(false)
|
|
497
|
-
})
|
|
498
|
-
|
|
499
|
-
it("selects item on Enter key when item is highlighted", async () => {
|
|
500
|
-
controller.open()
|
|
501
|
-
await nextFrame()
|
|
502
|
-
|
|
503
|
-
controller.selectedIndexValue = 1
|
|
504
|
-
controller.updateSelection()
|
|
505
|
-
|
|
506
|
-
keydown(document, "Enter")
|
|
507
|
-
await wait(250)
|
|
508
|
-
|
|
509
|
-
expect(controller.valueValue).toBe("vue")
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
it("does nothing on Enter if no item is highlighted", () => {
|
|
513
|
-
controller.open()
|
|
514
|
-
|
|
515
|
-
const initialValue = controller.valueValue
|
|
516
|
-
keydown(document, "Enter")
|
|
517
|
-
|
|
518
|
-
expect(controller.valueValue).toBe(initialValue)
|
|
519
|
-
})
|
|
520
|
-
})
|
|
521
|
-
|
|
522
|
-
describe("Value Persistence", () => {
|
|
523
|
-
beforeEach(async () => {
|
|
524
|
-
const html = getComboboxHTML()
|
|
525
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
526
|
-
application = setup.application
|
|
527
|
-
element = setup.element
|
|
528
|
-
controller = setup.controller
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
it("maintains selected value after filtering", async () => {
|
|
532
|
-
click(controller.itemTargets[0]) // Select React
|
|
533
|
-
|
|
534
|
-
controller.inputTarget.value = "vue"
|
|
535
|
-
controller.filter()
|
|
536
|
-
await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
|
|
537
|
-
|
|
538
|
-
expect(controller.valueValue).toBe("react")
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
it("maintains selected value after closing and reopening", async () => {
|
|
542
|
-
controller.open()
|
|
543
|
-
await nextFrame()
|
|
544
|
-
|
|
545
|
-
click(controller.itemTargets[1]) // Select Vue
|
|
546
|
-
await wait(250)
|
|
547
|
-
|
|
548
|
-
controller.open()
|
|
549
|
-
await nextFrame()
|
|
550
|
-
|
|
551
|
-
expect(controller.valueValue).toBe("vue")
|
|
552
|
-
expect(controller.itemTargets[1].dataset.selected).toBe("true")
|
|
553
|
-
})
|
|
554
|
-
|
|
555
|
-
it("maintains display value after reopening", async () => {
|
|
556
|
-
controller.open()
|
|
557
|
-
await nextFrame()
|
|
558
|
-
|
|
559
|
-
click(controller.itemTargets[2]) // Select Angular
|
|
560
|
-
await wait(250)
|
|
561
|
-
|
|
562
|
-
controller.open()
|
|
563
|
-
await nextFrame()
|
|
564
|
-
|
|
565
|
-
expect(controller.displayValueTarget.textContent).toBe("Angular")
|
|
566
|
-
})
|
|
567
|
-
})
|
|
568
|
-
|
|
569
|
-
describe("Animation Timing", () => {
|
|
570
|
-
beforeEach(async () => {
|
|
571
|
-
const html = getComboboxHTML()
|
|
572
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
573
|
-
application = setup.application
|
|
574
|
-
element = setup.element
|
|
575
|
-
controller = setup.controller
|
|
576
|
-
})
|
|
577
|
-
|
|
578
|
-
it("sets state to open immediately when opening", () => {
|
|
579
|
-
controller.open()
|
|
580
|
-
|
|
581
|
-
expect(controller.contentTarget.dataset.state).toBe("open")
|
|
582
|
-
})
|
|
583
|
-
|
|
584
|
-
it("sets state to closed immediately when closing", () => {
|
|
585
|
-
controller.open()
|
|
586
|
-
controller.close()
|
|
587
|
-
|
|
588
|
-
expect(controller.contentTarget.dataset.state).toBe("closed")
|
|
589
|
-
})
|
|
590
|
-
|
|
591
|
-
it("hides content after close animation with fallback timeout", async () => {
|
|
592
|
-
controller.open()
|
|
593
|
-
await nextFrame()
|
|
594
|
-
|
|
595
|
-
controller.close()
|
|
596
|
-
|
|
597
|
-
// Content should still be visible during animation
|
|
598
|
-
expect(controller.contentTarget.hidden).toBe(false)
|
|
599
|
-
|
|
600
|
-
// Wait for fallback timeout (200ms)
|
|
601
|
-
await wait(250)
|
|
602
|
-
|
|
603
|
-
// Content should now be hidden
|
|
604
|
-
expect(controller.contentTarget.hidden).toBe(true)
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
it("listens for animationend event on close", async () => {
|
|
608
|
-
controller.open()
|
|
609
|
-
await nextFrame()
|
|
610
|
-
|
|
611
|
-
const spy = jest.spyOn(controller.contentTarget, "addEventListener")
|
|
612
|
-
controller.close()
|
|
613
|
-
|
|
614
|
-
expect(spy).toHaveBeenCalledWith("animationend", expect.any(Function))
|
|
615
|
-
spy.mockRestore()
|
|
616
|
-
await wait(250)
|
|
617
|
-
})
|
|
618
|
-
})
|
|
619
|
-
|
|
620
|
-
describe("Keyboard Navigation", () => {
|
|
621
|
-
beforeEach(async () => {
|
|
622
|
-
const html = getComboboxHTML()
|
|
623
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
624
|
-
application = setup.application
|
|
625
|
-
element = setup.element
|
|
626
|
-
controller = setup.controller
|
|
627
|
-
controller.open()
|
|
628
|
-
await nextFrame()
|
|
629
|
-
})
|
|
630
|
-
|
|
631
|
-
it("navigates down with ArrowDown", () => {
|
|
632
|
-
expect(controller.selectedIndexValue).toBe(-1)
|
|
633
|
-
|
|
634
|
-
keydown(document, "ArrowDown")
|
|
635
|
-
expect(controller.selectedIndexValue).toBe(0)
|
|
636
|
-
|
|
637
|
-
keydown(document, "ArrowDown")
|
|
638
|
-
expect(controller.selectedIndexValue).toBe(1)
|
|
639
|
-
})
|
|
640
|
-
|
|
641
|
-
it("navigates up with ArrowUp", () => {
|
|
642
|
-
controller.selectedIndexValue = 2
|
|
643
|
-
|
|
644
|
-
keydown(document, "ArrowUp")
|
|
645
|
-
expect(controller.selectedIndexValue).toBe(1)
|
|
646
|
-
|
|
647
|
-
keydown(document, "ArrowUp")
|
|
648
|
-
expect(controller.selectedIndexValue).toBe(0)
|
|
649
|
-
})
|
|
650
|
-
|
|
651
|
-
it("does not go below 0 with ArrowUp", () => {
|
|
652
|
-
controller.selectedIndexValue = 0
|
|
653
|
-
|
|
654
|
-
keydown(document, "ArrowUp")
|
|
655
|
-
expect(controller.selectedIndexValue).toBe(0)
|
|
656
|
-
})
|
|
657
|
-
|
|
658
|
-
it("does not go beyond last item with ArrowDown", () => {
|
|
659
|
-
const lastIndex = controller.itemTargets.length - 1
|
|
660
|
-
controller.selectedIndexValue = lastIndex
|
|
661
|
-
|
|
662
|
-
keydown(document, "ArrowDown")
|
|
663
|
-
expect(controller.selectedIndexValue).toBe(lastIndex)
|
|
664
|
-
})
|
|
665
|
-
|
|
666
|
-
it("applies highlight classes to selected item", () => {
|
|
667
|
-
controller.selectedIndexValue = 1
|
|
668
|
-
controller.updateSelection()
|
|
669
|
-
|
|
670
|
-
expect(controller.itemTargets[1].classList.contains("bg-accent")).toBe(true)
|
|
671
|
-
expect(controller.itemTargets[1].classList.contains("text-accent-foreground")).toBe(true)
|
|
672
|
-
})
|
|
673
|
-
|
|
674
|
-
it("removes highlight classes from unselected items", () => {
|
|
675
|
-
controller.selectedIndexValue = 1
|
|
676
|
-
controller.updateSelection()
|
|
677
|
-
|
|
678
|
-
expect(controller.itemTargets[0].classList.contains("bg-accent")).toBe(false)
|
|
679
|
-
expect(controller.itemTargets[2].classList.contains("bg-accent")).toBe(false)
|
|
680
|
-
})
|
|
681
|
-
|
|
682
|
-
it("scrolls selected item into view", () => {
|
|
683
|
-
const spy = jest.spyOn(controller.itemTargets[2], "scrollIntoView")
|
|
684
|
-
|
|
685
|
-
controller.selectedIndexValue = 2
|
|
686
|
-
controller.updateSelection()
|
|
687
|
-
|
|
688
|
-
expect(spy).toHaveBeenCalledWith({ block: "nearest" })
|
|
689
|
-
spy.mockRestore()
|
|
690
|
-
})
|
|
691
|
-
|
|
692
|
-
it("navigates only through visible items after filtering", async () => {
|
|
693
|
-
controller.inputTarget.value = "react"
|
|
694
|
-
controller.filter()
|
|
695
|
-
await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
|
|
696
|
-
|
|
697
|
-
keydown(document, "ArrowDown")
|
|
698
|
-
|
|
699
|
-
// Should select first (and only) visible item
|
|
700
|
-
expect(controller.selectedIndexValue).toBe(0)
|
|
701
|
-
|
|
702
|
-
keydown(document, "ArrowDown")
|
|
703
|
-
|
|
704
|
-
// Should stay at 0 since it's the last visible item
|
|
705
|
-
expect(controller.selectedIndexValue).toBe(0)
|
|
706
|
-
})
|
|
707
|
-
|
|
708
|
-
it("prevents default on navigation keys", () => {
|
|
709
|
-
const event = new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true })
|
|
710
|
-
const spy = jest.spyOn(event, "preventDefault")
|
|
711
|
-
|
|
712
|
-
document.dispatchEvent(event)
|
|
713
|
-
|
|
714
|
-
expect(spy).toHaveBeenCalled()
|
|
715
|
-
})
|
|
716
|
-
|
|
717
|
-
it("prevents default on Escape key", () => {
|
|
718
|
-
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })
|
|
719
|
-
const spy = jest.spyOn(event, "preventDefault")
|
|
720
|
-
|
|
721
|
-
document.dispatchEvent(event)
|
|
722
|
-
|
|
723
|
-
expect(spy).toHaveBeenCalled()
|
|
724
|
-
})
|
|
725
|
-
|
|
726
|
-
it("prevents default on Enter key", () => {
|
|
727
|
-
controller.selectedIndexValue = 0
|
|
728
|
-
const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })
|
|
729
|
-
const spy = jest.spyOn(event, "preventDefault")
|
|
730
|
-
|
|
731
|
-
document.dispatchEvent(event)
|
|
732
|
-
|
|
733
|
-
expect(spy).toHaveBeenCalled()
|
|
734
|
-
})
|
|
735
|
-
})
|
|
736
|
-
|
|
737
|
-
describe("ARIA Attributes", () => {
|
|
738
|
-
beforeEach(async () => {
|
|
739
|
-
const html = getComboboxHTML()
|
|
740
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
741
|
-
application = setup.application
|
|
742
|
-
element = setup.element
|
|
743
|
-
controller = setup.controller
|
|
744
|
-
})
|
|
745
|
-
|
|
746
|
-
it("sets aria-expanded to false when closed", () => {
|
|
747
|
-
expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
|
|
748
|
-
})
|
|
749
|
-
|
|
750
|
-
it("sets aria-expanded to true when opened", async () => {
|
|
751
|
-
controller.open()
|
|
752
|
-
await nextFrame()
|
|
753
|
-
|
|
754
|
-
expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
|
|
755
|
-
})
|
|
756
|
-
|
|
757
|
-
it("updates aria-expanded when toggling", async () => {
|
|
758
|
-
controller.toggle()
|
|
759
|
-
await nextFrame()
|
|
760
|
-
expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("true")
|
|
761
|
-
|
|
762
|
-
controller.toggle()
|
|
763
|
-
await wait(250)
|
|
764
|
-
expect(controller.triggerTarget.getAttribute("aria-expanded")).toBe("false")
|
|
765
|
-
})
|
|
766
|
-
})
|
|
767
|
-
|
|
768
|
-
describe("Helper Methods", () => {
|
|
769
|
-
beforeEach(async () => {
|
|
770
|
-
const html = getComboboxHTML()
|
|
771
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
772
|
-
application = setup.application
|
|
773
|
-
element = setup.element
|
|
774
|
-
controller = setup.controller
|
|
775
|
-
})
|
|
776
|
-
|
|
777
|
-
it("getVisibleItems returns all items when none are filtered", () => {
|
|
778
|
-
const visibleItems = controller.getVisibleItems()
|
|
779
|
-
expect(visibleItems.length).toBe(4)
|
|
780
|
-
})
|
|
781
|
-
|
|
782
|
-
it("getVisibleItems returns only visible items after filtering", () => {
|
|
783
|
-
controller.itemTargets[0].style.display = "none"
|
|
784
|
-
controller.itemTargets[2].style.display = "none"
|
|
785
|
-
|
|
786
|
-
const visibleItems = controller.getVisibleItems()
|
|
787
|
-
|
|
788
|
-
expect(visibleItems.length).toBe(2)
|
|
789
|
-
expect(visibleItems[0]).toBe(controller.itemTargets[1])
|
|
790
|
-
expect(visibleItems[1]).toBe(controller.itemTargets[3])
|
|
791
|
-
})
|
|
792
|
-
})
|
|
793
|
-
|
|
794
|
-
describe("Click Outside Handling", () => {
|
|
795
|
-
beforeEach(async () => {
|
|
796
|
-
const html = getComboboxHTML()
|
|
797
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
798
|
-
application = setup.application
|
|
799
|
-
element = setup.element
|
|
800
|
-
controller = setup.controller
|
|
801
|
-
})
|
|
802
|
-
|
|
803
|
-
it("closes when clicking outside the element", async () => {
|
|
804
|
-
controller.open()
|
|
805
|
-
await nextFrame()
|
|
806
|
-
|
|
807
|
-
const outsideElement = document.createElement("div")
|
|
808
|
-
document.body.appendChild(outsideElement)
|
|
809
|
-
|
|
810
|
-
// Use clickOutside directly since stimulus-use doesn't trigger via DOM events in jsdom
|
|
811
|
-
controller.clickOutside({ target: outsideElement })
|
|
812
|
-
await wait(250)
|
|
813
|
-
|
|
814
|
-
expect(controller.openValue).toBe(false)
|
|
815
|
-
outsideElement.remove()
|
|
816
|
-
})
|
|
817
|
-
|
|
818
|
-
it("does not close when clicking inside the element", async () => {
|
|
819
|
-
controller.open()
|
|
820
|
-
await nextFrame()
|
|
821
|
-
|
|
822
|
-
// Clicking inside the controller element should not close via clickOutside
|
|
823
|
-
// The clickOutside method from stimulus-use only fires for clicks outside the element
|
|
824
|
-
// So we verify the combobox stays open
|
|
825
|
-
expect(controller.openValue).toBe(true)
|
|
826
|
-
})
|
|
827
|
-
|
|
828
|
-
it("does nothing when already closed", () => {
|
|
829
|
-
expect(controller.openValue).toBe(false)
|
|
830
|
-
|
|
831
|
-
const outsideElement = document.createElement("div")
|
|
832
|
-
document.body.appendChild(outsideElement)
|
|
833
|
-
|
|
834
|
-
// Calling clickOutside on closed combobox should have no effect
|
|
835
|
-
controller.clickOutside({ target: outsideElement })
|
|
836
|
-
|
|
837
|
-
expect(controller.openValue).toBe(false)
|
|
838
|
-
outsideElement.remove()
|
|
839
|
-
})
|
|
840
|
-
})
|
|
841
|
-
|
|
842
|
-
describe("Disconnect", () => {
|
|
843
|
-
beforeEach(async () => {
|
|
844
|
-
const html = getComboboxHTML()
|
|
845
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
846
|
-
application = setup.application
|
|
847
|
-
element = setup.element
|
|
848
|
-
controller = setup.controller
|
|
849
|
-
})
|
|
850
|
-
|
|
851
|
-
it("removes keyboard event listener on disconnect", () => {
|
|
852
|
-
controller.open()
|
|
853
|
-
|
|
854
|
-
const spy = jest.spyOn(document, "removeEventListener")
|
|
855
|
-
controller.disconnect()
|
|
856
|
-
|
|
857
|
-
expect(spy).toHaveBeenCalledWith("keydown", controller.boundHandleKeydown)
|
|
858
|
-
spy.mockRestore()
|
|
859
|
-
})
|
|
860
|
-
})
|
|
861
|
-
|
|
862
|
-
describe("Edge Cases", () => {
|
|
863
|
-
it("handles combobox without empty target", async () => {
|
|
864
|
-
const html = getComboboxHTML({ includeEmpty: false })
|
|
865
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
866
|
-
application = setup.application
|
|
867
|
-
element = setup.element
|
|
868
|
-
controller = setup.controller
|
|
869
|
-
|
|
870
|
-
expect(controller.hasEmptyTarget).toBe(false)
|
|
871
|
-
|
|
872
|
-
controller.inputTarget.value = "nonexistent"
|
|
873
|
-
|
|
874
|
-
// Should not throw error (filter is debounced, but should still not throw)
|
|
875
|
-
expect(() => controller.filter()).not.toThrow()
|
|
876
|
-
await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
|
|
877
|
-
})
|
|
878
|
-
|
|
879
|
-
it("handles combobox without display value target", async () => {
|
|
880
|
-
const html = getComboboxHTML({ includeDisplayValue: false })
|
|
881
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
882
|
-
application = setup.application
|
|
883
|
-
element = setup.element
|
|
884
|
-
controller = setup.controller
|
|
885
|
-
|
|
886
|
-
expect(controller.hasDisplayValueTarget).toBe(false)
|
|
887
|
-
|
|
888
|
-
// Should not throw error when selecting
|
|
889
|
-
expect(() => click(controller.itemTargets[0])).not.toThrow()
|
|
890
|
-
})
|
|
891
|
-
|
|
892
|
-
it("handles combobox without hidden input target", async () => {
|
|
893
|
-
const html = getComboboxHTML({ includeHiddenInput: false })
|
|
894
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
895
|
-
application = setup.application
|
|
896
|
-
element = setup.element
|
|
897
|
-
controller = setup.controller
|
|
898
|
-
|
|
899
|
-
expect(controller.hasHiddenInputTarget).toBe(false)
|
|
900
|
-
|
|
901
|
-
// Should not throw error when selecting
|
|
902
|
-
expect(() => click(controller.itemTargets[0])).not.toThrow()
|
|
903
|
-
})
|
|
904
|
-
|
|
905
|
-
it("handles items without check icons", () => {
|
|
906
|
-
const html = `
|
|
907
|
-
<div data-controller="shadcn--combobox">
|
|
908
|
-
<button data-shadcn--combobox-target="trigger" data-action="click->shadcn--combobox#toggle">
|
|
909
|
-
Select
|
|
910
|
-
</button>
|
|
911
|
-
<div data-shadcn--combobox-target="content" hidden>
|
|
912
|
-
<input data-shadcn--combobox-target="input" type="text" data-action="input->shadcn--combobox#filter">
|
|
913
|
-
<div data-shadcn--combobox-target="item" data-value="item1" data-label="Item 1" data-action="click->shadcn--combobox#select">
|
|
914
|
-
Item 1
|
|
915
|
-
</div>
|
|
916
|
-
</div>
|
|
917
|
-
</div>
|
|
918
|
-
`
|
|
919
|
-
|
|
920
|
-
return setupController(ComboboxController, html, "shadcn--combobox").then(setup => {
|
|
921
|
-
application = setup.application
|
|
922
|
-
element = setup.element
|
|
923
|
-
controller = setup.controller
|
|
924
|
-
|
|
925
|
-
// Should not throw error when selecting item without icon
|
|
926
|
-
expect(() => click(controller.itemTargets[0])).not.toThrow()
|
|
927
|
-
})
|
|
928
|
-
})
|
|
929
|
-
|
|
930
|
-
it("handles empty item list", async () => {
|
|
931
|
-
const html = getComboboxHTML({ items: [] })
|
|
932
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
933
|
-
application = setup.application
|
|
934
|
-
element = setup.element
|
|
935
|
-
controller = setup.controller
|
|
936
|
-
|
|
937
|
-
expect(controller.itemTargets.length).toBe(0)
|
|
938
|
-
|
|
939
|
-
// Should not throw errors (filter is debounced)
|
|
940
|
-
expect(() => controller.filter()).not.toThrow()
|
|
941
|
-
await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
|
|
942
|
-
expect(() => keydown(document, "ArrowDown")).not.toThrow()
|
|
943
|
-
expect(() => controller.updateSelection()).not.toThrow()
|
|
944
|
-
})
|
|
945
|
-
|
|
946
|
-
it("handles item without label attribute falling back to textContent", async () => {
|
|
947
|
-
const html = `
|
|
948
|
-
<div data-controller="shadcn--combobox" data-shadcn--combobox-debounce-wait-value="0">
|
|
949
|
-
<button data-shadcn--combobox-target="trigger"></button>
|
|
950
|
-
<div data-shadcn--combobox-target="content" hidden>
|
|
951
|
-
<input data-shadcn--combobox-target="input" type="text" data-action="input->shadcn--combobox#filter">
|
|
952
|
-
<div data-shadcn--combobox-target="item" data-value="test" data-action="click->shadcn--combobox#select">
|
|
953
|
-
Text Content Only
|
|
954
|
-
</div>
|
|
955
|
-
</div>
|
|
956
|
-
</div>
|
|
957
|
-
`
|
|
958
|
-
|
|
959
|
-
const setup = await setupController(ComboboxController, html, "shadcn--combobox")
|
|
960
|
-
application = setup.application
|
|
961
|
-
element = setup.element
|
|
962
|
-
controller = setup.controller
|
|
963
|
-
|
|
964
|
-
controller.inputTarget.value = "text"
|
|
965
|
-
controller.filter()
|
|
966
|
-
await new Promise(resolve => setTimeout(resolve, 0)) // Allow debounce to execute
|
|
967
|
-
|
|
968
|
-
expect(controller.itemTargets[0].style.display).toBe("")
|
|
969
|
-
})
|
|
970
|
-
})
|
|
971
|
-
})
|