querylane 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/README.md +2 -0
  5. data/app/assets/stylesheets/querylane.css +12 -1
  6. data/app/javascript/querylane/controllers/box_controller.js +1 -1
  7. data/app/javascript/querylane/controllers/results_controller.js +8 -1
  8. data/app/views/querylane/_chips.html.erb +2 -1
  9. data/app/views/querylane/_denied.html.erb +2 -1
  10. data/app/views/querylane/_empty.html.erb +10 -4
  11. data/app/views/querylane/_facets.html.erb +3 -2
  12. data/app/views/querylane/_first_load.html.erb +2 -1
  13. data/app/views/querylane/_form.html.erb +19 -9
  14. data/app/views/querylane/_group.html.erb +2 -1
  15. data/app/views/querylane/_help.html.erb +3 -2
  16. data/app/views/querylane/_options.html.erb +5 -3
  17. data/app/views/querylane/_options_row.html.erb +18 -7
  18. data/app/views/querylane/_outcome.html.erb +5 -3
  19. data/app/views/querylane/_pager.html.erb +3 -2
  20. data/app/views/querylane/_preview.html.erb +2 -1
  21. data/app/views/querylane/_record.html.erb +2 -1
  22. data/app/views/querylane/_related.html.erb +2 -1
  23. data/app/views/querylane/_results.html.erb +2 -2
  24. data/app/views/querylane/_rows.html.erb +2 -1
  25. data/app/views/querylane/_saved.html.erb +2 -1
  26. data/app/views/querylane/_suggest_box.html.erb +10 -7
  27. data/app/views/querylane/_suggestions.html.erb +2 -2
  28. data/app/views/querylane/_toolbar.html.erb +2 -1
  29. data/app/views/querylane/_unavailable.html.erb +2 -1
  30. data/app/views/querylane/_writes.html.erb +4 -3
  31. data/config/locales/querylane.en.yml +27 -0
  32. data/lib/querylane/chips.rb +24 -7
  33. data/lib/querylane/configuration.rb +9 -0
  34. data/lib/querylane/copy.rb +28 -0
  35. data/lib/querylane/definition.rb +44 -10
  36. data/lib/querylane/parser.rb +16 -13
  37. data/lib/querylane/presenter.rb +19 -7
  38. data/lib/querylane/query.rb +59 -17
  39. data/lib/querylane/request.rb +9 -6
  40. data/lib/querylane/rewriter.rb +44 -2
  41. data/lib/querylane/search.rb +2 -0
  42. data/lib/querylane/searchers/meilisearch.rb +19 -2
  43. data/lib/querylane/version.rb +1 -1
  44. data/lib/querylane.rb +1 -0
  45. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f62396550c8585a01958050e34f4e31628ddb53ad02cf97074900e0882a6e510
4
- data.tar.gz: 68f1c601f5a1083d1fd6711db29c87fde0a994962091578cb7f58c5b0b12f98d
3
+ metadata.gz: 76873d12e555209a71b9eafed1f3fa4beee0c3377015cf3bc064a13b01098153
4
+ data.tar.gz: fd06740404a5f449fe11b43e0669be267cea0f8ef0ce1a44243b472eca985b5b
5
5
  SHA512:
6
- metadata.gz: 8c35bb1029b86f33ca864824980c847c532a0a6b5ad4ee9bcd44a53c6ec3370fded089c1146025cf4db3d17736baf5c1383d8ee31edd030c677e3fb78b43b460
7
- data.tar.gz: 82f3e9d07e46f33876573d5f4227070965f352e3ec67bf479c90f9a98e457b5e017cdb822e1e7355af17a2e208b9a22b479acf474c546c60078cee98474a4b9e
6
+ metadata.gz: a31aa8846f0f7338af5ac7cf6c28b0b840a1e0a423653b04d68d5127f0a00c973a47a9d368273b0010679ea1e5165896df38693bb808a5d8d19311949a393ee3
7
+ data.tar.gz: e42d68c96660e86fa202330655388f411a5993a7cbda5d7ad14e6341fd66dd25d90b1ce83d4aee6d22e346c2ea8c3341d4e26f0afdb2121b5135f03ae6b80f97
data/CHANGELOG.md CHANGED
@@ -7,6 +7,77 @@ change public APIs; each entry says how.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-11-10
11
+
12
+ ### Added
13
+
14
+ - `config.logger`: querylane logs why a search was unavailable (Meilisearch
15
+ unreachable, timed out, or an API error such as a missing index). API
16
+ errors are logged by code, type and HTTP status only, never their message,
17
+ which can echo the filter; connection errors lose any URL credentials and
18
+ query string. The default logger writes to standard output; a Rails host
19
+ passes `Rails.logger`.
20
+ - `p.row ... hint:, placeholder:` on options-panel rows: a line under the row
21
+ (its fields' `aria-describedby`) and a placeholder for its empty inputs (a
22
+ masked field's "In the box: …" still wins). `placeholder:` on a flags or
23
+ options row raises.
24
+ - `d.filter ... invalid:`: a second sentence for the bad-value error.
25
+ - `d.filter ... as_words: true`: when nothing matches, the empty state offers
26
+ "Search "smiht" as part of a name" (`querylane.states.as_words`, request
27
+ param `as_words=<chip index>`, `Rewriter.as_words`), which replaces the
28
+ filter term with its typed value as plain words. Refused on a sensitive
29
+ filter.
30
+ - `d.filter ... display_value: :typed`: chips and the options panel show the
31
+ value as typed, and the panel writes it back as typed; the search still
32
+ uses the normalized value. Needs `mask: :none`.
33
+ - `hint:`, `placeholder:` and `invalid:` take a locale key (a Symbol or a
34
+ dotted String) or plain text (`Querylane::Copy`), translated when shown.
35
+
36
+ ### Changed
37
+
38
+ - Development and CI dependencies: rspec-rails 8.1.0, playwright 1.65.0,
39
+ ruby/setup-ruby 1.338.0.
40
+ - Development Ruby 3.3.13 (from 3.3.10): the lowest supported series' release
41
+ with the Onigmo regexp fixes (CVE-2026-89078, CVE-2026-93577,
42
+ CVE-2026-97229, CVE-2026-110264). `required_ruby_version` and the CI
43
+ matrix are unchanged.
44
+ - Every query error is a translation under `querylane.errors.*` (syntax,
45
+ limits, unknown and parked operators, bad filter and flag values, names in
46
+ OR, NOT and parentheses, one text operator). The English text is unchanged
47
+ and stays the default (`Querylane::Query::MESSAGES`).
48
+ - With no filters declared, the one-text-operator error no longer ends in
49
+ "or combine with ."; it reads "Search name or id one at a time."
50
+ - `Definition::FilterOperator` gains `invalid`, `as_words` and
51
+ `display_value`, `Definition::PanelRow` gains `hint` and `placeholder`, and
52
+ `Chips::Chip` gains `as_words`. Each new member has a default, so code that
53
+ builds these structs with the earlier members, by position or keyword,
54
+ still works.
55
+
56
+ - Every partial declares its locals on its first line (Rails strict locals,
57
+ `<%# locals: (...) -%>`). A host override must keep the line, and a render
58
+ with other locals now raises. `_help` takes `popover_id: nil` instead of
59
+ reading `local_assigns`.
60
+ - Controls follow one hierarchy (theming guide, "Bootstrap"): the box's
61
+ Search and the options panel's Search are outline buttons
62
+ (`btn-outline-primary`); Open and View are Go links (`.ql-go`, still a
63
+ button that posts); the empty state's actions and the panel's Cancel are
64
+ quiet links (`.ql-link`). `Presenter::Action#style` no longer changes the
65
+ look.
66
+ - No Bootstrap utility classes: `.ql-visually-hidden` replaces
67
+ `visually-hidden`, `.ql-open-form` replaces `d-inline`, and `.ql-pager`
68
+ sets its own margin instead of `mb-0`.
69
+ - The search box and the suggestion box sit in a `<search>` landmark
70
+ (`.ql-search`) instead of a form with `role="search"`.
71
+ - The facet column's accessible name comes from `querylane.facets.label`.
72
+
73
+ ### Fixed
74
+
75
+ - Space on a record right after a new answer drew its rows opens the preview.
76
+ The preview state was reset on `turbo:frame-render`, which Turbo fires a few
77
+ repaints after the rows are in, so the preview's answer was dropped as if
78
+ Esc had closed it. It is now reset when the new, empty preview frame
79
+ connects.
80
+
10
81
  ## [0.1.0] - 2026-10-20
11
82
 
12
83
  The first release.
@@ -80,5 +151,6 @@ The first release.
80
151
  - `search.querylane` and `saved_search_failed.querylane` instrumentation, without
81
152
  query text. The query parameter and `compose` are filtered from logs.
82
153
 
83
- [Unreleased]: https://github.com/clearstackio/querylane/compare/v0.1.0...HEAD
154
+ [Unreleased]: https://github.com/clearstackio/querylane/compare/v0.2.0...HEAD
155
+ [0.2.0]: https://github.com/clearstackio/querylane/compare/v0.1.0...v0.2.0
84
156
  [0.1.0]: https://github.com/clearstackio/querylane/releases/tag/v0.1.0
data/CONTRIBUTING.md CHANGED
@@ -73,7 +73,7 @@ also the example the guides quote.
73
73
  request (`spec/requests/`) or browser (`spec/system/`).
74
74
  - **Changelog:** add a line under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md)
75
75
  for anything a host would notice.
76
- - **CI:** GitHub Actions (`.github/workflows/ci.yml`): `rake` on Ruby 3.3, 3.4
76
+ - **CI:** GitHub Actions ([design](docs/design/ci.md), [operations](docs/ops/ci.md)): `rake` on Ruby 3.3, 3.4
77
77
  and 4.0 against Meilisearch 1.13 and headless Chromium. Actions are pinned to
78
78
  commit SHAs. FracturedJSON is not run in CI; run `bin/lint` before pushing.
79
79
  - **Security:** no secrets in the repo; see [SECURITY.md](SECURITY.md).
data/README.md CHANGED
@@ -169,6 +169,8 @@ A complete, working version of this example lives in [`spec/dummy`](spec/dummy).
169
169
  Rails.application.config.to_prepare do
170
170
  Querylane.configure do |config|
171
171
  config.searcher = -> { Querylane::Searchers::Meilisearch.new(client: Meilisearch::Client.new(ENV["MEILISEARCH_URL"], ENV["MEILISEARCH_API_KEY"])) }
172
+ # Why a search could not run (default: standard output).
173
+ config.logger = Rails.logger
172
174
  end
173
175
  end
174
176
  ```
@@ -6,6 +6,11 @@
6
6
  */
7
7
  /* The hidden attribute wins over the display rules below. */
8
8
  :where(.querylane-form, .ql-results, .ql-suggest, .ql-palette) [hidden] { display: none !important; }
9
+ /* A label read by assistive technology only. */
10
+ .ql-visually-hidden { position: absolute !important; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
11
+ clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
12
+ /* The search landmark around a box and its form. */
13
+ .ql-search { display: block; }
9
14
 
10
15
  :root {
11
16
  --ql-accent: var(--bs-primary, #0d6efd);
@@ -69,6 +74,9 @@
69
74
  border-radius: 13px; background: var(--ql-chip); color: var(--ql-text); }
70
75
  .ql-chip-remove { border: 0; background: none; padding: 0; color: var(--ql-muted); line-height: 1; }
71
76
  .ql-link { border: 0; background: none; padding: 0; color: var(--bs-link-color, #0865d1); font-weight: 500; }
77
+ /* A Go link: opens a record or a page (bold, no box), even when it is a button that posts. */
78
+ .ql-go { border: 0; background: none; padding: 0; color: var(--bs-link-color, #0865d1); font-weight: 700; }
79
+ .ql-link:hover, .ql-go:hover { text-decoration: underline; }
72
80
  [data-querylane-stale] .ql-chips { opacity: 0.55; }
73
81
  .ql-state { padding: 28px 0; font-size: 15px; color: var(--ql-muted); }
74
82
  .ql-state-unavailable { padding: 20px 24px; border-left: 3px solid #977405; background: #fffbea; color: var(--ql-text); }
@@ -95,6 +103,7 @@
95
103
  .ql-count-note { margin-left: 10px; font-size: 13px; font-weight: 400; color: var(--ql-muted); }
96
104
  .ql-count { font-size: 18px; line-height: 22px; font-weight: 700; color: var(--ql-heading); }
97
105
  .ql-toolbar-controls { display: flex; align-items: center; gap: 16px; }
106
+ .ql-pager { margin-bottom: 0; }
98
107
  .ql-control { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; color: var(--ql-muted); }
99
108
  .ql-control .form-select { width: auto; }
100
109
 
@@ -125,7 +134,7 @@
125
134
  .ql-badge-neutral { background: var(--ql-neutral-bg); color: var(--ql-neutral-fg); }
126
135
  .ql-flag-neutral { border: 1px solid var(--ql-border); color: var(--ql-muted); }
127
136
  .ql-reason { font-size: 13px; line-height: 17px; color: var(--ql-muted); }
128
- .ql-open { font-weight: 700; }
137
+ .ql-open-form { display: inline; }
129
138
 
130
139
  /* Footer ---------------------------------------------------------------------- */
131
140
  .ql-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 16px; font-size: 13px; color: var(--ql-muted); }
@@ -173,6 +182,8 @@
173
182
  .ql-options-inputs { display: flex; gap: 12px; }
174
183
  .ql-options-input { position: relative; flex: 1; min-width: 0; }
175
184
  .ql-options-input input { width: 100%; }
185
+ /* A row's hint: under its fields, in the field column. */
186
+ .ql-options-hint { grid-column: 2; margin: -6px 0 0; color: var(--ql-muted); font-size: 12px; line-height: 16px; }
176
187
  .ql-masked { -webkit-text-security: disc; }
177
188
  /* Above the field even when a host lifts a focused .form-control (Bootstrap 3 themes set z-index: 2). */
178
189
  .ql-options-reveal { position: absolute; top: 50%; right: 10px; z-index: 3; transform: translateY(-50%); font-size: 13px; }
@@ -90,7 +90,7 @@ export default class extends Controller {
90
90
  // new revision; the rewrite comes back at that same revision.
91
91
  submitted(event) {
92
92
  clearTimeout(this.timer)
93
- if (["toggle", "remove", "clear", "compose_submit", "saved", "fix", "instead", "widen", "relax"].includes(event.submitter?.name)) this.bump()
93
+ if (["toggle", "remove", "clear", "compose_submit", "saved", "fix", "instead", "widen", "relax", "as_words"].includes(event.submitter?.name)) this.bump()
94
94
  }
95
95
 
96
96
  setQuery(event) {
@@ -23,7 +23,6 @@ export default class extends Controller {
23
23
  this.element.addEventListener("turbo:before-frame-render", this.guardPreview)
24
24
  this.restore = (event) => {
25
25
  if (event.target !== this.element) return
26
- this.previewGroup = null
27
26
  this.restoreFocus()
28
27
  }
29
28
  this.element.addEventListener("querylane:focus-first", this.focusFirst)
@@ -95,6 +94,14 @@ export default class extends Controller {
95
94
  if (event.target.matches("select[form]")) event.target.form?.requestSubmit()
96
95
  }
97
96
 
97
+ // A new answer brings a new, empty preview frame: nothing is previewed any
98
+ // more. Stimulus calls this as the rows are swapped in; turbo:frame-render
99
+ // comes a few repaints later, after a quick Space may have started a preview.
100
+ previewTargetConnected() {
101
+ this.previewGroup = null
102
+ this.previewKey = null
103
+ }
104
+
98
105
  // private
99
106
 
100
107
  get records() {
@@ -1,4 +1,5 @@
1
- <%# "Searching for" chips. × removes that term (server rewrites the query). Locals: result, id. %>
1
+ <%# locals: (result:, id:) -%>
2
+ <%# "Searching for" chips. × removes that term (server rewrites the query). %>
2
3
  <% if result.chips.any? %>
3
4
  <div class="ql-chips">
4
5
  <span class="ql-chips-label"><%= t("querylane.chips.searching_for") %></span>
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# The user's access could not be checked (Querylane::Scope.denied): nothing
2
- is shown rather than records outside it. Locals: result, id. %>
3
+ is shown rather than records outside it. %>
3
4
  <div class="ql-state ql-state-denied" role="alert">
4
5
  <h2><%= t("querylane.states.denied_title") %></h2>
5
6
  <p><%= t("querylane.states.denied") %></p>
@@ -1,16 +1,22 @@
1
- <%# No hits: say what found nothing and offer one-click ways to widen. Locals: result, id. %>
1
+ <%# locals: (result:, id:) -%>
2
+ <%# No hits: say what found nothing and offer one-click ways to widen. Each
3
+ way only rewrites the query, so it is a quiet link, never a button. %>
2
4
  <div class="ql-empty">
3
5
  <h2><%= t("querylane.states.empty_title", searched: result.chips.map(&:label).join(" · ")) %></h2>
4
6
  <div class="ql-empty-actions">
5
7
  <% result.widenable_facets.each do |facet| %>
6
- <button type="submit" form="<%= id %>-form" name="widen" value="<%= facet.key %>" class="btn btn-outline-primary btn-sm"><%= t("querylane.states.widen_#{facet.key}", default: t("querylane.states.widen", facet: facet.label.downcase)) %></button>
8
+ <button type="submit" form="<%= id %>-form" name="widen" value="<%= facet.key %>" class="ql-link"><%= t("querylane.states.widen_#{facet.key}", default: t("querylane.states.widen", facet: facet.label.downcase)) %></button>
7
9
  <% end %>
8
10
  <%# Every filter can be dropped; the words are the search itself. %>
9
11
  <% result.chips.reject(&:words).each do |chip| %>
10
- <button type="submit" form="<%= id %>-form" name="remove" value="<%= chip.index %>" class="btn btn-outline-secondary btn-sm"><%= t("querylane.states.drop", label: chip.label) %></button>
12
+ <button type="submit" form="<%= id %>-form" name="remove" value="<%= chip.index %>" class="ql-link"><%= t("querylane.states.drop", label: chip.label) %></button>
13
+ <% end %>
14
+ <%# An exact filter that opts in (as_words) can search its typed value as plain words. %>
15
+ <% result.chips.select(&:as_words).each do |chip| %>
16
+ <button type="submit" form="<%= id %>-form" name="as_words" value="<%= chip.index %>" class="ql-link"><%= t("querylane.states.as_words", value: chip.as_words) %></button>
11
17
  <% end %>
12
18
  <% unless result.relaxed %>
13
- <button type="submit" form="<%= id %>-form" name="relax" value="1" class="btn btn-outline-secondary btn-sm"><%= t("querylane.states.relax") %></button>
19
+ <button type="submit" form="<%= id %>-form" name="relax" value="1" class="ql-link"><%= t("querylane.states.relax") %></button>
14
20
  <% end %>
15
21
  </div>
16
22
  <p class="ql-facet-note"><%= t("querylane.states.empty_note") %></p>
@@ -1,6 +1,7 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# Facets. A value is a submit button with checkbox semantics: it asks the server
2
- to tick or untick that value in the query text. Locals: result, id. %>
3
- <aside class="ql-facets" aria-label="Filters">
3
+ to tick or untick that value in the query text. %>
4
+ <aside class="ql-facets" aria-label="<%= t("querylane.facets.label") %>">
4
5
  <% result.facets.each do |facet| %>
5
6
  <section class="ql-facet">
6
7
  <h3 class="ql-facet-title"><%= facet.label %></h3>
@@ -1,4 +1,5 @@
1
- <%# Before anything is typed: what to type, with the definition's examples. Locals: definition. %>
1
+ <%# locals: (definition:) -%>
2
+ <%# Before anything is typed: what to type, with the definition's examples. %>
2
3
  <div class="ql-first-load">
3
4
  <div class="ql-first-load-lead">
4
5
  <h2><%= t("querylane.states.first_load_title") %></h2>
@@ -1,10 +1,19 @@
1
- <%# The search form and box. Outside the results frame, so typing never loses the caret.
2
- Locals: result, id, url, input_id, label, placeholder, mask, button_id, options, help, extra. %>
3
- <%= form_with url: url, method: :post, id: "#{id}-form", class: "querylane-form",
4
- data: {turbo: true, turbo_frame: "#{id}-results", controller: "querylane--box",
1
+ <%# locals: (result:, id:, url:, input_id:, label:, placeholder:, mask:, button_id:, options:, help:, extra:) -%>
2
+ <%# The search form and box, in a search landmark. Outside the results frame,
3
+ so typing never loses the caret. Search applies the box (an outline
4
+ button): the host's page keeps its one solid button for a commit. %>
5
+ <%
6
+ form_id = "#{id}-form"
7
+ frame_id = "#{id}-results"
8
+ help_id = "#{id}-help-text" if help
9
+ described_by = [help_id, "#{id}-error"].compact.join(" ")
10
+ %>
11
+ <search class="ql-search">
12
+ <%= form_with url: url, method: :post, id: form_id, class: "querylane-form",
13
+ data: {turbo: true, turbo_frame: frame_id, controller: "querylane--box",
5
14
  querylane__box_debounce_value: Querylane.configuration.debounce_ms,
6
15
  querylane__box_mask_value: mask.to_s,
7
- querylane__box_frame_value: "#{id}-results",
16
+ querylane__box_frame_value: frame_id,
8
17
  querylane__box_min_value: result.definition.min_text_length.to_i,
9
18
  action: "submit->querylane--box#submitted turbo:before-cache@document->querylane--box#forget keydown@document->querylane--box#shortcut"} do %>
10
19
  <%# The default button: Enter in the box searches (Options comes first in the box). %>
@@ -14,10 +23,10 @@
14
23
  <svg class="ql-box-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="6" fill="none" stroke="currentColor" stroke-width="2"/><path d="M13 13l5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
15
24
  <div class="ql-box-field">
16
25
  <div class="ql-box-overlay" aria-hidden="true" data-querylane--box-target="overlay"></div>
17
- <label class="visually-hidden" for="<%= input_id %>"><%= label %></label>
26
+ <label class="ql-visually-hidden" for="<%= input_id %>"><%= label %></label>
18
27
  <input type="text" id="<%= input_id %>" name="<%= Querylane.configuration.param_name %>" value="<%= result.query %>"
19
28
  class="ql-box-input" placeholder="<%= placeholder %>" autocomplete="off" autocapitalize="off" spellcheck="false"
20
- aria-describedby="<%= [("#{id}-help-text" if help), "#{id}-error"].compact.join(" ") %>"
29
+ aria-describedby="<%= described_by %>"
21
30
  data-querylane--box-target="input"
22
31
  data-action="input->querylane--box#typed keydown->querylane--box#keys scroll->querylane--box#paint select->querylane--box#paint click->querylane--box#paint compositionstart->querylane--box#compose compositionend->querylane--box#composed">
23
32
  </div>
@@ -39,8 +48,9 @@
39
48
  <%= t("querylane.box.options") %>
40
49
  </button>
41
50
  <% end %>
42
- <button type="submit" class="btn btn-primary ql-box-submit" <%= %(id="#{button_id}").html_safe if button_id %>><%= t("querylane.box.search") %></button>
51
+ <button type="submit" class="btn btn-outline-primary ql-box-submit" <%= tag.attributes(id: button_id) %>><%= t("querylane.box.search") %></button>
43
52
  </div>
44
- <% if help %><p class="ql-box-help" id="<%= id %>-help-text"><%= help %></p><% end %>
53
+ <% if help %><p class="ql-box-help" id="<%= help_id %>"><%= help %></p><% end %>
45
54
  <div class="ql-hint" role="status" hidden data-querylane--box-target="hint"><%= t("querylane.states.short", count: result.definition.min_text_length) if result.definition.min_text_length %></div>
46
55
  <% end %>
56
+ </search>
@@ -1,5 +1,6 @@
1
+ <%# locals: (presenter:, group:, columns:) -%>
1
2
  <%# One group (e.g. a person) and its records, with "N more records" only
2
- when the group has records the page does not show. Locals: presenter, group, columns. %>
3
+ when the group has records the page does not show. %>
3
4
  <% token = presenter.group_token(group) %>
4
5
  <div class="ql-group" <%= tag.attributes(data: {querylane_group: token}) %>>
5
6
  <div class="ql-group-head">
@@ -1,6 +1,7 @@
1
+ <%# locals: (definition:, id:, popover_id: nil) -%>
1
2
  <%# Syntax help card generated from the definition; a native popover (no JS).
2
- Locals: definition, id, popover_id (optional, when shown twice on a page). %>
3
- <% popover_id = local_assigns.fetch(:popover_id, "#{id}-help") %>
3
+ popover_id names it when it is shown twice on a page. %>
4
+ <% popover_id ||= "#{id}-help" %>
4
5
  <button type="button" class="ql-link" popovertarget="<%= popover_id %>"><%= t("querylane.chips.syntax_help") %></button>
5
6
  <div id="<%= popover_id %>" popover class="ql-help">
6
7
  <div class="ql-help-head">
@@ -1,6 +1,8 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# The options panel (M3): fields read from the box, Search writes them back.
2
3
  Fields belong to the search form (form=…), so the live box text travels with
3
- them; the server composes. Locals: result, id. %>
4
+ them; the server composes. Search applies the panel (outline), Cancel and
5
+ Reset are quiet links. %>
4
6
  <section class="ql-options" id="<%= id %>-options" aria-labelledby="<%= id %>-options-title"
5
7
  data-controller="querylane--options" data-querylane--options-debounce-value="<%= Querylane.configuration.debounce_ms %>"
6
8
  data-action="input->querylane--options#changed change->querylane--options#changed keydown.esc->querylane--options#cancel keydown.enter->querylane--options#apply">
@@ -25,8 +27,8 @@
25
27
  <%= render "querylane/help", definition: result.definition, id: id, popover_id: "#{id}-options-help" %>
26
28
  <button type="button" class="ql-link" data-action="querylane--options#reset"><%= t("querylane.options.reset") %></button>
27
29
  <span class="ql-options-spacer"></span>
28
- <button type="submit" form="<%= id %>-form" class="btn btn-link" data-querylane--options-target="cancel"><%= t("querylane.options.cancel") %></button>
29
- <button type="submit" form="<%= id %>-form" name="compose_submit" value="1" class="btn btn-primary"
30
+ <button type="submit" form="<%= id %>-form" class="ql-link" data-querylane--options-target="cancel"><%= t("querylane.options.cancel") %></button>
31
+ <button type="submit" form="<%= id %>-form" name="compose_submit" value="1" class="btn btn-outline-primary"
30
32
  data-querylane--options-target="apply"><%= t("querylane.options.search") %></button>
31
33
  </footer>
32
34
  </section>
@@ -1,7 +1,15 @@
1
- <%# One options-panel row. Locals: row (Definition::PanelRow), values, held, definition, id.
1
+ <%# locals: (row:, values:, held:, definition:, id:) -%>
2
+ <%# One options-panel row (a Definition::PanelRow).
2
3
  A masked field starts empty: its current terms stay in the box (shown
3
- masked as the placeholder); what is typed here is added. %>
4
- <% field_id = ->(name) { "#{id}-compose-#{name}" } %>
4
+ masked as the placeholder, which wins over the row's own placeholder);
5
+ what is typed here is added. The row's hint sits under it and describes
6
+ its fields. %>
7
+ <%
8
+ field_id = ->(name) { "#{id}-compose-#{name}" }
9
+ hint = Querylane::Copy.text(row.hint)
10
+ hint_id = "#{field_id.(row.name)}-hint" if hint
11
+ placeholder = Querylane::Copy.text(row.placeholder)
12
+ %>
5
13
  <div class="ql-options-row">
6
14
  <% case row.kind %>
7
15
  <% when :inputs %>
@@ -13,8 +21,8 @@
13
21
  <%# Sensitive fields stay type=text (a password field would invite the browser to save it), masked by CSS. %>
14
22
  <input type="text" id="<%= field_id.(name) %>" name="compose[<%= name %>]" form="<%= id %>-form"
15
23
  value="<%= values[name] %>" class="form-control form-control-sm <%= "ql-masked" if row.masked %>" autocomplete="off" spellcheck="false"
16
- <%= tag.attributes(placeholder: (t("querylane.options.held", text: held[name]) if held[name])) %>
17
- <%= tag.attributes(aria: {label: ("#{row.label} #{name.humanize}" if position.positive?)}) %>
24
+ <%= tag.attributes(placeholder: (held[name] ? t("querylane.options.held", text: held[name]) : placeholder)) %>
25
+ <%= tag.attributes(aria: {label: ("#{row.label} #{name.humanize}" if position.positive?), describedby: hint_id}) %>
18
26
  data-querylane--options-target="field">
19
27
  <% if row.masked %>
20
28
  <button type="button" class="ql-link ql-options-reveal" aria-controls="<%= field_id.(name) %>" aria-pressed="false"
@@ -27,7 +35,7 @@
27
35
  <% when :flags %>
28
36
  <% flags = definition.operator(row.keys.first) %>
29
37
  <span class="ql-options-label" id="<%= field_id.(row.name) %>-label"><%= row.label %></span>
30
- <div class="ql-options-checks" role="group" aria-labelledby="<%= field_id.(row.name) %>-label">
38
+ <div class="ql-options-checks" role="group" aria-labelledby="<%= field_id.(row.name) %>-label" <%= tag.attributes(aria: {describedby: hint_id}) %>>
31
39
  <% definition.flag_values(flags, row.values).each do |value, label| %>
32
40
  <label class="ql-options-check">
33
41
  <input type="checkbox" name="compose[<%= row.name %>][]" value="<%= value %>" form="<%= id %>-form"
@@ -42,10 +50,13 @@
42
50
  <% row.keys.each do |key| %>
43
51
  <label class="ql-options-check">
44
52
  <input type="checkbox" name="compose[options][]" value="<%= key %>" form="<%= id %>-form"
45
- <%= "checked" if values["options"].include?(key.to_s) %> data-querylane--options-target="field">
53
+ <%= "checked" if values["options"].include?(key.to_s) %> <%= tag.attributes(aria: {describedby: hint_id}) %> data-querylane--options-target="field">
46
54
  <%= definition.option(key).label %>
47
55
  </label>
48
56
  <% end %>
49
57
  </div>
50
58
  <% end %>
59
+ <% if hint %>
60
+ <p class="ql-options-hint" id="<%= hint_id %>"><%= hint %></p>
61
+ <% end %>
51
62
  </div>
@@ -1,9 +1,11 @@
1
- <%# Open/View button (full-page POST) or the reason it cannot be opened. Locals: outcome. %>
1
+ <%# locals: (outcome:) -%>
2
+ <%# Open/View or the reason a record cannot be opened. Open/View is a Go link
3
+ (rows hold no buttons) drawn on a button, because it posts (a full page). %>
2
4
  <% case outcome %>
3
5
  <% when Querylane::Presenter::Action %>
4
6
  <%= button_to outcome.label, outcome.url, params: outcome.params || {}, method: :post,
5
- class: "btn btn-sm #{(outcome.style == :primary) ? "btn-primary" : "btn-outline-primary"} ql-open",
6
- form: {class: "d-inline", data: {turbo: false}}, data: {querylane_open: true} %>
7
+ class: "ql-go ql-open",
8
+ form: {class: "ql-open-form", data: {turbo: false}}, data: {querylane_open: true} %>
7
9
  <% when Querylane::Presenter::Reason %>
8
10
  <span class="ql-reason"><%= outcome.text %><% if outcome.link_url %> <%= link_to outcome.link_label, outcome.link_url, data: {turbo: false} %><% end %>
9
11
  <% if outcome.more.any? %>
@@ -1,9 +1,10 @@
1
- <%# Previous, up to five pages, Next. Submit buttons of the search form. Locals: result, id. %>
1
+ <%# locals: (result:, id:) -%>
2
+ <%# Previous, up to five pages, Next. Submit buttons of the search form. %>
2
3
  <% pages = [result.pages, 1].max
3
4
  start = [[result.page - 2, 1].max, [pages - 4, 1].max].min
4
5
  window = (start..[start + 4, pages].min) %>
5
6
  <nav aria-label="<%= t("querylane.toolbar.pages") %>">
6
- <ul class="pagination pagination-sm mb-0 ql-pager">
7
+ <ul class="pagination pagination-sm ql-pager">
7
8
  <li class="page-item previous <%= "disabled" if result.page <= 1 %>">
8
9
  <button type="submit" form="<%= id %>-form" name="page" value="<%= result.page - 1 %>" class="page-link" <%= "disabled" if result.page <= 1 %>><%= t("querylane.toolbar.previous") %></button>
9
10
  </li>
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, presenter:, id:) -%>
1
2
  <%# The preview (Space on a record): every record of its group, any status.
2
- Locals: result (from querylane_related), presenter, id. %>
3
+ result comes from querylane_related. %>
3
4
  <turbo-frame id="<%= id %>-preview" class="ql-preview">
4
5
  <section class="ql-preview-card" aria-labelledby="<%= id %>-preview-title">
5
6
  <% if result.state == :unavailable %>
@@ -1,4 +1,5 @@
1
- <%# One record in lanes. Focusable for ↑/↓/Enter. Locals: presenter, hit, columns. %>
1
+ <%# locals: (presenter:, hit:, columns:) -%>
2
+ <%# One record in lanes. Focusable for ↑/↓/Enter. %>
2
3
  <div class="ql-record" tabindex="-1" data-querylane-record="<%= presenter.record_key(hit) %>">
3
4
  <% presenter.cells(hit).each_with_index do |cell, index| %>
4
5
  <% width = columns[index]&.width %>
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, presenter:, frame:) -%>
1
2
  <%# "More records": every record of one group the page did not show.
2
- Locals: result (from querylane_related), presenter, frame. %>
3
+ result comes from querylane_related; frame is the requesting frame id. %>
3
4
  <turbo-frame id="<%= frame %>" class="ql-related">
4
5
  <% if result.state == :unavailable %>
5
6
  <div class="ql-related-head" role="status"><%= t("querylane.group.unavailable") %></div>
@@ -1,5 +1,5 @@
1
- <%# The results frame: everything one search request answers.
2
- Locals: result, id, info_id, per_id, first_load, rows. %>
1
+ <%# locals: (result:, id:, info_id:, per_id:, first_load:, rows:) -%>
2
+ <%# The results frame: everything one search request answers. %>
3
3
  <turbo-frame id="<%= id %>-results" class="ql-results" data-revision="<%= result.revision %>" data-controller="querylane--results"
4
4
  data-action="change->querylane--results#submitForm keydown->querylane--results#keys">
5
5
  <%# The answer's state, inside the frame (Turbo keeps the frame element's own
@@ -1,4 +1,5 @@
1
- <%# Grouped records drawn from a Querylane::Presenter. Locals: presenter. %>
1
+ <%# locals: (presenter:) -%>
2
+ <%# Grouped records drawn from a Querylane::Presenter. %>
2
3
  <% columns = presenter.columns %>
3
4
  <div class="ql-record ql-record-head" aria-hidden="true">
4
5
  <% columns.each do |column| %>
@@ -1,6 +1,7 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# Saved searches (M12): a native popover opened by the box's Saved button.
2
3
  Name and Save belong to the search form; choosing one sends its id and the
3
- server rewrites the box. Locals: result, id. %>
4
+ server rewrites the box. %>
4
5
  <div id="<%= id %>-saved" popover class="ql-saved">
5
6
  <div class="ql-saved-head">
6
7
  <label for="<%= id %>-saved-name"><%= t("querylane.saved.title") %></label>
@@ -1,8 +1,9 @@
1
- <%# A search box with suggestions for any page (M7 header, M13 palette).
2
- The form posts the text for suggestions into the list frame; the hidden
3
- submitters post it to the search page (full page, never in a URL).
4
- Locals: id, url, search_url, mode (:header or :palette), label, placeholder,
5
- trigger (palette: show its button; ⌘K / Ctrl-K always opens it). %>
1
+ <%# locals: (id:, url:, search_url:, mode:, label:, placeholder:, trigger:) -%>
2
+ <%# A search box with suggestions for any page (M7 header, M13 palette), in a
3
+ search landmark. The form posts the text for suggestions into the list
4
+ frame; the hidden submitters post it to the search page (full page, never
5
+ in a URL). mode is :header or :palette; trigger (palette) shows its
6
+ button; ⌘K / Ctrl-K always opens it. %>
6
7
  <% palette = mode == :palette %>
7
8
  <div class="ql-suggest ql-suggest-<%= mode %>" data-controller="querylane--suggest"
8
9
  data-querylane--suggest-debounce-value="<%= Querylane.configuration.debounce_ms %>"
@@ -17,11 +18,12 @@
17
18
  <dialog class="ql-palette" aria-label="<%= label %>" data-querylane--suggest-target="dialog"
18
19
  data-action="close->querylane--suggest#closed click->querylane--suggest#backdrop">
19
20
  <% end %>
20
- <%= form_with url: url, method: :post, id: "#{id}-form", class: "ql-suggest-form", html: {role: "search"},
21
+ <search class="ql-search">
22
+ <%= form_with url: url, method: :post, id: "#{id}-form", class: "ql-suggest-form",
21
23
  data: {turbo: true, turbo_frame: "#{id}-list", action: "submit->querylane--suggest#submitted"} do %>
22
24
  <%= hidden_field_tag :revision, 0, id: nil, data: {querylane__suggest_target: "revision"} %>
23
25
  <svg class="ql-box-icon" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><circle cx="8.5" cy="8.5" r="6" fill="none" stroke="currentColor" stroke-width="2"/><path d="M13 13l5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
24
- <label class="visually-hidden" for="<%= id %>-input"><%= label %></label>
26
+ <label class="ql-visually-hidden" for="<%= id %>-input"><%= label %></label>
25
27
  <input type="text" id="<%= id %>-input" name="<%= Querylane.configuration.param_name %>" class="ql-suggest-input"
26
28
  placeholder="<%= placeholder %>" autocomplete="off" autocapitalize="off" spellcheck="false"
27
29
  role="combobox" aria-autocomplete="list" aria-controls="<%= id %>-list" aria-expanded="false"
@@ -31,6 +33,7 @@
31
33
  <button type="submit" formaction="<%= search_url %>" data-turbo="false" hidden tabindex="-1" data-querylane--suggest-target="searchAll"></button>
32
34
  <button type="submit" formaction="<%= search_url %>" name="options" value="1" data-turbo="false" hidden tabindex="-1" data-querylane--suggest-target="openOptions"></button>
33
35
  <% end %>
36
+ </search>
34
37
  <div class="ql-suggest-pop" hidden data-querylane--suggest-target="pop">
35
38
  <turbo-frame id="<%= id %>-list" class="ql-suggest-list" role="listbox" aria-label="<%= label %>"
36
39
  data-querylane--suggest-target="list" data-action="combobox-commit->querylane--suggest#commit"></turbo-frame>
@@ -1,6 +1,6 @@
1
+ <%# locals: (result:, presenter:, groups:, id:) -%>
1
2
  <%# Suggestions: operator completions, people (groups), go-to. Options carry
2
- no query text: a completion holds only its word, people their own Action.
3
- Locals: result, presenter, groups, id. %>
3
+ no query text: a completion holds only its word, people their own Action. %>
4
4
  <turbo-frame id="<%= id %>-list" class="ql-suggest-list" role="listbox" data-revision="<%= result.revision %>">
5
5
  <% if result.completions.any? %>
6
6
  <div role="group" aria-labelledby="<%= id %>-operators">
@@ -1,4 +1,5 @@
1
- <%# Count, sort, page size and pager; controls belong to the search form. Locals: result, id, per_id. %>
1
+ <%# locals: (result:, id:, per_id:) -%>
2
+ <%# Count, sort, page size and pager; controls belong to the search form. %>
2
3
  <div class="ql-toolbar">
3
4
  <div class="ql-count">
4
5
  <%= case result.total_kind
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# The backend did not answer: nothing was searched. Hosts override this
2
- partial to add their own way round (e.g. a roster page). Locals: result, id. %>
3
+ partial to add their own way round (e.g. a roster page). %>
3
4
  <div class="ql-state ql-state-unavailable" role="status">
4
5
  <h2><%= t("querylane.states.unavailable_title") %></h2>
5
6
  <p><%= t("querylane.states.unavailable") %></p>
@@ -1,6 +1,7 @@
1
- <%# The options panel's "Writes" line: what Search would put in the box, with
2
- sensitive values masked. Its own frame, refreshed as fields change.
3
- Locals: preview (Querylane::Preview), id. %>
1
+ <%# locals: (preview:, id:) -%>
2
+ <%# The options panel's "Writes" line (a Querylane::Preview): what Search
3
+ would put in the box, with sensitive values masked. Its own frame,
4
+ refreshed as fields change. %>
4
5
  <turbo-frame id="<%= id %>-writes" class="ql-writes" aria-live="polite">
5
6
  <div class="ql-writes-line">
6
7
  <span class="ql-writes-label"><%= t("querylane.options.writes") %></span>