layered-ui-rails 0.26.0 → 0.27.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: 63d902f842afed6b1e9d7784677a2a748d3775ef9884bb27f9ed6439133b3638
4
- data.tar.gz: 25d8cf1f06bfadd24dc8e97efd1cc14f4d818ce5285e4da980dcd65f21d96ea9
3
+ metadata.gz: 9dfe5e770b8f6bbc52666be84b646ebe235fdf2cf1e15f7d9759fb1b880bc5be
4
+ data.tar.gz: d829e69ff914ace72a2e80da0ff468459dfbac0942cea828a6e28693ac46126c
5
5
  SHA512:
6
- metadata.gz: 97a9c888d83d7a4a2317eae38fdd77e91dcb8eeab33b76b85bb7dc8afb9c7d8349ff5f9b693633f16218b8a906d3f2029b1753a6f730910a83f47169d523f4e0
7
- data.tar.gz: 80572d1c7b424944cc9a7b324b749a01bc3708f0251eacc0ea2f4faa6be864bfbc8249b4ea6ed68961878a2f29bd1d516acb84f6af7cd50d73123bb09302c062
6
+ metadata.gz: e288640f0616aa44e630c238e17eb5213ab7ddedf0d7d3cd334b010481f51b4726c681c969dc6ed0b6ccece28471e9b8c84fffa9dba713b395f9eec1fdf2f747
7
+ data.tar.gz: 6c3e63f142ccdff6917c7e792f8cdf94f0446ac50134db3b639ea96faff354cb5b138178133e5b0acff3f7234beed2a20d71876e6143de1e2550e8e9cf60978e
@@ -196,33 +196,48 @@ Drag handle for resizing the panel width on desktop.
196
196
 
197
197
  ## Search form (`l-ui--search-form`)
198
198
 
199
- Manages multi-scope search forms with parameter preservation and Turbo frame support.
199
+ Searches as the user types, and manages multi-scope search forms with parameter preservation and Turbo frame support.
200
200
 
201
- **Values:** `scope` (String, default `"q"`)
202
- **Actions:** `preserve`, `clear`, `rewriteLink`
201
+ **Targets:** `input`, `clear`
202
+ **Values:** `scope` (String, default `"q"`), `pageParam` (String, default `"page"` - Pagy's page key for this collection, given rather than derived from `scope`), `live` (Boolean, default `false`), `minChars` (Number, default `0`), `count` (Number, default `-1` - meaning no count was given, so nothing is announced)
203
+ **Actions:** `search`, `clearSearch`, `toggleClear`, `submitNow`, `preserve`, `clear`, `rewriteLink`
203
204
 
204
205
  ```html
205
206
  <form data-controller="l-ui--search-form"
206
207
  data-l-ui--search-form-scope-value="q"
208
+ data-l-ui--search-form-page-param-value="page"
209
+ data-l-ui--search-form-live-value="true"
210
+ data-l-ui--search-form-count-value="12"
207
211
  data-action="submit->l-ui--search-form#preserve"
208
- data-turbo-frame="results">
209
- <!-- search fields -->
210
- <button type="button" data-action="click->l-ui--search-form#clear">
211
- Clear
212
- </button>
212
+ data-turbo-frame="results"
213
+ data-turbo-action="replace"
214
+ role="search" aria-label="Search">
215
+ <input data-l-ui--search-form-target="input"
216
+ data-action="input->l-ui--search-form#search keydown.esc->l-ui--search-form#clearSearch">
217
+ <button type="button" data-l-ui--search-form-target="clear"
218
+ data-action="l-ui--search-form#clearSearch" hidden>Clear search</button>
213
219
  </form>
214
220
  ```
215
221
 
216
- When multiple search forms exist on one page (each with a different `scope` value), submitting one form automatically preserves the other forms' query parameters. The `page` param and any scoped page param matching the scope (e.g. `users_page` for scope `users_q`) are reset on submit so pagination returns to page 1.
222
+ - **`search`** - debounced (300ms, longer than the combobox's, since a search here re-renders a whole collection). Submits with `requestSubmit()`, not `submit()`, so the `submit` event fires and `preserve` still runs. A keystroke arriving mid-request is fine: Turbo stops a frame submission already in flight, so no `AbortController` is needed here.
223
+ - **`toggleClear`** - shows or hides the clear button to match the field. Only wired on a form that does not search as you type, where nothing else runs on a keystroke; `search` does it as part of its own work.
224
+ - **`clearSearch`** - the ✕ and Escape in the field. Skips the debounce, and focuses the input *before* the button hides itself, so focus does not fall to the body (WCAG 2.4.3).
225
+ - **Caret preservation** - a frame render replaces the form and the input being typed into. The value, selection and focus are stashed in module state (the only thing that survives the swap), keyed per form, and restored by the controller that connects in its place. Only a render answering one of this form's own searches restores focus, so a sort link or a page link never steals it; entries older than 5s are ignored.
226
+ - **Announcements** - after a search, the result count goes to the layout's `#l-ui-live-region`, which sits outside every frame. The term is included in the message because a live region does not re-announce text identical to what it already holds.
227
+ - **`clear`** - for a *hand-built* clear link beside an unframed form, which has no controller of its own (`l_ui_search_form` wires the in-field clear button to `clearSearch` instead, on any framed form, live or not). It rewrites the clicked link's href so the other scopes' params survive the navigation; give the link its own `data-turbo-frame` and `data-turbo-action` to keep it inside the frame.
228
+
229
+ When multiple search forms exist on one page (each with a different `scope` value), submitting one form automatically preserves the other forms' query parameters. This form's own `pageParam` is reset on submit so pagination returns to page 1 - give each collection its own (`page_param: "users_page"` on the helper, `l_ui__search_form_page_param_value` on the frame), since Pagy's `page_key:` is the host's choice and cannot be inferred from the Ransack `search_key`.
217
230
 
218
231
  **`rewriteLink`** - merges current URL params into a clicked link's href. Useful for pagination links inside Turbo Frames where the server-rendered href may be missing params from other scopes. Attach to a parent element (e.g. the Turbo Frame):
219
232
 
220
233
  ```html
221
234
  &lt;%= turbo_frame_tag "users_collection", data: { turbo_action: "advance",
222
235
  controller: "l-ui--search-form", l_ui__search_form_scope_value: "users_q",
236
+ l_ui__search_form_page_param_value: "users_page",
223
237
  action: "click->l-ui--search-form#rewriteLink" } do %&gt;
224
238
  &lt;%= l_ui_search_form(@users_q, url: users_path, fields: [:name, :email],
225
- clear: true, turbo_frame: "users_collection") %&gt;
239
+ live: true, count: @users_pagy.count,
240
+ page_param: "users_page", turbo_frame: "users_collection") %&gt;
226
241
  &lt;%= l_ui_table(@users, ..., query: @users_q, turbo_frame: "users_collection") %&gt;
227
242
  &lt;%= l_ui_pagy(@users_pagy) %&gt;
228
243
  &lt;% end %&gt;
@@ -192,6 +192,9 @@ Always combine the `l-ui-surface` base class with any modifiers (e.g. `l-ui-surf
192
192
  .l-ui-select-container Select wrapper (custom arrow)
193
193
 
194
194
  .l-ui-search-inline Inline search form layout
195
+ .l-ui-search-control Field + clear button wrapper (own positioning context)
196
+ .l-ui-search-control--clearable Leaves room in the field for the clear button
197
+ .l-ui-search-control__clear Clear button inside the field's trailing edge (24x24)
195
198
 
196
199
  .l-ui-radio Radio button group
197
200
  .l-ui-radio__item Radio item wrapper
@@ -131,8 +131,9 @@ l_ui_pagy(pagy)
131
131
 
132
132
  ```ruby
133
133
  l_ui_search_form(query, url: nil, fields: [], predicate: :cont, combinator: :or,
134
- label: "Search", placeholder: nil, button: "Search",
135
- clear: nil, turbo_frame: nil, html: {}, &block)
134
+ label: "Search", placeholder: nil, button: nil, clear: nil,
135
+ live: false, count: nil, min_chars: nil,
136
+ page_param: "page", turbo_frame: nil, html: {}, &block)
136
137
  ```
137
138
 
138
139
  - `query` (Ransack::Search) - the `@q` object from controller
@@ -140,19 +141,35 @@ l_ui_search_form(query, url: nil, fields: [], predicate: :cont, combinator: :or,
140
141
  - `fields` (Array<Symbol>) - fields to search (simple mode)
141
142
  - `predicate` (Symbol) - ransack predicate, default `:cont`
142
143
  - `combinator` (Symbol) - `:or` or `:and` for multiple fields
143
- - `label` (String) - hidden label for the search input
144
+ - `label` (String) - hidden label for the search input; also labels the `search` landmark in live mode
144
145
  - `placeholder` (String) - input placeholder
145
- - `button` (String) - submit button text
146
- - `clear` (String|Boolean) - clear button text; `true` for default, `false` to hide
146
+ - `button` (String) - text for a *visible* submit button. In live mode, omitting it renders a submit that is present but neither seen nor tabbed to, which is what Enter and a JS-less browser submit through. A non-live form always gets a visible button, since it needs one that can be pressed
147
+ - `clear` (String|Boolean) - the clear button built into the field; defaults to on whenever `turbo_frame:` is given, `false` to omit, a string to name it. Clearing means clearing the field and submitting, which the `l-ui--search-form` controller does, and that goes on any framed form - so a non-live form clears too. Asking for it without a frame raises, since there is no controller to drive it
148
+ - `live` (Boolean, default `false`) - search as the user types. **Opt-in, and requires `turbo_frame:`** - passing `live: true` without one raises, since typing into an unframed form would mean a page load per keystroke. A frame alone does not turn it on: where a response lands and whether typing submits are separate decisions
149
+ - `count` (Integer) - size of the result set, announced after each search. Live mode only. Without it nothing is announced, and `live: true` with no `count:` logs a warning in development
150
+ - `min_chars` (Integer) - hold the search back until the term is this long. Live mode only
151
+ - `page_param` (String, default `"page"`) - Pagy's page key for this collection, dropped when carrying other collections' params across a submit. Pagy's `page_key:` is the host's choice and bears no fixed relation to the Ransack `search_key`, so it is passed rather than guessed
147
152
  - `turbo_frame` (String) - turbo frame to target
148
153
  - `html` (Hash) - additional form HTML attributes
149
154
  - `&block` - custom form markup (overrides simple mode)
150
155
 
151
- Simple mode:
156
+ `count:` and `min_chars:` describe typing, so they mean nothing to a form that submits when asked to: passing one alongside `live: false` raises rather than being quietly dropped. `clear:` is not one of these - it follows the controller, not live mode.
157
+
158
+ A live search sets `data-turbo-action="replace"` on the *form*, so a keystroke does not push a history entry. Turbo reads the action from the submitter, then the form, then the frame, so sort links and pagination inside the same frame still advance.
159
+
160
+ Simple mode - submits when asked to, with a visible Search button:
152
161
 
153
162
  ```erb
154
163
  <%= l_ui_search_form(@q, url: users_path, fields: [:name, :email],
155
- placeholder: "Search users", clear: true, turbo_frame: "users") %>
164
+ placeholder: "Search users", turbo_frame: "users") %>
165
+ ```
166
+
167
+ Searching as you type, with a clear button in the field:
168
+
169
+ ```erb
170
+ <%= l_ui_search_form(@q, url: users_path, fields: [:name, :email],
171
+ placeholder: "Search users", turbo_frame: "users", live: true,
172
+ count: @pagy.count, page_param: "users_page") %>
156
173
  ```
157
174
 
158
175
  Custom mode:
@@ -166,6 +183,29 @@ Custom mode:
166
183
  <% end %>
167
184
  ```
168
185
 
186
+ ## Search control (requires ransack gem)
187
+
188
+ ```ruby
189
+ l_ui_search_control(form, attribute, label: "Search", placeholder: nil,
190
+ clear: nil, button: nil, live: false)
191
+ ```
192
+
193
+ The control on its own - field, clear button and submit - for a caller who passes a block to `l_ui_search_form` and builds the row by hand. Both `live:` and `clear:` need the `l-ui--search-form` controller, which `l_ui_search_form` puts on the form when given a `turbo_frame:`. A hand-built form may not carry it, so `live:` is off unless asked for. `clear:` follows `live:` - `live: true` vouches for the controller being there - so pass `clear: true` explicitly for a form that carries the controller but should not submit as you type.
194
+
195
+ ```erb
196
+ <%= l_ui_search_form(@q, url: users_path, turbo_frame: "users", live: true,
197
+ count: @pagy.count) do |f| %>
198
+ <div class="l-ui-search-inline">
199
+ <%= my_filter_hidden_fields %>
200
+ <%= l_ui_search_control(f, :name_or_email_cont, placeholder: "Search users", live: true) %>
201
+ </div>
202
+ <% end %>
203
+ ```
204
+
205
+ The input is deliberately `type="text"`, not `type="search"`: WebKit draws its own cancel button on the latter, which would sit under this one and take the same tap.
206
+
207
+ Left non-live the Search button is visible and the field still clears. Only a form with no `turbo_frame:` goes without a clear button; to put one beside such a form, build the link yourself and give it `data-action="click->l-ui--search-form#clear"`, which rewrites the href to carry any other scope's params.
208
+
169
209
  ## Sort link (requires ransack gem)
170
210
 
171
211
  ```ruby
data/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [0.27.0] - 2026-09-20
6
+
7
+ ### Added
8
+
9
+ - The search box can search as you type. Pass `live: true` with a `turbo_frame:` and `l_ui_search_form` debounces a submit as the field changes, so results narrow without a button to press. Each search replaces the history entry rather than pushing one, so Back leaves the collection instead of replaying the term letter by letter - the URL still carries the term, so a search stays shareable and survives a reload. The action is set on the form, which Turbo reads before the frame's own, so sort links and pagination inside the same frame still advance.
10
+ - The field carries its own clear button, built into its trailing edge and shown only once there is something to clear. Escape in the field clears it too, and focus returns to the input rather than falling to the body. `clear: false` omits the button; a string names it.
11
+ - `l_ui_search_control` renders the control - field, clear button and submit - on its own, for a caller who passes a block to `l_ui_search_form` and builds the row by hand.
12
+ - `count:` has the size of the result set announced after each search. Without it nothing is announced: a screen reader is otherwise told nothing when results change with no button press. The message goes to the layout's live region, which sits outside every frame, since a live region replaced in the same render as its own text is not reliably announced.
13
+ - `min_chars:` holds a search back until the term is worth asking about.
14
+ - The caret survives the frame render that answers a search, so a typed word is never interrupted mid-letter.
15
+ - `page_param:` names the collection's Pagy page key, which is reset when a search carries the other collections' params across a submit. It was previously guessed from the Ransack `search_key` (`users_q` -> `users_page`), which is a convention nothing enforces: a host whose `page_key:` did not follow it leaked the page param into every preserved submit.
16
+
17
+ ### Fixed
18
+
19
+ - The clear button keeps up with the field on a form that does not search as you type. Nothing ran on a keystroke there, so it stayed as the server rendered it: still showing over a field the user had just emptied, and still hidden when they typed a term into an empty one.
20
+ - A response no longer pulls focus back into the search field. Whether the field had focus was a snapshot taken when the request went out, so a user who tabbed to something outside the frame while it was in flight was dragged back (WCAG 3.2.5). Focus is now taken back only when the render left it nowhere - the input that held it was destroyed with the frame - which a control outside the frame, still alive and still focused, is not.
21
+ - Clearing the field no longer risks the old term coming back. Clearing changes the field without an input event, and the submit it asks for is declined when the empty term is already the one in flight, so nothing recorded the clear; the pending response then restored the text that had been typed and searched for it again.
22
+ - The result count is announced against the term it actually counted. A response that answered `a` while `ada` was being typed announced its count as results for `ada`. Announcements now wait until the field and the answered term agree, which is the point at which the count is the answer to what the user can see.
23
+
24
+ ### Changed
25
+
26
+ - **Breaking.** Searching as you type is opt-in. `live:` defaults to `false` and is no longer inferred from `turbo_frame:`: a frame says where a response lands, and whether typing should submit at all is a separate decision about the collection. `live: true` without a `turbo_frame:` raises. Existing framed call sites keep their current behaviour and need `live: true` added to take up the new one.
27
+ - **Breaking.** `count:` and `min_chars:` describe typing, so they mean nothing to a form that submits when asked to: passing one alongside `live: false` now raises rather than being quietly dropped - the same treatment `l_ui_combobox` gives an option it cannot take.
28
+ - **Breaking.** `l_ui_search_control` defaults to `live: false`, matching `l_ui_search_form`. It exists for hand-built forms, which are exactly the forms that may not carry the controller, so the default that works without one is the right one. Its `clear:` follows `live:`, since `live: true` vouches for the controller being present; pass `clear: true` for a form that carries the controller but should not submit as you type.
29
+ - `live: true` with no `count:` logs a warning in development. Nothing is announced when results change without it, which is an accessibility hole rather than a default, and it had no symptom a sighted developer would notice.
30
+ - **Breaking.** `button:` now defaults to `nil`. In live mode that renders a submit which is present but neither seen nor tabbed to - it is what Enter in the field and a browser with no JavaScript submit through. A form that submits only when asked to still gets a visible button, since it needs one that can be pressed; pass a string to name it.
31
+ - **Breaking.** `clear:` now means the clear button inside the field, and defaults to on whenever `turbo_frame:` is given - it follows the `l-ui--search-form` controller, which goes on every framed form to preserve the other scopes, not live mode. A form that submits when asked to clears from inside the field too. Asking for `clear:` without a frame raises, since there is then no controller to drive it. The separate outline clear button is **gone**, along with the "requires an explicit `url:`" error it raised; the controller's `clear` action remains for a hand-built clear link beside an unframed form.
32
+ - **Breaking.** A live search form is now a `search` landmark, labelled from `label:`.
33
+
5
34
  ## [0.26.0] - 2026-09-19
6
35
 
7
36
  ### Added
@@ -1559,6 +1559,37 @@ pre.l-ui-surface {
1559
1559
  }
1560
1560
  }
1561
1561
 
1562
+ /* The field and its clear button share a positioning context of their own, so
1563
+ the button sits inside the input's edge whatever else the row holds. */
1564
+ .l-ui-search-control {
1565
+ @apply relative flex-1;
1566
+ }
1567
+
1568
+ /* Room for the button, so a long term scrolls under it rather than behind it. */
1569
+ .l-ui-search-control--clearable .l-ui-form__field {
1570
+ @apply pr-10;
1571
+ }
1572
+
1573
+ /* 24x24 is the smallest target WCAG 2.2 SC 2.5.8 accepts; the mark inside is
1574
+ 16px, so the rest is padding rather than a heavier glyph. Drawn in
1575
+ currentColor from the foreground tokens, so it needs no dark-mode invert. */
1576
+ .l-ui-search-control__clear {
1577
+ @apply absolute top-1/2 right-2 flex items-center justify-center
1578
+ w-6 h-6
1579
+ p-0
1580
+ text-foreground-muted hover:text-foreground
1581
+ bg-transparent
1582
+ border-0 rounded-full
1583
+ focus-ring
1584
+ transition-colors
1585
+ cursor-pointer;
1586
+ transform: translateY(-50%);
1587
+ }
1588
+
1589
+ .l-ui-search-control__clear[hidden] {
1590
+ @apply hidden;
1591
+ }
1592
+
1562
1593
  /* Switch */
1563
1594
 
1564
1595
  .l-ui-switch {
@@ -11,14 +11,43 @@ module Layered
11
11
  # render "layered_ui/shared/search_field", form: f, field: :name_cont, label: "Name"
12
12
  # f.submit "Go", class: "l-ui-button l-ui-button--primary"
13
13
  # end
14
- def l_ui_search_form(query, url: nil, fields: [], predicate: :cont, combinator: :or, label: "Search", placeholder: nil, button: "Search", clear: nil, turbo_frame: nil, html: {}, &block)
14
+ #
15
+ # Pass live: true (with a turbo_frame:) to search as the user types: no
16
+ # Search button to press, a clear button in the field, and count: to have
17
+ # the size of the result set announced. It is opt-in rather than inferred
18
+ # from turbo_frame:, which only says where a response lands.
19
+ #
20
+ # Left alone the form submits when asked to. It still gets the clear
21
+ # button: clearing means clearing the field and submitting, which the
22
+ # l-ui--search-form controller does, and that is attached to any framed
23
+ # form. Only an unframed form goes without - for a clear beside one,
24
+ # build the link and wire it to the controller's `clear` action.
25
+ def l_ui_search_form(query, url: nil, fields: [], predicate: :cont, combinator: :or,
26
+ label: "Search", placeholder: nil, button: nil, clear: nil,
27
+ live: false, count: nil, min_chars: nil,
28
+ page_param: "page", turbo_frame: nil, html: {}, &block)
15
29
  result = require_ransack("l_ui_search_form") { |msg| tag.p(msg, class: "l-ui-notice l-ui-notice--warning") }
16
30
  return result unless result == true
17
31
 
32
+ validate_search_options!("l_ui_search_form", live: live, turbo_frame: turbo_frame,
33
+ clear: clear, count: count, min_chars: min_chars)
34
+ warn_missing_search_count if live && count.nil?
35
+
18
36
  scope = query.context&.search_key || :q
19
- turbo_action = "advance"
37
+ # The clear button needs the controller, not live mode - and the
38
+ # controller goes on every framed form, to preserve the other scopes.
39
+ clear = turbo_frame.present? if clear.nil?
40
+ # Replace, so Back leaves the collection rather than replaying the term
41
+ # letter by letter. On the form, which Turbo reads before the frame's
42
+ # own, so sort links and pagination in that frame still advance.
43
+ turbo_action = live ? "replace" : "advance"
20
44
  html = html.merge(class: ["l-ui-form", html[:class]].compact.join(" "))
21
45
 
46
+ if live
47
+ html[:role] ||= "search"
48
+ html[:aria] = { label: label }.merge(html[:aria] || {})
49
+ end
50
+
22
51
  if turbo_frame
23
52
  existing_data = (html[:data] || {}).symbolize_keys
24
53
  existing_controller = existing_data[:controller]
@@ -27,10 +56,22 @@ module Layered
27
56
  action = [existing_action, "submit->l-ui--search-form#preserve"].compact.join(" ")
28
57
  turbo_action = existing_data[:turbo_action] || turbo_action
29
58
 
30
- html[:data] = existing_data.except(:controller, :action, :l_ui__search_form_scope_value).merge(
59
+ # What `preserve` and `rewriteLink` drop when carrying other
60
+ # collections' params across a submit. Passed rather than guessed from
61
+ # the search key: Pagy's page key is the host's to choose.
62
+ values = { l_ui__search_form_scope_value: scope,
63
+ l_ui__search_form_page_param_value: page_param }
64
+ if live
65
+ values[:l_ui__search_form_live_value] = true
66
+ values[:l_ui__search_form_min_chars_value] = min_chars if min_chars.to_i.positive?
67
+ values[:l_ui__search_form_count_value] = count unless count.nil?
68
+ end
69
+
70
+ html[:data] = existing_data.except(:controller, :action, :l_ui__search_form_scope_value,
71
+ :l_ui__search_form_page_param_value).merge(
31
72
  turbo_frame: turbo_frame, turbo_action: turbo_action,
32
73
  controller: controller, action: action,
33
- l_ui__search_form_scope_value: scope
74
+ **values
34
75
  )
35
76
  end
36
77
 
@@ -43,22 +84,69 @@ module Layered
43
84
  placeholder ||= "Search by #{fields.map { |f| f.to_s.humanize.downcase }.join(', ')}"
44
85
 
45
86
  search_form_for(query, url: url, html: html, as: scope) do |f|
46
- f.label(combined_field, label, class: "l-ui-sr-only") +
47
- tag.div(class: "l-ui-search-inline") do
48
- content = f.text_field(combined_field, class: "l-ui-form__field", placeholder: placeholder) +
49
- f.submit(button, class: "l-ui-button l-ui-button--primary")
50
- if clear
51
- raise ArgumentError, "l_ui_search_form requires an explicit url: when clear: is set" unless url
52
- clear_options = { class: "l-ui-button l-ui-button--outline" }
53
- clear_options[:data] = { turbo_frame: turbo_frame, turbo_action: turbo_action, action: "click->l-ui--search-form#clear" } if turbo_frame
54
- content += link_to(clear == true ? "Clear" : clear, url, **clear_options)
55
- end
56
- content
57
- end
87
+ tag.div(class: "l-ui-search-inline") do
88
+ l_ui_search_control(f, combined_field,
89
+ label: label, placeholder: placeholder,
90
+ button: button, clear: clear, live: live)
91
+ end
58
92
  end
59
93
  end
60
94
  end
61
95
 
96
+ # The search control itself - the field, a clear button built into its
97
+ # trailing edge, and the submit that keeps Enter and a JavaScript-less
98
+ # browser working. Rendered by l_ui_search_form's simple mode, and
99
+ # available on its own to a caller who passes a block and builds the row
100
+ # by hand:
101
+ #
102
+ # <%= l_ui_search_form(@q, url: users_path, turbo_frame: "users",
103
+ # live: true, count: @pagy.count) do |f| %>
104
+ # <div class="l-ui-search-inline">
105
+ # <%= my_filter_hidden_fields %>
106
+ # <%= l_ui_search_control(f, :name_or_email_cont, live: true) %>
107
+ # </div>
108
+ # <% end %>
109
+ #
110
+ # Both live: and clear: need the l-ui--search-form controller, which
111
+ # l_ui_search_form puts on the form when given a turbo_frame:. A
112
+ # hand-built form may not carry it, so both are off unless asked for.
113
+ def l_ui_search_control(form, attribute, label: "Search", placeholder: nil,
114
+ clear: nil, button: nil, live: false)
115
+ # live: could not work without the controller, so it vouches for one
116
+ # being there. On its own the control cannot tell, so clear: is opted
117
+ # into by a caller who knows their form carries it.
118
+ clear = live if clear.nil?
119
+ # Either behaviour drives the field, so either needs it as a target.
120
+ controlled = live || clear.present?
121
+ hint_id = ("#{form.object_name}_#{attribute}_hint" if live)
122
+ actions = []
123
+ # Live, `search` refreshes the clear button as part of its own work.
124
+ actions << "input->l-ui--search-form#search" if live
125
+ actions << "input->l-ui--search-form#toggleClear" if clear.present? && !live
126
+ actions << "keydown.esc->l-ui--search-form#clearSearch" if controlled
127
+
128
+ field = form.text_field(
129
+ attribute,
130
+ class: "l-ui-form__field",
131
+ placeholder: placeholder,
132
+ autocomplete: "off",
133
+ # Not type="search": WebKit draws its own cancel button, which would
134
+ # sit under this one and take the same tap.
135
+ aria: { describedby: hint_id }.compact,
136
+ data: (controlled ? { "l-ui--search-form-target" => "input",
137
+ action: actions.join(" ") } : {})
138
+ )
139
+
140
+ parts = [ form.label(attribute, label, class: "l-ui-sr-only"), field ]
141
+ parts << tag.span("Results update as you type.", id: hint_id, class: "l-ui-sr-only") if live
142
+ parts << l_ui_search_clear_button(form, attribute, clear) if clear
143
+
144
+ control = tag.div(safe_join(parts.compact),
145
+ class: [ "l-ui-search-control", ("l-ui-search-control--clearable" if clear) ].compact.join(" "))
146
+
147
+ safe_join([ control, l_ui_search_submit(form, button, live: live) ].compact)
148
+ end
149
+
62
150
  SORT_INDICATORS = {
63
151
  "asc" => { symbol: "▲", label: ", sorted ascending", aria: "ascending" },
64
152
  "desc" => { symbol: "▼", label: ", sorted descending", aria: "descending" }
@@ -111,6 +199,100 @@ module Layered
111
199
 
112
200
  private
113
201
 
202
+ # Options that say something about typing, so they mean nothing to a form
203
+ # that submits when asked to. Passing one has no visible symptom, so it
204
+ # names itself rather than being quietly dropped.
205
+ LIVE_ONLY_OPTIONS = {
206
+ count: "there is nothing to announce when the user pressed the button themselves",
207
+ min_chars: "nothing is sent until the form is submitted"
208
+ }.freeze
209
+
210
+ def validate_search_options!(helper, live:, turbo_frame:, clear: nil, **options)
211
+ if live && turbo_frame.blank?
212
+ raise ArgumentError,
213
+ "#{helper} requires a turbo_frame: for live: true - typing into an unframed form " \
214
+ "would mean a full page load per keystroke"
215
+ end
216
+
217
+ if clear && turbo_frame.blank?
218
+ raise ArgumentError,
219
+ "#{helper}'s clear: requires a turbo_frame: - clearing means clearing the field and " \
220
+ "submitting, which the l-ui--search-form controller does, and it is only attached " \
221
+ "to a framed form"
222
+ end
223
+
224
+ return if live
225
+
226
+ options.each do |key, value|
227
+ # false and nil both say "not asked for", which is never a conflict.
228
+ next if value.nil? || value == false
229
+
230
+ raise ArgumentError,
231
+ "#{helper}'s #{key}: applies to live: true only - #{LIVE_ONLY_OPTIONS.fetch(key)}"
232
+ end
233
+ end
234
+
235
+ # count: is what a screen reader hears when results change with no button
236
+ # press, so its absence is a hole rather than a default - and one nothing
237
+ # on screen would reveal. A log line, not markup: the form itself is fine.
238
+ def warn_missing_search_count
239
+ return unless Rails.env.development?
240
+
241
+ Rails.logger.warn(
242
+ "[layered-ui-rails] l_ui_search_form with live: true was given no count:, so nothing is " \
243
+ "announced when results change. Pass count: @pagy.count (or the size of the result set)."
244
+ )
245
+ end
246
+
247
+ # Live, an unnamed submit is present but neither seen nor tabbed to: what
248
+ # Enter in the field and a JavaScript-less browser submit through, without
249
+ # a focus stop nobody can see (WCAG 2.4.7). A form that submits when asked
250
+ # to needs a button that can be asked, so it always gets a visible one.
251
+ def l_ui_search_submit(form, button, live: true)
252
+ return form.submit(button, class: "l-ui-button l-ui-button--primary") if button.is_a?(String)
253
+ return form.submit("Search", class: "l-ui-button l-ui-button--primary") unless live
254
+
255
+ form.submit("Search", class: "l-ui-sr-only", tabindex: -1)
256
+ end
257
+
258
+ def l_ui_search_clear_button(form, attribute, clear)
259
+ name = clear.is_a?(String) ? clear : "Clear search"
260
+
261
+ # Hidden until there is something to clear; the controller corrects it
262
+ # on connect. Ransack answers a combined reader like `name_or_email_cont`
263
+ # through method_missing and denies respond_to?, so the value is asked
264
+ # for rather than checked for.
265
+ blank = begin
266
+ form.object.public_send(attribute).blank?
267
+ rescue NoMethodError
268
+ true
269
+ end
270
+
271
+ tag.button(
272
+ safe_join([ l_ui_search_clear_icon, tag.span(name, class: "l-ui-sr-only") ]),
273
+ type: "button",
274
+ class: "l-ui-search-control__clear",
275
+ hidden: blank,
276
+ data: { "l-ui--search-form-target" => "clear", action: "l-ui--search-form#clearSearch" }
277
+ )
278
+ end
279
+
280
+ # Rendered inline rather than via image_tag so it inherits the surrounding
281
+ # currentColor, as the combobox icons do; an <img>-loaded SVG cannot, and
282
+ # would need the dark:invert of .l-ui-icon.
283
+ def l_ui_search_clear_icon
284
+ tag.svg(
285
+ tag.path("d" => "M6 6 18 18M18 6 6 18",
286
+ "stroke-linecap" => "round", "stroke-linejoin" => "round"),
287
+ class: "l-ui-icon--xs",
288
+ fill: "none",
289
+ stroke: "currentColor",
290
+ "stroke-width" => "2",
291
+ "viewBox" => "0 0 24 24",
292
+ "aria-hidden" => "true"
293
+ )
294
+ end
295
+
114
296
  def ransack_available?
115
297
  defined?(Ransack)
116
298
  end
@@ -1,12 +1,103 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { announce, clearAnnounceTimeout } from "layered_ui/utilities/announce"
3
+
4
+ // Longer than the combobox's: a search re-renders a whole collection, so a
5
+ // keystroke costs more here than a listbox fetch does.
6
+ const SEARCH_DEBOUNCE = 300
7
+
8
+ // A frame render destroys the input being typed into, so the field's state is
9
+ // held outside the DOM and restored by the controller that connects in its
10
+ // place. Keyed per form: two collections on a page must not restore into each
11
+ // other.
12
+ const stashes = new Map()
13
+
14
+ // Older than this and the search has been walked away from, so focus is left
15
+ // where the user since put it.
16
+ const STASH_TTL = 5000
2
17
 
3
18
  // When multiple scoped Ransack collections share one page, preserves other
4
19
  // scopes' params across form submits (preserve), clear links (clear), and
5
- // pagination clicks (rewriteLink).
20
+ // pagination clicks (rewriteLink). In live mode it also searches as the user
21
+ // types, carrying the caret across the frame render that answers.
6
22
  export default class extends Controller {
7
- static values = { scope: String }
23
+ static targets = ["input", "clear"]
24
+ static values = {
25
+ scope: String,
26
+ // Given, not derived from scope: Pagy's page key is the host's choice and
27
+ // follows no rule relating it to the Ransack search key.
28
+ pageParam: { type: String, default: "page" },
29
+ live: { type: Boolean, default: false },
30
+ minChars: { type: Number, default: 0 },
31
+ // -1 means the caller passed no count, so there is nothing to announce.
32
+ count: { type: Number, default: -1 }
33
+ }
34
+
35
+ connect() {
36
+ this._searchTimer = null
37
+ this._restore()
38
+ this._refreshClear()
39
+ }
40
+
41
+ disconnect() {
42
+ clearTimeout(this._searchTimer)
43
+ // An announcement schedules the shared region to be blanked. Left pending,
44
+ // it would wipe what the instance replacing this one has just announced.
45
+ clearAnnounceTimeout(this)
46
+ }
47
+
48
+ search(event) {
49
+ // A half-composed IME string is not a term yet.
50
+ if (event && event.isComposing) return
51
+
52
+ this._stash()
53
+ this._refreshClear()
54
+ this._schedule()
55
+ }
56
+
57
+ // The in-field clear button, and Escape in the field. An intentional search
58
+ // for the empty term, so it skips the debounce.
59
+ clearSearch(event) {
60
+ if (event) event.preventDefault()
61
+ if (!this.hasInputTarget) return
8
62
 
63
+ this.inputTarget.value = ""
64
+ // Focused before the button hides itself, or focus falls to the body and
65
+ // the user loses their place (WCAG 2.4.3).
66
+ this.inputTarget.focus()
67
+ this._refreshClear()
68
+ // No input event fires for this, and submitNow declines when the empty term
69
+ // is already in flight - so without this the stash keeps the old text, and
70
+ // the pending response restores it over the cleared field and searches it.
71
+ this._stash()
72
+ this.submitNow()
73
+ }
74
+
75
+ // A form that does not search as you type runs nothing on a keystroke, so the
76
+ // clear button needs telling that the field has gained or lost its term.
77
+ toggleClear() {
78
+ this._refreshClear()
79
+ }
80
+
81
+ submitNow() {
82
+ clearTimeout(this._searchTimer)
83
+
84
+ const term = this._term
85
+ // Too short to be worth asking about, but not yet abandoned.
86
+ if (term.length > 0 && term.length < this.minCharsValue) return
87
+ if (term === this._sent) return
88
+
89
+ // requestSubmit, not submit: it fires the submit event, so `preserve` runs
90
+ // and the form's own data-turbo-action is honoured.
91
+ this.element.requestSubmit()
92
+ }
93
+
94
+ // Every submit lands here - typed, Enter, or the clear button - so it is
95
+ // where the field's state is recorded for the render about to replace it.
9
96
  preserve(event) {
97
+ clearTimeout(this._searchTimer)
98
+ this._sent = this._term
99
+ this._stash({ submitted: this._sent })
100
+
10
101
  this.element.querySelectorAll("[data-l-ui-preserved]").forEach(el => el.remove())
11
102
 
12
103
  for (const [key, value] of this.#otherParams()) {
@@ -41,21 +132,98 @@ export default class extends Controller {
41
132
  link.href = url.pathname + url.search
42
133
  }
43
134
 
135
+ _schedule() {
136
+ clearTimeout(this._searchTimer)
137
+ this._searchTimer = setTimeout(() => this.submitNow(), SEARCH_DEBOUNCE)
138
+ }
139
+
140
+ _stash(extra = {}) {
141
+ if (!this.hasInputTarget) return
142
+
143
+ const input = this.inputTarget
144
+ stashes.set(this._key, {
145
+ ...(stashes.get(this._key) || {}),
146
+ value: input.value,
147
+ start: input.selectionStart,
148
+ end: input.selectionEnd,
149
+ focused: document.activeElement === input,
150
+ at: Date.now(),
151
+ ...extra
152
+ })
153
+ }
154
+
155
+ _restore() {
156
+ const stash = stashes.get(this._key)
157
+ if (!stash) return
158
+ stashes.delete(this._key)
159
+
160
+ // Only a render that answers a search of ours takes focus back: a sort link
161
+ // or a page link renders the same frame, and must not.
162
+ if (!("submitted" in stash)) return
163
+ if (Date.now() - stash.at > STASH_TTL) return
164
+ if (!this.hasInputTarget) return
165
+
166
+ this._sent = stash.submitted
167
+
168
+ // Anything typed while the request was in flight is newer than the term the
169
+ // response echoes, and wins.
170
+ if (this.inputTarget.value !== stash.value) this.inputTarget.value = stash.value
171
+
172
+ // The stash says the field had focus when the request went out, not whether
173
+ // it still should. A control outside the frame survives the render and is
174
+ // still focused; only focus left on the body was destroyed with the input,
175
+ // and an answer arriving must not pull the user back (WCAG 3.2.5).
176
+ const focusWentNowhere = !document.activeElement || document.activeElement === document.body
177
+ if (stash.focused && focusWentNowhere) {
178
+ this.inputTarget.focus({ preventScroll: true })
179
+ this.inputTarget.setSelectionRange(stash.start, stash.end)
180
+ }
181
+
182
+ // The count answers the term submitted, not what has been typed since, so
183
+ // announcing waits until they agree - the scheduling below means a truer
184
+ // answer is already on its way.
185
+ if (stash.value === stash.submitted) this._announceResults(stash.submitted)
186
+
187
+ // Keystrokes that landed after the request went out are still unsearched.
188
+ if (stash.value !== stash.submitted) this._schedule()
189
+ }
190
+
191
+ // The response renders the count onto the form, so it is already the new one
192
+ // here. It goes to the layout's region, outside every frame: a live region
193
+ // replaced in the same render as its own text is not reliably announced.
194
+ _announceResults(term) {
195
+ if (this.countValue < 0) return
196
+
197
+ const count = this.countValue
198
+ const results = count === 0 ? "No results" : count === 1 ? "1 result" : `${count} results`
199
+
200
+ // The term is included so no two announcements repeat verbatim - identical
201
+ // text in a live region is not announced again.
202
+ announce(term ? `${results} for ${term}` : results, this)
203
+ }
204
+
205
+ _refreshClear() {
206
+ if (this.hasClearTarget) this.clearTarget.hidden = this._term.length === 0
207
+ }
208
+
209
+ get _term() {
210
+ return this.hasInputTarget ? this.inputTarget.value : ""
211
+ }
212
+
213
+ get _key() {
214
+ return `${this.scopeValue}|${this.element.action}`
215
+ }
216
+
44
217
  #otherParams() {
45
218
  const currentParams = new URLSearchParams(window.location.search)
46
219
  const scope = this.scopeValue
47
220
  const result = new URLSearchParams()
48
221
 
49
222
  for (const [key, value] of currentParams) {
50
- if (key === scope || key.startsWith(scope + "[") || key === "commit" || key === "page" || key === this.#pageParam) continue
223
+ if (key === scope || key.startsWith(scope + "[") || key === "commit" || key === this.pageParamValue) continue
51
224
  result.append(key, value)
52
225
  }
53
226
 
54
227
  return result
55
228
  }
56
-
57
- get #pageParam() {
58
- const scope = this.scopeValue
59
- return scope.endsWith("_q") ? scope.slice(0, -2) + "_page" : null
60
- }
61
229
  }
@@ -1,5 +1,5 @@
1
1
  module Layered
2
2
  module Ui
3
- VERSION = "0.26.0"
3
+ VERSION = "0.27.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: layered-ui-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - layered.ai