tramway 3.0.0.1 → 3.0.2

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -11
  3. data/app/assets/javascripts/tramway/{multiselect_controller.js → tramway-select_controller.js} +44 -6
  4. data/app/components/tailwind_component.rb +3 -3
  5. data/app/components/tramway/form/builder.rb +81 -28
  6. data/app/components/tramway/form/checkbox_component.html.haml +2 -2
  7. data/app/components/tramway/form/checkbox_component.rb +14 -0
  8. data/app/components/tramway/form/date_field_component.html.haml +1 -1
  9. data/app/components/tramway/form/datetime_field_component.html.haml +1 -1
  10. data/app/components/tramway/form/label_component.html.haml +1 -1
  11. data/app/components/tramway/form/label_component.rb +2 -1
  12. data/app/components/tramway/form/number_field_component.html.haml +1 -1
  13. data/app/components/tramway/form/select_component.html.haml +1 -1
  14. data/app/components/tramway/form/text_area_component.html.haml +1 -1
  15. data/app/components/tramway/form/text_field_component.html.haml +1 -1
  16. data/app/components/tramway/form/time_field_component.html.haml +1 -1
  17. data/app/components/tramway/form/tramway_select/autocomplete_input_component.html.haml +1 -0
  18. data/app/components/tramway/form/tramway_select/autocomplete_input_component.rb +10 -0
  19. data/app/components/tramway/form/{multiselect → tramway_select}/caret_component.rb +1 -1
  20. data/app/components/tramway/form/{multiselect → tramway_select}/dropdown_container_component.html.haml +1 -1
  21. data/app/components/tramway/form/{multiselect → tramway_select}/dropdown_container_component.rb +1 -1
  22. data/app/components/tramway/form/tramway_select/item_container_component.html.haml +2 -0
  23. data/app/components/tramway/form/{multiselect → tramway_select}/item_container_component.rb +1 -1
  24. data/app/components/tramway/form/{multiselect → tramway_select}/select_as_input_component.html.haml +1 -1
  25. data/app/components/tramway/form/{multiselect → tramway_select}/select_as_input_component.rb +1 -1
  26. data/app/components/tramway/form/{multiselect → tramway_select}/selected_item_template_component.html.haml +1 -1
  27. data/app/components/tramway/form/tramway_select/selected_item_template_component.rb +29 -0
  28. data/app/components/tramway/form/tramway_select_component.html.haml +13 -0
  29. data/app/components/tramway/form/{multiselect_component.rb → tramway_select_component.rb} +38 -15
  30. data/config/tailwind.config.js +8 -2
  31. data/docs/AGENTS.md +28 -2
  32. data/lib/generators/tramway/install/install_generator.rb +4 -4
  33. data/lib/tramway/engine.rb +1 -1
  34. data/lib/tramway/helpers/views_helper.rb +6 -6
  35. data/lib/tramway/utils/field.rb +2 -2
  36. data/lib/tramway/version.rb +1 -1
  37. metadata +16 -14
  38. data/app/components/tramway/form/multiselect/item_container_component.html.haml +0 -2
  39. data/app/components/tramway/form/multiselect/selected_item_template_component.rb +0 -26
  40. data/app/components/tramway/form/multiselect_component.html.haml +0 -13
  41. /data/app/components/tramway/form/{multiselect → tramway_select}/caret_component.html.haml +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddcb9362d874433878e243879b046128dab5bceb12dab30d62ae2f8f39ab3aa5
4
- data.tar.gz: 1452740d4bd20c3fff0741dac5aa99b21534b27b1493ecb1539b7e4ec84b38f6
3
+ metadata.gz: def2792e8c3e1ebae5e4d2fa04b7e5edf92b72e4aed0f892cf8152e203c8daf2
4
+ data.tar.gz: 77eace6936af7c9597f49634ea5da28f699b4011f4ccac811259e659732ca192
5
5
  SHA512:
6
- metadata.gz: 34233f340fce1fce40ccc9baa2848541fac897ecdfa9c6bfd1f28552dac5b08be19c4a6bb5f810034bfdeda1adf00494c9cb2f5f6e77cb78bbbcb1403e351cda
7
- data.tar.gz: 417cdfd0e3cfbe1e2bc9f47a8563e0bd4e56cfa20a250a4bdd8abaac535e1d3edc007ca2d1b59e743da226d7b314392314055589ec35e68a4a115e2b9e980a74
6
+ metadata.gz: d6f7a8ed6f51a4658625b8df8f1db6ec2eb7619284eafc6acf0b46d67f0fb9eaf3bfe95e818d7226f52a93a6b81eb271e64335acf76640501132b890e000938d
7
+ data.tar.gz: 197a0227f09dbccacf37e82d2e5d15a585928b0ec60863c1ce43c20e012b568037e0880f9ffda49a21846c66dcec72f6b1931ff0790509d9c01b6b8da71727d8
data/README.md CHANGED
@@ -1105,7 +1105,7 @@ Tramway provides `tramway_form_for` helper that renders Tailwind-styled forms by
1105
1105
  <%= f.select :role, [:admin, :user] %>
1106
1106
  <%= f.date_field :birth_date %>
1107
1107
  <%= f.datetime_field :confirmed_at %>
1108
- <%= f.multiselect :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
1108
+ <%= f.tramway_select :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
1109
1109
  <%= f.file_field :file %>
1110
1110
  <%= f.submit 'Create User' %>
1111
1111
  <% end %>
@@ -1144,9 +1144,20 @@ Available form helpers:
1144
1144
  * date_field
1145
1145
  * datetime_field
1146
1146
  * time_field
1147
- * multiselect ([Stimulus-based](https://github.com/Purple-Magic/tramway#stimulus-based-inputs))
1147
+ * tramway_select ([Stimulus-based](https://github.com/Purple-Magic/tramway#stimulus-based-inputs))
1148
1148
  * submit
1149
1149
 
1150
+ Autocomplete select example:
1151
+
1152
+ ```erb
1153
+ <%= tramway_form_for @user do |f| %>
1154
+ <%= f.select :role, [:admin, :user], autocomplete: true %>
1155
+ <% end %>
1156
+ ```
1157
+
1158
+ `autocomplete: true` renders an autocomplete-enabled select. It cannot be used together with `multiselect: true` in the
1159
+ same select field.
1160
+
1150
1161
  **Examples**
1151
1162
 
1152
1163
  1. Sign In Form for `devise` authentication
@@ -1178,44 +1189,55 @@ Available form helpers:
1178
1189
 
1179
1190
  `tramway_form_for` provides Tailwind-styled Stimulus-based custom inputs.
1180
1191
 
1181
- ##### Multiselect
1192
+ ##### Tramway Select
1182
1193
 
1183
- In case you want to use tailwind-styled multiselect this way
1194
+ In case you want to use the tailwind-styled Tramway select this way
1184
1195
 
1185
1196
  ```erb
1186
1197
  <%= tramway_form_for @user do |f| %>
1187
- <%= f.multiselect :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
1198
+ <%= f.tramway_select :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
1188
1199
  <%# ... %>
1189
1200
  <% end %>
1190
1201
  ```
1191
1202
 
1192
- you should add Tramway Multiselect Stimulus controller to your application.
1203
+ you should add the Tramway Select Stimulus controller to your application.
1193
1204
 
1194
1205
  Example for [importmap-rails](https://github.com/rails/importmap-rails) config
1195
1206
 
1196
1207
  *config/importmap.rb*
1197
1208
  ```ruby
1198
- pin '@tramway/multiselect', to: 'tramway/multiselect_controller.js'
1209
+ pin '@tramway/tramway-select', to: 'tramway/tramway-select_controller.js'
1199
1210
  ```
1200
1211
 
1201
1212
  *app/javascript/controllers/index.js*
1202
1213
  ```js
1203
1214
  import { application } from "controllers/application"
1204
1215
  import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
1205
- import { Multiselect } from "@tramway/multiselect" // importing Multiselect controller class
1216
+ import { TramwaySelect } from "@tramway/tramway-select" // importing TramwaySelect controller class
1206
1217
  eagerLoadControllersFrom("controllers", application)
1207
1218
 
1208
- application.register('multiselect', Multiselect) // register Multiselect controller class as `multiselect` stimulus controller
1219
+ application.register('tramway-select', TramwaySelect) // register TramwaySelect controller class as `tramway-select` stimulus controller
1209
1220
  ```
1210
1221
 
1211
- In case you need to use Stimulus `change` action with Tramway Multiselect
1222
+ In case you need to use Stimulus `change` action with Tramway Select
1212
1223
 
1213
1224
  ```erb
1214
1225
  <%= tramway_form_for @user do |f| %>
1215
- <%= f.multiselect :role, data: { action: 'change->user-form#updateForm' } %>
1226
+ <%= f.tramway_select :role, data: { action: 'change->user-form#updateForm' } %>
1216
1227
  <% end %>
1217
1228
  ```
1218
1229
 
1230
+ Remote form example:
1231
+
1232
+ ```erb
1233
+ <%= tramway_form_for @user, remote: true do |f| %>
1234
+ <%= f.text_field :name %>
1235
+ <%= f.email_field :email %>
1236
+ <% end %>
1237
+ ```
1238
+
1239
+ With `remote: true`, Tramway submits the form on each input `change` via inline JavaScript; no additional controller setup is required.
1240
+
1219
1241
  ### Tailwind-styled pagination for Kaminari
1220
1242
 
1221
1243
  Tramway uses [Tailwind](https://tailwindcss.com/) by default. It has tailwind-styled pagination for [kaminari](https://github.com/kaminari/kaminari).
@@ -1,6 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- export default class Multiselect extends Controller {
3
+ export default class TramwaySelect extends Controller {
4
4
  static targets = ["dropdown", "showSelectedArea", "hiddenInput", "caretDown", "caretUp"]
5
5
 
6
6
  static values = {
@@ -13,11 +13,15 @@ export default class Multiselect extends Controller {
13
13
  placeholder: String,
14
14
  selectAsInput: String,
15
15
  value: Array,
16
- onChange: String
16
+ onChange: String,
17
+ multiple: Boolean,
18
+ autocomplete: Boolean,
19
+ autocompleteInput: String
17
20
  }
18
21
 
19
22
  connect() {
20
23
  this.dropdownState = 'closed';
24
+
21
25
  this.items = JSON.parse(this.element.dataset.items).map((item, index) => {
22
26
  return {
23
27
  index,
@@ -40,9 +44,15 @@ export default class Multiselect extends Controller {
40
44
  }
41
45
 
42
46
  renderSelectedItems() {
43
- const allItems = this.fillTemplate(this.element.dataset.selectedItemTemplate, this.selectedItems);
47
+ const allItems = this.fillTemplate(this.element.dataset.selectedItemTemplate, this.selectedItems)
48
+
49
+ let content = allItems;
50
+
51
+ if (this.autocomplete() && this.selectedItems.length === 0) {
52
+ content += this.element.dataset.autocompleteInput;
53
+ }
44
54
 
45
- this.showSelectedAreaTarget.innerHTML = allItems;
55
+ this.showSelectedAreaTarget.innerHTML = content;
46
56
  this.showSelectedAreaTarget.insertAdjacentHTML("beforeEnd", this.input());
47
57
  this.updateInputOptions();
48
58
  }
@@ -90,6 +100,7 @@ export default class Multiselect extends Controller {
90
100
 
91
101
  closeDropdown() {
92
102
  this.dropdownState = 'closed';
103
+
93
104
  if (this.dropdown()) {
94
105
  this.dropdown().remove();
95
106
  }
@@ -126,6 +137,12 @@ export default class Multiselect extends Controller {
126
137
  const itemIndex = this.items.findIndex(x => x.value === currentTarget.dataset.value);
127
138
  const itemSelectedIndex = this.selectedItems.findIndex(x => x.value === currentTarget.dataset.value);
128
139
 
140
+ if (!this.multiple()) {
141
+ this.selectedItems = [];
142
+ this.items.forEach(item => item.selected = false);
143
+ this.closeDropdown()
144
+ }
145
+
129
146
  if (itemSelectedIndex !== -1) {
130
147
  this.selectedItems = this.selectedItems.filter((_, index) => index !== itemSelectedIndex);
131
148
  this.items[itemIndex].selected = false;
@@ -135,7 +152,10 @@ export default class Multiselect extends Controller {
135
152
  }
136
153
 
137
154
  this.renderSelectedItems();
138
- this.rerenderItems();
155
+
156
+ if (this.multiple()) {
157
+ this.rerenderItems();
158
+ }
139
159
  }
140
160
 
141
161
  input() {
@@ -155,6 +175,24 @@ export default class Multiselect extends Controller {
155
175
 
156
176
  this.hiddenInputTarget.value = this.selectedItems.map(item => item.value);
157
177
  }
178
+
179
+ multiple() {
180
+ return this.element.dataset.multiple == 'true';
181
+ }
182
+
183
+ autocomplete() {
184
+ return this.element.dataset.autocomplete == 'true';
185
+ }
186
+
187
+ search(event) {
188
+ const searchTerm = event.target.value.toLowerCase();
189
+ const filteredItems = this.items.filter(item => item.text.toLowerCase().includes(searchTerm) && !item.selected);
190
+ const dropdown = this.dropdown();
191
+
192
+ if (dropdown) {
193
+ dropdown.innerHTML = this.fillTemplate(this.element.dataset.itemContainer, filteredItems);
194
+ }
195
+ }
158
196
  }
159
197
 
160
- export { Multiselect }
198
+ export { TramwaySelect }
@@ -18,7 +18,7 @@ class TailwindComponent < Tramway::BaseComponent
18
18
  select_input: 'text-sm px-2 py-1',
19
19
  file_button: 'text-sm px-3 py-1',
20
20
  submit_button: 'text-sm px-3 py-1',
21
- multiselect_input: 'text-sm px-2 py-1 h-10',
21
+ tramway_select_input: 'text-sm px-2 py-1 h-10',
22
22
  checkbox_input: 'h-4 w-4'
23
23
  },
24
24
  medium: {
@@ -26,7 +26,7 @@ class TailwindComponent < Tramway::BaseComponent
26
26
  select_input: 'text-base px-3 py-2',
27
27
  file_button: 'text-base px-4 py-2',
28
28
  submit_button: 'text-base px-4 py-2',
29
- multiselect_input: 'text-base px-2 py-1 h-12',
29
+ tramway_select_input: 'text-base px-2 py-1 h-12',
30
30
  checkbox_input: 'h-5 w-5'
31
31
  },
32
32
  large: {
@@ -34,7 +34,7 @@ class TailwindComponent < Tramway::BaseComponent
34
34
  select_input: 'text-xl px-4 py-3',
35
35
  file_button: 'text-xl px-5 py-3',
36
36
  submit_button: 'text-xl px-5 py-3',
37
- multiselect_input: 'text-xl px-3 py-2 h-15',
37
+ tramway_select_input: 'text-xl px-3 py-2 h-15',
38
38
  checkbox_input: 'h-6 w-6'
39
39
  }
40
40
  }.freeze
@@ -12,13 +12,19 @@ module Tramway
12
12
 
13
13
  def initialize(object_name, object, template, options)
14
14
  @horizontal = options[:horizontal] || false
15
+ @remote = options[:remote_submit] || false
15
16
 
16
17
  options.merge!(class: [options[:class], 'flex flex-row items-center gap-2'].compact.join(' ')) if @horizontal
17
18
 
18
- super
19
+ @form_object_class = options[:form_object_class]
20
+
21
+ if form_object(object)
22
+ super(object_name, form_object(object), template, options)
23
+ else
24
+ super
25
+ end
19
26
 
20
27
  @form_size = options[:size] || options['size'] || :medium
21
- @form_object_class = options[:form_object_class]
22
28
  end
23
29
 
24
30
  def common_field(component_name, input_method, attribute, **options, &)
@@ -83,25 +89,11 @@ module Tramway
83
89
  end
84
90
 
85
91
  def select(attribute, collection, **options, &)
86
- sanitized_options = sanitize_options(options)
87
-
88
- render(Tramway::Form::SelectComponent.new(
89
- input: input(:select),
90
- value: sanitized_options[:selected] || object.public_send(attribute),
91
- collection: explicitly_add_blank_option(collection, sanitized_options),
92
- **default_options(attribute, sanitized_options)
93
- ), &)
94
- end
95
-
96
- def multiselect(attribute, collection, **options, &)
97
- sanitized_options = sanitize_options(options)
98
-
99
- render(Tramway::Form::MultiselectComponent.new(
100
- input: input(:text_field),
101
- value: sanitized_options[:value] || sanitized_options[:selected] || object.public_send(attribute),
102
- collection:,
103
- **default_options(attribute, sanitized_options)
104
- ), &)
92
+ if options[:multiple] || options[:autocomplete]
93
+ tramway_select(attribute, collection, **options, &)
94
+ else
95
+ default_select(attribute, collection, **options, &)
96
+ end
105
97
  end
106
98
 
107
99
  def submit(action, **options, &)
@@ -122,25 +114,84 @@ module Tramway
122
114
 
123
115
  attr_reader :form_size
124
116
 
117
+ def default_select(attribute, collection, **options, &)
118
+ sanitized_options = sanitize_options(options)
119
+
120
+ render(Tramway::Form::SelectComponent.new(
121
+ input: input(:select),
122
+ value: sanitized_options[:selected] || object.public_send(attribute),
123
+ collection: explicitly_add_blank_option(collection, sanitized_options),
124
+ **default_options(attribute, sanitized_options)
125
+ ), &)
126
+ end
127
+
128
+ def tramway_select(attribute, collection, **options, &)
129
+ sanitized_options = sanitize_options(options)
130
+
131
+ render(Tramway::Form::TramwaySelectComponent.new(
132
+ input: input(:text_field),
133
+ value: sanitized_options[:value] || sanitized_options[:selected] || object.public_send(attribute),
134
+ collection:,
135
+ multiple: options[:multiple],
136
+ autocomplete: options[:autocomplete],
137
+ **default_options(attribute, sanitized_options)
138
+ ), &)
139
+ end
140
+
125
141
  def input(method_name)
126
142
  unbound_method = self.class.superclass.instance_method(method_name)
127
143
  unbound_method.bind(self)
128
144
  end
129
145
 
130
- def form_object
131
- @form_object_class&.new object
146
+ def form_object(obj = nil)
147
+ return obj if obj.is_a?(Tramway::BaseForm)
148
+ return object if object.is_a?(Tramway::BaseForm)
149
+
150
+ @form_object_class&.new(obj || object)
132
151
  end
133
152
 
134
153
  def get_value(attribute, options)
135
- options[:value] || form_object&.public_send(attribute).presence || object.presence&.public_send(attribute)
154
+ return options[:value] if options.key?(:value)
155
+
156
+ form_obj = form_object
157
+ form_value = form_object_value(form_obj, attribute)
158
+ return form_value unless form_value.nil?
159
+
160
+ ensure_object_responds!(attribute, form_obj)
161
+ object_value(attribute)
162
+ end
163
+
164
+ def form_object_value(form_obj, attribute)
165
+ return if form_obj.blank?
166
+
167
+ form_obj.public_send(attribute)
168
+ end
169
+
170
+ def ensure_object_responds!(attribute, form_obj)
171
+ return unless object.present? && !object.respond_to?(attribute)
172
+
173
+ form_object_part = form_obj.present? ? "#{form_obj.class} or " : ''
174
+ message = "Neither form object nor object respond to #{attribute}. " \
175
+ "You should define #{attribute} method in #{form_object_part}#{object.class}"
176
+
177
+ raise ArgumentError, message
178
+ end
179
+
180
+ def object_value(attribute)
181
+ return if object.blank?
182
+
183
+ object.public_send(attribute)
136
184
  end
137
185
 
138
186
  def default_options(attribute, options)
187
+ options.merge!(horizontal: true) if @horizontal
188
+ options.merge!(onchange: 'this.form.requestSubmit()') if @remote
189
+
139
190
  {
140
191
  attribute:,
141
192
  label: label_build(attribute, options),
142
- for: for_id(attribute),
143
- options: options.merge(horizontal: @horizontal),
193
+ for: options[:id].presence || for_id(attribute),
194
+ options: options,
144
195
  size: form_size
145
196
  }
146
197
  end
@@ -157,8 +208,10 @@ module Tramway
157
208
 
158
209
  def sanitize_options(options)
159
210
  options.dup.tap do |opts|
160
- opts.delete(:size)
161
- opts.delete('size')
211
+ %i[size multiple autocomplete].each do |key|
212
+ opts.delete(key)
213
+ opts.delete(key.to_s)
214
+ end
162
215
  end
163
216
  end
164
217
 
@@ -1,7 +1,7 @@
1
- .flex.items-start.gap-2{ class: default_container_classes }
1
+ .flex.items-center.gap-2.cursor-pointer{ class: default_container_classes }
2
2
  - classes = "#{size_class(:checkbox_input)} #{checkbox_base_classes}"
3
3
  = @input.call @attribute, **@options.merge(class: classes)
4
4
  - if @label
5
5
  %div
6
- = component('tramway/form/label', for: @for) do
6
+ = component('tramway/form/label', for: @for, options: { class: label_classes }) do
7
7
  = @label
@@ -4,6 +4,20 @@ module Tramway
4
4
  module Form
5
5
  # Tailwind-styled checkbox field
6
6
  class CheckboxComponent < TailwindComponent
7
+ def label_classes
8
+ default_classes = 'cursor-pointer mb-0'
9
+
10
+ case size
11
+ when :small
12
+ default_classes += ' text-sm'
13
+ when :medium
14
+ default_classes += ' text-base'
15
+ when :large
16
+ default_classes += ' text-lg'
17
+ end
18
+
19
+ default_classes
20
+ end
7
21
  end
8
22
  end
9
23
  end
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,2 +1,2 @@
1
- %label{ for: @for, class: form_label_classes }
1
+ %label{ for: @for, class: "#{form_label_classes} #{options[:class]}" }
2
2
  = content
@@ -5,10 +5,11 @@ module Tramway
5
5
  # Form label for all tailwind-styled forms
6
6
  class LabelComponent < Tramway::BaseComponent
7
7
  option :for
8
+ option :options, optional: true, default: -> { {} }
8
9
 
9
10
  def form_label_classes
10
11
  theme_classes(
11
- classic: 'block text-sm font-semibold mb-2 text-white'
12
+ classic: 'block font-semibold text-white'
12
13
  )
13
14
  end
14
15
  end
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:select_input)} #{select_base_classes}"
6
6
  = @input.call(@attribute, @collection, { selected: @value }, @options.merge(class: classes))
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,6 +1,6 @@
1
1
  %div{ class: default_container_classes }
2
2
  - if @label
3
- = component('tramway/form/label', for: @for) do
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
4
  = @label
5
5
  - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
6
  = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -0,0 +1 @@
1
+ %input.focus:outline-none.focus:ring-0.w-full{ type: :text, data: { action: "input->tramway-select#search" } }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ module Form
5
+ module TramwaySelect
6
+ class AutocompleteInputComponent < Tramway::BaseComponent
7
+ end
8
+ end
9
+ end
10
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Tramway
4
4
  module Form
5
- module Multiselect
5
+ module TramwaySelect
6
6
  # Caret icon component
7
7
  class CaretComponent < Tramway::BaseComponent
8
8
  option :direction
@@ -1,3 +1,3 @@
1
- #dropdown{ class: dropdown_classes, data: { action: "click@window->multiselect#closeOnClickOutside" } }
1
+ #dropdown{ class: dropdown_classes, data: { action: "click@window->tramway-select#closeOnClickOutside" } }
2
2
  .flex.flex-col.w-full
3
3
  {{content}}
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Tramway
4
4
  module Form
5
- module Multiselect
5
+ module TramwaySelect
6
6
  # Container for dropdown component
7
7
  class DropdownContainerComponent < Tramway::BaseComponent
8
8
  option :size
@@ -0,0 +1,2 @@
1
+ %div{ class: item_classes, data: { action: "click->tramway-select#toggleItem", text: "{{text}}", value: "{{value}}" } }
2
+ {{text}}
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Tramway
4
4
  module Form
5
- module Multiselect
5
+ module TramwaySelect
6
6
  # Container for item in dropdown component
7
7
  class ItemContainerComponent < Tramway::BaseComponent
8
8
  option :size
@@ -1,3 +1,3 @@
1
1
  .flex-1
2
2
  - classes = "#{@size_class} #{base_classes}"
3
- = @input.call(@attribute, @options.merge(placeholder: "{{placeholder}}", class: classes, data: { 'multiselect-target' => 'hiddenInput' }))
3
+ = @input.call(@attribute, @options.merge(placeholder: "{{placeholder}}", class: classes, data: { 'tramway-select-target' => 'hiddenInput' }))
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Tramway
4
4
  module Form
5
- module Multiselect
5
+ module TramwaySelect
6
6
  # Renders input as select
7
7
  class SelectAsInputComponent < Tramway::BaseComponent
8
8
  option :options
@@ -1,4 +1,4 @@
1
- %div{ class: selected_item_classes, data: { action: "click->multiselect#toggleItem", text: "{{text}}", value: "{{value}}" } }
1
+ %div{ class: selected_item_classes, data: { action: "click->tramway-select#toggleItem", text: "{{text}}", value: "{{value}}" } }
2
2
  .font-normal.leading-none.max-w-full.flex-initial
3
3
  {{text}}
4
4
  .flex.flex-auto.flex-row-reverse
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ module Form
5
+ module TramwaySelect
6
+ # Tailwind-styled tramway select field
7
+ class SelectedItemTemplateComponent < Tramway::BaseComponent
8
+ option :size
9
+ option :multiple
10
+
11
+ SIZE_CLASSES = {
12
+ small: 'text-sm',
13
+ medium: 'text-base',
14
+ large: 'text-lg'
15
+ }.freeze
16
+
17
+ def selected_item_classes
18
+ classes = 'flex justify-center items-center font-medium py-1 px-2 rounded-xl ' \
19
+ 'text-white shadow-md hover:bg-gray-800 cursor-pointer ' \
20
+ 'space-x-1 selected-option ' + SIZE_CLASSES[size].to_s
21
+
22
+ classes += ' border border-gray-700' if multiple
23
+
24
+ theme_classes classic: classes
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ .relative{ class: default_container_classes }
2
+ - if @label
3
+ = component('tramway/form/label', for: @for, class: 'mb-0') do
4
+ = @label
5
+ %div{ role: :combobox, data: tramway_select_hash, id: "#{@for}_tramway_select", class: tramway_select_classes }
6
+ - classes = "#{size_class(:tramway_select_input)} #{select_base_classes}"
7
+ .flex.flex-end.justify-between{ data: dropdown_data, **dropdown_options }
8
+ .flex.flex-row.flex-nowrap.overflow-x-auto.space-x-1.w-full{ data: { "tramway-select-target" => "showSelectedArea" } }
9
+ .flex.flex-col.justify-center
10
+ .caret-down{ data: { "tramway-select-target" => "caretDown" } }
11
+ = component 'tramway/form/tramway_select/caret', size:, direction: :down
12
+ .caret-up.hidden{ data: { "tramway-select-target" => "caretUp" } }
13
+ = component 'tramway/form/tramway_select/caret', size:, direction: :up
@@ -3,8 +3,10 @@
3
3
  module Tramway
4
4
  module Form
5
5
  # Tailwind-styled multi-select field
6
- class MultiselectComponent < TailwindComponent
6
+ class TramwaySelectComponent < TailwindComponent
7
7
  option :collection
8
+ option :multiple, optional: true, default: -> { false }
9
+ option :autocomplete, optional: true, default: -> { false }
8
10
 
9
11
  def before_render
10
12
  @collection = collection.map do |(text, value)|
@@ -13,11 +15,12 @@ module Tramway
13
15
  end
14
16
 
15
17
  # rubocop:disable Metrics/MethodLength
16
- def multiselect_hash
17
- {
18
+ # rubocop:disable Metrics/AbcSize
19
+ def tramway_select_hash
20
+ default = {
18
21
  controller:,
19
22
  selected_item_template:,
20
- multiselect_selected_items_value:,
23
+ tramway_select_selected_items_value:,
21
24
  dropdown_container:,
22
25
  item_container:,
23
26
  items:,
@@ -25,13 +28,33 @@ module Tramway
25
28
  select_as_input:,
26
29
  placeholder:,
27
30
  value:,
28
- on_change:
31
+ on_change:,
32
+ multiple: multiple.to_s,
33
+ autocomplete: autocomplete.to_s
29
34
  }.transform_keys { |key| key.to_s.gsub('_', '-') }
35
+
36
+ default.merge!(autocomplete_input:) if autocomplete
37
+
38
+ default
30
39
  end
31
40
  # rubocop:enable Metrics/MethodLength
41
+ # rubocop:enable Metrics/AbcSize
42
+
43
+ def autocomplete_input
44
+ component 'tramway/form/tramway_select/autocomplete_input'
45
+ end
46
+
47
+ def tramway_select_classes
48
+ classes = ''
49
+
50
+ classes += 'select--multiple ' if multiple
51
+ classes += 'select--autocomplete ' if autocomplete
52
+
53
+ classes
54
+ end
32
55
 
33
56
  def controller
34
- controllers = [:multiselect]
57
+ controllers = ['tramway-select']
35
58
  controllers << external_action.split('->').last.split('#').first if external_action
36
59
  controllers += external_controllers
37
60
  controllers.join(' ')
@@ -39,7 +62,7 @@ module Tramway
39
62
 
40
63
  def dropdown_data
41
64
  (options[:data] || {}).merge(
42
- 'multiselect-target' => 'dropdown',
65
+ 'tramway-select-target' => 'dropdown',
43
66
  'dropdown-container' => dropdown_container,
44
67
  'item-container' => item_container
45
68
  )
@@ -50,13 +73,13 @@ module Tramway
50
73
  end
51
74
 
52
75
  def input_classes
53
- "#{size_class(:multiselect_input)} #{select_base_classes}"
76
+ "#{size_class(:tramway_select_input)} #{select_base_classes}"
54
77
  end
55
78
 
56
79
  private
57
80
 
58
81
  def action
59
- 'click->multiselect#toggleDropdown'
82
+ 'click->tramway-select#toggleDropdown'
60
83
  end
61
84
 
62
85
  def items
@@ -67,17 +90,17 @@ module Tramway
67
90
  options[:placeholder]
68
91
  end
69
92
 
70
- def multiselect_selected_items_value
93
+ def tramway_select_selected_items_value
71
94
  []
72
95
  end
73
96
 
74
97
  def select_as_input
75
98
  component(
76
- 'tramway/form/multiselect/select_as_input',
99
+ 'tramway/form/tramway_select/select_as_input',
77
100
  options:,
78
101
  attribute:,
79
102
  input:,
80
- size_class: size_class(:multiselect_input)
103
+ size_class: size_class(:tramway_select_input)
81
104
  )
82
105
  end
83
106
 
@@ -96,15 +119,15 @@ module Tramway
96
119
  end
97
120
 
98
121
  def selected_item_template
99
- component('tramway/form/multiselect/selected_item_template', size:)
122
+ component 'tramway/form/tramway_select/selected_item_template', size:, multiple:
100
123
  end
101
124
 
102
125
  def dropdown_container
103
- component('tramway/form/multiselect/dropdown_container', size:)
126
+ component('tramway/form/tramway_select/dropdown_container', size:)
104
127
  end
105
128
 
106
129
  def item_container
107
- component('tramway/form/multiselect/item_container', size:)
130
+ component('tramway/form/tramway_select/item_container', size:)
108
131
  end
109
132
  end
110
133
  end
@@ -333,8 +333,12 @@ module.exports = {
333
333
  'bg-red-100',
334
334
  'text-red-800',
335
335
  'space-x-2',
336
+ 'h-5',
337
+ 'w-5',
338
+ 'rounded-full',
339
+ 'mb-0',
336
340
 
337
- // === Multiselect dropdown positioning ===
341
+ // === Tramway select dropdown positioning ===
338
342
  'absolute',
339
343
  'relative',
340
344
  'shadow',
@@ -345,7 +349,7 @@ module.exports = {
345
349
  'border-gray-600',
346
350
  'text-gray-100',
347
351
 
348
- // === Multiselect option styling ===
352
+ // === Tramway select option styling ===
349
353
  'border-b',
350
354
  'border',
351
355
  'border-l',
@@ -378,6 +382,8 @@ module.exports = {
378
382
  'py-1',
379
383
  'flex-nowrap',
380
384
  'overflow-x-auto',
385
+ 'focus:outline-none',
386
+ 'focus:ring-0',
381
387
 
382
388
  // === Flash message styles ===
383
389
  'fixed',
data/docs/AGENTS.md CHANGED
@@ -155,6 +155,9 @@ Use Tramway Button for buttons. Always add a color of the button via `color:` or
155
155
  ### Rule 7
156
156
  Use `tramway_form_for` instead `form_with`, `form_for`
157
157
 
158
+ `tramway_form_for` has an upgraded `select` helper. Use `autocomplete: true` when you need an autocomplete select instead
159
+ of the usual select element. Do not use `autocomplete: true` together with `multiselect: true` on the same field.
160
+
158
161
  Available `tramway_form_for` helpers:
159
162
  - `text_field`
160
163
  - `email_field`
@@ -167,9 +170,13 @@ Available `tramway_form_for` helpers:
167
170
  - `date_field`
168
171
  - `datetime_field`
169
172
  - `time_field`
170
- - `multiselect`
173
+ - `tramway_select`
171
174
  - `submit`
172
175
 
176
+ ### Rule 7.1
177
+ Use `tramway_form_for(remote: true)` only when the form must submit asynchronously and update part of the current page (for example: modal forms, inline edits, or list updates without full page reload).
178
+ For standard create/update flows that redirect and show regular flash messages, keep it synchronous (do not set `remote: true`).
179
+
173
180
  ### Rule 8
174
181
  Inherit all components from Tramway::BaseComponent
175
182
 
@@ -529,7 +536,15 @@ Always `tramway_decorate` and `tramway_form` for creating these types of objects
529
536
  In Tramway Decorators, use `delegate_attributes` method instead of `delegate :something, to: :object`
530
537
 
531
538
  ### Rule 33
532
- In case you want to use container on the page, use `tramway_container` helper instead of creating a component for that or using a plain div with Tailwind classes.
539
+ In case you want to use container on the page, use `tramway_container` helper instead of creating a component for that or using a plain div with Tailwind classes. In case you need to use container in layout view, use `tramway_main_container` for this. Here is example of using `tramway_main_container` inside application layout.
540
+
541
+ ```
542
+ = tramway_main_container do
543
+ - if flash.any?
544
+ = tramway_flash text: flash[:notice].presence || flash[:alert],
545
+ type: flash[:notice].present? ? :will : :rage,
546
+ id: 'flash-container'
547
+ ```
533
548
 
534
549
  ## Controller Patterns
535
550
 
@@ -595,6 +610,17 @@ end
595
610
  = f.submit 'Save'
596
611
  ```
597
612
 
613
+ Autocomplete select example:
614
+
615
+ ```ruby
616
+ = tramway_form_for @user do |f|
617
+ = f.select :role, [["Admin", "admin"], ["Manager", "manager"]], autocomplete: true
618
+ = f.submit 'Save'
619
+ ```
620
+
621
+ `autocomplete: true` renders an autocomplete select. `autocomplete: true` and `multiselect: true` cannot be used together
622
+ in one select field.
623
+
598
624
  `tramway_form_for` supports `horizontal: true` for horizontal form layout.
599
625
 
600
626
  ```ruby
@@ -52,8 +52,8 @@ module Tramway
52
52
  @importmap_path ||= File.join(destination_root, 'config/importmap.rb')
53
53
  end
54
54
 
55
- def importmap_multiselect_pin
56
- 'pin "@tramway/multiselect", to: "tramway/multiselect_controller.js"'
55
+ def importmap_tramway_select_pin
56
+ 'pin "@tramway/tramway-select", to: "tramway/tramway-select_controller.js"'
57
57
  end
58
58
 
59
59
  def agents_file_path
@@ -242,11 +242,11 @@ module Tramway
242
242
  return unless File.exist?(importmap_path)
243
243
 
244
244
  content = File.read(importmap_path)
245
- return if content.include?(importmap_multiselect_pin)
245
+ return if content.include?(importmap_tramway_select_pin)
246
246
 
247
247
  File.open(importmap_path, 'a') do |file|
248
248
  file.write("\n") unless content.empty? || content.end_with?("\n")
249
- file.write("#{importmap_multiselect_pin}\n")
249
+ file.write("#{importmap_tramway_select_pin}\n")
250
250
  end
251
251
  end
252
252
  end
@@ -17,7 +17,7 @@ module Tramway
17
17
  end
18
18
 
19
19
  initializer 'tramway.assets.precompile' do |app|
20
- app.config.assets.precompile += %w[tramway/multiselect.js]
20
+ app.config.assets.precompile += %w[tramway/tramway-select_controller.js]
21
21
  end
22
22
 
23
23
  private
@@ -11,12 +11,12 @@ module Tramway
11
11
  def tramway_form_for(object, *, size: :medium, **options, &)
12
12
  form_object_class = object.is_a?(Tramway::BaseForm) ? object.class : nil
13
13
 
14
- form_for(
15
- object,
16
- *,
17
- **options.merge(builder: Tramway::Form::Builder, size: normalize_form_size(size), form_object_class:),
18
- &
19
- )
14
+ form_for(object, *, **options.merge(
15
+ builder: Tramway::Form::Builder,
16
+ size: normalize_form_size(size),
17
+ form_object_class:,
18
+ remote_submit: options[:remote] || false
19
+ ), &)
20
20
  end
21
21
 
22
22
  def tramway_table(**options, &)
@@ -10,7 +10,7 @@ module Tramway
10
10
  input_options = field_options(field_data).merge(options.compact)
11
11
 
12
12
  case input_type.to_sym
13
- when :select, :multiselect
13
+ when :select, :tramway_select
14
14
  collection = input_options.delete(:collection)
15
15
 
16
16
  public_send(input_name, attribute, collection, **input_options, &)
@@ -23,7 +23,7 @@ module Tramway
23
23
 
24
24
  def field_name(field_data)
25
25
  case field_data.to_sym
26
- when :text_area, :select, :multiselect, :check_box
26
+ when :text_area, :select, :tramway_select, :check_box
27
27
  field_data
28
28
  when :checkbox
29
29
  :check_box
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '3.0.0.1'
4
+ VERSION = '3.0.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tramway
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.1
4
+ version: 3.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kalashnikovisme
@@ -138,8 +138,8 @@ files:
138
138
  - MIT-LICENSE
139
139
  - README.md
140
140
  - Rakefile
141
- - app/assets/javascripts/tramway/multiselect_controller.js
142
141
  - app/assets/javascripts/tramway/table_row_preview_controller.js
142
+ - app/assets/javascripts/tramway/tramway-select_controller.js
143
143
  - app/components/tailwind_component.html.haml
144
144
  - app/components/tailwind_component.rb
145
145
  - app/components/tramway/actions_buttons_container_component.html.haml
@@ -182,18 +182,6 @@ files:
182
182
  - app/components/tramway/form/file_field_component.rb
183
183
  - app/components/tramway/form/label_component.html.haml
184
184
  - app/components/tramway/form/label_component.rb
185
- - app/components/tramway/form/multiselect/caret_component.html.haml
186
- - app/components/tramway/form/multiselect/caret_component.rb
187
- - app/components/tramway/form/multiselect/dropdown_container_component.html.haml
188
- - app/components/tramway/form/multiselect/dropdown_container_component.rb
189
- - app/components/tramway/form/multiselect/item_container_component.html.haml
190
- - app/components/tramway/form/multiselect/item_container_component.rb
191
- - app/components/tramway/form/multiselect/select_as_input_component.html.haml
192
- - app/components/tramway/form/multiselect/select_as_input_component.rb
193
- - app/components/tramway/form/multiselect/selected_item_template_component.html.haml
194
- - app/components/tramway/form/multiselect/selected_item_template_component.rb
195
- - app/components/tramway/form/multiselect_component.html.haml
196
- - app/components/tramway/form/multiselect_component.rb
197
185
  - app/components/tramway/form/number_field_component.html.haml
198
186
  - app/components/tramway/form/number_field_component.rb
199
187
  - app/components/tramway/form/select_component.html.haml
@@ -204,6 +192,20 @@ files:
204
192
  - app/components/tramway/form/text_field_component.rb
205
193
  - app/components/tramway/form/time_field_component.html.haml
206
194
  - app/components/tramway/form/time_field_component.rb
195
+ - app/components/tramway/form/tramway_select/autocomplete_input_component.html.haml
196
+ - app/components/tramway/form/tramway_select/autocomplete_input_component.rb
197
+ - app/components/tramway/form/tramway_select/caret_component.html.haml
198
+ - app/components/tramway/form/tramway_select/caret_component.rb
199
+ - app/components/tramway/form/tramway_select/dropdown_container_component.html.haml
200
+ - app/components/tramway/form/tramway_select/dropdown_container_component.rb
201
+ - app/components/tramway/form/tramway_select/item_container_component.html.haml
202
+ - app/components/tramway/form/tramway_select/item_container_component.rb
203
+ - app/components/tramway/form/tramway_select/select_as_input_component.html.haml
204
+ - app/components/tramway/form/tramway_select/select_as_input_component.rb
205
+ - app/components/tramway/form/tramway_select/selected_item_template_component.html.haml
206
+ - app/components/tramway/form/tramway_select/selected_item_template_component.rb
207
+ - app/components/tramway/form/tramway_select_component.html.haml
208
+ - app/components/tramway/form/tramway_select_component.rb
207
209
  - app/components/tramway/native_text_component.html.haml
208
210
  - app/components/tramway/native_text_component.rb
209
211
  - app/components/tramway/nav/item/button_component.html.haml
@@ -1,2 +0,0 @@
1
- %div{ class: item_classes, data: { action: "click->multiselect#toggleItem", text: "{{text}}", value: "{{value}}" } }
2
- {{text}}
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Tramway
4
- module Form
5
- module Multiselect
6
- # Tailwind-styled multi-select field
7
- class SelectedItemTemplateComponent < Tramway::BaseComponent
8
- option :size
9
-
10
- SIZE_CLASSES = {
11
- small: 'text-sm',
12
- medium: 'text-base',
13
- large: 'text-lg'
14
- }.freeze
15
-
16
- def selected_item_classes
17
- theme_classes(
18
- classic: 'flex justify-center items-center font-medium py-1 px-2 rounded-xl border ' \
19
- 'text-white border-gray-700 shadow-md hover:bg-gray-800 cursor-pointer space-x-1 ' \
20
- 'selected-option ' + SIZE_CLASSES[size].to_s
21
- )
22
- end
23
- end
24
- end
25
- end
26
- end
@@ -1,13 +0,0 @@
1
- .relative{ class: default_container_classes }
2
- - if @label
3
- = component('tramway/form/label', for: @for) do
4
- = @label
5
- %div{ role: :combobox, data: multiselect_hash, id: "#{@for}_multiselect" }
6
- - classes = "#{size_class(:multiselect_input)} #{select_base_classes}"
7
- .flex.flex-end.justify-between{ data: dropdown_data, **dropdown_options }
8
- .flex.flex-row.flex-nowrap.overflow-x-auto.space-x-1{ data: { "multiselect-target" => "showSelectedArea" } }
9
- .flex.flex-col.justify-center
10
- .caret-down{ data: { "multiselect-target" => "caretDown" } }
11
- = component 'tramway/form/multiselect/caret', size: size, direction: :down
12
- .caret-up.hidden{ data: { "multiselect-target" => "caretUp" } }
13
- = component 'tramway/form/multiselect/caret', size: size, direction: :up