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 +4 -4
- data/.claude/skills/layered-ui-rails/references/CONTROLLERS.md +25 -10
- data/.claude/skills/layered-ui-rails/references/CSS.md +3 -0
- data/.claude/skills/layered-ui-rails/references/HELPERS.md +47 -7
- data/CHANGELOG.md +29 -0
- data/app/assets/tailwind/layered_ui/engine.css +31 -0
- data/app/helpers/layered/ui/ransack_helper.rb +198 -16
- data/app/javascript/layered_ui/controllers/l_ui/search_form_controller.js +176 -8
- data/lib/layered/ui/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9dfe5e770b8f6bbc52666be84b646ebe235fdf2cf1e15f7d9759fb1b880bc5be
|
|
4
|
+
data.tar.gz: d829e69ff914ace72a2e80da0ff468459dfbac0942cea828a6e28693ac46126c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
199
|
+
Searches as the user types, and manages multi-scope search forms with parameter preservation and Turbo frame support.
|
|
200
200
|
|
|
201
|
-
**
|
|
202
|
-
**
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
<%= 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 %>
|
|
224
238
|
<%= l_ui_search_form(@users_q, url: users_path, fields: [:name, :email],
|
|
225
|
-
|
|
239
|
+
live: true, count: @users_pagy.count,
|
|
240
|
+
page_param: "users_page", turbo_frame: "users_collection") %>
|
|
226
241
|
<%= l_ui_table(@users, ..., query: @users_q, turbo_frame: "users_collection") %>
|
|
227
242
|
<%= l_ui_pagy(@users_pagy) %>
|
|
228
243
|
<% end %>
|
|
@@ -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:
|
|
135
|
-
|
|
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
|
|
146
|
-
- `clear` (String|Boolean) - clear button
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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 ===
|
|
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
|
}
|
data/lib/layered/ui/version.rb
CHANGED