turbo_form 0.9.1 → 0.10.0

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: e1637d6cbac8d370efc21308cc1ace27f278e2153fde411829fa4031d5980a54
4
- data.tar.gz: 0247d756f8f6217149c1bd24e30acca602b0fa9a7af69acc7d07d0228a1a727c
3
+ metadata.gz: dbeda5baf3151daeee78f081393fcb7ad320a927c7c928b1a1af4940afe02580
4
+ data.tar.gz: 62f16acb9a560d7b7abb946c02d72c6a021a7e790ae1d8abf984a5627e44a0f0
5
5
  SHA512:
6
- metadata.gz: b53a713122f8443e248f787953724cc0204aca67ae53b994122838fabaf7c9bf3300c720557f8a7818dba2154495d5f1ec98c289458f5456245c7f8550120349
7
- data.tar.gz: fab34492c6b7b1b7f01efea76aa25a157d96c7ccaaed682c9e15f424e3e4512cc37543119caa9dcc381c52208cb891c73ee727d6a1c54c1c435610a0b8e9c8b4
6
+ metadata.gz: fa3df3560e87aa90ad79e86664c14cfc1a4d44cd28474e43b3bfbc66032fe676531c444d3d3716421628a3590f7b7c32e6eb3f04d77ca324f4bd223630a1b225
7
+ data.tar.gz: 4de2fedf514e70e5f37f5fa49e74bec3389dfed6626eb848180d90b2fa216963db372ab55bad53f0b6cedddaac9b93d3c29ed80045960ac356226311ae121996
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Dynamic forms for Rails, by convention.
4
4
 
5
+ ![title image](title_image.png)
6
+
5
7
  A form that needs to change as it is filled in — a dependent dropdown, a section
6
8
  that appears once you pick a type — usually costs you a route, a controller
7
9
  action, a Stimulus controller and some wiring. This gem asks for two words
@@ -68,9 +70,9 @@ the route concern listens.
68
70
  ```
69
71
 
70
72
  `true` picks the element's natural event: `change` for a select, `click` for a
71
- button, `input` for everything else. Name an event when you want
72
- something else — `:blur` on text fields is usually what you want, since the
73
- default fires on every keystroke. Any event name works, including custom ones
73
+ button, `input` for everything else. An `input` event fires on every keystroke,
74
+ so it waits for a 300ms pause before sending the form. Name an event when you
75
+ want something else, like `:blur`. Any event name works, including custom ones
74
76
  dispatched by other Stimulus controllers — `dynamic_trigger: "autocomplete:selected"`
75
77
  — as long as the event is dispatched on the field or bubbles up from inside it.
76
78
 
@@ -81,6 +83,20 @@ the select and date families where Rails keeps HTML attributes in a separate has
81
83
  <%= f.collection_select :category_id, Category.all, :id, :name, dynamic_trigger: true %>
82
84
  ```
83
85
 
86
+ ### `dynamic_debounce:` on a field
87
+
88
+ ```erb
89
+ <%= f.text_field :name, dynamic_trigger: true, dynamic_debounce: 500 %> <%# wait longer %>
90
+ <%= f.range_field :size, dynamic_trigger: true, dynamic_debounce: false %> <%# don't wait %>
91
+ ```
92
+
93
+ How many milliseconds a trigger waits for its events to pause before sending
94
+ the form, as it stands by then. `input` events wait 300ms unless told
95
+ otherwise; every other event sends at once unless it names a wait.
96
+
97
+ Text typed into a field while its request is out is kept when the response
98
+ arrives, and a response overtaken by a later one to the same URL isn't rendered.
99
+
84
100
  ### `dynamic_action:` on a field
85
101
 
86
102
  ```erb
@@ -217,8 +233,9 @@ it doesn't care what the trigger changed.
217
233
 
218
234
  It handles the common case well and gets out of the way otherwise.
219
235
 
220
- - **No debouncing, request cancellation or loading state** beyond `aria-busy`.
221
- Write the action by hand when you need those — that path is still open.
236
+ - **No request cancellation or loading state** beyond `aria-busy`. An overtaken
237
+ request still runs on the server; its response is just not rendered. Write
238
+ the action by hand when you need more — that path is still open.
222
239
  - **Resourceful pages only.** The form's URL comes from its model, so a page
223
240
  whose `new` or `edit` isn't the model's own route has nowhere to send it.
224
241
  `dynamic_action:` is the way out, at the cost of writing the action and its
@@ -33,21 +33,34 @@ class FormRefresh {
33
33
  try {
34
34
  const response = await Turbo.fetch(this.location, { method: "PATCH", body: new FormData(this.formElement), headers: { ...this.target.headers, "X-CSRF-Token": csrfToken() } })
35
35
 
36
- await this.target.render(response)
36
+ if (!this.superseded) await this.target.render(response)
37
37
  } finally {
38
38
  this.requestFinished()
39
39
  }
40
40
  }
41
41
 
42
42
  requestStarted() {
43
+ latestRefreshes(this.formElement).set(this.location.href, this)
43
44
  markAsBusy(this.formElement, ...this.target.elements)
44
45
  Turbo.navigator.formSubmissionStarted(this)
45
46
  }
46
47
 
47
48
  requestFinished() {
48
- clearBusyState(this.formElement, ...this.target.elements)
49
+ if (!this.superseded) clearBusyState(this.formElement, ...this.target.elements)
49
50
  Turbo.navigator.formSubmissionFinished(this)
50
51
  }
52
+
53
+ // Responses can arrive out of order. Only the last one sent to a URL is
54
+ // rendered, so the page ends up answering the form as it was last sent.
55
+ get superseded() {
56
+ return latestRefreshes(this.formElement).get(this.location.href) != this
57
+ }
58
+ }
59
+
60
+ const refreshes = new WeakMap()
61
+
62
+ function latestRefreshes(form) {
63
+ return refreshes.get(form) ?? refreshes.set(form, new Map).get(form)
51
64
  }
52
65
 
53
66
  class PageTarget {
@@ -146,10 +159,24 @@ class TriggerObserver {
146
159
  const trigger = target.closest?.(triggerSelector)
147
160
  const form = trigger?.form
148
161
 
149
- if (form && (trigger.dataset.turboFormAction || form.dataset.turboFormUrl) && eventFor(trigger) == type) this.delegate.triggerFired(form, trigger)
162
+ if (form && (trigger.dataset.turboFormAction || form.dataset.turboFormUrl) && eventFor(trigger) == type) debounce(trigger, type, () => this.delegate.triggerFired(form, trigger))
150
163
  }
151
164
  }
152
165
 
166
+ const timers = new WeakMap()
167
+
168
+ // An input event, fired on every keystroke, waits for a 300ms pause before
169
+ // sending the form as it stands by then. data-turbo-form-debounce names a wait
170
+ // of its own for any event, or 0 for none. A trigger gone from the page by then,
171
+ // as when the user has moved on, sends nothing.
172
+ function debounce(trigger, type, fire) {
173
+ const wait = Number(trigger.dataset.turboFormDebounce ?? (type == "input" ? 300 : 0))
174
+ if (!wait) return fire()
175
+
176
+ clearTimeout(timers.get(trigger))
177
+ timers.set(trigger, setTimeout(() => trigger.isConnected && fire(), wait))
178
+ }
179
+
153
180
  // A trigger with no event named fires on its element's natural one.
154
181
  function eventFor(trigger) {
155
182
  const named = trigger.dataset.turboFormTrigger
@@ -183,4 +210,13 @@ function countVisit() {
183
210
  root.turboFormVisits = Number(root.turboFormVisits || 0) + 1
184
211
  }
185
212
 
213
+ // A response answers the form as it was sent, which may be a keystroke or more
214
+ // behind the field being typed into. That field keeps what it has.
215
+ function keepFocusedValue(event) {
216
+ const { target, detail } = event
217
+
218
+ if (target == document.activeElement && target.form?.querySelector(triggerSelector) && detail.attributeName == "value") event.preventDefault()
219
+ }
220
+
186
221
  new TriggerObserver({ triggerFired: refresh }, document).start()
222
+ addEventListener("turbo:before-morph-attribute", keepFocusedValue)
@@ -4,13 +4,14 @@ module TurboForm
4
4
  # f.text_field :name, dynamic_trigger: true # re-render on the default event
5
5
  # f.select :category, categories, {}, dynamic_trigger: :blur
6
6
  # f.select :flavor, flavors, {}, dynamic_action: summary_widgets_path # stream from a URL of its own
7
+ # f.text_field :name, dynamic_trigger: true, dynamic_debounce: 500 # once typing pauses for 500ms
7
8
  #
8
9
  # `dynamic_trigger:` always has to end up as a data attribute, but where it
9
10
  # *arrives* depends on the helper. Most treat their `options` hash as the tag's
10
11
  # HTML attributes; the select and date families keep those in a trailing
11
12
  # `html_options` instead, which is where these overrides earn their keep.
12
13
  module FormBuilder
13
- DYNAMIC_OPTIONS = %i[dynamic_trigger dynamic_action].freeze
14
+ DYNAMIC_OPTIONS = %i[dynamic_trigger dynamic_action dynamic_debounce].freeze
14
15
 
15
16
  def select(method, choices = nil, options = {}, html_options = {}, &block)
16
17
  super(method, choices, *hoist_trigger(options, html_options), &block)
@@ -85,17 +86,20 @@ module TurboForm
85
86
  def self.absorb_trigger(attributes, template)
86
87
  return attributes unless attributes.keys.intersect?(DYNAMIC_OPTIONS)
87
88
 
88
- trigger, action = attributes.values_at(*DYNAMIC_OPTIONS)
89
+ trigger, action, debounce = attributes.values_at(*DYNAMIC_OPTIONS)
89
90
  attributes = attributes.except(*DYNAMIC_OPTIONS)
90
91
  trigger ||= action.present?
91
92
  return attributes unless trigger
92
93
 
93
- attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger), turbo_form_action: (template.url_for(action) if action) })
94
+ attributes.merge(data: { **attributes[:data].to_h, turbo_form_trigger: trigger_event(trigger), turbo_form_action: (template.url_for(action) if action), turbo_form_debounce: debounce_wait(debounce) })
94
95
  end
95
96
 
96
97
  # Left blank for `true`, which the script reads as the element's own
97
98
  # default: `change` for a select, `input` for a text field, `click` for a
98
99
  # button.
99
100
  def self.trigger_event(trigger) = trigger == true ? '' : trigger.to_s
101
+
102
+ # The script debounces input events on its own; `false` turns that off.
103
+ def self.debounce_wait(debounce) = debounce == false ? 0 : debounce
100
104
  end
101
105
  end
@@ -1,3 +1,3 @@
1
1
  module TurboForm
2
- VERSION = '0.9.1'
2
+ VERSION = '0.10.0'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbo_form
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Cohen