advanced_select 0.1.3 → 0.1.5

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: 254f883547b3f323b98150becc0db7f8ff76f609a9a7186a5d1eb8903a0afb7f
4
- data.tar.gz: 56b4d1866feaad22d4c4ed00bb2ab573240dbedda3d2f7cc6a1759c7da53b571
3
+ metadata.gz: 43e9d7178d6a064d18c4243d1fad3ec15fbb935834eb2d07da8cd89d33c955a1
4
+ data.tar.gz: 389f74b51ecd1615a2d3b11c2794e10c57557ce6085595ebe06f3a296b88874a
5
5
  SHA512:
6
- metadata.gz: f6a74b01aed5465044972b7f89d75148a9192120d8bba05e7a212899a11715198a3a4743092e329e1dc8ae4ccb0c86c2ab21ccafb48b88fa52059212b6d4acc3
7
- data.tar.gz: 9df6c7b80f331cdc57fabaddce592f1f899728fe643d73ac3c98149acdb6b9f4c7a559e2fd87113655338ece19695fcfed7fbb6b570808bf02395f44c568301b
6
+ metadata.gz: faf098b583a4d1ea48aeb788943b3da2400fdca7946f32f02419aef63ca34af371c7ce9cd97368205ff6dcb96e55b5e3044794e5f8f5cf8237faf0e53dd7e62b
7
+ data.tar.gz: 5c8fea29044aa4e5a9da1cb804b93f0124e9e302dbc82c0f63d8a216f767e2e13d518c5768704285874596ef6a809750f3033e7ff759176c35a4991f04ee87f0
data/README.md CHANGED
@@ -16,6 +16,8 @@ AdvancedSelect is a small Rails engine for rendering an advanced select input wi
16
16
  - [Basic Local Select](#basic-local-select)
17
17
  - [Remote Search](#remote-search)
18
18
  - [Multiple Select](#multiple-select)
19
+ - [Count Summary](#count-summary)
20
+ - [Selection Tooltip](#selection-tooltip)
19
21
  - [Add Mode](#add-mode)
20
22
  - [Dependent Fields](#dependent-fields)
21
23
  - [Custom Option Content](#custom-option-content)
@@ -75,7 +77,7 @@ AdvancedSelect does not provide query objects, model concerns, authorization log
75
77
  Add the gem to the host Rails app:
76
78
 
77
79
  ```ruby
78
- gem "advanced_select", "~> 0.1.0"
80
+ gem "advanced_select", "~> 0.1.4"
79
81
  ```
80
82
 
81
83
  Run the installer:
@@ -428,6 +430,79 @@ Pass `include_hidden: false` to opt out — for example, when your controller re
428
430
  ) %>
429
431
  ```
430
432
 
433
+ ### Count Summary
434
+
435
+ By default a multiple select collapses its trigger to the first two selected labels plus an `& +N` token. For long selections — or when the detail belongs in a [tooltip](#selection-tooltip) — pass `summary_mode: :count` to render a single `"N selected"` label instead:
436
+
437
+ ```erb
438
+ <%= advanced_select_tag(
439
+ "record[item_ids][]",
440
+ id: "record_item_ids",
441
+ selected: selected_options,
442
+ options: options,
443
+ placeholder: t(".items_placeholder"),
444
+ multiple: true,
445
+ summary_mode: :count
446
+ ) %>
447
+ ```
448
+
449
+ The label text comes from the `shared.advanced_select.selected_count` translation (`"%{count} selected"`), so override it per locale to localize or reword it. `summary_mode` only affects multiple selects; single selects always show the selected label.
450
+
451
+ ### Selection Tooltip
452
+
453
+ Multiple selects can show an optional tooltip when the user hovers the trigger — handy together with `summary_mode: :count`, where the trigger only shows a count and the tooltip reveals the detail. The tooltip is hover-driven (no info icon) and is positioned with CSS below the trigger.
454
+
455
+ Pass `tooltip: true` for a built-in list of the selected display labels:
456
+
457
+ ```erb
458
+ <%= advanced_select_tag(
459
+ "record[item_ids][]",
460
+ id: "record_item_ids",
461
+ selected: selected_options,
462
+ options: options,
463
+ placeholder: t(".items_placeholder"),
464
+ multiple: true,
465
+ summary_mode: :count,
466
+ tooltip: true
467
+ ) %>
468
+ ```
469
+
470
+ The built-in list stays in sync as the selection changes on the client.
471
+
472
+ For richer layouts — a table, badges, compatibility columns, etc. — pass `tooltip_partial:` instead. The partial receives `selected_options` and `options` as locals and may render any markup:
473
+
474
+ ```erb
475
+ <%= advanced_select_tag(
476
+ "record[alternative_ids][]",
477
+ id: "record_alternative_ids",
478
+ selected: selected_options,
479
+ options: options,
480
+ placeholder: t(".alternatives_placeholder"),
481
+ multiple: true,
482
+ summary_mode: :count,
483
+ tooltip_partial: "alternatives/tooltip"
484
+ ) %>
485
+ ```
486
+
487
+ ```erb
488
+ <%# app/views/alternatives/_tooltip.html.erb %>
489
+ <table>
490
+ <thead>
491
+ <tr><th>Code &amp; Name</th><th>Type</th></tr>
492
+ </thead>
493
+ <tbody>
494
+ <% selected_options.each do |option| %>
495
+ <tr>
496
+ <td><%= option.fetch(:display_label) %></td>
497
+ <td><%= option.fetch(:value) %></td>
498
+ </tr>
499
+ <% end %>
500
+ </tbody>
501
+ </table>
502
+ ```
503
+
504
+ A custom `tooltip_partial` is rendered once on the server, so it is best for display-oriented content. If your selection changes on the client and the custom tooltip must reflect it live, re-render the field (for example via a Turbo Stream) after the change. The built-in `tooltip: true` list updates client-side automatically.
505
+
431
506
  ### Add Mode
432
507
 
433
508
  Set `add_mode: true` when users may submit a new typed value:
@@ -502,6 +577,48 @@ Use `dependent_fields` when a remote option endpoint depends on another field va
502
577
 
503
578
  The remote request will include `parent_id=<current field value>`.
504
579
 
580
+ By default the field is **eager**: it listens for `change` on each dependent field and reloads its options immediately, without waiting for the dropdown to open. When a parent changes, the field clears its current value, fetches fresh options for the new parent, and (combined with auto select, below) can settle on a value while staying closed. It also loads once on initial render so a parent's default value is reflected right away. An existing server-rendered selection is left untouched.
581
+
582
+ Because the field re-broadcasts a `change` event whenever its own value changes, you can chain advanced selects: a child can depend on another advanced select via its hidden input id (`dependent_fields: { parent_id: "#record_parent_id" }`).
583
+
584
+ Pass `eager: false` for the lazy behaviour instead — options are only fetched when the dropdown opens, and the field does not react to parent changes until then:
585
+
586
+ ```erb
587
+ <%= advanced_select_tag(
588
+ "record[item_id]",
589
+ id: "record_item_id",
590
+ selected: selected_option,
591
+ options: [],
592
+ placeholder: t(".item_placeholder"),
593
+ options_url: item_options_path,
594
+ dependent_fields: { parent_id: "#record_parent_id" },
595
+ eager: false
596
+ ) %>
597
+ ```
598
+
599
+ ### Auto Select Single Option
600
+
601
+ When a remote option list is populated and resolves to exactly one selectable option, the field selects that option automatically. This is enabled by default and is meant for dependent fields where a parent value narrows the children down to a single valid choice.
602
+
603
+ Auto selection only runs when the dropdown is **populated through a remote request** (the cascade case): it fires after the options finish loading, either when the dropdown opens or — with the default eager dependent fields above — as soon as a parent value loads them, so a single valid option is selected without the user opening the field. It does **not** run for statically rendered local options on page load, and it never triggers while the user is actively typing a search.
604
+
605
+ It never overrides an existing selection and is skipped for `multiple` fields.
606
+
607
+ Pass `auto_select_single: false` to opt out:
608
+
609
+ ```erb
610
+ <%= advanced_select_tag(
611
+ "record[item_id]",
612
+ id: "record_item_id",
613
+ selected: selected_option,
614
+ options: [],
615
+ placeholder: t(".item_placeholder"),
616
+ options_url: item_options_path,
617
+ dependent_fields: { parent_id: "#record_parent_id" },
618
+ auto_select_single: false
619
+ ) %>
620
+ ```
621
+
505
622
  ### Custom Option Content
506
623
 
507
624
  Use a custom option content partial when an option needs richer content. The engine still renders the option button, Stimulus data attributes, and ARIA attributes:
@@ -631,12 +748,21 @@ advanced_select_tag(
631
748
  add_mode: false,
632
749
  dependent_fields: {},
633
750
  include_hidden: true,
751
+ auto_select_single: true,
752
+ eager: true,
753
+ summary_mode: :tokens,
754
+ tooltip: false,
755
+ tooltip_partial: nil,
634
756
  option_content_partial: nil,
635
757
  classes: {},
636
758
  append_classes: {}
637
759
  )
638
760
  ```
639
761
 
762
+ `summary_mode:` controls how a multiple select renders its collapsed trigger label. The default `:tokens` shows the first two selected display labels followed by `& +N`. Pass `:count` to render a single localized `"N selected"` summary instead (see [Count Summary](#count-summary)).
763
+
764
+ `tooltip:` / `tooltip_partial:` enable an optional hover tooltip on the trigger (see [Selection Tooltip](#selection-tooltip)).
765
+
640
766
  `advanced_select_options_tag`:
641
767
 
642
768
  ```ruby
@@ -694,8 +820,12 @@ shared:
694
820
  empty: "No options found"
695
821
  error: "Options could not be loaded"
696
822
  loading: "Loading..."
823
+ search_placeholder: "Search..."
824
+ selected_count: "%{count} selected"
697
825
  ```
698
826
 
827
+ `selected_count` is used by the [count summary](#count-summary) (`summary_mode: :count`).
828
+
699
829
  Override these keys in the host app as needed.
700
830
 
701
831
  ## Styling
@@ -807,6 +937,7 @@ Common styling hooks:
807
937
 
808
938
  - `.ui-advanced-select-trigger` controls the visible input button, border, radius, height, background, and focus outline.
809
939
  - `.ui-advanced-select-dropdown` controls the popup container, border, radius, shadow, width, and `z-index`.
940
+ - `.ui-advanced-select-tooltip`, `.ui-advanced-select-tooltip-list`, and `.ui-advanced-select-tooltip-item` control the optional [selection tooltip](#selection-tooltip) container and its built-in list. The tooltip is positioned with CSS (`position: absolute` below the trigger); adjust `top`, `min-width`, or `z-index` here if your layout needs it.
810
941
  - `.ui-advanced-select-options` controls the scroll container and default `max-height`.
811
942
  - `.ui-advanced-select-option` controls option row spacing, hover state, and font sizing.
812
943
  - `.ui-advanced-select-option[aria-selected="true"]` controls selected option colors.
@@ -101,6 +101,40 @@
101
101
  z-index: 20;
102
102
  }
103
103
 
104
+ .ui-advanced-select-tooltip {
105
+ background: #ffffff;
106
+ border: 1px solid #e5e7eb;
107
+ border-radius: 0.75rem;
108
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
109
+ box-sizing: border-box;
110
+ color: #374151;
111
+ font-size: 0.875rem;
112
+ left: 0;
113
+ line-height: 1.25rem;
114
+ margin-top: 0.25rem;
115
+ min-width: 100%;
116
+ padding: 0.75rem 1rem;
117
+ position: absolute;
118
+ top: 100%;
119
+ width: max-content;
120
+ z-index: 30;
121
+ }
122
+
123
+ .ui-advanced-select-tooltip-list {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 0.25rem;
127
+ list-style: none;
128
+ margin: 0;
129
+ padding: 0;
130
+ }
131
+
132
+ .ui-advanced-select-tooltip-item {
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
135
+ white-space: nowrap;
136
+ }
137
+
104
138
  .ui-advanced-select-search {
105
139
  border: 1px solid #e5e7eb;
106
140
  border-radius: 0.5rem;
@@ -2,11 +2,13 @@ import { Controller } from "@hotwired/stimulus"
2
2
  import { Turbo } from "@hotwired/turbo-rails"
3
3
 
4
4
  export default class extends Controller {
5
- static targets = ["hiddenFields", "trigger", "summary", "dropdown", "search", "options", "caret", "clear"]
5
+ static targets = ["hiddenFields", "trigger", "summary", "dropdown", "search", "options", "caret", "clear", "tooltip"]
6
6
  static values = {
7
7
  addMode: Boolean,
8
+ autoSelectSingle: { type: Boolean, default: true },
8
9
  delay: { type: Number, default: 200 },
9
10
  dependentFields: Object,
11
+ eager: { type: Boolean, default: true },
10
12
  emptyText: String,
11
13
  errorText: String,
12
14
  includeHidden: { type: Boolean, default: true },
@@ -17,6 +19,8 @@ export default class extends Controller {
17
19
  placeholder: String,
18
20
  searchable: Boolean,
19
21
  selected: Array,
22
+ selectedCountText: String,
23
+ summaryMode: { type: String, default: "tokens" },
20
24
  targetId: String,
21
25
  url: String
22
26
  }
@@ -28,6 +32,7 @@ export default class extends Controller {
28
32
  this.placeholderClass = this.element.dataset.advancedSelectPlaceholderClass || "ui-advanced-select-placeholder"
29
33
  this.valueClass = this.element.dataset.advancedSelectValueClass || "ui-advanced-select-value"
30
34
  this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
35
+ this.tooltipItemClass = this.element.dataset.advancedSelectTooltipItemClass || "ui-advanced-select-tooltip-item"
31
36
  this.loadingClass = this.element.dataset.advancedSelectLoadingClass || "ui-advanced-select-loading"
32
37
  this.errorClass = this.element.dataset.advancedSelectErrorClass || "ui-advanced-select-error"
33
38
  this.emptyClass = this.element.dataset.advancedSelectEmptyClass || "ui-advanced-select-empty"
@@ -41,11 +46,17 @@ export default class extends Controller {
41
46
  }))
42
47
  this.close = this.close.bind(this)
43
48
  this.renderOptionsState()
49
+ this.setupEagerDependentFields()
44
50
  }
45
51
 
46
52
  disconnect() {
47
53
  window.clearTimeout(this.timer)
54
+ window.clearTimeout(this.tooltipHideTimer)
48
55
  document.removeEventListener("click", this.close)
56
+
57
+ if (this.dependentChangeListener) {
58
+ document.removeEventListener("change", this.dependentChangeListener)
59
+ }
49
60
  }
50
61
 
51
62
  toggle(event) {
@@ -59,11 +70,12 @@ export default class extends Controller {
59
70
  }
60
71
 
61
72
  open() {
73
+ this.hideTooltip(true)
62
74
  this.dropdownTarget.classList.remove("hidden")
63
75
  this.triggerTarget.setAttribute("aria-expanded", "true")
64
76
  document.addEventListener("click", this.close)
65
77
  this.activate(-1)
66
- this.fetchOptions({ selected: true })
78
+ this.fetchOptions({ selected: true, autoSelect: true })
67
79
 
68
80
  if (this.searchableValue) {
69
81
  requestAnimationFrame(() => this.searchTarget.focus())
@@ -186,7 +198,7 @@ export default class extends Controller {
186
198
  return (text || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
187
199
  }
188
200
 
189
- fetchOptions({ selected = false } = {}) {
201
+ fetchOptions({ selected = false, autoSelect = false, eager = false } = {}) {
190
202
  if (!this.urlValue) {
191
203
  return
192
204
  }
@@ -221,23 +233,27 @@ export default class extends Controller {
221
233
  return response.text()
222
234
  })
223
235
  .then((html) => {
224
- if (!this.expanded || requestSequence !== this.requestSequence) {
236
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
225
237
  return
226
238
  }
227
239
 
228
240
  Turbo.renderStreamMessage(html)
229
241
  requestAnimationFrame(() => {
230
- if (!this.expanded || requestSequence !== this.requestSequence) {
242
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
231
243
  return
232
244
  }
233
245
 
234
246
  this.currentOptionsTarget.setAttribute("aria-busy", "false")
235
247
  this.renderOptionsState()
236
248
  this.activate(-1)
249
+
250
+ if (autoSelect) {
251
+ this.autoSelectSingle()
252
+ }
237
253
  })
238
254
  })
239
255
  .catch(() => {
240
- if (!this.expanded || requestSequence !== this.requestSequence) {
256
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
241
257
  return
242
258
  }
243
259
 
@@ -291,9 +307,18 @@ export default class extends Controller {
291
307
  renderSelection() {
292
308
  this.hiddenFieldsTarget.replaceChildren(...this.hiddenFieldElements)
293
309
  this.summaryTarget.replaceChildren(...this.selectionElements)
310
+ this.renderTooltip()
294
311
  this.renderOptionsState()
295
312
  this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
296
313
  this.clearTarget.classList.toggle("hidden", this.selectedValue.length === 0)
314
+ this.dispatchValueChange()
315
+ }
316
+
317
+ dispatchValueChange() {
318
+ const input = this.hiddenFieldsTarget.querySelector("input")
319
+ if (input) {
320
+ input.dispatchEvent(new Event("change", { bubbles: true }))
321
+ }
297
322
  }
298
323
 
299
324
  renderOptionsState() {
@@ -319,6 +344,54 @@ export default class extends Controller {
319
344
  }
320
345
  }
321
346
 
347
+ setupEagerDependentFields() {
348
+ if (!this.eagerValue || !this.urlValue) {
349
+ return
350
+ }
351
+
352
+ this.dependentSelectors = [...new Set(Object.values(this.dependentFieldsValue))]
353
+ if (this.dependentSelectors.length === 0) {
354
+ return
355
+ }
356
+
357
+ this.dependentChangeListener = (event) => {
358
+ if (event.target instanceof Element && this.dependentSelectors.some((selector) => event.target.matches(selector))) {
359
+ this.reloadDependentOptions()
360
+ }
361
+ }
362
+ document.addEventListener("change", this.dependentChangeListener)
363
+
364
+ if (this.selectedValue.length === 0) {
365
+ this.fetchOptions({ eager: true, autoSelect: true })
366
+ }
367
+ }
368
+
369
+ reloadDependentOptions() {
370
+ this.selectedValue = []
371
+ this.renderSelection()
372
+ this.clearSearch()
373
+ this.fetchOptions({ eager: true, autoSelect: true })
374
+ }
375
+
376
+ autoSelectSingle() {
377
+ if (!this.autoSelectSingleValue || this.multipleValue || this.selectedValue.length > 0) {
378
+ return
379
+ }
380
+
381
+ const options = this.selectableOptionElements
382
+ if (options.length !== 1) {
383
+ return
384
+ }
385
+
386
+ const option = options[0]
387
+ this.selectOption(
388
+ option.dataset.advancedSelectValueParam,
389
+ option.dataset.advancedSelectLabelParam,
390
+ option.dataset.advancedSelectSubmitValueParam || option.dataset.advancedSelectValueParam,
391
+ { displayLabel: option.dataset.advancedSelectDisplayLabelParam, refreshOptions: false }
392
+ )
393
+ }
394
+
322
395
  chooseActiveOption() {
323
396
  if (this.activeOption.hasAttribute("data-advanced-select-add-option")) {
324
397
  this.addOption(
@@ -364,6 +437,10 @@ export default class extends Controller {
364
437
  return this.optionElements[this.activeIndex]
365
438
  }
366
439
 
440
+ get selectableOptionElements() {
441
+ return Array.from(this.currentOptionsTarget.querySelectorAll("[data-advanced-select-option]")).filter((option) => !option.classList.contains("hidden"))
442
+ }
443
+
367
444
  get currentOptionsTarget() {
368
445
  return document.getElementById(this.targetIdValue) || this.optionsTarget
369
446
  }
@@ -400,6 +477,10 @@ export default class extends Controller {
400
477
  return [this.textElement("span", this.valueClass, this.displayLabel(this.selectedValue[0]))]
401
478
  }
402
479
 
480
+ if (this.summaryModeValue === "count") {
481
+ return [this.textElement("span", this.valueClass, this.selectedCountLabel(this.selectedValue.length))]
482
+ }
483
+
403
484
  const tokens = this.selectedValue.slice(0, 2).map((option) => this.textElement("span", this.tokenClass, this.displayLabel(option)))
404
485
 
405
486
  if (this.selectedValue.length > 2) {
@@ -409,10 +490,60 @@ export default class extends Controller {
409
490
  return tokens
410
491
  }
411
492
 
493
+ selectedCountLabel(count) {
494
+ return (this.selectedCountTextValue || "%{count}").replace("%{count}", count)
495
+ }
496
+
412
497
  displayLabel(option) {
413
498
  return option.displayLabel || option.label
414
499
  }
415
500
 
501
+ showTooltip() {
502
+ if (!this.hasTooltipTarget || this.expanded || this.selectedValue.length === 0) {
503
+ return
504
+ }
505
+
506
+ window.clearTimeout(this.tooltipHideTimer)
507
+ this.tooltipTarget.classList.remove("hidden")
508
+ }
509
+
510
+ hideTooltip(immediate = false) {
511
+ if (!this.hasTooltipTarget) {
512
+ return
513
+ }
514
+
515
+ window.clearTimeout(this.tooltipHideTimer)
516
+
517
+ if (immediate === true) {
518
+ this.tooltipTarget.classList.add("hidden")
519
+ } else {
520
+ this.tooltipHideTimer = window.setTimeout(() => this.tooltipTarget.classList.add("hidden"), 150)
521
+ }
522
+ }
523
+
524
+ keepTooltip() {
525
+ window.clearTimeout(this.tooltipHideTimer)
526
+ }
527
+
528
+ renderTooltip() {
529
+ if (!this.hasTooltipTarget || this.tooltipTarget.hasAttribute("data-advanced-select-tooltip-custom")) {
530
+ return
531
+ }
532
+
533
+ const list = this.tooltipTarget.querySelector("[data-advanced-select-tooltip-list]")
534
+ if (!list) {
535
+ return
536
+ }
537
+
538
+ list.replaceChildren(
539
+ ...this.selectedValue.map((option) => this.textElement("li", this.tooltipItemClass, this.displayLabel(option)))
540
+ )
541
+
542
+ if (this.selectedValue.length === 0) {
543
+ this.hideTooltip(true)
544
+ }
545
+ }
546
+
416
547
  textElement(tagName, className, text) {
417
548
  const element = document.createElement(tagName)
418
549
  element.className = className
@@ -13,9 +13,14 @@
13
13
  data-advanced-select-searchable-value="<%= searchable %>"
14
14
  data-advanced-select-add-mode-value="<%= add_mode %>"
15
15
  data-advanced-select-include-hidden-value="<%= include_hidden %>"
16
+ data-advanced-select-auto-select-single-value="<%= auto_select_single %>"
17
+ data-advanced-select-eager-value="<%= eager %>"
18
+ data-advanced-select-summary-mode-value="<%= summary_mode %>"
19
+ data-advanced-select-selected-count-text-value="<%= t("shared.advanced_select.selected_count", count: "%{count}") %>"
16
20
  data-advanced-select-placeholder-class="<%= advanced_select_class(class_map, :placeholder) %>"
17
21
  data-advanced-select-value-class="<%= advanced_select_class(class_map, :value) %>"
18
22
  data-advanced-select-token-class="<%= advanced_select_class(class_map, :token) %>"
23
+ data-advanced-select-tooltip-item-class="<%= advanced_select_class(class_map, :tooltip_item) %>"
19
24
  data-advanced-select-empty-class="<%= advanced_select_class(class_map, :empty) %>"
20
25
  data-advanced-select-loading-class="<%= advanced_select_class(class_map, :loading) %>"
21
26
  data-advanced-select-error-class="<%= advanced_select_class(class_map, :error) %>"
@@ -36,6 +41,8 @@
36
41
  <% end %>
37
42
  </div>
38
43
 
44
+ <% tooltip_enabled = tooltip || tooltip_partial.present? %>
45
+
39
46
  <button type="button"
40
47
  id="<%= "#{id}_trigger" %>"
41
48
  class="<%= advanced_select_class(class_map, :trigger) %>"
@@ -43,14 +50,33 @@
43
50
  aria-expanded="false"
44
51
  aria-controls="<%= "#{id}_dropdown" %>"
45
52
  data-advanced-select-target="trigger"
46
- data-action="advanced-select#toggle keydown->advanced-select#keydown">
53
+ data-action="advanced-select#toggle keydown->advanced-select#keydown<%= " mouseenter->advanced-select#showTooltip mouseleave->advanced-select#hideTooltip" if tooltip_enabled %>">
47
54
  <span id="<%= "#{id}_summary" %>" class="<%= advanced_select_class(class_map, :summary) %>" data-advanced-select-target="summary">
48
- <%= render partial: "advanced_select/summary", locals: { selected_options: selected_options, multiple: multiple, placeholder: placeholder, class_map: class_map } %>
55
+ <%= render partial: "advanced_select/summary", locals: { selected_options: selected_options, multiple: multiple, placeholder: placeholder, summary_mode: summary_mode, class_map: class_map } %>
49
56
  </span>
50
57
  <span id="<%= "#{id}_caret" %>" class="<%= [advanced_select_class(class_map, :caret), ("hidden" if selected_options.any?)].compact.join(" ") %>" data-advanced-select-target="caret">&#8964;</span>
51
58
  <span id="<%= "#{id}_clear" %>" class="<%= [advanced_select_class(class_map, :clear), ("hidden" if selected_options.empty?)].compact.join(" ") %>" data-advanced-select-target="clear" data-action="click->advanced-select#clear">&times;</span>
52
59
  </button>
53
60
 
61
+ <% if tooltip_enabled %>
62
+ <div id="<%= "#{id}_tooltip" %>"
63
+ class="<%= [advanced_select_class(class_map, :tooltip), "hidden"].join(" ") %>"
64
+ role="tooltip"
65
+ data-advanced-select-target="tooltip"
66
+ <%= "data-advanced-select-tooltip-custom" if tooltip_partial.present? %>
67
+ data-action="mouseenter->advanced-select#keepTooltip mouseleave->advanced-select#hideTooltip">
68
+ <% if tooltip_partial.present? %>
69
+ <%= render partial: tooltip_partial, locals: { selected_options: selected_options, options: options } %>
70
+ <% else %>
71
+ <ul class="<%= advanced_select_class(class_map, :tooltip_list) %>" data-advanced-select-tooltip-list>
72
+ <% selected_options.each do |option| %>
73
+ <li class="<%= advanced_select_class(class_map, :tooltip_item) %>"><%= option.fetch(:display_label) %></li>
74
+ <% end %>
75
+ </ul>
76
+ <% end %>
77
+ </div>
78
+ <% end %>
79
+
54
80
  <div id="<%= "#{id}_dropdown" %>"
55
81
  class="<%= [advanced_select_class(class_map, :dropdown), "hidden"].join(" ") %>"
56
82
  data-advanced-select-target="dropdown">
@@ -1,10 +1,15 @@
1
+ <% summary_mode = local_assigns.fetch(:summary_mode, :tokens) %>
1
2
  <% if selected_options.any? %>
2
3
  <% if multiple %>
3
- <% selected_options.first(2).each do |option| %>
4
- <span class="<%= advanced_select_class(class_map, :token) %>"><%= option.fetch(:display_label) %></span>
5
- <% end %>
6
- <% if selected_options.size > 2 %>
7
- <span class="<%= advanced_select_class(class_map, :token) %>"><%= "& +#{selected_options.size - 2}" %></span>
4
+ <% if summary_mode.to_s == "count" %>
5
+ <span class="<%= advanced_select_class(class_map, :value) %>"><%= t("shared.advanced_select.selected_count", count: selected_options.size) %></span>
6
+ <% else %>
7
+ <% selected_options.first(2).each do |option| %>
8
+ <span class="<%= advanced_select_class(class_map, :token) %>"><%= option.fetch(:display_label) %></span>
9
+ <% end %>
10
+ <% if selected_options.size > 2 %>
11
+ <span class="<%= advanced_select_class(class_map, :token) %>"><%= "& +#{selected_options.size - 2}" %></span>
12
+ <% end %>
8
13
  <% end %>
9
14
  <% else %>
10
15
  <span class="<%= advanced_select_class(class_map, :value) %>"><%= selected_options.first.fetch(:display_label) %></span>
@@ -6,3 +6,4 @@ en:
6
6
  error: "Options could not be loaded"
7
7
  loading: "Loading..."
8
8
  search_placeholder: "Search..."
9
+ selected_count: "%{count} selected"
@@ -6,3 +6,4 @@ tr:
6
6
  error: "Seçenekler yüklenemedi"
7
7
  loading: "Yükleniyor..."
8
8
  search_placeholder: "Ara..."
9
+ selected_count: "%{count} seçili"
@@ -9,6 +9,9 @@ module AdvancedSelect
9
9
  token: "ui-advanced-select-token",
10
10
  caret: "ui-advanced-select-caret",
11
11
  clear: "ui-advanced-select-clear",
12
+ tooltip: "ui-advanced-select-tooltip",
13
+ tooltip_list: "ui-advanced-select-tooltip-list",
14
+ tooltip_item: "ui-advanced-select-tooltip-item",
12
15
  dropdown: "ui-advanced-select-dropdown",
13
16
  search: "ui-advanced-select-search",
14
17
  options: "ui-advanced-select-options",
@@ -1,6 +1,6 @@
1
1
  module AdvancedSelect
2
2
  module Helper
3
- def advanced_select_tag(name, id:, selected:, options:, placeholder:, options_url: nil, multiple: false, searchable: true, add_mode: false, dependent_fields: {}, include_hidden: true, option_content_partial: nil, classes: {}, append_classes: {})
3
+ def advanced_select_tag(name, id:, selected:, options:, placeholder:, options_url: nil, multiple: false, searchable: true, add_mode: false, dependent_fields: {}, include_hidden: true, auto_select_single: true, eager: true, summary_mode: :tokens, tooltip: false, tooltip_partial: nil, option_content_partial: nil, classes: {}, append_classes: {})
4
4
  selected_options = advanced_select_selected_options(selected)
5
5
  class_map = advanced_select_class_map(classes, append_classes)
6
6
 
@@ -16,6 +16,11 @@ module AdvancedSelect
16
16
  add_mode: add_mode,
17
17
  dependent_fields: dependent_fields,
18
18
  include_hidden: include_hidden,
19
+ auto_select_single: auto_select_single,
20
+ eager: eager,
21
+ summary_mode: summary_mode,
22
+ tooltip: tooltip,
23
+ tooltip_partial: tooltip_partial,
19
24
  target_id: "#{id}_options",
20
25
  option_content_partial: option_content_partial,
21
26
  class_map: class_map
@@ -1,3 +1,3 @@
1
1
  module AdvancedSelect
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.5"
3
3
  end
@@ -101,6 +101,40 @@
101
101
  z-index: 20;
102
102
  }
103
103
 
104
+ .ui-advanced-select-tooltip {
105
+ background: #ffffff;
106
+ border: 1px solid #e5e7eb;
107
+ border-radius: 0.75rem;
108
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
109
+ box-sizing: border-box;
110
+ color: #374151;
111
+ font-size: 0.875rem;
112
+ left: 0;
113
+ line-height: 1.25rem;
114
+ margin-top: 0.25rem;
115
+ min-width: 100%;
116
+ padding: 0.75rem 1rem;
117
+ position: absolute;
118
+ top: 100%;
119
+ width: max-content;
120
+ z-index: 30;
121
+ }
122
+
123
+ .ui-advanced-select-tooltip-list {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 0.25rem;
127
+ list-style: none;
128
+ margin: 0;
129
+ padding: 0;
130
+ }
131
+
132
+ .ui-advanced-select-tooltip-item {
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
135
+ white-space: nowrap;
136
+ }
137
+
104
138
  .ui-advanced-select-search {
105
139
  border: 1px solid #e5e7eb;
106
140
  border-radius: 0.5rem;
@@ -2,11 +2,13 @@ import { Controller } from "@hotwired/stimulus"
2
2
  import { Turbo } from "@hotwired/turbo-rails"
3
3
 
4
4
  export default class extends Controller {
5
- static targets = ["hiddenFields", "trigger", "summary", "dropdown", "search", "options", "caret", "clear"]
5
+ static targets = ["hiddenFields", "trigger", "summary", "dropdown", "search", "options", "caret", "clear", "tooltip"]
6
6
  static values = {
7
7
  addMode: Boolean,
8
+ autoSelectSingle: { type: Boolean, default: true },
8
9
  delay: { type: Number, default: 200 },
9
10
  dependentFields: Object,
11
+ eager: { type: Boolean, default: true },
10
12
  emptyText: String,
11
13
  errorText: String,
12
14
  includeHidden: { type: Boolean, default: true },
@@ -17,6 +19,8 @@ export default class extends Controller {
17
19
  placeholder: String,
18
20
  searchable: Boolean,
19
21
  selected: Array,
22
+ selectedCountText: String,
23
+ summaryMode: { type: String, default: "tokens" },
20
24
  targetId: String,
21
25
  url: String
22
26
  }
@@ -28,6 +32,7 @@ export default class extends Controller {
28
32
  this.placeholderClass = this.element.dataset.advancedSelectPlaceholderClass || "ui-advanced-select-placeholder"
29
33
  this.valueClass = this.element.dataset.advancedSelectValueClass || "ui-advanced-select-value"
30
34
  this.tokenClass = this.element.dataset.advancedSelectTokenClass || "ui-advanced-select-token"
35
+ this.tooltipItemClass = this.element.dataset.advancedSelectTooltipItemClass || "ui-advanced-select-tooltip-item"
31
36
  this.loadingClass = this.element.dataset.advancedSelectLoadingClass || "ui-advanced-select-loading"
32
37
  this.errorClass = this.element.dataset.advancedSelectErrorClass || "ui-advanced-select-error"
33
38
  this.emptyClass = this.element.dataset.advancedSelectEmptyClass || "ui-advanced-select-empty"
@@ -41,11 +46,17 @@ export default class extends Controller {
41
46
  }))
42
47
  this.close = this.close.bind(this)
43
48
  this.renderOptionsState()
49
+ this.setupEagerDependentFields()
44
50
  }
45
51
 
46
52
  disconnect() {
47
53
  window.clearTimeout(this.timer)
54
+ window.clearTimeout(this.tooltipHideTimer)
48
55
  document.removeEventListener("click", this.close)
56
+
57
+ if (this.dependentChangeListener) {
58
+ document.removeEventListener("change", this.dependentChangeListener)
59
+ }
49
60
  }
50
61
 
51
62
  toggle(event) {
@@ -59,11 +70,12 @@ export default class extends Controller {
59
70
  }
60
71
 
61
72
  open() {
73
+ this.hideTooltip(true)
62
74
  this.dropdownTarget.classList.remove("hidden")
63
75
  this.triggerTarget.setAttribute("aria-expanded", "true")
64
76
  document.addEventListener("click", this.close)
65
77
  this.activate(-1)
66
- this.fetchOptions({ selected: true })
78
+ this.fetchOptions({ selected: true, autoSelect: true })
67
79
 
68
80
  if (this.searchableValue) {
69
81
  requestAnimationFrame(() => this.searchTarget.focus())
@@ -186,7 +198,7 @@ export default class extends Controller {
186
198
  return (text || "").trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "")
187
199
  }
188
200
 
189
- fetchOptions({ selected = false } = {}) {
201
+ fetchOptions({ selected = false, autoSelect = false, eager = false } = {}) {
190
202
  if (!this.urlValue) {
191
203
  return
192
204
  }
@@ -221,23 +233,27 @@ export default class extends Controller {
221
233
  return response.text()
222
234
  })
223
235
  .then((html) => {
224
- if (!this.expanded || requestSequence !== this.requestSequence) {
236
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
225
237
  return
226
238
  }
227
239
 
228
240
  Turbo.renderStreamMessage(html)
229
241
  requestAnimationFrame(() => {
230
- if (!this.expanded || requestSequence !== this.requestSequence) {
242
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
231
243
  return
232
244
  }
233
245
 
234
246
  this.currentOptionsTarget.setAttribute("aria-busy", "false")
235
247
  this.renderOptionsState()
236
248
  this.activate(-1)
249
+
250
+ if (autoSelect) {
251
+ this.autoSelectSingle()
252
+ }
237
253
  })
238
254
  })
239
255
  .catch(() => {
240
- if (!this.expanded || requestSequence !== this.requestSequence) {
256
+ if (!(eager || this.expanded) || requestSequence !== this.requestSequence) {
241
257
  return
242
258
  }
243
259
 
@@ -291,9 +307,18 @@ export default class extends Controller {
291
307
  renderSelection() {
292
308
  this.hiddenFieldsTarget.replaceChildren(...this.hiddenFieldElements)
293
309
  this.summaryTarget.replaceChildren(...this.selectionElements)
310
+ this.renderTooltip()
294
311
  this.renderOptionsState()
295
312
  this.caretTarget.classList.toggle("hidden", this.selectedValue.length > 0)
296
313
  this.clearTarget.classList.toggle("hidden", this.selectedValue.length === 0)
314
+ this.dispatchValueChange()
315
+ }
316
+
317
+ dispatchValueChange() {
318
+ const input = this.hiddenFieldsTarget.querySelector("input")
319
+ if (input) {
320
+ input.dispatchEvent(new Event("change", { bubbles: true }))
321
+ }
297
322
  }
298
323
 
299
324
  renderOptionsState() {
@@ -319,6 +344,56 @@ export default class extends Controller {
319
344
  }
320
345
  }
321
346
 
347
+ setupEagerDependentFields() {
348
+ if (!this.eagerValue || !this.urlValue) {
349
+ return
350
+ }
351
+
352
+ this.dependentSelectors = [...new Set(Object.values(this.dependentFieldsValue))]
353
+ if (this.dependentSelectors.length === 0) {
354
+ return
355
+ }
356
+
357
+ // Delegated on document so it keeps working after a parent advanced select
358
+ // rewrites its hidden input on each selection.
359
+ this.dependentChangeListener = (event) => {
360
+ if (event.target instanceof Element && this.dependentSelectors.some((selector) => event.target.matches(selector))) {
361
+ this.reloadDependentOptions()
362
+ }
363
+ }
364
+ document.addEventListener("change", this.dependentChangeListener)
365
+
366
+ if (this.selectedValue.length === 0) {
367
+ this.fetchOptions({ eager: true, autoSelect: true })
368
+ }
369
+ }
370
+
371
+ reloadDependentOptions() {
372
+ this.selectedValue = []
373
+ this.renderSelection()
374
+ this.clearSearch()
375
+ this.fetchOptions({ eager: true, autoSelect: true })
376
+ }
377
+
378
+ autoSelectSingle() {
379
+ if (!this.autoSelectSingleValue || this.multipleValue || this.selectedValue.length > 0) {
380
+ return
381
+ }
382
+
383
+ const options = this.selectableOptionElements
384
+ if (options.length !== 1) {
385
+ return
386
+ }
387
+
388
+ const option = options[0]
389
+ this.selectOption(
390
+ option.dataset.advancedSelectValueParam,
391
+ option.dataset.advancedSelectLabelParam,
392
+ option.dataset.advancedSelectSubmitValueParam || option.dataset.advancedSelectValueParam,
393
+ { displayLabel: option.dataset.advancedSelectDisplayLabelParam, refreshOptions: false }
394
+ )
395
+ }
396
+
322
397
  chooseActiveOption() {
323
398
  if (this.activeOption.hasAttribute("data-advanced-select-add-option")) {
324
399
  this.addOption(
@@ -364,6 +439,10 @@ export default class extends Controller {
364
439
  return this.optionElements[this.activeIndex]
365
440
  }
366
441
 
442
+ get selectableOptionElements() {
443
+ return Array.from(this.currentOptionsTarget.querySelectorAll("[data-advanced-select-option]")).filter((option) => !option.classList.contains("hidden"))
444
+ }
445
+
367
446
  get currentOptionsTarget() {
368
447
  return document.getElementById(this.targetIdValue) || this.optionsTarget
369
448
  }
@@ -400,6 +479,10 @@ export default class extends Controller {
400
479
  return [this.textElement("span", this.valueClass, this.displayLabel(this.selectedValue[0]))]
401
480
  }
402
481
 
482
+ if (this.summaryModeValue === "count") {
483
+ return [this.textElement("span", this.valueClass, this.selectedCountLabel(this.selectedValue.length))]
484
+ }
485
+
403
486
  const tokens = this.selectedValue.slice(0, 2).map((option) => this.textElement("span", this.tokenClass, this.displayLabel(option)))
404
487
 
405
488
  if (this.selectedValue.length > 2) {
@@ -409,10 +492,60 @@ export default class extends Controller {
409
492
  return tokens
410
493
  }
411
494
 
495
+ selectedCountLabel(count) {
496
+ return (this.selectedCountTextValue || "%{count}").replace("%{count}", count)
497
+ }
498
+
412
499
  displayLabel(option) {
413
500
  return option.displayLabel || option.label
414
501
  }
415
502
 
503
+ showTooltip() {
504
+ if (!this.hasTooltipTarget || this.expanded || this.selectedValue.length === 0) {
505
+ return
506
+ }
507
+
508
+ window.clearTimeout(this.tooltipHideTimer)
509
+ this.tooltipTarget.classList.remove("hidden")
510
+ }
511
+
512
+ hideTooltip(immediate = false) {
513
+ if (!this.hasTooltipTarget) {
514
+ return
515
+ }
516
+
517
+ window.clearTimeout(this.tooltipHideTimer)
518
+
519
+ if (immediate === true) {
520
+ this.tooltipTarget.classList.add("hidden")
521
+ } else {
522
+ this.tooltipHideTimer = window.setTimeout(() => this.tooltipTarget.classList.add("hidden"), 150)
523
+ }
524
+ }
525
+
526
+ keepTooltip() {
527
+ window.clearTimeout(this.tooltipHideTimer)
528
+ }
529
+
530
+ renderTooltip() {
531
+ if (!this.hasTooltipTarget || this.tooltipTarget.hasAttribute("data-advanced-select-tooltip-custom")) {
532
+ return
533
+ }
534
+
535
+ const list = this.tooltipTarget.querySelector("[data-advanced-select-tooltip-list]")
536
+ if (!list) {
537
+ return
538
+ }
539
+
540
+ list.replaceChildren(
541
+ ...this.selectedValue.map((option) => this.textElement("li", this.tooltipItemClass, this.displayLabel(option)))
542
+ )
543
+
544
+ if (this.selectedValue.length === 0) {
545
+ this.hideTooltip(true)
546
+ }
547
+ }
548
+
416
549
  textElement(tagName, className, text) {
417
550
  const element = document.createElement(tagName)
418
551
  element.className = className
@@ -448,4 +581,4 @@ export default class extends Controller {
448
581
  get expanded() {
449
582
  return this.triggerTarget.getAttribute("aria-expanded") === "true"
450
583
  }
451
- }
584
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: advanced_select
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik