quicksilver_ui 0.1.0 → 0.1.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/app/helpers/app_form_builder.rb +4 -0
- data/app/javascript/controllers/combobox_controller.js +467 -0
- data/app/javascript/mixins/use_keyboard_navigation.js +142 -0
- data/app/views/form/combobox/async_search_result.rb +17 -0
- data/app/views/form/combobox/choice.rb +36 -0
- data/app/views/form/combobox/selected_item.rb +25 -0
- data/app/views/form/combobox.rb +214 -0
- data/app/views/ui/tag.rb +73 -0
- data/lib/quicksilver_ui/dependencies.rb +15 -0
- data/lib/quicksilver_ui/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd50a32b96fa881fd2feedfb1ecfa23e696c69647c03ba16ab4a43a97a87aa3e
|
|
4
|
+
data.tar.gz: b288dd0d7c928899779d461654ea5b8fef74429b92c7cc095ab3e98c4b7bf0b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37aeef541b3edd00ad012bac5a8a63f4c4550101dfdfa181e4dc8b9974b82302f3e7530e0d9f3c23170f41c93e34c8421d1146105485d25afefeea56a8f20290
|
|
7
|
+
data.tar.gz: cacdd0405dd303b4bd492f0551dc7fe763605171c280f96c3ae4998f3249ac509d5f3e6b303f23c36374640c8e30411cc3e473bcb7de68ded04fe80d941198f0
|
|
@@ -91,4 +91,8 @@ class AppFormBuilder < ActionView::Helpers::FormBuilder
|
|
|
91
91
|
def radio_button(method, tag_value, options = {})
|
|
92
92
|
render Form::RadioButton.new(form: self, tag_value:, method:, **options)
|
|
93
93
|
end
|
|
94
|
+
|
|
95
|
+
def combobox(method, choices, options = {})
|
|
96
|
+
render Form::Combobox.new(form: self, method:, choices:, **options)
|
|
97
|
+
end
|
|
94
98
|
end
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { useDebounce } from "stimulus-use"
|
|
3
|
+
import { useTransition } from "stimulus-use"
|
|
4
|
+
import { useKeyboardNavigation } from "mixins/use_keyboard_navigation"
|
|
5
|
+
import { useFloatingUI } from "mixins/use_floating_ui"
|
|
6
|
+
import { offset, flip, shift } from "@floating-ui/dom"
|
|
7
|
+
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["input", "menu", "choice", "selectedItem", "hiddenInput",
|
|
10
|
+
"selectedItemsContainer", "tagTemplate", "choicesContainer", "helpText",
|
|
11
|
+
"searching", "noResults", "allChoicesSelected", "turboFrame"]
|
|
12
|
+
static debounces = ["search"]
|
|
13
|
+
static values = {
|
|
14
|
+
placement: { type: String, default: "bottom-start" },
|
|
15
|
+
shiftPadding: { type: Number, default: 8 },
|
|
16
|
+
offset: { type: Number, default: 4 },
|
|
17
|
+
multiple: { type: Boolean, default: false },
|
|
18
|
+
busy: { type: Boolean, default: false },
|
|
19
|
+
open: { type: Boolean, default: false },
|
|
20
|
+
inputName: String,
|
|
21
|
+
searchUrl: String
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
initialize() {
|
|
25
|
+
useDebounce(this)
|
|
26
|
+
useTransition(this, {
|
|
27
|
+
element: this.menuTarget,
|
|
28
|
+
hiddenClass: "hidden",
|
|
29
|
+
})
|
|
30
|
+
useKeyboardNavigation(this, this.inputTarget)
|
|
31
|
+
useFloatingUI(this, this.inputTarget, this.menuTarget, this.positioningOptions)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
connect() {
|
|
35
|
+
this.hideAlreadySelectedChoices()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
disconnect() {
|
|
39
|
+
this.teardown()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
teardown() {
|
|
43
|
+
if (this.autoUpdateCleanup) {
|
|
44
|
+
this.autoUpdateCleanup()
|
|
45
|
+
this.autoUpdateCleanup = null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async search() {
|
|
50
|
+
const searchValue = this.inputTarget.value.trim()
|
|
51
|
+
|
|
52
|
+
if (this.searchUrlValue) {
|
|
53
|
+
await this.performAsyncSearch(searchValue)
|
|
54
|
+
} else {
|
|
55
|
+
this.performClientSearch(searchValue)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async performAsyncSearch(searchValue) {
|
|
60
|
+
if (searchValue.length === 0) {
|
|
61
|
+
this.handleEmptySearch()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.prepareAsyncSearch()
|
|
66
|
+
|
|
67
|
+
const response = await this.makeSearchRequest(searchValue)
|
|
68
|
+
await this.handleSearchResponse(response)
|
|
69
|
+
this.finalizeAsyncSearch()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
performClientSearch(searchValue) {
|
|
73
|
+
const hits = this.findMatchingChoices(searchValue)
|
|
74
|
+
this.updateChoiceVisibility(hits)
|
|
75
|
+
this.updateMenuAfterClientSearch()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
handleEmptySearch() {
|
|
79
|
+
this.showHelpText()
|
|
80
|
+
this.hideTurboFrame()
|
|
81
|
+
this.actuallyShowMenu()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
prepareAsyncSearch() {
|
|
85
|
+
this.hideTurboFrame()
|
|
86
|
+
this.showSearchingMessage()
|
|
87
|
+
this.actuallyShowMenu()
|
|
88
|
+
this.setBusyState(true)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async makeSearchRequest(searchValue) {
|
|
92
|
+
const url = this.buildSearchUrl(searchValue)
|
|
93
|
+
|
|
94
|
+
return await fetch(url, {
|
|
95
|
+
headers: {
|
|
96
|
+
'Accept': 'text/vnd.turbo-stream.html',
|
|
97
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
buildSearchUrl(searchValue) {
|
|
103
|
+
const url = new URL(this.searchUrlValue, window.location.origin)
|
|
104
|
+
url.searchParams.set('q', searchValue)
|
|
105
|
+
|
|
106
|
+
if (this.turboFrameTarget) {
|
|
107
|
+
url.searchParams.set('frame_id', this.turboFrameTarget.id)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (this.multipleValue && this.selectedItemValues.length > 0) {
|
|
111
|
+
this.selectedItemValues.forEach(value => {
|
|
112
|
+
url.searchParams.append('selected[]', value)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return url
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async handleSearchResponse(response) {
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`HTTP error! status: ${response.status}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const turboStreamResponse = await response.text()
|
|
125
|
+
|
|
126
|
+
this.hideMessages()
|
|
127
|
+
this.showTurboFrame()
|
|
128
|
+
Turbo.renderStreamMessage(turboStreamResponse)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
showNoResults() {
|
|
132
|
+
this.showNoResultsMessage()
|
|
133
|
+
this.actuallyShowMenu()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
finalizeAsyncSearch() {
|
|
137
|
+
this.setBusyState(false)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
findMatchingChoices(searchValue) {
|
|
141
|
+
if (searchValue === "") {
|
|
142
|
+
return this.choiceTargets
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const searchTerms = this.getSearchTerms(searchValue)
|
|
146
|
+
return this.choiceTargets.filter(choice =>
|
|
147
|
+
this.choiceMatchesSearch(choice, searchTerms)
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getSearchTerms(searchValue) {
|
|
152
|
+
return searchValue.toLowerCase().split(" ").filter(term => term.length > 0)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
choiceMatchesSearch(choice, searchTerms) {
|
|
156
|
+
const choiceText = choice.textContent.trim().toLowerCase()
|
|
157
|
+
return searchTerms.some(term => choiceText.includes(term))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
updateChoiceVisibility(matchingChoices) {
|
|
161
|
+
this.choiceTargets.forEach(choice => {
|
|
162
|
+
const isMatch = matchingChoices.includes(choice)
|
|
163
|
+
const isSelected = this.isChoiceSelected(choice)
|
|
164
|
+
|
|
165
|
+
if (isMatch && !isSelected) {
|
|
166
|
+
choice.classList.remove("hidden")
|
|
167
|
+
} else {
|
|
168
|
+
choice.classList.add("hidden")
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
updateMenuAfterClientSearch() {
|
|
174
|
+
if (this.visibleChoices.length > 0 && this.isInputFocused()) {
|
|
175
|
+
this.showMenu()
|
|
176
|
+
} else if (this.visibleChoices.length === 0) {
|
|
177
|
+
this.showNoResultsMessage()
|
|
178
|
+
this.actuallyShowMenu()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
showMenu() {
|
|
183
|
+
this.openValue = true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
actuallyShowMenu() {
|
|
187
|
+
this.clearHideTimeout()
|
|
188
|
+
this.enter()
|
|
189
|
+
this.showWithPositioning()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
hideMenu() {
|
|
193
|
+
this.openValue = false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
hideMenuWithDelay() {
|
|
197
|
+
this.hideTimeout = setTimeout(() => this.hideMenu(), 150)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
shouldShowHelpText() {
|
|
201
|
+
return this.searchUrlValue && this.inputTarget.value.trim() === ""
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
shouldTriggerAsyncSearch() {
|
|
205
|
+
return this.searchUrlValue &&
|
|
206
|
+
this.visibleChoices.length === 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
clearHideTimeout() {
|
|
210
|
+
if (this.hideTimeout) {
|
|
211
|
+
clearTimeout(this.hideTimeout)
|
|
212
|
+
this.hideTimeout = null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
selectChoice(event) {
|
|
217
|
+
const choice = event.currentTarget
|
|
218
|
+
const value = choice.dataset.value
|
|
219
|
+
const displayValue = choice.textContent.trim()
|
|
220
|
+
|
|
221
|
+
if (this.multipleValue) {
|
|
222
|
+
this.handleMultipleSelection(value, displayValue)
|
|
223
|
+
} else {
|
|
224
|
+
this.handleSingleSelection(value, displayValue)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
selectHighlighted() {
|
|
229
|
+
const visibleChoices = this.visibleChoices
|
|
230
|
+
if (!this.isValidHighlightIndex(visibleChoices)) return
|
|
231
|
+
|
|
232
|
+
const choice = visibleChoices[this.highlightedIndex]
|
|
233
|
+
const value = choice.dataset.value
|
|
234
|
+
const displayValue = choice.textContent.trim()
|
|
235
|
+
|
|
236
|
+
if (this.multipleValue) {
|
|
237
|
+
this.handleMultipleSelection(value, displayValue)
|
|
238
|
+
} else {
|
|
239
|
+
this.handleSingleSelection(value, displayValue)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
handleMultipleSelection(value, displayValue) {
|
|
244
|
+
this.addSelection(value, displayValue)
|
|
245
|
+
this.hideSelectedChoiceFromResults(value)
|
|
246
|
+
this.inputTarget.focus()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
handleSingleSelection(value, displayValue) {
|
|
250
|
+
this.inputTarget.value = displayValue
|
|
251
|
+
this.hiddenInputTarget.value = value
|
|
252
|
+
this.hideMenu()
|
|
253
|
+
this.inputTarget.focus()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
addSelection(value, displayValue) {
|
|
257
|
+
if (this.isSelectionUnique(value)) {
|
|
258
|
+
let tag = this.createTagElement(value, displayValue)
|
|
259
|
+
this.selectedItemsContainerTarget.appendChild(tag)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
hideAlreadySelectedChoices() {
|
|
264
|
+
if (!this.multipleValue) return
|
|
265
|
+
|
|
266
|
+
this.selectedItemValues.forEach(value => {
|
|
267
|
+
this.hideSelectedChoiceFromResults(value)
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
hideSelectedChoiceFromResults(value) {
|
|
272
|
+
const choice = this.choiceTargets.find(choice => choice.dataset.value === value)
|
|
273
|
+
if (choice) {
|
|
274
|
+
choice.classList.add("hidden")
|
|
275
|
+
}
|
|
276
|
+
if (this.choiceTargets.every(choice => choice.classList.contains("hidden"))) {
|
|
277
|
+
this.showAllChoicesSelectedMessage()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
showChoiceInResults(value) {
|
|
282
|
+
const choice = this.choiceTargets.find(choice => choice.dataset.value === value)
|
|
283
|
+
if (choice && this.choiceMatchesCurrentSearch(choice)) {
|
|
284
|
+
choice.classList.remove("hidden")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
choiceMatchesCurrentSearch(choice) {
|
|
289
|
+
const searchValue = this.inputTarget.value.trim()
|
|
290
|
+
if (!searchValue) return true
|
|
291
|
+
|
|
292
|
+
const searchTerms = this.getSearchTerms(searchValue)
|
|
293
|
+
return this.choiceMatchesSearch(choice, searchTerms)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
isSelectionUnique(value) {
|
|
297
|
+
return !this.selectedItemValues.find(selectedValue => selectedValue === value)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
isChoiceSelected(choice) {
|
|
301
|
+
return this.multipleValue &&
|
|
302
|
+
this.selectedItemValues.find(selectedValue => selectedValue === choice.dataset.value)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
clearInput() {
|
|
306
|
+
this.inputTarget.value = ""
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
refocusAndSearch() {
|
|
310
|
+
setTimeout(() => {
|
|
311
|
+
this.search()
|
|
312
|
+
if (this.visibleChoices.length > 0) {
|
|
313
|
+
this.showMenu()
|
|
314
|
+
}
|
|
315
|
+
}, 10)
|
|
316
|
+
this.inputTarget.focus()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
createTagElement(value, displayValue) {
|
|
320
|
+
if (!this.hasTagTemplateTarget) return null
|
|
321
|
+
|
|
322
|
+
const tag = this.cloneTagTemplate()
|
|
323
|
+
this.populateTagContent(tag, value, displayValue)
|
|
324
|
+
this.addHiddenInput(tag, value)
|
|
325
|
+
|
|
326
|
+
return tag
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
cloneTagTemplate() {
|
|
330
|
+
const tag = this.tagTemplateTarget.content.cloneNode(true).firstElementChild
|
|
331
|
+
tag.removeAttribute("data-combobox-target")
|
|
332
|
+
return tag
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
populateTagContent(tag, value, displayValue) {
|
|
336
|
+
const textSpan = tag.querySelector("span")
|
|
337
|
+
if (textSpan) {
|
|
338
|
+
textSpan.textContent = displayValue
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
addHiddenInput(tag, value) {
|
|
343
|
+
const hiddenInput = document.createElement("input")
|
|
344
|
+
hiddenInput.type = "hidden"
|
|
345
|
+
hiddenInput.name = this.inputNameValue
|
|
346
|
+
hiddenInput.value = value
|
|
347
|
+
tag.appendChild(hiddenInput)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
preventBlur(event) {
|
|
351
|
+
event.preventDefault()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
showHelpText() {
|
|
355
|
+
this.showMessage(this.helpTextTarget)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
showSearchingMessage() {
|
|
359
|
+
this.showMessage(this.searchingTarget)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
showNoResultsMessage() {
|
|
363
|
+
this.showMessage(this.noResultsTarget)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
showAllChoicesSelectedMessage() {
|
|
367
|
+
this.showMessage(this.allChoicesSelectedTarget)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
hideTurboFrame() {
|
|
371
|
+
if (this.hasTurboFrameTarget) {
|
|
372
|
+
this.turboFrameTarget.classList.add("hidden")
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
showTurboFrame() {
|
|
377
|
+
if (this.hasTurboFrameTarget) {
|
|
378
|
+
this.turboFrameTarget.classList.remove("hidden")
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
setBusyState(busy) {
|
|
383
|
+
if (busy) {
|
|
384
|
+
this.busyValue = true
|
|
385
|
+
this.inputTarget.setAttribute("aria-busy", "true")
|
|
386
|
+
} else {
|
|
387
|
+
this.busyValue = false
|
|
388
|
+
this.inputTarget.removeAttribute("aria-busy")
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
showMessage(target) {
|
|
393
|
+
this.hideMessages()
|
|
394
|
+
target.classList.remove("hidden")
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
hideMessages() {
|
|
398
|
+
this.messageTargets.forEach((messageTarget) => {
|
|
399
|
+
messageTarget.classList.add("hidden")
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
isInputFocused() {
|
|
404
|
+
return document.activeElement === this.inputTarget
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
openValueChanged(value) {
|
|
408
|
+
this.clearHideTimeout()
|
|
409
|
+
|
|
410
|
+
if (value) {
|
|
411
|
+
this.inputTarget.setAttribute("aria-expanded", "true")
|
|
412
|
+
|
|
413
|
+
if (this.shouldShowHelpText()) {
|
|
414
|
+
this.showHelpText()
|
|
415
|
+
this.actuallyShowMenu()
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (this.shouldTriggerAsyncSearch()) {
|
|
420
|
+
this.search()
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (this.visibleChoices.length === 0) {
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
this.actuallyShowMenu()
|
|
429
|
+
} else {
|
|
430
|
+
this.inputTarget.setAttribute("aria-expanded", "false")
|
|
431
|
+
this.leave()
|
|
432
|
+
this.hideWithPositioning()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
get visibleChoices() {
|
|
437
|
+
return this.choiceTargets.filter(choice => !choice.classList.contains("hidden"))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
get isMenuVisible() {
|
|
441
|
+
return !this.menuTarget.classList.contains("hidden")
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
get positioningOptions() {
|
|
445
|
+
return {
|
|
446
|
+
placement: this.placementValue,
|
|
447
|
+
middleware: [
|
|
448
|
+
offset(this.offsetValue),
|
|
449
|
+
flip(),
|
|
450
|
+
shift({ padding: this.shiftPaddingValue })
|
|
451
|
+
]
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
get messageTargets() {
|
|
456
|
+
return [
|
|
457
|
+
this.helpTextTarget,
|
|
458
|
+
this.noResultsTarget,
|
|
459
|
+
this.searchingTarget,
|
|
460
|
+
this.allChoicesSelectedTarget
|
|
461
|
+
]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
get selectedItemValues() {
|
|
465
|
+
return this.selectedItemTargets.map(item => item.querySelector("input").value)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useKeyboardNavigation stimulus mixin
|
|
3
|
+
*
|
|
4
|
+
* Adds keyboard navigation capabilities to a Stimulus controller for navigating through choices
|
|
5
|
+
* Requires the controller to have:
|
|
6
|
+
* - choiceTargets (array of selectable elements)
|
|
7
|
+
* - menuTarget (container to check visibility)
|
|
8
|
+
* - visibleChoices getter (filtered choices that are currently visible)
|
|
9
|
+
* - isMenuVisible getter (boolean for menu visibility)
|
|
10
|
+
* - showMenu() method
|
|
11
|
+
* - selectHighlighted() method (callback for selection)
|
|
12
|
+
* - hideMenu() method (callback for hiding menu)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const useKeyboardNavigation = (controller, input, options = {}) => {
|
|
16
|
+
const {
|
|
17
|
+
highlightClass = ["tw:bg-gray-200", "tw:text-gray-900"],
|
|
18
|
+
ariaSelected = "aria-selected",
|
|
19
|
+
scrollBehavior = { block: "nearest" }
|
|
20
|
+
} = options
|
|
21
|
+
|
|
22
|
+
controller.highlightedIndex = -1
|
|
23
|
+
|
|
24
|
+
const isValidHighlightIndex = (visibleChoices) => {
|
|
25
|
+
return controller.highlightedIndex >= 0 &&
|
|
26
|
+
controller.highlightedIndex < visibleChoices.length
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const clearAllHighlights = () => {
|
|
30
|
+
controller.choiceTargets.forEach(choice => {
|
|
31
|
+
choice.classList.remove(...highlightClass)
|
|
32
|
+
choice.removeAttribute(ariaSelected)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const highlightCurrentChoice = () => {
|
|
37
|
+
const visibleChoices = controller.visibleChoices
|
|
38
|
+
if (isValidHighlightIndex(visibleChoices)) {
|
|
39
|
+
const choice = visibleChoices[controller.highlightedIndex]
|
|
40
|
+
choice.classList.add(...highlightClass)
|
|
41
|
+
choice.setAttribute(ariaSelected, "true")
|
|
42
|
+
choice.scrollIntoView(scrollBehavior)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const updateHighlight = () => {
|
|
47
|
+
clearAllHighlights()
|
|
48
|
+
highlightCurrentChoice()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resetHighlightedIndex = () => {
|
|
52
|
+
controller.highlightedIndex = -1
|
|
53
|
+
updateHighlight()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const highlightNext = () => {
|
|
57
|
+
const visibleChoices = controller.visibleChoices
|
|
58
|
+
if (visibleChoices.length === 0) return
|
|
59
|
+
|
|
60
|
+
controller.highlightedIndex = Math.min(
|
|
61
|
+
controller.highlightedIndex + 1,
|
|
62
|
+
visibleChoices.length - 1
|
|
63
|
+
)
|
|
64
|
+
updateHighlight()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const highlightPrevious = () => {
|
|
68
|
+
const visibleChoices = controller.visibleChoices
|
|
69
|
+
if (visibleChoices.length === 0) return
|
|
70
|
+
|
|
71
|
+
controller.highlightedIndex = Math.max(controller.highlightedIndex - 1, 0)
|
|
72
|
+
updateHighlight()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const selectAndHighlighNext = () => {
|
|
76
|
+
controller.selectHighlighted()
|
|
77
|
+
highlightNext()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const highlightFirst = () => {
|
|
81
|
+
const visibleChoices = controller.visibleChoices
|
|
82
|
+
if (visibleChoices.length === 0) return
|
|
83
|
+
|
|
84
|
+
controller.highlightedIndex = 0
|
|
85
|
+
updateHighlight()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const highlightLast = () => {
|
|
89
|
+
const visibleChoices = controller.visibleChoices
|
|
90
|
+
if (visibleChoices.length === 0) return
|
|
91
|
+
|
|
92
|
+
controller.highlightedIndex = visibleChoices.length - 1
|
|
93
|
+
updateHighlight()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const hideAndBlur = () => {
|
|
97
|
+
controller.hideMenu()
|
|
98
|
+
input.blur()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const handleKeydownWhenMenuHidden = (event) => {
|
|
102
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
103
|
+
event.preventDefault()
|
|
104
|
+
controller.showMenu()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const keydown = (event) => {
|
|
109
|
+
if (!controller.isMenuVisible) {
|
|
110
|
+
return handleKeydownWhenMenuHidden(event)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const keyHandlers = {
|
|
114
|
+
"ArrowDown": () => highlightNext(),
|
|
115
|
+
"ArrowUp": () => highlightPrevious(),
|
|
116
|
+
"Enter": () => selectAndHighlighNext(),
|
|
117
|
+
"Escape": () => hideAndBlur(),
|
|
118
|
+
"Tab": () => hideAndBlur(),
|
|
119
|
+
"Home": () => highlightFirst(),
|
|
120
|
+
"End": () => highlightLast()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const handler = keyHandlers[event.key]
|
|
124
|
+
if (handler) {
|
|
125
|
+
event.preventDefault()
|
|
126
|
+
handler()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Object.assign(controller, {
|
|
131
|
+
keydown,
|
|
132
|
+
highlightNext,
|
|
133
|
+
highlightPrevious,
|
|
134
|
+
highlightFirst,
|
|
135
|
+
highlightLast,
|
|
136
|
+
resetHighlightedIndex,
|
|
137
|
+
updateHighlight,
|
|
138
|
+
clearAllHighlights,
|
|
139
|
+
highlightCurrentChoice,
|
|
140
|
+
isValidHighlightIndex: (visibleChoices) => isValidHighlightIndex(visibleChoices)
|
|
141
|
+
})
|
|
142
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class Form::Combobox::AsyncSearchResult < UI::Base
|
|
2
|
+
prop :results, _Union(Array), reader: :private
|
|
3
|
+
prop :text_method, _Union(String, Symbol, _Lambda), default: :name, reader: :private
|
|
4
|
+
prop :value_method, _Union(String, Symbol), default: :to_param, reader: :private
|
|
5
|
+
|
|
6
|
+
def view_template
|
|
7
|
+
if results.any?
|
|
8
|
+
results.each do |result|
|
|
9
|
+
render Form::Combobox::Choice.new(choice: result, text_method:, value_method:)
|
|
10
|
+
end
|
|
11
|
+
else
|
|
12
|
+
li(class: "tw:px-4 tw:py-2 tw:text-gray-500 tw:text-sm tw:italic") do
|
|
13
|
+
"No results"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class Form::Combobox::Choice < UI::Base
|
|
2
|
+
prop :choice, _Any, reader: :private
|
|
3
|
+
prop :text_method, _Union(String, Symbol, _Lambda), default: :name, reader: :private
|
|
4
|
+
prop :value_method, _Union(String, Symbol), default: :to_param, reader: :private
|
|
5
|
+
|
|
6
|
+
def view_template
|
|
7
|
+
li role: "option",
|
|
8
|
+
class: choice_classes,
|
|
9
|
+
data: {
|
|
10
|
+
combobox_target: "choice",
|
|
11
|
+
value: choice.public_send(value_method),
|
|
12
|
+
action: "mousedown->combobox#preventBlur click->combobox#selectChoice"
|
|
13
|
+
} do
|
|
14
|
+
if text_method.respond_to?(:call)
|
|
15
|
+
text_method.call(choice)
|
|
16
|
+
else
|
|
17
|
+
choice.public_send(text_method)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def choice_classes
|
|
25
|
+
class_names(
|
|
26
|
+
"w-full px-3 py-2 gap-1 cursor-pointer",
|
|
27
|
+
"hover:bg-gray-100 focus:bg-gray-100 active:bg-gray-200",
|
|
28
|
+
"transition-colors duration-150",
|
|
29
|
+
text_classes
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def text_classes
|
|
34
|
+
"text-brand-turqoise-900 text-sm font-normal leading-5"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class Form::Combobox::SelectedItem < Form::BaseTag
|
|
2
|
+
prop :item, _Any, reader: :private
|
|
3
|
+
prop :text_method, _Union(String, Symbol), default: :name, reader: :private
|
|
4
|
+
prop :value_method, _Union(String, Symbol), default: :to_param, reader: :private
|
|
5
|
+
|
|
6
|
+
def view_template
|
|
7
|
+
render UI::Tag.new(text:, size: :sm, data: {combobox_target: "selectedItem"}) do
|
|
8
|
+
input(
|
|
9
|
+
type: "hidden",
|
|
10
|
+
name: "#{name}[]",
|
|
11
|
+
value:
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def text
|
|
19
|
+
item.public_send(text_method)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def value
|
|
23
|
+
item.public_send(value_method)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Form::Combobox < Form::BaseTag
|
|
4
|
+
ALLOWED_OPTIONS = [
|
|
5
|
+
:choices,
|
|
6
|
+
:text_method,
|
|
7
|
+
:value_method,
|
|
8
|
+
:multiple,
|
|
9
|
+
:selected,
|
|
10
|
+
:help_text,
|
|
11
|
+
:searching_text,
|
|
12
|
+
:no_results_text,
|
|
13
|
+
:all_choices_selected_text
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def allowed_options
|
|
18
|
+
super + ALLOWED_OPTIONS
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
prop :choices, _Union(Array, String), reader: :private
|
|
23
|
+
prop :text_method, _Union(String, Symbol), default: :name, reader: :private
|
|
24
|
+
prop :value_method, _Union(String, Symbol), default: :to_param, reader: :private
|
|
25
|
+
prop :multiple, _Boolean?, default: false, reader: :private
|
|
26
|
+
prop :selected, _Any?, predicate: :private, reader: :private
|
|
27
|
+
prop :help_text, _String?, default: -> { "Start typing to search" }, reader: :private
|
|
28
|
+
prop :searching_text, _String?, default: -> { "Searching" }, reader: :private
|
|
29
|
+
prop :no_results_text, _String?, default: -> { "No results" }, reader: :private
|
|
30
|
+
prop :all_choices_selected_text, _String?, default: -> { "All choices selected" }, reader: :private
|
|
31
|
+
|
|
32
|
+
def view_template
|
|
33
|
+
div data: {
|
|
34
|
+
controller: "combobox",
|
|
35
|
+
combobox_multiple_value: multiple,
|
|
36
|
+
combobox_search_url_value: choices.is_a?(String) ? choices : nil,
|
|
37
|
+
combobox_help_text_value: help_text,
|
|
38
|
+
combobox_searching_text_value: searching_text,
|
|
39
|
+
combobox_no_results_text_value: no_results_text,
|
|
40
|
+
combobox_all_choices_selected_text_value: all_choices_selected_text,
|
|
41
|
+
combobox_input_name_value: name,
|
|
42
|
+
has_choices: multiple && existing_selections.any?
|
|
43
|
+
}, class: "relative group" do
|
|
44
|
+
fieldset(class: classes, data:) do
|
|
45
|
+
input(
|
|
46
|
+
id: input_id,
|
|
47
|
+
type: :search,
|
|
48
|
+
role: "combobox",
|
|
49
|
+
"aria-expanded": "false",
|
|
50
|
+
"aria-autocomplete": "list",
|
|
51
|
+
"aria-haspopup": "listbox",
|
|
52
|
+
data: {
|
|
53
|
+
combobox_target: "input",
|
|
54
|
+
action: "keydown->combobox#keydown input->combobox#search focus->combobox#showMenu blur->combobox#hideMenuWithDelay"
|
|
55
|
+
},
|
|
56
|
+
value: input_value,
|
|
57
|
+
class: input_classes,
|
|
58
|
+
placeholder: options[:placeholder] || ""
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
render_selected
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
div data: {
|
|
65
|
+
combobox_target: "menu",
|
|
66
|
+
transition_enter_from: "opacity-0 scale-95",
|
|
67
|
+
transition_enter_to: "opacity-100 scale-100",
|
|
68
|
+
transition_leave_from: "opacity-100 scale-100",
|
|
69
|
+
transition_leave_to: "opacity-0 scale-95"
|
|
70
|
+
}, class: "hidden w-full transition transform origin-top-left absolute left-0 top-0 z-50" do
|
|
71
|
+
menu role: "listbox", class: "w-full bg-white rounded-lg shadow-lg outline -outline-offset-1
|
|
72
|
+
outline-gray-300 inline-flex flex-col justify-start items-start overflow-hidden max-h-60 overflow-y-auto" do
|
|
73
|
+
render_choices_container
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def default_classes
|
|
82
|
+
"flex flex-col-reverse overflow-hidden relative rounded outline outline-gray-400 bg-white hover:outline-gray-900 focus-within:outline-gray-900 has-disabled:hover:outline-gray-400 focus-within:outline-2 data-invalid:outline-red-600 data-invalid:focus-within:outline-gray-900 data-invalid:hover:outline-gray-900"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def input_id = @input_id ||= "#{name}_#{Random.uuid}"
|
|
86
|
+
|
|
87
|
+
def default_options
|
|
88
|
+
{id:, name:, data: {controller: "combobox"}}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_selected
|
|
92
|
+
if multiple?
|
|
93
|
+
render_selected_items_container
|
|
94
|
+
render_tag_template
|
|
95
|
+
else
|
|
96
|
+
input(type: "hidden", name:, data: {combobox_target: "hiddenInput"}, value: selected&.public_send(value_method))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_selected_items_container
|
|
101
|
+
div(
|
|
102
|
+
data: {combobox_target: "selectedItemsContainer"},
|
|
103
|
+
class: selected_items_classes
|
|
104
|
+
) do
|
|
105
|
+
selected&.each do |item|
|
|
106
|
+
render Form::Combobox::SelectedItem.new(form:, method:, item:)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_tag_template
|
|
112
|
+
template(
|
|
113
|
+
data: {combobox_target: "tagTemplate"}
|
|
114
|
+
) do
|
|
115
|
+
render UI::Tag.new(text: "", size: :sm)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def input_classes
|
|
120
|
+
class_names(
|
|
121
|
+
"peer border-0 group-data-[combobox-busy-value=true]:animate-pulse"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def existing_selections
|
|
126
|
+
existing_values = if selected?
|
|
127
|
+
selected
|
|
128
|
+
elsif form&.object&.respond_to?(method)
|
|
129
|
+
form.object.public_send(method)
|
|
130
|
+
else
|
|
131
|
+
return []
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
values = Array(existing_values)
|
|
135
|
+
|
|
136
|
+
values = [values.first] if single?
|
|
137
|
+
|
|
138
|
+
values.compact.map do |value|
|
|
139
|
+
{
|
|
140
|
+
value: value.public_send(value_method),
|
|
141
|
+
displayValue: value.public_send(text_method)
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def input_value
|
|
147
|
+
return unless single?
|
|
148
|
+
return unless existing_selections.any?
|
|
149
|
+
|
|
150
|
+
existing_selections.first[:displayValue]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def single?
|
|
154
|
+
!multiple
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def render_choices_container
|
|
158
|
+
div(
|
|
159
|
+
data: {combobox_target: "choicesContainer"},
|
|
160
|
+
class: "w-full"
|
|
161
|
+
) do
|
|
162
|
+
render_messages
|
|
163
|
+
if choices_is_a_url?
|
|
164
|
+
turbo_frame_tag turbo_frame_id, data: {combobox_target: "turboFrame"}, class: "w-full"
|
|
165
|
+
else
|
|
166
|
+
choices.each do |choice|
|
|
167
|
+
render Form::Combobox::Choice.new(choice:, text_method:)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def choices_is_a_url?
|
|
174
|
+
choices.is_a? String
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def render_messages
|
|
178
|
+
div(data: {combobox_target: "helpText"},
|
|
179
|
+
class: "hidden px-4 py-2 text-gray-500 text-sm italic") { help_text }
|
|
180
|
+
|
|
181
|
+
div(data: {combobox_target: "noResults"},
|
|
182
|
+
class: "hidden px-4 py-2 text-gray-500 text-sm italic") { no_results_text }
|
|
183
|
+
|
|
184
|
+
div(data: {combobox_target: "searching"},
|
|
185
|
+
class: "hidden px-4 py-2 text-gray-500 text-sm italic animate-pulse") { searching_text }
|
|
186
|
+
|
|
187
|
+
div(data: {combobox_target: "allChoicesSelected"},
|
|
188
|
+
class: "hidden px-4 py-2 text-gray-500 text-sm italic") { all_choices_selected_text }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def selected_items_classes
|
|
192
|
+
class_names(
|
|
193
|
+
"peer/selected-items flex flex-wrap gap-1 p-1 bg-gray-100 border-b border-gray-200 empty:hidden"
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def name
|
|
198
|
+
multiple? ? "#{super}[]" : super
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def turbo_frame_id
|
|
202
|
+
"combobox_choices_#{input_id}".parameterize
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def multiple?
|
|
206
|
+
multiple
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def errors
|
|
210
|
+
return [] unless error?
|
|
211
|
+
|
|
212
|
+
form.object.errors.where(method).map(&:full_message)
|
|
213
|
+
end
|
|
214
|
+
end
|
data/app/views/ui/tag.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class UI::Tag < UI::Base
|
|
4
|
+
prop :text, String, reader: :private
|
|
5
|
+
prop :size, _Union("sm", "md"),
|
|
6
|
+
default: :md, reader: :private do |value|
|
|
7
|
+
value.to_s.inquiry
|
|
8
|
+
end
|
|
9
|
+
prop :button_data, Hash, default: {}.freeze, reader: :private
|
|
10
|
+
|
|
11
|
+
def view_template
|
|
12
|
+
div data:, class: classes do
|
|
13
|
+
span(class: text_classes) { text }
|
|
14
|
+
button type: :button, data: button_data_with_defaults, class: button_classes do
|
|
15
|
+
render UI::Icon.new(name: :x_mark, class: icon_classes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
yield if block_given?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def data
|
|
25
|
+
mix default_data, super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def default_classes
|
|
29
|
+
class_names(
|
|
30
|
+
"inline-flex items-center gap-1
|
|
31
|
+
text-gray-950 border border-gray-400 bg-white",
|
|
32
|
+
"py-1 pl-3 pr-2": size.md?,
|
|
33
|
+
"py-0.5 pl-1.5 pr-1": size.sm?
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def text_classes
|
|
38
|
+
return "ui-text-xs" if size.sm?
|
|
39
|
+
|
|
40
|
+
"ui-text-sm" if size.md?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def button_data_with_defaults
|
|
44
|
+
mix default_button_data, button_data
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def button_classes
|
|
48
|
+
"pl-0.5 text-primary-900 hover:text-primary-800
|
|
49
|
+
cursor-pointer"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def icon_classes
|
|
53
|
+
return "size-2.5" if size.sm?
|
|
54
|
+
|
|
55
|
+
"size-3" if size.md?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_data
|
|
59
|
+
{
|
|
60
|
+
controller: "dismissable",
|
|
61
|
+
transition_enter_active: "transition ease-out duration-300",
|
|
62
|
+
transition_enter_from: "transform opacity-0 scale-10",
|
|
63
|
+
transition_enter_to: "transform opacity-10 0 scale-100",
|
|
64
|
+
transition_leave_active: "transition ease-in duration-300",
|
|
65
|
+
transition_leave_from: "transform opacity-100 scale-100",
|
|
66
|
+
transition_leave_to: "transform opacity-0 scale-10"
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def default_button_data
|
|
71
|
+
{action: "dismissable#dismiss"}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -51,6 +51,13 @@ module QuicksilverUI
|
|
|
51
51
|
mixins: [],
|
|
52
52
|
gems: []
|
|
53
53
|
},
|
|
54
|
+
"tag" => {
|
|
55
|
+
components: %w[icon],
|
|
56
|
+
stylesheets: [],
|
|
57
|
+
controllers: %w[dismissable],
|
|
58
|
+
mixins: [],
|
|
59
|
+
gems: []
|
|
60
|
+
},
|
|
54
61
|
"toast" => {
|
|
55
62
|
components: %w[icon],
|
|
56
63
|
stylesheets: [],
|
|
@@ -152,6 +159,14 @@ module QuicksilverUI
|
|
|
152
159
|
stylesheets: %w[form],
|
|
153
160
|
components: %w[icon],
|
|
154
161
|
gems: []
|
|
162
|
+
},
|
|
163
|
+
"combobox" => {
|
|
164
|
+
form_components: %w[base_tag],
|
|
165
|
+
stylesheets: %w[form],
|
|
166
|
+
components: %w[tag],
|
|
167
|
+
controllers: %w[combobox],
|
|
168
|
+
mixins: %w[use_floating_ui use_keyboard_navigation],
|
|
169
|
+
gems: []
|
|
155
170
|
}
|
|
156
171
|
}.freeze
|
|
157
172
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: quicksilver_ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Evovia
|
|
@@ -26,13 +26,19 @@ files:
|
|
|
26
26
|
- app/helpers/app_form_builder.rb
|
|
27
27
|
- app/helpers/app_form_helper.rb
|
|
28
28
|
- app/javascript/controllers/autogrow_controller.js
|
|
29
|
+
- app/javascript/controllers/combobox_controller.js
|
|
29
30
|
- app/javascript/controllers/dismissable_controller.js
|
|
30
31
|
- app/javascript/controllers/dropdown_controller.js
|
|
31
32
|
- app/javascript/controllers/modal_controller.js
|
|
32
33
|
- app/javascript/controllers/tabs_controller.js
|
|
33
34
|
- app/javascript/mixins/use_floating_ui.js
|
|
35
|
+
- app/javascript/mixins/use_keyboard_navigation.js
|
|
34
36
|
- app/views/form/base_tag.rb
|
|
35
37
|
- app/views/form/checkbox.rb
|
|
38
|
+
- app/views/form/combobox.rb
|
|
39
|
+
- app/views/form/combobox/async_search_result.rb
|
|
40
|
+
- app/views/form/combobox/choice.rb
|
|
41
|
+
- app/views/form/combobox/selected_item.rb
|
|
36
42
|
- app/views/form/date_field.rb
|
|
37
43
|
- app/views/form/email_field.rb
|
|
38
44
|
- app/views/form/error.rb
|
|
@@ -58,6 +64,7 @@ files:
|
|
|
58
64
|
- app/views/ui/dropdown/item.rb
|
|
59
65
|
- app/views/ui/icon.rb
|
|
60
66
|
- app/views/ui/modal.rb
|
|
67
|
+
- app/views/ui/tag.rb
|
|
61
68
|
- app/views/ui/toast.rb
|
|
62
69
|
- lib/generators/quicksilver_ui/affordance/affordance_generator.rb
|
|
63
70
|
- lib/generators/quicksilver_ui/component/all_generator.rb
|