hotwire_combobox 0.1.40 → 0.1.42

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3b6e7d5c71ba2836a388709890b4402ab2b3ff4ab194a2d1ce75928d96b3886
4
- data.tar.gz: 78a2543c2354e7dccfe0477288144845b6d06a7611c01204612fd24b53cb6bc9
3
+ metadata.gz: 1fc3750ecb7bd58bd28e13d0cc2d543ae4c614535c927c9483e504b643171a8d
4
+ data.tar.gz: dc6482de8a4344a6d801797d497f6adec8afcc0357e821e1dff8188ecf4c2f68
5
5
  SHA512:
6
- metadata.gz: d1e72f48fb5b0387b454afe6cfa7484b0e5c84dfe18000d025005558fc2e29232047f5179e82dbc41b7b48147f3b76d66ae72ff2d0f8c777340a1ab06a3ab9d8
7
- data.tar.gz: 840447f162fba6892a920e74ddde5b862bb9ac4deac7d56948e62998470d19e9747112a066dfbd45db476c628d1910442d33b7f28b2fed242bc1de698a4a8a12
6
+ metadata.gz: 8884e9e8295b73d37d15762f08c1a69452a7117f99af09dba4369aedea9af2116bc9683127f6f29535813fa5182b21969eb35e4d077edee1f94ce1a6808aadfe
7
+ data.tar.gz: b58dd5870a0508912c910b387f7970ffc4a08a16e40612b448e7c35ae244d0f8e02f9dd9356e5cdd8ae7f0c45c1bd65513ece94fcbeba38818c39d39c9ecd870
data/README.md CHANGED
@@ -80,6 +80,9 @@ import HwComboboxController from "@josefarias/hotwire_combobox"
80
80
  application.register("hw-combobox", HwComboboxController)
81
81
  ```
82
82
 
83
+ > [!WARNING]
84
+ > Keep in mind you need to update both the npm package and the gem every time there's a new version of HotwireCombobox. You should always run the same version number on both sides.
85
+
83
86
  ### Configuring CSS
84
87
 
85
88
  This library comes with optional default styles. Follow the instructions below to include them in your app.
@@ -109,6 +112,19 @@ Require the styles in `app/assets/stylesheets/application.css`:
109
112
  Visit [the docs site](https://hotwirecombobox.com/) for a demo and detailed documentation.
110
113
  If the site is down, you can run the docs locally by cloning [the docs repo](https://github.com/josefarias/hotwire_combobox_docs).
111
114
 
115
+ ## Notes about accessibility
116
+
117
+ This gem follows the [APG combobox pattern guidelines](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) with some exceptions we feel increase the usefulness of the component without much detriment to the overall accessible experience.
118
+
119
+ These are the exceptions:
120
+
121
+ 1. Users cannot manipulate the combobox while it's closed. As long as the combobox is focused, the listbox is shown.
122
+ 2. The escape key closes the listbox and blurs the combobox. It does not clear the combobox.
123
+ 3. The listbox has wrap-around selection. That is, pressing `Up Arrow` when the user is on the first option will select the last option. And pressing `Down Arrow` when on the last option will select the first option. In paginated comboboxes, the first and last options refer to the currently available options. More options may be loaded after navigating to the last currently available option.
124
+ 4. It is possible to have an unlabled combobox, as that responsibility is delegated to the implementing user.
125
+
126
+ It should be noted none of the maintainers use assistive technologies in their daily lives. If you do, and you feel these exceptions are detrimental to your ability to use the component, or if you find an undocumented exception, please [open a GitHub issue](https://github.com/josefarias/hotwire_combobox/issues). We'll get it sorted.
127
+
112
128
  ## Contributing
113
129
 
114
130
  Please read [CONTRIBUTING.md](./CONTRIBUTING.md).
@@ -35,7 +35,8 @@ export default class HwComboboxController extends Concerns(...concerns) {
35
35
  "endOfOptionsStream",
36
36
  "handle",
37
37
  "hiddenField",
38
- "listbox"
38
+ "listbox",
39
+ "mainWrapper"
39
40
  ]
40
41
 
41
42
  static values = {
@@ -237,16 +237,21 @@ Combobox.Dialog = Base => class extends Base {
237
237
 
238
238
  Combobox.Events = Base => class extends Base {
239
239
  _dispatchSelectionEvent({ isNew }) {
240
- const detail = {
240
+ dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
241
+ }
242
+
243
+ _dispatchClosedEvent() {
244
+ dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
245
+ }
246
+
247
+ get _eventableDetails() {
248
+ return {
241
249
  value: this.hiddenFieldTarget.value,
242
250
  display: this._fullQuery,
243
251
  query: this._typedQuery,
244
252
  fieldName: this.hiddenFieldTarget.name,
245
- isValid: this._valueIsValid,
246
- isNew: isNew
247
- };
248
-
249
- dispatch("hw-combobox:selection", { target: this.element, detail });
253
+ isValid: this._valueIsValid
254
+ }
250
255
  }
251
256
  };
252
257
 
@@ -534,6 +539,8 @@ Combobox.Filtering = Base => class extends Base {
534
539
  } else {
535
540
  this._filterSync(event);
536
541
  }
542
+
543
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
537
544
  }
538
545
 
539
546
  _initializeFiltering() {
@@ -565,6 +572,8 @@ Combobox.Filtering = Base => class extends Base {
565
572
  this._selectNew();
566
573
  } else if (isDeleteEvent(event)) {
567
574
  this._deselect();
575
+ } else if (event.inputType === "hw:lockInSelection") {
576
+ this._select(this._ensurableOption);
568
577
  } else if (this._isOpen) {
569
578
  this._select(this._visibleOptionElements[0]);
570
579
  }
@@ -706,7 +715,7 @@ Combobox.Options = Base => class extends Base {
706
715
  Combobox.Selection = Base => class extends Base {
707
716
  selectOptionOnClick(event) {
708
717
  this.filter(event);
709
- this._select(event.currentTarget);
718
+ this._select(event.currentTarget, { forceAutocomplete: true });
710
719
  this.close();
711
720
  }
712
721
 
@@ -1095,6 +1104,7 @@ Combobox.Toggle = Base => class extends Base {
1095
1104
  if (this._isOpen) {
1096
1105
  this._lockInSelection();
1097
1106
  this.expandedValue = false;
1107
+ this._dispatchClosedEvent();
1098
1108
  }
1099
1109
  }
1100
1110
 
@@ -1110,7 +1120,7 @@ Combobox.Toggle = Base => class extends Base {
1110
1120
  const target = event.target;
1111
1121
 
1112
1122
  if (!this._isOpen) return
1113
- if (this.element.contains(target) && !this._isDialogDismisser(target)) return
1123
+ if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
1114
1124
  if (this._withinElementBounds(event)) return
1115
1125
 
1116
1126
  this.close();
@@ -1123,12 +1133,21 @@ Combobox.Toggle = Base => class extends Base {
1123
1133
  this.close();
1124
1134
  }
1125
1135
 
1136
+ clearOrToggleOnHandleClick() {
1137
+ if (this._isQueried) {
1138
+ this._clearQuery();
1139
+ this._actingCombobox.focus();
1140
+ } else {
1141
+ this.toggle();
1142
+ }
1143
+ }
1144
+
1126
1145
  // Some browser extensions like 1Password overlay elements on top of the combobox.
1127
1146
  // Hovering over these elements emits a click event for some reason.
1128
1147
  // These events don't contain any telling information, so we use `_withinElementBounds`
1129
1148
  // as an alternative to check whether the click is legitimate.
1130
1149
  _withinElementBounds(event) {
1131
- const { left, right, top, bottom } = this.element.getBoundingClientRect();
1150
+ const { left, right, top, bottom } = this.mainWrapperTarget.getBoundingClientRect();
1132
1151
  const { clientX, clientY } = event;
1133
1152
 
1134
1153
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
@@ -1275,7 +1294,8 @@ class HwComboboxController extends Concerns(...concerns) {
1275
1294
  "endOfOptionsStream",
1276
1295
  "handle",
1277
1296
  "hiddenField",
1278
- "listbox"
1297
+ "listbox",
1298
+ "mainWrapper"
1279
1299
  ]
1280
1300
 
1281
1301
  static values = {
@@ -241,16 +241,21 @@
241
241
 
242
242
  Combobox.Events = Base => class extends Base {
243
243
  _dispatchSelectionEvent({ isNew }) {
244
- const detail = {
244
+ dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
245
+ }
246
+
247
+ _dispatchClosedEvent() {
248
+ dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
249
+ }
250
+
251
+ get _eventableDetails() {
252
+ return {
245
253
  value: this.hiddenFieldTarget.value,
246
254
  display: this._fullQuery,
247
255
  query: this._typedQuery,
248
256
  fieldName: this.hiddenFieldTarget.name,
249
- isValid: this._valueIsValid,
250
- isNew: isNew
251
- };
252
-
253
- dispatch("hw-combobox:selection", { target: this.element, detail });
257
+ isValid: this._valueIsValid
258
+ }
254
259
  }
255
260
  };
256
261
 
@@ -538,6 +543,8 @@
538
543
  } else {
539
544
  this._filterSync(event);
540
545
  }
546
+
547
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
541
548
  }
542
549
 
543
550
  _initializeFiltering() {
@@ -569,6 +576,8 @@
569
576
  this._selectNew();
570
577
  } else if (isDeleteEvent(event)) {
571
578
  this._deselect();
579
+ } else if (event.inputType === "hw:lockInSelection") {
580
+ this._select(this._ensurableOption);
572
581
  } else if (this._isOpen) {
573
582
  this._select(this._visibleOptionElements[0]);
574
583
  }
@@ -710,7 +719,7 @@
710
719
  Combobox.Selection = Base => class extends Base {
711
720
  selectOptionOnClick(event) {
712
721
  this.filter(event);
713
- this._select(event.currentTarget);
722
+ this._select(event.currentTarget, { forceAutocomplete: true });
714
723
  this.close();
715
724
  }
716
725
 
@@ -1099,6 +1108,7 @@
1099
1108
  if (this._isOpen) {
1100
1109
  this._lockInSelection();
1101
1110
  this.expandedValue = false;
1111
+ this._dispatchClosedEvent();
1102
1112
  }
1103
1113
  }
1104
1114
 
@@ -1114,7 +1124,7 @@
1114
1124
  const target = event.target;
1115
1125
 
1116
1126
  if (!this._isOpen) return
1117
- if (this.element.contains(target) && !this._isDialogDismisser(target)) return
1127
+ if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
1118
1128
  if (this._withinElementBounds(event)) return
1119
1129
 
1120
1130
  this.close();
@@ -1127,12 +1137,21 @@
1127
1137
  this.close();
1128
1138
  }
1129
1139
 
1140
+ clearOrToggleOnHandleClick() {
1141
+ if (this._isQueried) {
1142
+ this._clearQuery();
1143
+ this._actingCombobox.focus();
1144
+ } else {
1145
+ this.toggle();
1146
+ }
1147
+ }
1148
+
1130
1149
  // Some browser extensions like 1Password overlay elements on top of the combobox.
1131
1150
  // Hovering over these elements emits a click event for some reason.
1132
1151
  // These events don't contain any telling information, so we use `_withinElementBounds`
1133
1152
  // as an alternative to check whether the click is legitimate.
1134
1153
  _withinElementBounds(event) {
1135
- const { left, right, top, bottom } = this.element.getBoundingClientRect();
1154
+ const { left, right, top, bottom } = this.mainWrapperTarget.getBoundingClientRect();
1136
1155
  const { clientX, clientY } = event;
1137
1156
 
1138
1157
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
@@ -1279,7 +1298,8 @@
1279
1298
  "endOfOptionsStream",
1280
1299
  "handle",
1281
1300
  "hiddenField",
1282
- "listbox"
1301
+ "listbox",
1302
+ "mainWrapper"
1283
1303
  ]
1284
1304
 
1285
1305
  static values = {
@@ -3,15 +3,20 @@ import { dispatch } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Events = Base => class extends Base {
5
5
  _dispatchSelectionEvent({ isNew }) {
6
- const detail = {
6
+ dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } })
7
+ }
8
+
9
+ _dispatchClosedEvent() {
10
+ dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails })
11
+ }
12
+
13
+ get _eventableDetails() {
14
+ return {
7
15
  value: this.hiddenFieldTarget.value,
8
16
  display: this._fullQuery,
9
17
  query: this._typedQuery,
10
18
  fieldName: this.hiddenFieldTarget.name,
11
- isValid: this._valueIsValid,
12
- isNew: isNew
19
+ isValid: this._valueIsValid
13
20
  }
14
-
15
- dispatch("hw-combobox:selection", { target: this.element, detail })
16
21
  }
17
22
  }
@@ -10,6 +10,8 @@ Combobox.Filtering = Base => class extends Base {
10
10
  } else {
11
11
  this._filterSync(event)
12
12
  }
13
+
14
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
13
15
  }
14
16
 
15
17
  _initializeFiltering() {
@@ -41,6 +43,8 @@ Combobox.Filtering = Base => class extends Base {
41
43
  this._selectNew()
42
44
  } else if (isDeleteEvent(event)) {
43
45
  this._deselect()
46
+ } else if (event.inputType === "hw:lockInSelection") {
47
+ this._select(this._ensurableOption)
44
48
  } else if (this._isOpen) {
45
49
  this._select(this._visibleOptionElements[0])
46
50
  }
@@ -4,7 +4,7 @@ import { wrapAroundAccess } from "hw_combobox/helpers"
4
4
  Combobox.Selection = Base => class extends Base {
5
5
  selectOptionOnClick(event) {
6
6
  this.filter(event)
7
- this._select(event.currentTarget)
7
+ this._select(event.currentTarget, { forceAutocomplete: true })
8
8
  this.close()
9
9
  }
10
10
 
@@ -10,6 +10,7 @@ Combobox.Toggle = Base => class extends Base {
10
10
  if (this._isOpen) {
11
11
  this._lockInSelection()
12
12
  this.expandedValue = false
13
+ this._dispatchClosedEvent()
13
14
  }
14
15
  }
15
16
 
@@ -25,7 +26,7 @@ Combobox.Toggle = Base => class extends Base {
25
26
  const target = event.target
26
27
 
27
28
  if (!this._isOpen) return
28
- if (this.element.contains(target) && !this._isDialogDismisser(target)) return
29
+ if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
29
30
  if (this._withinElementBounds(event)) return
30
31
 
31
32
  this.close()
@@ -38,12 +39,21 @@ Combobox.Toggle = Base => class extends Base {
38
39
  this.close()
39
40
  }
40
41
 
42
+ clearOrToggleOnHandleClick() {
43
+ if (this._isQueried) {
44
+ this._clearQuery()
45
+ this._actingCombobox.focus()
46
+ } else {
47
+ this.toggle()
48
+ }
49
+ }
50
+
41
51
  // Some browser extensions like 1Password overlay elements on top of the combobox.
42
52
  // Hovering over these elements emits a click event for some reason.
43
53
  // These events don't contain any telling information, so we use `_withinElementBounds`
44
54
  // as an alternative to check whether the click is legitimate.
45
55
  _withinElementBounds(event) {
46
- const { left, right, top, bottom } = this.element.getBoundingClientRect()
56
+ const { left, right, top, bottom } = this.mainWrapperTarget.getBoundingClientRect()
47
57
  const { clientX, clientY } = event
48
58
 
49
59
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
@@ -1,7 +1,11 @@
1
1
  :root {
2
2
  --hw-active-bg-color: #F3F4F6;
3
-
4
3
  --hw-border-color: #D1D5DB;
4
+ --hw-invalid-color: #EF4444;
5
+ --hw-dialog-label-color: #1D1D1D;
6
+ --hw-focus-color: #2563EB;
7
+ --hw-option-bg-color: #FFFFFF;
8
+
5
9
  --hw-border-radius: 0.375rem;
6
10
  --hw-border-width--slim: 1px;
7
11
  --hw-border-width--thick: 2px;
@@ -9,7 +13,6 @@
9
13
  --hw-dialog-font-size: 1.25rem;
10
14
  --hw-dialog-input-height: 2.5rem;
11
15
  --hw-dialog-label-alignment: center;
12
- --hw-dialog-label-color: #1D1D1D;
13
16
  --hw-dialog-label-padding: 0.5rem 0 0.375rem;
14
17
  --hw-dialog-label-size: 1.05rem;
15
18
  --hw-dialog-listbox-margin: 1.25rem 0 0;
@@ -19,21 +22,19 @@
19
22
  --hw-font-size: 1rem;
20
23
 
21
24
  --hw-handle-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
25
+ --hw-handle-image--queried: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18 18 6M6 6l12 12'/%3E%3C/svg%3E");
22
26
  --hw-handle-offset-right: 0.375rem;
23
27
  --hw-handle-width: 1.5em;
28
+ --hw-handle-width--queried: 1em;
24
29
 
25
30
  --hw-combobox-width: 10rem;
26
31
 
27
- --hw-focus-color: #2563EB;
28
-
29
32
  --hw-line-height: 1.5rem;
30
33
 
31
34
  --hw-listbox-height: calc(var(--hw-line-height) * 10);
32
35
  --hw-listbox-offset-top: calc(var(--hw-line-height) * 1.625);
33
36
  --hw-listbox-z-index: 10;
34
37
 
35
- --hw-option-bg-color: #FFFFFF;
36
-
37
38
  --hw-padding--slim: 0.375rem;
38
39
  --hw-padding--thick: 0.75rem;
39
40
 
@@ -42,8 +43,10 @@
42
43
 
43
44
  .hw-combobox {
44
45
  border-width: 0;
45
- display: inline-block;
46
+ display: inline-flex;
47
+ flex-direction: column;
46
48
  font-size: var(--hw-font-size);
49
+ gap: var(--hw-padding--slim);
47
50
  margin: 0;
48
51
  padding: 0;
49
52
  position: relative;
@@ -53,6 +56,11 @@
53
56
  }
54
57
  }
55
58
 
59
+ .hw-combobox__main__wrapper {
60
+ position: relative;
61
+ width: min-content;
62
+ }
63
+
56
64
  .hw-combobox__input {
57
65
  border: var(--hw-border-width--slim) solid var(--hw-border-color);
58
66
  border-radius: var(--hw-border-radius);
@@ -64,6 +72,15 @@
64
72
  text-overflow: ellipsis;
65
73
  }
66
74
 
75
+ .hw-combobox__input:focus-visible {
76
+ box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
77
+ outline: none;
78
+ }
79
+
80
+ .hw-combobox__input--invalid {
81
+ border: var(--hw-border-width--slim) solid var(--hw-invalid-color);
82
+ }
83
+
67
84
  .hw-combobox__handle {
68
85
  height: 100%;
69
86
  position: absolute;
@@ -85,9 +102,10 @@
85
102
  top: 0;
86
103
  }
87
104
 
88
- .hw-combobox__input:focus-visible {
89
- box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
90
- outline: none;
105
+ .hw-combobox__input[data-queried] + .hw-combobox__handle::before {
106
+ background-image: var(--hw-handle-image--queried);
107
+ background-size: var(--hw-handle-width--queried);
108
+ cursor: pointer;
91
109
  }
92
110
 
93
111
  .hw-combobox__listbox {
@@ -0,0 +1,52 @@
1
+ module HotwireCombobox::Component::Customizable
2
+ CUSTOMIZABLE_ELEMENTS = %i[
3
+ fieldset
4
+ label
5
+ hidden_field
6
+ main_wrapper
7
+ input
8
+ handle
9
+ listbox
10
+ dialog
11
+ dialog_wrapper
12
+ dialog_label
13
+ dialog_input
14
+ dialog_listbox
15
+ ].freeze
16
+
17
+ PROTECTED_ATTRS = %i[
18
+ id
19
+ name
20
+ value
21
+ open
22
+ role
23
+ hidden
24
+ for
25
+ ].freeze
26
+
27
+ CUSTOMIZABLE_ELEMENTS.each do |element|
28
+ define_method "customize_#{element}" do |**attrs|
29
+ customize element, **attrs
30
+ end
31
+ end
32
+
33
+ private
34
+ def custom_attrs
35
+ @custom_attrs ||= Hash.new { |h, k| h[k] = {} }
36
+ end
37
+
38
+ def customize(element, **attrs)
39
+ element = element.to_sym.presence_in(CUSTOMIZABLE_ELEMENTS)
40
+ sanitized_attrs = attrs.deep_symbolize_keys.except(*PROTECTED_ATTRS)
41
+
42
+ custom_attrs.store element, sanitized_attrs
43
+ end
44
+
45
+ def apply_customizations_to(element, base: {})
46
+ custom = custom_attrs[element]
47
+ coalesce = ->(k, v) { v.is_a?(String) ? view.token_list(v, custom.delete(k)) : v }
48
+ default = base.map { |k, v| [ k, coalesce.(k, v) ] }.to_h
49
+
50
+ custom.deep_merge default
51
+ end
52
+ end
@@ -1,7 +1,9 @@
1
1
  require "securerandom"
2
2
 
3
3
  class HotwireCombobox::Component
4
- attr_reader :options, :dialog_label
4
+ include Customizable
5
+
6
+ attr_reader :options, :label
5
7
 
6
8
  def initialize \
7
9
  view, name,
@@ -13,31 +15,47 @@ class HotwireCombobox::Component
13
15
  form: nil,
14
16
  id: nil,
15
17
  input: {},
18
+ label: nil,
16
19
  mobile_at: "640px",
17
20
  name_when_new: nil,
18
21
  open: false,
19
22
  options: [],
20
23
  value: nil,
21
24
  **rest
22
- @view, @autocomplete, @id, @name, @value, @form, @async_src,
25
+ @view, @autocomplete, @id, @name, @value, @form, @async_src, @label,
23
26
  @name_when_new, @open, @data, @mobile_at, @options, @dialog_label =
24
- view, autocomplete, id, name.to_s, value, form, async_src,
27
+ view, autocomplete, id, name.to_s, value, form, async_src, label,
25
28
  name_when_new, open, data, mobile_at, options, dialog_label
26
29
 
27
- @combobox_attrs = input.reverse_merge(rest).with_indifferent_access
30
+ @combobox_attrs = input.reverse_merge(rest).deep_symbolize_keys
28
31
  @association_name = association_name || infer_association_name
29
32
  end
30
33
 
34
+ def render_in(view_context, &block)
35
+ block.call(self) if block_given?
36
+ view_context.render partial: "hotwire_combobox/component", locals: { component: self }
37
+ end
38
+
39
+
31
40
  def fieldset_attrs
32
- {
41
+ apply_customizations_to :fieldset, base: {
33
42
  class: "hw-combobox",
34
43
  data: fieldset_data
35
44
  }
36
45
  end
37
46
 
38
47
 
48
+ def label_attrs
49
+ apply_customizations_to :label, base: {
50
+ class: "hw-combobox__label",
51
+ for: input_id,
52
+ hidden: label.blank?
53
+ }
54
+ end
55
+
56
+
39
57
  def hidden_field_attrs
40
- {
58
+ apply_customizations_to :hidden_field, base: {
41
59
  id: hidden_field_id,
42
60
  name: hidden_field_name,
43
61
  data: hidden_field_data,
@@ -46,10 +64,18 @@ class HotwireCombobox::Component
46
64
  end
47
65
 
48
66
 
67
+ def main_wrapper_attrs
68
+ apply_customizations_to :main_wrapper, base: {
69
+ class: "hw-combobox__main__wrapper",
70
+ data: main_wrapper_data
71
+ }
72
+ end
73
+
74
+
49
75
  def input_attrs
50
76
  nested_attrs = %i[ data aria ]
51
77
 
52
- {
78
+ base = {
53
79
  id: input_id,
54
80
  role: :combobox,
55
81
  class: "hw-combobox__input",
@@ -57,12 +83,14 @@ class HotwireCombobox::Component
57
83
  data: input_data,
58
84
  aria: input_aria,
59
85
  autocomplete: :off
60
- }.with_indifferent_access.merge combobox_attrs.except(*nested_attrs)
86
+ }.merge combobox_attrs.except(*nested_attrs)
87
+
88
+ apply_customizations_to :input, base: base
61
89
  end
62
90
 
63
91
 
64
92
  def handle_attrs
65
- {
93
+ apply_customizations_to :handle, base: {
66
94
  class: "hw-combobox__handle",
67
95
  data: handle_data
68
96
  }
@@ -70,7 +98,7 @@ class HotwireCombobox::Component
70
98
 
71
99
 
72
100
  def listbox_attrs
73
- {
101
+ apply_customizations_to :listbox, base: {
74
102
  id: listbox_id,
75
103
  role: :listbox,
76
104
  class: "hw-combobox__listbox",
@@ -81,28 +109,32 @@ class HotwireCombobox::Component
81
109
 
82
110
 
83
111
  def dialog_wrapper_attrs
84
- {
112
+ apply_customizations_to :dialog_wrapper, base: {
85
113
  class: "hw-combobox__dialog__wrapper"
86
114
  }
87
115
  end
88
116
 
89
117
  def dialog_attrs
90
- {
118
+ apply_customizations_to :dialog, base: {
91
119
  class: "hw-combobox__dialog",
92
120
  role: :dialog,
93
121
  data: dialog_data
94
122
  }
95
123
  end
96
124
 
125
+ def dialog_label
126
+ @dialog_label || label
127
+ end
128
+
97
129
  def dialog_label_attrs
98
- {
130
+ apply_customizations_to :dialog_label, base: {
99
131
  class: "hw-combobox__dialog__label",
100
132
  for: dialog_input_id
101
133
  }
102
134
  end
103
135
 
104
136
  def dialog_input_attrs
105
- {
137
+ apply_customizations_to :dialog_input, base: {
106
138
  id: dialog_input_id,
107
139
  role: :combobox,
108
140
  class: "hw-combobox__dialog__input",
@@ -114,7 +146,7 @@ class HotwireCombobox::Component
114
146
  end
115
147
 
116
148
  def dialog_listbox_attrs
117
- {
149
+ apply_customizations_to :dialog_listbox, base: {
118
150
  id: dialog_listbox_id,
119
151
  class: "hw-combobox__dialog__listbox",
120
152
  role: :listbox,
@@ -194,6 +226,11 @@ class HotwireCombobox::Component
194
226
  end
195
227
 
196
228
 
229
+ def main_wrapper_data
230
+ { hw_combobox_target: "mainWrapper" }
231
+ end
232
+
233
+
197
234
  def hidden_field_id
198
235
  "#{canonical_id}-hw-hidden-field"
199
236
  end
@@ -250,7 +287,7 @@ class HotwireCombobox::Component
250
287
 
251
288
  def handle_data
252
289
  {
253
- action: "click->hw-combobox#toggle",
290
+ action: "click->hw-combobox#clearOrToggleOnHandleClick",
254
291
  hw_combobox_target: "handle"
255
292
  }
256
293
  end
@@ -0,0 +1,11 @@
1
+ <%= tag.fieldset **component.fieldset_attrs do %>
2
+ <%= tag.label component.label, **component.label_attrs %>
3
+
4
+ <%= render "hotwire_combobox/combobox/hidden_field", component: component %>
5
+
6
+ <%= tag.div **component.main_wrapper_attrs do %>
7
+ <%= render "hotwire_combobox/combobox/input", component: component %>
8
+ <%= render "hotwire_combobox/combobox/paginated_listbox", component: component %>
9
+ <%= render "hotwire_combobox/combobox/dialog", component: component %>
10
+ <% end %>
11
+ <% end %>
@@ -9,8 +9,8 @@ module HotwireCombobox
9
9
 
10
10
  unless HotwireCombobox.bypass_convenience_methods?
11
11
  module FormBuilderExtensions
12
- def combobox(*args, **kwargs)
13
- @template.hw_combobox_tag(*args, **kwargs.merge(form: self))
12
+ def combobox(*args, **kwargs, &block)
13
+ @template.hw_combobox_tag(*args, **kwargs.merge(form: self), &block)
14
14
  end
15
15
  end
16
16
 
@@ -15,11 +15,10 @@ module HotwireCombobox
15
15
  end
16
16
  hw_alias :hw_combobox_style_tag
17
17
 
18
- def hw_combobox_tag(name, options_or_src = [], render_in: {}, include_blank: nil, **kwargs)
18
+ def hw_combobox_tag(name, options_or_src = [], render_in: {}, include_blank: nil, **kwargs, &block)
19
19
  options, src = hw_extract_options_and_src(options_or_src, render_in, include_blank)
20
20
  component = HotwireCombobox::Component.new self, name, options: options, async_src: src, **kwargs
21
-
22
- render "hotwire_combobox/combobox", component: component
21
+ render component, &block
23
22
  end
24
23
  hw_alias :hw_combobox_tag
25
24
 
@@ -27,11 +26,9 @@ module HotwireCombobox
27
26
  if options.first.is_a? HotwireCombobox::Listbox::Option
28
27
  options
29
28
  else
30
- render_in_proc = hw_render_in_proc(render_in) if render_in.present?
31
-
32
- hw_parse_combobox_options(options, render_in: render_in_proc, **methods.merge(display: display)).tap do |options|
33
- options.unshift(hw_blank_option(include_blank)) if include_blank.present?
34
- end
29
+ opts = hw_parse_combobox_options options, render_in_proc: hw_render_in_proc(render_in), **methods.merge(display: display)
30
+ opts.unshift(hw_blank_option(include_blank)) if include_blank.present?
31
+ opts
35
32
  end
36
33
  end
37
34
  hw_alias :hw_combobox_options
@@ -86,7 +83,7 @@ module HotwireCombobox
86
83
  if include_blank.is_a? Hash
87
84
  text = include_blank.delete(:text)
88
85
 
89
- [ text, hw_render_in_proc(include_blank).(text) ]
86
+ [ text, hw_call_render_in_proc(hw_render_in_proc(include_blank), text, display: text, value: "") ]
90
87
  else
91
88
  [ include_blank, include_blank ]
92
89
  end
@@ -103,7 +100,9 @@ module HotwireCombobox
103
100
 
104
101
  private
105
102
  def hw_render_in_proc(render_in)
106
- ->(object) { render(**render_in.reverse_merge(object: object)) }
103
+ if render_in.present?
104
+ ->(object, locals) { render(**render_in.reverse_merge(object: object, locals: locals)) }
105
+ end
107
106
  end
108
107
 
109
108
  def hw_extract_options_and_src(options_or_src, render_in, include_blank)
@@ -114,40 +113,50 @@ module HotwireCombobox
114
113
  end
115
114
  end
116
115
 
117
- def hw_parse_combobox_options(options, render_in: nil, **methods)
116
+ def hw_parse_combobox_options(options, render_in_proc: nil, **methods)
118
117
  options.map do |option|
119
118
  HotwireCombobox::Listbox::Option.new \
120
- **hw_option_attrs_for(option, render_in: render_in, **methods)
119
+ **hw_option_attrs_for(option, render_in_proc: render_in_proc, **methods)
121
120
  end
122
121
  end
123
122
 
124
- def hw_option_attrs_for(option, render_in: nil, **methods)
123
+ def hw_option_attrs_for(option, render_in_proc: nil, **methods)
125
124
  case option
126
125
  when Hash
127
- option
126
+ option.tap do |attrs|
127
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
128
+ end
128
129
  when String
129
130
  {}.tap do |attrs|
130
131
  attrs[:display] = option
131
132
  attrs[:value] = option
132
- attrs[:content] = render_in.(option) if render_in
133
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
133
134
  end
134
135
  when Array
135
136
  {}.tap do |attrs|
136
137
  attrs[:display] = option.first
137
138
  attrs[:value] = option.last
138
- attrs[:content] = render_in.(option.first) if render_in
139
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
139
140
  end
140
141
  else
141
142
  {}.tap do |attrs|
142
- attrs[:value] = hw_call_method_or_proc(option, methods[:value] || :id)
143
-
144
143
  attrs[:id] = hw_call_method_or_proc(option, methods[:id]) if methods[:id]
145
144
  attrs[:display] = hw_call_method_or_proc(option, methods[:display]) if methods[:display]
146
- attrs[:content] = hw_call_method_or_proc(option, render_in || methods[:content]) if render_in || methods[:content]
145
+ attrs[:value] = hw_call_method_or_proc(option, methods[:value] || :id)
146
+
147
+ if render_in_proc
148
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, option, attrs)
149
+ elsif methods[:content]
150
+ attrs[:content] = hw_call_method_or_proc(option, methods[:content])
151
+ end
147
152
  end
148
153
  end
149
154
  end
150
155
 
156
+ def hw_call_render_in_proc(render_in_proc, object, attrs)
157
+ render_in_proc.(object, combobox_display: attrs[:display], combobox_value: attrs[:value])
158
+ end
159
+
151
160
  def hw_call_method_or_proc(object, method_or_proc)
152
161
  if method_or_proc.is_a? Proc
153
162
  method_or_proc.call object
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.40"
2
+ VERSION = "0.1.42"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_combobox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.40
4
+ version: 0.1.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Farias
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-07 00:00:00.000000000 Z
11
+ date: 2024-03-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -85,8 +85,9 @@ files:
85
85
  - app/assets/javascripts/hw_combobox/vendor/requestjs.js
86
86
  - app/assets/stylesheets/hotwire_combobox.css
87
87
  - app/presenters/hotwire_combobox/component.rb
88
+ - app/presenters/hotwire_combobox/component/customizable.rb
88
89
  - app/presenters/hotwire_combobox/listbox/option.rb
89
- - app/views/hotwire_combobox/_combobox.html.erb
90
+ - app/views/hotwire_combobox/_component.html.erb
90
91
  - app/views/hotwire_combobox/_next_page.turbo_stream.erb
91
92
  - app/views/hotwire_combobox/_paginated_options.turbo_stream.erb
92
93
  - app/views/hotwire_combobox/_pagination.html.erb
@@ -1,6 +0,0 @@
1
- <%= tag.fieldset **component.fieldset_attrs do %>
2
- <%= render "hotwire_combobox/combobox/hidden_field", component: component %>
3
- <%= render "hotwire_combobox/combobox/input", component: component %>
4
- <%= render "hotwire_combobox/combobox/paginated_listbox", component: component %>
5
- <%= render "hotwire_combobox/combobox/dialog", component: component %>
6
- <% end %>