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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98070a613f6cd27f56a14c30d26f7a769f92e3434dcb38ec980d6af9f99ead6b
4
- data.tar.gz: b54c5beab648c18a73c6a916bacf70c5391c88ec0c1cd483ca84f8ea431b82c8
3
+ metadata.gz: dd50a32b96fa881fd2feedfb1ecfa23e696c69647c03ba16ab4a43a97a87aa3e
4
+ data.tar.gz: b288dd0d7c928899779d461654ea5b8fef74429b92c7cc095ab3e98c4b7bf0b1
5
5
  SHA512:
6
- metadata.gz: 634ea19a66852a9bd502eaeaea72edf39fab4c6c1d6b989b51e32d56e0562b8ebfc92dd8d7b226760556730c0c9788b5d290cf1764383f02fdb3c5528fc4cd89
7
- data.tar.gz: a7ae72ff22c80a1ce740901c6accbe471e05ed59e15cc469ea60b6208d7ad30aed554a246fbc8cc2af3eaa5419f3ee9a80a6fe3782d9d4a188aac47604ae66a5
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
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QuicksilverUI
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
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.0
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