querylane 0.1.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/CONTRIBUTING.md +118 -0
  4. data/LICENSE +21 -0
  5. data/README.md +287 -0
  6. data/SECURITY.md +29 -0
  7. data/app/assets/stylesheets/querylane.css +300 -0
  8. data/app/helpers/querylane/search_helper.rb +111 -0
  9. data/app/javascript/querylane/controllers/box_controller.js +170 -0
  10. data/app/javascript/querylane/controllers/options_controller.js +54 -0
  11. data/app/javascript/querylane/controllers/results_controller.js +137 -0
  12. data/app/javascript/querylane/controllers/suggest_controller.js +187 -0
  13. data/app/javascript/querylane/index.js +33 -0
  14. data/app/javascript/querylane/overlay.js +46 -0
  15. data/app/javascript/querylane/package.json +7 -0
  16. data/app/views/querylane/_chips.html.erb +15 -0
  17. data/app/views/querylane/_denied.html.erb +6 -0
  18. data/app/views/querylane/_empty.html.erb +17 -0
  19. data/app/views/querylane/_facets.html.erb +30 -0
  20. data/app/views/querylane/_first_load.html.erb +15 -0
  21. data/app/views/querylane/_form.html.erb +46 -0
  22. data/app/views/querylane/_group.html.erb +21 -0
  23. data/app/views/querylane/_help.html.erb +29 -0
  24. data/app/views/querylane/_options.html.erb +32 -0
  25. data/app/views/querylane/_options_row.html.erb +51 -0
  26. data/app/views/querylane/_outcome.html.erb +16 -0
  27. data/app/views/querylane/_pager.html.erb +19 -0
  28. data/app/views/querylane/_preview.html.erb +39 -0
  29. data/app/views/querylane/_record.html.erb +16 -0
  30. data/app/views/querylane/_related.html.erb +17 -0
  31. data/app/views/querylane/_results.html.erb +66 -0
  32. data/app/views/querylane/_rows.html.erb +13 -0
  33. data/app/views/querylane/_saved.html.erb +31 -0
  34. data/app/views/querylane/_suggest_box.html.erb +45 -0
  35. data/app/views/querylane/_suggestions.html.erb +47 -0
  36. data/app/views/querylane/_toolbar.html.erb +28 -0
  37. data/app/views/querylane/_unavailable.html.erb +7 -0
  38. data/app/views/querylane/_writes.html.erb +16 -0
  39. data/config/importmap.rb +34 -0
  40. data/config/locales/querylane.en.yml +136 -0
  41. data/lib/querylane/ast.rb +17 -0
  42. data/lib/querylane/chips.rb +85 -0
  43. data/lib/querylane/compiler.rb +52 -0
  44. data/lib/querylane/configuration.rb +49 -0
  45. data/lib/querylane/context.rb +11 -0
  46. data/lib/querylane/controller.rb +85 -0
  47. data/lib/querylane/definition.rb +447 -0
  48. data/lib/querylane/doctor.rb +52 -0
  49. data/lib/querylane/engine.rb +61 -0
  50. data/lib/querylane/errors.rb +27 -0
  51. data/lib/querylane/filter.rb +75 -0
  52. data/lib/querylane/group_token.rb +33 -0
  53. data/lib/querylane/parser.rb +178 -0
  54. data/lib/querylane/presenter.rb +160 -0
  55. data/lib/querylane/preview.rb +45 -0
  56. data/lib/querylane/query.rb +183 -0
  57. data/lib/querylane/request.rb +66 -0
  58. data/lib/querylane/result.rb +90 -0
  59. data/lib/querylane/rewriter.rb +245 -0
  60. data/lib/querylane/saving.rb +49 -0
  61. data/lib/querylane/scope.rb +53 -0
  62. data/lib/querylane/search.rb +347 -0
  63. data/lib/querylane/searchers/meilisearch.rb +226 -0
  64. data/lib/querylane/suggest.rb +67 -0
  65. data/lib/querylane/version.rb +5 -0
  66. data/lib/querylane.rb +60 -0
  67. data/lib/tasks/querylane.rake +14 -0
  68. metadata +132 -0
@@ -0,0 +1,137 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Lives on the results <turbo-frame> (which persists while its contents are
4
+ // replaced): ↑/↓ move between records, Enter opens the focused record, Space
5
+ // previews its group (the preview follows ↑/↓, Esc closes it), opening a
6
+ // group's "more records" loads them once, the focused record survives a frame
7
+ // refresh, selects submit the search form.
8
+ export default class extends Controller {
9
+ static targets = ["preview", "previewButton"]
10
+
11
+ connect() {
12
+ this.focusFirst = () => this.move(0, true)
13
+ this.remember = (event) => {
14
+ if (event.target !== this.element) return
15
+ const focused = this.element.contains(document.activeElement) ? document.activeElement.closest("[data-querylane-record]") : null
16
+ this.focusedKey = focused?.dataset.querylaneRecord
17
+ }
18
+ // A preview answer that arrives after Esc must not reopen it.
19
+ this.guardPreview = (event) => {
20
+ if (event.target !== this.previewTargetOrNull || this.previewGroup) return
21
+ event.detail.render = () => {}
22
+ }
23
+ this.element.addEventListener("turbo:before-frame-render", this.guardPreview)
24
+ this.restore = (event) => {
25
+ if (event.target !== this.element) return
26
+ this.previewGroup = null
27
+ this.restoreFocus()
28
+ }
29
+ this.element.addEventListener("querylane:focus-first", this.focusFirst)
30
+ this.element.addEventListener("turbo:before-frame-render", this.remember)
31
+ this.element.addEventListener("turbo:frame-render", this.restore)
32
+ }
33
+
34
+ disconnect() {
35
+ this.element.removeEventListener("querylane:focus-first", this.focusFirst)
36
+ this.element.removeEventListener("turbo:before-frame-render", this.remember)
37
+ this.element.removeEventListener("turbo:frame-render", this.restore)
38
+ this.element.removeEventListener("turbo:before-frame-render", this.guardPreview)
39
+ }
40
+
41
+ keys(event) {
42
+ const record = event.target.closest("[data-querylane-record]")
43
+ if (!record) return
44
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
45
+ event.preventDefault()
46
+ const next = this.move(this.records.indexOf(record) + (event.key === "ArrowDown" ? 1 : -1))
47
+ if (next && this.previewing) this.showPreview(next)
48
+ } else if (event.key === "Enter" && event.target === record) {
49
+ event.preventDefault()
50
+ record.querySelector("[data-querylane-open]")?.click()
51
+ } else if (event.key === " " && event.target === record) {
52
+ event.preventDefault()
53
+ this.previewing ? this.closePreview() : this.showPreview(record)
54
+ } else if (event.key === "Escape" && this.previewing) {
55
+ event.preventDefault()
56
+ this.closePreview()
57
+ }
58
+ }
59
+
60
+ // A group's "more records" <details>: the first open loads them.
61
+ more(event) {
62
+ const details = event.currentTarget
63
+ if (!details.open || details.dataset.loaded) return
64
+ details.dataset.loaded = "true"
65
+ const button = details.querySelector("[data-querylane-more]")
66
+ button?.form?.requestSubmit(button)
67
+ }
68
+
69
+ // A facet's "Find…" box: show the loaded values matching it (all of them,
70
+ // not only the first few); empty shows the first few again.
71
+ findValue(event) {
72
+ const text = event.target.value.trim().toLowerCase()
73
+ event.target.closest(".ql-facet").querySelectorAll(".ql-facet-value").forEach((value) => {
74
+ const label = value.querySelector(".ql-facet-label").textContent.toLowerCase()
75
+ value.hidden = text ? !label.includes(text) : value.hasAttribute("data-querylane-beyond")
76
+ })
77
+ }
78
+
79
+ // Enter in a field that names its button (data-querylane-enter) clicks it.
80
+ enter(event) {
81
+ event.preventDefault()
82
+ document.getElementById(event.target.dataset.querylaneEnter)?.click()
83
+ }
84
+
85
+ closePreview() {
86
+ if (!this.hasPreviewTarget) return
87
+ this.previewTarget.innerHTML = ""
88
+ this.previewGroup = null
89
+ this.element.querySelector("[data-querylane-previewing]")?.removeAttribute("data-querylane-previewing")
90
+ this.element.querySelector(`[data-querylane-record="${CSS.escape(this.previewKey || "")}"]`)?.focus()
91
+ this.previewKey = null
92
+ }
93
+
94
+ submitForm(event) {
95
+ if (event.target.matches("select[form]")) event.target.form?.requestSubmit()
96
+ }
97
+
98
+ // private
99
+
100
+ get records() {
101
+ return Array.from(this.element.querySelectorAll("[data-querylane-record]"))
102
+ }
103
+
104
+ get previewTargetOrNull() {
105
+ return this.hasPreviewTarget ? this.previewTarget : null
106
+ }
107
+
108
+ get previewing() {
109
+ return Boolean(this.previewGroup)
110
+ }
111
+
112
+ move(index, first = false) {
113
+ const records = this.records
114
+ if (records.length === 0) return null
115
+ const target = records[Math.max(0, Math.min(first ? 0 : index, records.length - 1))]
116
+ target.focus()
117
+ return target
118
+ }
119
+
120
+ // Loads the record's group into the preview frame (once per group).
121
+ showPreview(record) {
122
+ const group = record.closest("[data-querylane-group]")
123
+ if (!group || !this.hasPreviewButtonTarget) return
124
+ this.element.querySelector("[data-querylane-previewing]")?.removeAttribute("data-querylane-previewing")
125
+ record.setAttribute("data-querylane-previewing", "")
126
+ this.previewKey = record.dataset.querylaneRecord
127
+ if (this.previewGroup === group.dataset.querylaneGroup) return
128
+ this.previewGroup = group.dataset.querylaneGroup
129
+ this.previewButtonTarget.value = this.previewGroup
130
+ this.previewButtonTarget.form?.requestSubmit(this.previewButtonTarget)
131
+ }
132
+
133
+ restoreFocus() {
134
+ if (!this.focusedKey) return
135
+ this.element.querySelector(`[data-querylane-record="${CSS.escape(this.focusedKey)}"]`)?.focus()
136
+ }
137
+ }
@@ -0,0 +1,187 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Combobox from "@github/combobox-nav"
3
+
4
+ // A search box with suggestions (header dropdown or ⌘K palette). Typing posts
5
+ // the text into the list frame (debounced, stale answers dropped); ↑/↓ and
6
+ // Enter come from combobox-nav; Tab completes an operator; Enter with nothing
7
+ // chosen, or "Search all", posts the text to the search page.
8
+ export default class extends Controller {
9
+ static targets = ["input", "list", "pop", "dialog", "revision", "searchAll", "openOptions"]
10
+ static values = { debounce: { type: Number, default: 300 }, palette: Boolean }
11
+
12
+ connect() {
13
+ this.combobox = new Combobox(this.inputTarget, this.listTarget, { tabInsertsSuggestions: false })
14
+ this.forget = () => this.clearAll()
15
+ document.addEventListener("turbo:before-cache", this.forget)
16
+ this.guardStale = (event) => this.guard(event)
17
+ this.listTarget.addEventListener("turbo:before-frame-render", this.guardStale)
18
+ this.outside = (event) => {
19
+ if (!this.paletteValue && !this.element.contains(event.target)) this.hide()
20
+ }
21
+ document.addEventListener("click", this.outside)
22
+ }
23
+
24
+ disconnect() {
25
+ clearTimeout(this.timer)
26
+ this.combobox.destroy()
27
+ this.listTarget.removeEventListener("turbo:before-frame-render", this.guardStale)
28
+ document.removeEventListener("click", this.outside)
29
+ document.removeEventListener("turbo:before-cache", this.forget)
30
+ }
31
+
32
+ // Every keystroke makes the shown suggestions stale: they stop answering
33
+ // keys and clicks until the answer for the new text arrives.
34
+ typed() {
35
+ this.bump()
36
+ this.combobox.stop()
37
+ this.listTarget.setAttribute("data-querylane-stale", "")
38
+ clearTimeout(this.timer)
39
+ if (this.composing) return
40
+ if (this.inputTarget.value.trim() === "") return this.hide()
41
+ this.timer = setTimeout(() => this.inputTarget.form.requestSubmit(), this.debounceValue)
42
+ }
43
+
44
+ compose() {
45
+ this.composing = true
46
+ clearTimeout(this.timer)
47
+ }
48
+
49
+ composed() {
50
+ this.composing = false
51
+ this.typed()
52
+ }
53
+
54
+ submitted() {
55
+ clearTimeout(this.timer)
56
+ }
57
+
58
+ keys(event) {
59
+ if (event.key === "Tab" && !event.shiftKey && this.visible) {
60
+ // While the next answer loads, Tab waits rather than leaving the box.
61
+ if (this.listTarget.hasAttribute("data-querylane-stale")) return event.preventDefault()
62
+ const option = this.listTarget.querySelector('[aria-selected="true"][data-querylane-complete]') ||
63
+ this.listTarget.querySelector("[data-querylane-complete]")
64
+ if (!option) return
65
+ event.preventDefault()
66
+ this.complete(option.dataset.querylaneComplete)
67
+ } else if (event.key === "Enter" && (this.listTarget.hasAttribute("data-querylane-stale") || !this.listTarget.querySelector('[aria-selected="true"]'))) {
68
+ event.preventDefault()
69
+ this.go("search")
70
+ } else if (event.key === "Escape" && !this.paletteValue) {
71
+ if (this.visible) this.hide()
72
+ else this.inputTarget.blur()
73
+ }
74
+ }
75
+
76
+ // combobox-nav: an option was chosen (Enter or click). Stale options do nothing.
77
+ commit(event) {
78
+ if (this.listTarget.hasAttribute("data-querylane-stale")) return
79
+ const option = event.target.closest('[role="option"]')
80
+ if (!option) return
81
+ if (option.dataset.querylaneComplete) return this.complete(option.dataset.querylaneComplete)
82
+ if (option.dataset.querylaneGo) return this.go(option.dataset.querylaneGo)
83
+ const open = option.querySelector("[data-querylane-open]")
84
+ open ? open.click() : this.go("search")
85
+ }
86
+
87
+ // "/" focuses the header box (unless the page has its own box); ⌘K / Ctrl-K opens the palette.
88
+ shortcut(event) {
89
+ if (this.paletteValue) {
90
+ if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
91
+ event.preventDefault()
92
+ this.open()
93
+ }
94
+ return
95
+ }
96
+ if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return
97
+ if (event.target.closest("input, textarea, select, [contenteditable]")) return
98
+ if (document.querySelector(".ql-box-input")) return
99
+ event.preventDefault()
100
+ this.inputTarget.focus()
101
+ }
102
+
103
+ open() {
104
+ this.dialogTarget.showModal()
105
+ this.inputTarget.focus()
106
+ }
107
+
108
+ closed() {
109
+ this.hide()
110
+ }
111
+
112
+ backdrop(event) {
113
+ if (event.target === this.dialogTarget) this.dialogTarget.close()
114
+ }
115
+
116
+ // Focus shows the last list again; a stale one stays inert until its answer renders.
117
+ reopen() {
118
+ if (this.listTarget.childElementCount === 0 || this.inputTarget.value.trim() === "") return
119
+ if (this.listTarget.hasAttribute("data-querylane-stale")) this.popTarget.hidden = false
120
+ else this.show()
121
+ }
122
+
123
+ // private
124
+
125
+ get visible() {
126
+ return !this.popTarget.hidden
127
+ }
128
+
129
+ get revision() {
130
+ return Number(this.revisionTarget.value)
131
+ }
132
+
133
+ bump() {
134
+ this.revisionTarget.value = String(this.revision + 1)
135
+ }
136
+
137
+ // Replaces the last word; the shown completions no longer apply, so they go
138
+ // until the next answer arrives (a quick Enter cannot pick a stale one).
139
+ complete(word) {
140
+ this.listTarget.querySelectorAll("[data-querylane-complete]").forEach((option) => option.remove())
141
+ const value = this.inputTarget.value.replace(/\S*$/, word)
142
+ this.inputTarget.value = word.endsWith(":") ? value : `${value} `
143
+ this.inputTarget.focus()
144
+ this.typed()
145
+ }
146
+
147
+ go(where) {
148
+ clearTimeout(this.timer)
149
+ const button = where === "options" ? this.openOptionsTarget : this.searchAllTarget
150
+ this.inputTarget.form.requestSubmit(button)
151
+ }
152
+
153
+ show() {
154
+ if (this.inputTarget.value.trim() === "") return
155
+ this.listTarget.removeAttribute("data-querylane-stale")
156
+ this.popTarget.hidden = false
157
+ this.combobox.start()
158
+ }
159
+
160
+ // Before Turbo snapshots the page: nothing typed or suggested is kept.
161
+ clearAll() {
162
+ clearTimeout(this.timer)
163
+ this.bump()
164
+ this.inputTarget.value = ""
165
+ this.inputTarget.removeAttribute("value")
166
+ this.listTarget.innerHTML = ""
167
+ this.hide()
168
+ if (this.paletteValue && this.dialogTarget.open) this.dialogTarget.close()
169
+ }
170
+
171
+ hide() {
172
+ this.popTarget.hidden = true
173
+ this.combobox.stop()
174
+ }
175
+
176
+ guard(event) {
177
+ if (event.target !== this.listTarget) return
178
+ const answered = Number(event.detail.newFrame?.dataset?.revision)
179
+ if (!Number.isFinite(answered)) return
180
+ const render = event.detail.render
181
+ event.detail.render = (current, next) => {
182
+ if (answered < this.revision) return
183
+ render(current, next)
184
+ this.show()
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,33 @@
1
+ // querylane — Stimulus controllers and a Turbo stream action.
2
+ //
3
+ // import { registerControllers } from "querylane"
4
+ // registerControllers(application) // the host's Stimulus application
5
+ //
6
+ // Never starts Stimulus or changes Turbo settings; the host owns both. Never
7
+ // imports Turbo either: the stream action is handled through a DOM event, so
8
+ // it works with whichever Turbo copy the page loaded (turbo-rails' bundle too).
9
+ import BoxController from "querylane/controllers/box_controller.js"
10
+ import ResultsController from "querylane/controllers/results_controller.js"
11
+ import OptionsController from "querylane/controllers/options_controller.js"
12
+ import SuggestController from "querylane/controllers/suggest_controller.js"
13
+
14
+ // Server rewrote the query (facet toggle, chip ×, Clear): set the box text in
15
+ // place, only if the box is still at the revision the response answers.
16
+ // Replacing `detail.render` is Turbo's documented hook for a custom action.
17
+ document.addEventListener("turbo:before-stream-render", (event) => {
18
+ if (event.target.getAttribute("action") !== "querylane_set_query") return
19
+
20
+ event.detail.render = (stream) => {
21
+ const detail = { revision: Number(stream.dataset.revision), query: stream.templateContent.textContent }
22
+ stream.targetElements.forEach((box) => box.dispatchEvent(new CustomEvent("querylane:set-query", { detail })))
23
+ }
24
+ })
25
+
26
+ export function registerControllers(application) {
27
+ application.register("querylane--box", BoxController)
28
+ application.register("querylane--results", ResultsController)
29
+ application.register("querylane--options", OptionsController)
30
+ application.register("querylane--suggest", SuggestController)
31
+ }
32
+
33
+ export { BoxController, OptionsController, ResultsController, SuggestController }
@@ -0,0 +1,46 @@
1
+ // Paints the decorative overlay behind the transparent input text: operator
2
+ // keys coloured, masked runs shown as dots (same width: the font is
3
+ // monospace), the server's error range underlined. Uses textContent only.
4
+ const TOKEN = /(-?)([A-Za-z][A-Za-z0-9_]*:)|\b(OR|NOT)\b|([()])/g
5
+
6
+ export function paint(overlay, input, { mask = "", error = null } = {}) {
7
+ const text = input.value
8
+ // One entry per UTF-16 unit, matching regexp indexes and input positions.
9
+ const classes = Array.from({ length: text.length }, () => new Set())
10
+
11
+ for (const match of text.matchAll(TOKEN)) {
12
+ const start = match.index + (match[1] ? match[1].length : 0)
13
+ const kind = match[2] ? "ql-tok-key" : "ql-tok-op"
14
+ if (match[1]) classes[match.index].add("ql-tok-op")
15
+ for (let i = start; i < match.index + match[0].length; i++) classes[i].add(kind)
16
+ }
17
+ if (mask) {
18
+ for (const match of text.matchAll(new RegExp(mask, "g"))) {
19
+ for (let i = match.index; i < match.index + match[0].length; i++) if (/[0-9A-Za-z]/.test(text[i])) classes[i].add("ql-tok-mask")
20
+ }
21
+ }
22
+ if (error) for (let i = error[0]; i < Math.min(error[1], text.length); i++) classes[i].add("ql-tok-error")
23
+
24
+ overlay.replaceChildren(...runs(text, classes))
25
+ overlay.scrollLeft = input.scrollLeft
26
+ }
27
+
28
+ function runs(text, classes) {
29
+ const spans = []
30
+ let start = 0
31
+ for (let i = 1; i <= text.length; i++) {
32
+ if (i < text.length && same(classes[i], classes[start])) continue
33
+ const span = document.createElement("span")
34
+ const names = Array.from(classes[start])
35
+ if (names.length) span.className = names.join(" ")
36
+ const chunk = text.slice(start, i)
37
+ span.textContent = names.includes("ql-tok-mask") ? chunk.replace(/[0-9A-Za-z]/g, "•") : chunk
38
+ spans.push(span)
39
+ start = i
40
+ }
41
+ return spans
42
+ }
43
+
44
+ function same(a, b) {
45
+ return a.size === b.size && [...a].every((name) => b.has(name))
46
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name" : "querylane",
3
+ "version" : "0.1.0",
4
+ "type" : "module",
5
+ "exports" : {".": "./index.js", "./*": "./*"},
6
+ "peerDependencies": {"@github/combobox-nav": "^3.0.0", "@hotwired/stimulus": "^3.2.0", "@hotwired/turbo": "^8.0.0"}
7
+ }
@@ -0,0 +1,15 @@
1
+ <%# "Searching for" chips. × removes that term (server rewrites the query). Locals: result, id. %>
2
+ <% if result.chips.any? %>
3
+ <div class="ql-chips">
4
+ <span class="ql-chips-label"><%= t("querylane.chips.searching_for") %></span>
5
+ <% result.chips.each do |chip| %>
6
+ <span class="ql-chip"><%= chip.label %><button type="submit" form="<%= id %>-form" name="remove" value="<%= chip.index %>"
7
+ class="ql-chip-remove" aria-label="<%= t("querylane.chips.remove", label: chip.label) %>">&times;</button></span>
8
+ <% chip.alternatives.each do |alternative| %>
9
+ <button type="submit" form="<%= id %>-form" name="instead" value="<%= chip.index %>.<%= alternative.index %>" class="ql-link"><%= alternative.label %></button>
10
+ <% end %>
11
+ <% end %>
12
+ <%= render "querylane/help", definition: result.definition, id: id %>
13
+ <button type="submit" form="<%= id %>-form" name="clear" value="1" class="ql-link"><%= t("querylane.chips.clear") %></button>
14
+ </div>
15
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%# The user's access could not be checked (Querylane::Scope.denied): nothing
2
+ is shown rather than records outside it. Locals: result, id. %>
3
+ <div class="ql-state ql-state-denied" role="alert">
4
+ <h2><%= t("querylane.states.denied_title") %></h2>
5
+ <p><%= t("querylane.states.denied") %></p>
6
+ </div>
@@ -0,0 +1,17 @@
1
+ <%# No hits: say what found nothing and offer one-click ways to widen. Locals: result, id. %>
2
+ <div class="ql-empty">
3
+ <h2><%= t("querylane.states.empty_title", searched: result.chips.map(&:label).join(" · ")) %></h2>
4
+ <div class="ql-empty-actions">
5
+ <% 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>
7
+ <% end %>
8
+ <%# Every filter can be dropped; the words are the search itself. %>
9
+ <% 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>
11
+ <% end %>
12
+ <% 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>
14
+ <% end %>
15
+ </div>
16
+ <p class="ql-facet-note"><%= t("querylane.states.empty_note") %></p>
17
+ </div>
@@ -0,0 +1,30 @@
1
+ <%# 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">
4
+ <% result.facets.each do |facet| %>
5
+ <section class="ql-facet">
6
+ <h3 class="ql-facet-title"><%= facet.label %></h3>
7
+ <% if facet.failed %>
8
+ <div class="ql-facet-note"><%= t("querylane.facets.unavailable") %></div>
9
+ <% else %>
10
+ <% shown = facet.values.select { |value| value.ticked || value.count.positive? } %>
11
+ <% if facet.search && shown.size > facet.show.to_i %>
12
+ <input type="search" class="form-control form-control-sm ql-facet-find" autocomplete="off"
13
+ aria-label="<%= t("querylane.facets.find_label", facet: facet.label) %>" placeholder="<%= t("querylane.facets.find", facet: facet.label.downcase) %>"
14
+ data-action="input->querylane--results#findValue">
15
+ <% end %>
16
+ <% shown.each_with_index do |value, position| %>
17
+ <% beyond = facet.show && position >= facet.show && !value.ticked %>
18
+ <button type="submit" form="<%= id %>-form" name="toggle" value="<%= facet.key %>:<%= value.value %>"
19
+ class="ql-facet-value" role="checkbox" aria-checked="<%= value.ticked %>" <%= "hidden" if beyond %>
20
+ <%= tag.attributes(data: {querylane_beyond: (true if beyond)}) %>>
21
+ <span class="ql-check" aria-hidden="true"></span>
22
+ <span class="ql-facet-label"><%= value.label %></span>
23
+ <span class="ql-facet-count"><%= "~" if facet.approximate %><%= number_with_delimiter(value.count) %></span>
24
+ </button>
25
+ <% end %>
26
+ <% end %>
27
+ </section>
28
+ <% end %>
29
+ <p class="ql-facet-note"><%= t("querylane.facets.note") %></p>
30
+ </aside>
@@ -0,0 +1,15 @@
1
+ <%# Before anything is typed: what to type, with the definition's examples. Locals: definition. %>
2
+ <div class="ql-first-load">
3
+ <div class="ql-first-load-lead">
4
+ <h2><%= t("querylane.states.first_load_title") %></h2>
5
+ <p><%= t("querylane.states.first_load") %></p>
6
+ </div>
7
+ <% if definition.examples.any? %>
8
+ <div class="ql-first-load-examples">
9
+ <div class="ql-facet-title"><%= t("querylane.states.try") %></div>
10
+ <% definition.examples.each do |example| %>
11
+ <div class="ql-example"><code><%= example.query %></code><span><%= example.description %></span></div>
12
+ <% end %>
13
+ </div>
14
+ <% end %>
15
+ </div>
@@ -0,0 +1,46 @@
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",
5
+ querylane__box_debounce_value: Querylane.configuration.debounce_ms,
6
+ querylane__box_mask_value: mask.to_s,
7
+ querylane__box_frame_value: "#{id}-results",
8
+ querylane__box_min_value: result.definition.min_text_length.to_i,
9
+ action: "submit->querylane--box#submitted turbo:before-cache@document->querylane--box#forget keydown@document->querylane--box#shortcut"} do %>
10
+ <%# The default button: Enter in the box searches (Options comes first in the box). %>
11
+ <button type="submit" hidden tabindex="-1" aria-hidden="true"></button>
12
+ <%= hidden_field_tag :revision, result.revision, id: nil, data: {querylane__box_target: "revision"} %>
13
+ <div class="ql-box" id="<%= id %>-box" data-querylane--box-target="box" data-action="querylane:set-query->querylane--box#setQuery">
14
+ <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
+ <div class="ql-box-field">
16
+ <div class="ql-box-overlay" aria-hidden="true" data-querylane--box-target="overlay"></div>
17
+ <label class="visually-hidden" for="<%= input_id %>"><%= label %></label>
18
+ <input type="text" id="<%= input_id %>" name="<%= Querylane.configuration.param_name %>" value="<%= result.query %>"
19
+ class="ql-box-input" placeholder="<%= placeholder %>" autocomplete="off" autocapitalize="off" spellcheck="false"
20
+ aria-describedby="<%= [("#{id}-help-text" if help), "#{id}-error"].compact.join(" ") %>"
21
+ data-querylane--box-target="input"
22
+ 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
+ </div>
24
+ <span class="ql-box-busy-label" aria-hidden="true"><%= t("querylane.states.searching") %></span>
25
+ <button type="button" class="ql-box-clear" aria-label="<%= t("querylane.box.clear") %>" data-action="querylane--box#clear">&times;</button>
26
+ <%= extra %>
27
+ <% if result.definition.saved_store %>
28
+ <%# Filled star only while the box holds one of the user's saved searches. %>
29
+ <button type="button" class="ql-box-saved" popovertarget="<%= id %>-saved" aria-pressed="<%= result.saved_active? %>"
30
+ data-querylane--box-target="saved">
31
+ <span class="ql-star-off" aria-hidden="true">☆</span><span class="ql-star-on" aria-hidden="true">★</span> <%= t("querylane.saved.button") %>
32
+ </button>
33
+ <% end %>
34
+ <% if options %>
35
+ <%# Opens the panel; while it is open, a click closes it (a plain search). %>
36
+ <button type="submit" name="options" value="1" class="ql-box-options" aria-expanded="<%= result.options_open? %>" aria-controls="<%= id %>-options"
37
+ data-querylane--box-target="options" data-action="querylane--box#toggleOptions">
38
+ <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 5h8M15 5h2M3 10h2M9 10h8M3 15h10M17 15h0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="13" cy="5" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="7" cy="10" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="15" cy="15" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
39
+ <%= t("querylane.box.options") %>
40
+ </button>
41
+ <% 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>
43
+ </div>
44
+ <% if help %><p class="ql-box-help" id="<%= id %>-help-text"><%= help %></p><% end %>
45
+ <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
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <%# 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
+ <% token = presenter.group_token(group) %>
4
+ <div class="ql-group" <%= tag.attributes(data: {querylane_group: token}) %>>
5
+ <div class="ql-group-head">
6
+ <div class="ql-group-title"><%= presenter.title(group) %></div>
7
+ <% if (subtitle = presenter.subtitle(group)) %><div class="ql-group-subtitle"><%= subtitle %></div><% end %>
8
+ <% if (note = presenter.note(group)) %><div class="ql-group-note"><%= note %></div><% end %>
9
+ </div>
10
+ <% group.hits.each do |hit| %>
11
+ <%= render "querylane/record", presenter: presenter, hit: hit, columns: columns %>
12
+ <% end %>
13
+ <% if token && (more = presenter.more_count(group)) %>
14
+ <details class="ql-more" data-action="toggle->querylane--results#more">
15
+ <summary class="ql-link"><span class="ql-more-open"><%= t("querylane.group.more", count: more) %></span><span class="ql-more-hide"><%= t("querylane.group.hide") %></span></summary>
16
+ <button type="submit" form="<%= presenter.id %>-form" name="group" value="<%= token %>" hidden tabindex="-1"
17
+ data-turbo-frame="<%= presenter.related_frame_id(group) %>" data-querylane-more></button>
18
+ <turbo-frame id="<%= presenter.related_frame_id(group) %>" class="ql-related"></turbo-frame>
19
+ </details>
20
+ <% end %>
21
+ </div>
@@ -0,0 +1,29 @@
1
+ <%# 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") %>
4
+ <button type="button" class="ql-link" popovertarget="<%= popover_id %>"><%= t("querylane.chips.syntax_help") %></button>
5
+ <div id="<%= popover_id %>" popover class="ql-help">
6
+ <div class="ql-help-head">
7
+ <strong><%= t("querylane.help.title") %></strong>
8
+ <span><%= t("querylane.help.lead") %></span>
9
+ </div>
10
+ <dl class="ql-help-list">
11
+ <% definition.texts.each do |text| %>
12
+ <dt><%= text.default ? t("querylane.help.words") : "#{text.key}:" %></dt><dd><%= text.label.gsub("%{value}", "…") %><%= " · #{t("querylane.help.masked")}" if text.sensitive %></dd>
13
+ <% end %>
14
+ <% definition.filters.each do |filter| %>
15
+ <dt><%= filter.key %>:</dt><dd><%= filter.label.gsub("%{value}", "…") %><%= " · #{t("querylane.help.masked")}" if filter.sensitive %></dd>
16
+ <% end %>
17
+ <% definition.flags.each do |flags| %>
18
+ <dt><%= ([flags.key] + flags.aliases).map { |key| "#{key}:" }.join(" ") %></dt><dd><%= flags.values.keys.join(", ") %></dd>
19
+ <% end %>
20
+ <dt>- NOT OR ( )</dt><dd><%= t("querylane.help.boolean") %></dd>
21
+ </dl>
22
+ <% if definition.examples.any? %>
23
+ <div class="ql-help-examples">
24
+ <% definition.examples.each do |example| %>
25
+ <div><code><%= example.query %></code> <span><%= example.description %></span></div>
26
+ <% end %>
27
+ </div>
28
+ <% end %>
29
+ </div>
@@ -0,0 +1,32 @@
1
+ <%# The options panel (M3): fields read from the box, Search writes them back.
2
+ Fields belong to the search form (form=…), so the live box text travels with
3
+ them; the server composes. Locals: result, id. %>
4
+ <section class="ql-options" id="<%= id %>-options" aria-labelledby="<%= id %>-options-title"
5
+ data-controller="querylane--options" data-querylane--options-debounce-value="<%= Querylane.configuration.debounce_ms %>"
6
+ data-action="input->querylane--options#changed change->querylane--options#changed keydown.esc->querylane--options#cancel keydown.enter->querylane--options#apply">
7
+ <header class="ql-options-head">
8
+ <h2 id="<%= id %>-options-title"><%= t("querylane.options.title") %></h2>
9
+ <span><%= t("querylane.options.lead") %></span>
10
+ </header>
11
+ <div class="ql-options-groups">
12
+ <% result.definition.panel.each do |group| %>
13
+ <fieldset class="ql-options-group">
14
+ <% if group.label %><legend><%= group.label %></legend><% end %>
15
+ <% group.rows.each do |row| %>
16
+ <%= render "querylane/options_row", row: row, values: result.panel.values, held: result.panel.held, definition: result.definition, id: id %>
17
+ <% end %>
18
+ </fieldset>
19
+ <% end %>
20
+ </div>
21
+ <button type="submit" form="<%= id %>-form" name="compose_preview" value="1" data-turbo-frame="<%= id %>-writes" hidden
22
+ tabindex="-1" data-querylane--options-target="preview"></button>
23
+ <%= render "querylane/writes", preview: result.preview, id: id %>
24
+ <footer class="ql-options-foot">
25
+ <%= render "querylane/help", definition: result.definition, id: id, popover_id: "#{id}-options-help" %>
26
+ <button type="button" class="ql-link" data-action="querylane--options#reset"><%= t("querylane.options.reset") %></button>
27
+ <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
+ data-querylane--options-target="apply"><%= t("querylane.options.search") %></button>
31
+ </footer>
32
+ </section>