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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/CONTRIBUTING.md +118 -0
- data/LICENSE +21 -0
- data/README.md +287 -0
- data/SECURITY.md +29 -0
- data/app/assets/stylesheets/querylane.css +300 -0
- data/app/helpers/querylane/search_helper.rb +111 -0
- data/app/javascript/querylane/controllers/box_controller.js +170 -0
- data/app/javascript/querylane/controllers/options_controller.js +54 -0
- data/app/javascript/querylane/controllers/results_controller.js +137 -0
- data/app/javascript/querylane/controllers/suggest_controller.js +187 -0
- data/app/javascript/querylane/index.js +33 -0
- data/app/javascript/querylane/overlay.js +46 -0
- data/app/javascript/querylane/package.json +7 -0
- data/app/views/querylane/_chips.html.erb +15 -0
- data/app/views/querylane/_denied.html.erb +6 -0
- data/app/views/querylane/_empty.html.erb +17 -0
- data/app/views/querylane/_facets.html.erb +30 -0
- data/app/views/querylane/_first_load.html.erb +15 -0
- data/app/views/querylane/_form.html.erb +46 -0
- data/app/views/querylane/_group.html.erb +21 -0
- data/app/views/querylane/_help.html.erb +29 -0
- data/app/views/querylane/_options.html.erb +32 -0
- data/app/views/querylane/_options_row.html.erb +51 -0
- data/app/views/querylane/_outcome.html.erb +16 -0
- data/app/views/querylane/_pager.html.erb +19 -0
- data/app/views/querylane/_preview.html.erb +39 -0
- data/app/views/querylane/_record.html.erb +16 -0
- data/app/views/querylane/_related.html.erb +17 -0
- data/app/views/querylane/_results.html.erb +66 -0
- data/app/views/querylane/_rows.html.erb +13 -0
- data/app/views/querylane/_saved.html.erb +31 -0
- data/app/views/querylane/_suggest_box.html.erb +45 -0
- data/app/views/querylane/_suggestions.html.erb +47 -0
- data/app/views/querylane/_toolbar.html.erb +28 -0
- data/app/views/querylane/_unavailable.html.erb +7 -0
- data/app/views/querylane/_writes.html.erb +16 -0
- data/config/importmap.rb +34 -0
- data/config/locales/querylane.en.yml +136 -0
- data/lib/querylane/ast.rb +17 -0
- data/lib/querylane/chips.rb +85 -0
- data/lib/querylane/compiler.rb +52 -0
- data/lib/querylane/configuration.rb +49 -0
- data/lib/querylane/context.rb +11 -0
- data/lib/querylane/controller.rb +85 -0
- data/lib/querylane/definition.rb +447 -0
- data/lib/querylane/doctor.rb +52 -0
- data/lib/querylane/engine.rb +61 -0
- data/lib/querylane/errors.rb +27 -0
- data/lib/querylane/filter.rb +75 -0
- data/lib/querylane/group_token.rb +33 -0
- data/lib/querylane/parser.rb +178 -0
- data/lib/querylane/presenter.rb +160 -0
- data/lib/querylane/preview.rb +45 -0
- data/lib/querylane/query.rb +183 -0
- data/lib/querylane/request.rb +66 -0
- data/lib/querylane/result.rb +90 -0
- data/lib/querylane/rewriter.rb +245 -0
- data/lib/querylane/saving.rb +49 -0
- data/lib/querylane/scope.rb +53 -0
- data/lib/querylane/search.rb +347 -0
- data/lib/querylane/searchers/meilisearch.rb +226 -0
- data/lib/querylane/suggest.rb +67 -0
- data/lib/querylane/version.rb +5 -0
- data/lib/querylane.rb +60 -0
- data/lib/tasks/querylane.rake +14 -0
- metadata +132 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* querylane — search box, chips, facets and grouped results.
|
|
3
|
+
* Plain CSS, scoped to .ql-* / .querylane-*. Theme with the custom properties
|
|
4
|
+
* below (hosts set them on :root or a wrapper). Works beside Bootstrap 5.
|
|
5
|
+
* No CSS min()/max()/clamp(): hosts that compress with SassC reject mixed units.
|
|
6
|
+
*/
|
|
7
|
+
/* The hidden attribute wins over the display rules below. */
|
|
8
|
+
:where(.querylane-form, .ql-results, .ql-suggest, .ql-palette) [hidden] { display: none !important; }
|
|
9
|
+
|
|
10
|
+
:root {
|
|
11
|
+
--ql-accent: var(--bs-primary, #0d6efd);
|
|
12
|
+
--ql-accent-soft: #e5edff;
|
|
13
|
+
--ql-heading: #262b31;
|
|
14
|
+
--ql-text: #262b31;
|
|
15
|
+
--ql-muted: #505050;
|
|
16
|
+
--ql-faint: #767676;
|
|
17
|
+
--ql-border: #c1c1c1;
|
|
18
|
+
--ql-rule: #e3e5ea;
|
|
19
|
+
--ql-surface: #ffffff;
|
|
20
|
+
--ql-surface-alt: #f9faff;
|
|
21
|
+
--ql-chip: #f3f4f9;
|
|
22
|
+
--ql-highlight: #fff0a8;
|
|
23
|
+
--ql-error: #b01c1c;
|
|
24
|
+
--ql-success-bg: #e7f6ee; --ql-success-fg: #1e7f4f;
|
|
25
|
+
--ql-warning-bg: #fff6d6; --ql-warning-fg: #7a5c00;
|
|
26
|
+
--ql-danger-bg: #fdf5f5; --ql-danger-fg: #b01c1c;
|
|
27
|
+
--ql-info-bg: #e5edff; --ql-info-fg: #002e64;
|
|
28
|
+
--ql-neutral-bg: #f2f2f2; --ql-neutral-fg: #505050;
|
|
29
|
+
--ql-font-mono: "IBM Plex Mono", "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
30
|
+
--ql-font-sans: inherit;
|
|
31
|
+
--ql-facets-width: 212px;
|
|
32
|
+
--ql-outcome-width: 220px;
|
|
33
|
+
--ql-status-width: 92px;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Box ------------------------------------------------------------------- */
|
|
37
|
+
.ql-box { display: flex; align-items: center; gap: 12px; height: 56px; padding: 0 8px 0 16px;
|
|
38
|
+
border: 2px solid var(--ql-accent); border-radius: 6px; background: var(--ql-surface); }
|
|
39
|
+
.ql-box:focus-within { box-shadow: 0 0 0 3px var(--ql-accent-soft); }
|
|
40
|
+
.ql-box-invalid { border-color: var(--ql-error); }
|
|
41
|
+
.ql-box-icon { flex-shrink: 0; color: var(--ql-heading); }
|
|
42
|
+
.ql-box-field { position: relative; flex: 1 1 auto; min-width: 0; height: 24px; }
|
|
43
|
+
.ql-box-input, .ql-box-overlay { position: absolute; inset: 0; margin: 0; padding: 0; border: 0;
|
|
44
|
+
font: 400 16px/24px var(--ql-font-mono); letter-spacing: 0; white-space: pre; }
|
|
45
|
+
.ql-box-input { width: 100%; background: transparent; color: transparent; caret-color: var(--ql-heading); outline: none; }
|
|
46
|
+
/* The box draws the only border: host input and focus styles stay off its fields. */
|
|
47
|
+
.ql-box .ql-box-input, .ql-box .ql-box-input:focus, .ql-suggest .ql-suggest-input, .ql-suggest .ql-suggest-input:focus {
|
|
48
|
+
height: auto; margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; outline: none; }
|
|
49
|
+
.ql-box-input::placeholder { color: var(--ql-faint); font-family: var(--ql-font-sans); }
|
|
50
|
+
.ql-box-input::selection { color: transparent; background: rgba(0, 78, 168, 0.25); }
|
|
51
|
+
.ql-box-overlay { overflow: hidden; color: var(--ql-text); pointer-events: none; }
|
|
52
|
+
.ql-tok-key { color: var(--ql-accent); }
|
|
53
|
+
.ql-tok-op { color: var(--ql-accent); }
|
|
54
|
+
.ql-tok-error { text-decoration: underline wavy var(--ql-error); color: var(--ql-error); }
|
|
55
|
+
.ql-box-clear { flex-shrink: 0; border: 0; background: none; color: var(--ql-muted); font-size: 20px; line-height: 1; padding: 4px 8px; }
|
|
56
|
+
.ql-box-submit { flex-shrink: 0; height: 40px; padding: 0 20px; font-weight: 700; }
|
|
57
|
+
@media (forced-colors: active) {
|
|
58
|
+
.ql-box-input { color: CanvasText; }
|
|
59
|
+
.ql-box-overlay { display: none; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Error, chips, states ------------------------------------------------------ */
|
|
63
|
+
.ql-error:empty { display: none; }
|
|
64
|
+
.ql-error { margin-top: 6px; font-size: 13px; line-height: 18px; color: var(--ql-text); }
|
|
65
|
+
.ql-error strong { color: var(--ql-error); margin-right: 6px; }
|
|
66
|
+
.ql-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; font-size: 13px; line-height: 18px; }
|
|
67
|
+
.ql-chips-label { color: var(--ql-muted); }
|
|
68
|
+
.ql-chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid var(--ql-border);
|
|
69
|
+
border-radius: 13px; background: var(--ql-chip); color: var(--ql-text); }
|
|
70
|
+
.ql-chip-remove { border: 0; background: none; padding: 0; color: var(--ql-muted); line-height: 1; }
|
|
71
|
+
.ql-link { border: 0; background: none; padding: 0; color: var(--bs-link-color, #0865d1); font-weight: 500; }
|
|
72
|
+
[data-querylane-stale] .ql-chips { opacity: 0.55; }
|
|
73
|
+
.ql-state { padding: 28px 0; font-size: 15px; color: var(--ql-muted); }
|
|
74
|
+
.ql-state-unavailable { padding: 20px 24px; border-left: 3px solid #977405; background: #fffbea; color: var(--ql-text); }
|
|
75
|
+
|
|
76
|
+
/* Layout -------------------------------------------------------------------- */
|
|
77
|
+
.ql-layout { display: grid; grid-template-columns: var(--ql-facets-width) minmax(0, 1fr); gap: 28px; margin-top: 28px; }
|
|
78
|
+
@media (max-width: 900px) { .ql-layout { grid-template-columns: 1fr; } }
|
|
79
|
+
|
|
80
|
+
/* Facets -------------------------------------------------------------------- */
|
|
81
|
+
.ql-facets { display: flex; flex-direction: column; gap: 22px; }
|
|
82
|
+
.ql-facet { display: flex; flex-direction: column; gap: 8px; }
|
|
83
|
+
.ql-facet-title { margin: 0 0 2px; font-size: 12px; line-height: 16px; font-weight: 700; letter-spacing: 0.06em; font-variant-caps: all-small-caps; color: var(--ql-heading); }
|
|
84
|
+
.ql-facet-value { display: flex; align-items: center; gap: 8px; width: 100%; padding: 0; border: 0; background: none;
|
|
85
|
+
font-size: 14px; line-height: 18px; color: var(--ql-text); text-align: left; }
|
|
86
|
+
.ql-check { width: 16px; height: 16px; flex-shrink: 0; border: 1px solid var(--ql-muted); border-radius: 2px; background: var(--ql-surface); }
|
|
87
|
+
.ql-facet-value[aria-checked="true"] .ql-check { border-color: var(--ql-accent); background: var(--ql-accent)
|
|
88
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 8'%3E%3Cpath d='M1 4l3 3 5-6' fill='none' stroke='white' stroke-width='1.6'/%3E%3C/svg%3E") center / 10px no-repeat; }
|
|
89
|
+
.ql-facet-label { flex: 1 1 auto; }
|
|
90
|
+
.ql-facet-count { color: var(--ql-muted); font-size: 13px; }
|
|
91
|
+
.ql-facet-note { margin: 0; font-size: 12px; line-height: 16px; color: var(--ql-faint); }
|
|
92
|
+
|
|
93
|
+
/* Toolbar, pager -------------------------------------------------------------- */
|
|
94
|
+
.ql-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 2px solid var(--ql-heading); }
|
|
95
|
+
.ql-count-note { margin-left: 10px; font-size: 13px; font-weight: 400; color: var(--ql-muted); }
|
|
96
|
+
.ql-count { font-size: 18px; line-height: 22px; font-weight: 700; color: var(--ql-heading); }
|
|
97
|
+
.ql-toolbar-controls { display: flex; align-items: center; gap: 16px; }
|
|
98
|
+
.ql-control { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; color: var(--ql-muted); }
|
|
99
|
+
.ql-control .form-select { width: auto; }
|
|
100
|
+
|
|
101
|
+
/* Records --------------------------------------------------------------------- */
|
|
102
|
+
.ql-record { display: flex; align-items: center; padding: 6px 16px 6px 28px; font-size: 14px; line-height: 18px; color: var(--ql-text); }
|
|
103
|
+
.ql-record:focus { outline: 2px solid var(--ql-accent); outline-offset: -2px; border-radius: 4px; background: var(--ql-surface); }
|
|
104
|
+
.ql-record-head { padding-top: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--ql-rule); font-size: 11px; line-height: 14px;
|
|
105
|
+
font-weight: 600; letter-spacing: 0.06em; font-variant-caps: all-small-caps; color: var(--ql-faint); }
|
|
106
|
+
.ql-lane { flex-shrink: 0; }
|
|
107
|
+
.ql-lane-status { width: var(--ql-status-width); }
|
|
108
|
+
.ql-lane-flags { flex: 1 1 0; min-width: 0; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
109
|
+
.ql-lane-outcome { width: var(--ql-outcome-width); display: flex; justify-content: flex-end; text-align: right; }
|
|
110
|
+
.ql-cell-strong { font-weight: 600; }
|
|
111
|
+
.ql-cell-muted { color: var(--ql-muted); }
|
|
112
|
+
.ql-group { border-bottom: 1px solid var(--ql-rule); padding-bottom: 8px; }
|
|
113
|
+
.ql-group:focus-within { box-shadow: inset 3px 0 0 var(--ql-accent); background: var(--ql-surface-alt); }
|
|
114
|
+
.ql-group-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px 6px; }
|
|
115
|
+
.ql-group-title { font-size: 16px; line-height: 20px; font-weight: 700; color: var(--ql-heading); }
|
|
116
|
+
.ql-group-subtitle { font: 400 14px/18px var(--ql-font-mono); color: var(--ql-muted); }
|
|
117
|
+
.ql-group-note { margin-left: auto; font-size: 12px; line-height: 16px; color: var(--ql-muted); }
|
|
118
|
+
.ql-group mark, .ql-group-subtitle mark { padding: 0; border-radius: 2px; background: var(--ql-highlight); color: var(--ql-heading); }
|
|
119
|
+
.ql-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; line-height: 16px; font-weight: 600; }
|
|
120
|
+
.ql-flag { display: inline-block; padding: 1px 7px; border-radius: 3px; font-size: 12px; line-height: 16px; font-weight: 600; white-space: nowrap; }
|
|
121
|
+
.ql-badge-success, .ql-flag-success { background: var(--ql-success-bg); color: var(--ql-success-fg); }
|
|
122
|
+
.ql-badge-warning, .ql-flag-warning { background: var(--ql-warning-bg); color: var(--ql-warning-fg); }
|
|
123
|
+
.ql-badge-danger, .ql-flag-danger { background: var(--ql-danger-bg); color: var(--ql-danger-fg); }
|
|
124
|
+
.ql-badge-info, .ql-flag-info { background: var(--ql-info-bg); color: var(--ql-info-fg); }
|
|
125
|
+
.ql-badge-neutral { background: var(--ql-neutral-bg); color: var(--ql-neutral-fg); }
|
|
126
|
+
.ql-flag-neutral { border: 1px solid var(--ql-border); color: var(--ql-muted); }
|
|
127
|
+
.ql-reason { font-size: 13px; line-height: 17px; color: var(--ql-muted); }
|
|
128
|
+
.ql-open { font-weight: 700; }
|
|
129
|
+
|
|
130
|
+
/* Footer ---------------------------------------------------------------------- */
|
|
131
|
+
.ql-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 16px; font-size: 13px; color: var(--ql-muted); }
|
|
132
|
+
.ql-keys { display: flex; gap: 14px; font-size: 12px; color: var(--ql-faint); }
|
|
133
|
+
.ql-keys kbd { padding: 0 5px; border: 1px solid var(--ql-border); border-radius: 3px; background: none; color: var(--ql-muted); font: 12px var(--ql-font-mono); }
|
|
134
|
+
|
|
135
|
+
/* Help, first load, empty ---------------------------------------------------- */
|
|
136
|
+
.ql-help { width: 720px; max-width: 92vw; padding: 0; border: 1px solid var(--ql-border); border-radius: 6px; box-shadow: 0 12px 32px rgba(38, 43, 49, 0.16); }
|
|
137
|
+
.ql-help-head { display: flex; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--ql-rule); font-size: 13px; color: var(--ql-muted); }
|
|
138
|
+
.ql-help-head strong { font-size: 16px; color: var(--ql-heading); }
|
|
139
|
+
.ql-help-list { display: grid; grid-template-columns: max-content 1fr; gap: 8px 24px; margin: 0; padding: 16px 20px; font-size: 13px; }
|
|
140
|
+
.ql-help-list dt { font: 600 13px var(--ql-font-mono); color: var(--ql-accent); }
|
|
141
|
+
.ql-help-list dd { margin: 0; color: var(--ql-muted); }
|
|
142
|
+
.ql-help-examples, .ql-first-load-examples { display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
|
|
143
|
+
.ql-help-examples { padding: 12px 20px 16px; border-top: 1px solid var(--ql-rule); background: var(--ql-surface-alt); }
|
|
144
|
+
.ql-help-examples code, .ql-example code { font-family: var(--ql-font-mono); color: var(--bs-link-color, #0865d1); }
|
|
145
|
+
.ql-first-load { display: flex; gap: 56px; padding: 28px 0 8px; margin-top: 16px; }
|
|
146
|
+
.ql-first-load-lead { width: 300px; flex-shrink: 0; }
|
|
147
|
+
.ql-empty h2 { margin: 0 0 8px; font-size: 22px; line-height: 28px; font-weight: 700; color: var(--ql-heading); }
|
|
148
|
+
/* First load is help, not a headline: a quiet lead-in. */
|
|
149
|
+
.ql-first-load-lead h2 { margin: 0 0 4px; font-size: 16px; line-height: 22px; font-weight: 600; color: var(--ql-text); }
|
|
150
|
+
.ql-first-load-lead p { font-size: 14px; color: var(--ql-muted); }
|
|
151
|
+
.ql-example { display: flex; gap: 16px; }
|
|
152
|
+
.ql-example code { width: 220px; }
|
|
153
|
+
.ql-example span, .ql-help-examples span { color: var(--ql-muted); }
|
|
154
|
+
.ql-empty { padding: 28px 0 8px; }
|
|
155
|
+
.ql-empty-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0; }
|
|
156
|
+
|
|
157
|
+
/* Options panel (M3): over the results, which dim and go inert beneath it. */
|
|
158
|
+
.ql-box-options { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; padding: 6px 10px; border: 0;
|
|
159
|
+
border-left: 1px solid var(--ql-rule); background: none; color: var(--ql-accent); font-weight: 500; }
|
|
160
|
+
.ql-box-options[aria-expanded="true"] { background: var(--ql-accent-soft); border-radius: 4px; }
|
|
161
|
+
.ql-options { margin: 0 0 16px; border: 1px solid var(--ql-border); border-radius: 6px; background: var(--ql-surface);
|
|
162
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); }
|
|
163
|
+
.ql-options-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 14px 24px;
|
|
164
|
+
border-bottom: 1px solid var(--ql-rule); }
|
|
165
|
+
.ql-options-head h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--ql-heading); }
|
|
166
|
+
.ql-options-head span { color: var(--ql-muted); font-size: 13px; }
|
|
167
|
+
.ql-options-groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 8px 40px; padding: 18px 24px; }
|
|
168
|
+
.ql-options-group { min-width: 0; margin: 0; padding: 0; border: 0; }
|
|
169
|
+
.ql-options-group legend { float: none; margin-bottom: 12px; font-size: 12px; font-weight: 600; letter-spacing: 0.08em;
|
|
170
|
+
font-variant-caps: all-small-caps; color: var(--ql-muted); }
|
|
171
|
+
.ql-options-row { display: grid; grid-template-columns: 132px 1fr; align-items: center; gap: 12px; min-height: 34px; margin-bottom: 14px; }
|
|
172
|
+
.ql-options-label { color: var(--ql-text); font-size: 14px; }
|
|
173
|
+
.ql-options-inputs { display: flex; gap: 12px; }
|
|
174
|
+
.ql-options-input { position: relative; flex: 1; min-width: 0; }
|
|
175
|
+
.ql-options-input input { width: 100%; }
|
|
176
|
+
.ql-masked { -webkit-text-security: disc; }
|
|
177
|
+
/* Above the field even when a host lifts a focused .form-control (Bootstrap 3 themes set z-index: 2). */
|
|
178
|
+
.ql-options-reveal { position: absolute; top: 50%; right: 10px; z-index: 3; transform: translateY(-50%); font-size: 13px; }
|
|
179
|
+
.ql-options-checks { display: flex; flex-wrap: wrap; gap: 6px 18px; }
|
|
180
|
+
.ql-options-check { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; color: var(--ql-text); }
|
|
181
|
+
.ql-writes { display: block; padding: 14px 24px; border-top: 1px solid var(--ql-rule); background: var(--ql-surface-alt); }
|
|
182
|
+
.ql-writes-line { display: grid; grid-template-columns: 132px 1fr; gap: 12px; align-items: baseline; }
|
|
183
|
+
.ql-writes-label { font-size: 14px; font-weight: 600; letter-spacing: 0.06em; font-variant-caps: all-small-caps; color: var(--ql-muted); }
|
|
184
|
+
.ql-writes-text { font: 400 14px/22px var(--ql-font-mono); color: var(--ql-heading); white-space: pre-wrap; word-break: break-word; }
|
|
185
|
+
.ql-writes-error { color: var(--ql-error); }
|
|
186
|
+
.ql-writes-kept { margin: 8px 0 0 144px; color: var(--ql-muted); font-size: 13px; }
|
|
187
|
+
.ql-options-foot { display: flex; align-items: center; gap: 16px; padding: 12px 24px; border-top: 1px solid var(--ql-rule);
|
|
188
|
+
background: var(--ql-surface-alt); border-radius: 0 0 6px 6px; }
|
|
189
|
+
.ql-options-spacer { flex: 1; }
|
|
190
|
+
.ql-under[inert] { opacity: 0.4; }
|
|
191
|
+
|
|
192
|
+
/* More records (M8) and the preview (M10) ----------------------------------- */
|
|
193
|
+
.ql-more { position: relative; margin: 4px 0 6px; }
|
|
194
|
+
.ql-more > summary { display: inline-block; padding: 2px 16px 2px 28px; list-style: none; cursor: pointer; font-size: 13px; }
|
|
195
|
+
.ql-more > summary::-webkit-details-marker { display: none; }
|
|
196
|
+
.ql-more > summary::before { content: "▸ "; }
|
|
197
|
+
.ql-more .ql-more-hide, .ql-more[open] .ql-more-open, .ql-more[open] > summary::before { display: none; }
|
|
198
|
+
.ql-more[open] .ql-more-hide { display: inline; }
|
|
199
|
+
.ql-more[open] > summary { position: absolute; top: 10px; right: 0; }
|
|
200
|
+
.ql-related { display: block; }
|
|
201
|
+
.ql-more[open] .ql-related { margin: 0 0 0 12px; padding-top: 8px; border-top: 1px dashed var(--ql-border); }
|
|
202
|
+
.ql-related-head { padding: 4px 16px 6px; font-size: 14px; font-weight: 600; letter-spacing: 0.06em; font-variant-caps: all-small-caps; color: var(--ql-muted); }
|
|
203
|
+
.ql-related-none { padding: 4px 16px 8px; color: var(--ql-muted); font-size: 13px; }
|
|
204
|
+
.ql-main { position: relative; }
|
|
205
|
+
.ql-preview { display: block; }
|
|
206
|
+
.ql-preview:empty { display: none; }
|
|
207
|
+
.ql-preview-card { position: absolute; top: 48px; right: 0; z-index: 5; width: 480px; max-width: 100%; padding: 20px 24px 14px;
|
|
208
|
+
border: 1px solid var(--ql-border); border-radius: 6px; background: var(--ql-surface); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); }
|
|
209
|
+
.ql-preview-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
|
210
|
+
.ql-preview-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--ql-heading); }
|
|
211
|
+
.ql-preview-subtitle { margin-top: 2px; font: 400 13px/18px var(--ql-font-mono); color: var(--ql-muted); }
|
|
212
|
+
.ql-preview-count { margin: 18px 0 8px; font-size: 13px; font-weight: 600; letter-spacing: 0.06em; font-variant-caps: all-small-caps; color: var(--ql-muted); }
|
|
213
|
+
.ql-preview-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ql-rule); }
|
|
214
|
+
.ql-preview-record { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--ql-rule); }
|
|
215
|
+
.ql-preview-line { display: flex; align-items: center; gap: 8px; color: var(--ql-heading); }
|
|
216
|
+
.ql-preview-detail { margin-top: 2px; font-size: 13px; color: var(--ql-muted); }
|
|
217
|
+
.ql-preview-outcome { flex-shrink: 0; max-width: 200px; text-align: right; font-size: 13px; }
|
|
218
|
+
.ql-preview-foot { display: flex; gap: 16px; justify-content: flex-end; margin-top: 12px; font-size: 12px; color: var(--ql-faint); }
|
|
219
|
+
.ql-record[data-querylane-previewing] { box-shadow: inset 3px 0 0 var(--ql-accent); background: var(--ql-surface-alt); }
|
|
220
|
+
|
|
221
|
+
/* Suggestions: header box (M7) and palette (M13) ----------------------------- */
|
|
222
|
+
.ql-suggest { position: relative; }
|
|
223
|
+
.ql-suggest-form { display: flex; align-items: center; gap: 8px; padding: 0 10px; height: 40px; border: 1px solid var(--ql-border);
|
|
224
|
+
border-radius: 6px; background: var(--ql-surface); color: var(--ql-muted); }
|
|
225
|
+
.ql-suggest-form:focus-within { border: 2px solid var(--ql-accent); padding: 0 9px; }
|
|
226
|
+
.ql-suggest-input { flex: 1; min-width: 0; border: 0; outline: none; background: transparent; color: var(--ql-heading);
|
|
227
|
+
font: 400 15px/22px var(--ql-font-mono); }
|
|
228
|
+
.ql-suggest-key, .ql-suggest-foot kbd, .ql-suggest-aside kbd { padding: 0 5px; border: 1px solid var(--ql-border); border-radius: 3px;
|
|
229
|
+
font: 400 12px/18px var(--ql-font-mono); color: var(--ql-muted); background: var(--ql-surface); }
|
|
230
|
+
.ql-suggest-pop { position: absolute; top: calc(100% + 8px); left: -24px; min-width: 600px; z-index: 20; border: 1px solid var(--ql-border);
|
|
231
|
+
border-radius: 6px; background: var(--ql-surface); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12); }
|
|
232
|
+
.ql-suggest-list[data-querylane-stale] { opacity: 0.55; }
|
|
233
|
+
.ql-suggest-list { display: block; max-height: 60vh; overflow-y: auto; padding: 6px 0; }
|
|
234
|
+
.ql-suggest-heading { padding: 10px 16px 4px; font-size: 13px; font-weight: 600; letter-spacing: 0.06em; font-variant-caps: all-small-caps;
|
|
235
|
+
color: var(--ql-muted); }
|
|
236
|
+
.ql-suggest-option { display: grid; grid-template-columns: 160px 1fr auto; align-items: center; gap: 12px; padding: 8px 16px; cursor: pointer;
|
|
237
|
+
color: var(--ql-text); font-size: 14px; }
|
|
238
|
+
.ql-suggest-person { grid-template-columns: 180px 110px 1fr auto; }
|
|
239
|
+
.ql-suggest-go { display: flex; justify-content: space-between; color: var(--ql-accent); }
|
|
240
|
+
.ql-suggest-option[aria-selected="true"], .ql-suggest-option:hover { background: var(--ql-accent-soft); }
|
|
241
|
+
.ql-suggest-word { color: var(--ql-accent); font: 600 14px/20px var(--ql-font-mono); }
|
|
242
|
+
.ql-suggest-title { font-weight: 600; color: var(--ql-heading); }
|
|
243
|
+
.ql-suggest-subtitle { font: 400 13px/18px var(--ql-font-mono); color: var(--ql-muted); }
|
|
244
|
+
.ql-suggest-detail { color: var(--ql-muted); font-size: 13px; }
|
|
245
|
+
.ql-suggest-aside { justify-self: end; font-size: 12px; color: var(--ql-faint); }
|
|
246
|
+
.ql-suggest-note { padding: 6px 16px; font-size: 13px; color: var(--ql-muted); }
|
|
247
|
+
.ql-suggest-foot { display: flex; gap: 16px; padding: 10px 16px; border-top: 1px solid var(--ql-rule); background: var(--ql-surface-alt);
|
|
248
|
+
border-radius: 0 0 6px 6px; font-size: 12px; color: var(--ql-faint); }
|
|
249
|
+
.ql-suggest-trigger { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; height: 40px; padding: 0 12px;
|
|
250
|
+
border: 1px solid var(--ql-border); border-radius: 6px; background: var(--ql-surface); color: var(--ql-muted); }
|
|
251
|
+
.ql-palette { width: 640px; max-width: calc(100vw - 32px); margin-top: 12vh; padding: 0; border: 0; border-radius: 10px;
|
|
252
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25); }
|
|
253
|
+
.ql-palette::backdrop { background: rgba(38, 43, 49, 0.35); }
|
|
254
|
+
.ql-palette .ql-suggest-form { height: 60px; border: 0; border-bottom: 1px solid var(--ql-rule); border-radius: 0; padding: 0 20px; }
|
|
255
|
+
.ql-palette .ql-suggest-input { font-size: 18px; }
|
|
256
|
+
.ql-palette .ql-suggest-pop { position: static; border: 0; box-shadow: none; }
|
|
257
|
+
|
|
258
|
+
/* Saved searches (M12) ---------------------------------------------------- */
|
|
259
|
+
.ql-box-saved .ql-star-on, .ql-box-saved[aria-pressed="true"] .ql-star-off { display: none; }
|
|
260
|
+
.ql-box-saved[aria-pressed="true"] .ql-star-on { display: inline; }
|
|
261
|
+
.ql-box-saved { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; padding: 6px 10px; border: 0; border-radius: 4px;
|
|
262
|
+
background: var(--ql-accent-soft); color: var(--ql-accent); font-weight: 500; }
|
|
263
|
+
.ql-saved { width: 520px; max-width: calc(100vw - 32px); padding: 0; border: 1px solid var(--ql-border);
|
|
264
|
+
border-radius: 6px; background: var(--ql-surface); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12); }
|
|
265
|
+
.ql-saved-head { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding: 14px 16px;
|
|
266
|
+
border-bottom: 1px solid var(--ql-rule); font-weight: 600; color: var(--ql-heading); }
|
|
267
|
+
.ql-saved-refusal { margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--ql-rule); font-size: 13px; color: var(--ql-text); }
|
|
268
|
+
.ql-saved-heading { padding: 10px 16px 4px; font-size: 13px; font-weight: 600; letter-spacing: 0.06em; font-variant-caps: all-small-caps; color: var(--ql-muted); }
|
|
269
|
+
.ql-saved-none { margin: 0; padding: 4px 16px 14px; color: var(--ql-muted); font-size: 13px; }
|
|
270
|
+
.ql-saved-item { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; width: 100%; padding: 8px 16px; border: 0;
|
|
271
|
+
background: none; text-align: left; }
|
|
272
|
+
.ql-saved-item:hover, .ql-saved-item:focus-visible { background: var(--ql-accent-soft); }
|
|
273
|
+
.ql-saved-name { font-weight: 600; color: var(--ql-heading); }
|
|
274
|
+
.ql-saved-query { grid-column: 1; font: 400 12px/18px var(--ql-font-mono); color: var(--ql-muted); }
|
|
275
|
+
.ql-saved-used { grid-column: 2; grid-row: 1 / span 2; align-self: center; font-size: 12px; color: var(--ql-faint); }
|
|
276
|
+
.ql-notice { margin-top: 8px; font-size: 13px; color: var(--ql-text); }
|
|
277
|
+
.ql-notice-refused, .ql-notice-store_failed { color: var(--ql-error); }
|
|
278
|
+
@supports (anchor-name: --ql-saved) {
|
|
279
|
+
.ql-box-saved { anchor-name: --ql-saved; }
|
|
280
|
+
.ql-saved { position-anchor: --ql-saved; inset: auto; top: anchor(bottom); right: anchor(right); margin: 8px 0 0; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* Searching, too short, reasons, unavailable, denied ------------------------ */
|
|
284
|
+
.ql-box-busy-label { display: none; flex-shrink: 0; font-size: 13px; color: var(--ql-muted); }
|
|
285
|
+
.ql-box-busy .ql-box-busy-label { display: inline; }
|
|
286
|
+
.ql-box-busy .ql-box-submit { opacity: 0.6; }
|
|
287
|
+
.ql-results[busy] .ql-under { opacity: 0.55; transition: opacity 0.15s 0.2s; }
|
|
288
|
+
.ql-results[busy] .ql-toolbar { background: linear-gradient(var(--ql-accent), var(--ql-accent)) left bottom / 25% 2px no-repeat; animation: ql-progress 1.2s linear infinite; }
|
|
289
|
+
@keyframes ql-progress { from { background-position: -25% 100%; } to { background-position: 125% 100%; } }
|
|
290
|
+
@media (prefers-reduced-motion: reduce) { .ql-results[busy] .ql-toolbar { animation: none; background-size: 100% 2px; } }
|
|
291
|
+
.ql-box-help { margin: 6px 0 0; font-size: 13px; line-height: 18px; color: var(--ql-muted); }
|
|
292
|
+
.ql-hint { margin-top: 6px; font-size: 13px; color: var(--ql-muted); }
|
|
293
|
+
.ql-reason-more { display: inline; }
|
|
294
|
+
.ql-reason-more > summary { display: inline; cursor: pointer; }
|
|
295
|
+
.ql-reason-more ul { margin: 4px 0 0; padding-left: 16px; text-align: left; }
|
|
296
|
+
.ql-state h2 { margin: 0 0 6px; font-size: 18px; font-weight: 600; color: var(--ql-heading); }
|
|
297
|
+
.ql-state p { margin: 0 0 8px; }
|
|
298
|
+
.ql-state-unavailable { padding: 20px 24px; border-left: 3px solid var(--ql-warning-fg); background: var(--ql-warning-bg); }
|
|
299
|
+
.ql-state-denied { padding: 20px 24px; border-left: 3px solid var(--ql-danger-fg); background: var(--ql-danger-bg); }
|
|
300
|
+
.ql-facet-find { margin: 4px 0 6px; }
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# View helpers for a querylane widget. A widget has an id (e.g.
|
|
5
|
+
# :member_search); its form is "#{id}-form", its box "#{id}-box" and its
|
|
6
|
+
# results frame "#{id}-results".
|
|
7
|
+
module SearchHelper
|
|
8
|
+
# The search form with the box. Renders outside the results frame so typing
|
|
9
|
+
# never loses the caret.
|
|
10
|
+
#
|
|
11
|
+
# @param result [Querylane::Result]
|
|
12
|
+
# @param id [Symbol, String] widget id
|
|
13
|
+
# @param url [String] the host endpoint (POST)
|
|
14
|
+
# @param input_id [String] id of the <input> (hosts keep test hooks stable)
|
|
15
|
+
# @param label [String] accessible label of the input
|
|
16
|
+
# @param placeholder [String]
|
|
17
|
+
# @param mask [String, nil] JS regexp source; matching text is shown as dots
|
|
18
|
+
# @param button_id [String, nil] id of the Search button
|
|
19
|
+
# @param options [Boolean] show the Options button (the options panel)
|
|
20
|
+
# @param help [String, nil] help text under the box (the input is described by it)
|
|
21
|
+
# @yield extra controls placed between the clear button and Options
|
|
22
|
+
def querylane_form(result, id:, url:, input_id: "#{id}-query", label: t("querylane.box.label"),
|
|
23
|
+
placeholder: t("querylane.box.placeholder"), mask: nil, button_id: nil, options: true, help: nil, &extra)
|
|
24
|
+
render "querylane/form", result: result, id: id.to_s, url: url, input_id: input_id, label: label,
|
|
25
|
+
placeholder: placeholder, mask: mask, button_id: button_id, options: options, help: help, extra: extra ? capture(&extra) : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The results frame: chips, state messages, facets, toolbar, the host's rows
|
|
29
|
+
# and the footer.
|
|
30
|
+
#
|
|
31
|
+
# @param result [Querylane::Result]
|
|
32
|
+
# @param id [Symbol, String] widget id
|
|
33
|
+
# @param info_id [String, nil] id of the "Showing x to y of z" line
|
|
34
|
+
# @param per_id [String, nil] id of the page-size select
|
|
35
|
+
# @param first_load [String, nil] HTML shown before anything is typed
|
|
36
|
+
# @param presenter [Class, nil] a Querylane::Presenter subclass; the gem draws the rows
|
|
37
|
+
# @yield instead of a presenter: the host's own rows for result.hits
|
|
38
|
+
def querylane_results(result, id:, presenter: nil, info_id: nil, per_id: nil, first_load: nil, &rows)
|
|
39
|
+
body = if rows
|
|
40
|
+
capture(&rows)
|
|
41
|
+
elsif presenter && result.hits.any?
|
|
42
|
+
render("querylane/rows", presenter: presenter.new(result, self, id: id))
|
|
43
|
+
end
|
|
44
|
+
render "querylane/results", result: result, id: id.to_s, info_id: info_id, per_id: per_id,
|
|
45
|
+
first_load: first_load, rows: body
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A hit attribute with matched words wrapped in <mark>. The value is
|
|
49
|
+
# escaped first; only querylane's private highlight markers become tags.
|
|
50
|
+
#
|
|
51
|
+
# @return [ActiveSupport::SafeBuffer]
|
|
52
|
+
def querylane_highlight(hit, attribute)
|
|
53
|
+
text = (hit.formatted[attribute.to_s] || hit.document[attribute.to_s]).to_s
|
|
54
|
+
parts = text.split(/(#{Searchers::Meilisearch::HIGHLIGHT_PRE}.*?#{Searchers::Meilisearch::HIGHLIGHT_POST})/o)
|
|
55
|
+
safe_join(parts.map do |part|
|
|
56
|
+
if part.start_with?(Searchers::Meilisearch::HIGHLIGHT_PRE)
|
|
57
|
+
tag.mark(part.delete(Searchers::Meilisearch::HIGHLIGHT_PRE + Searchers::Meilisearch::HIGHLIGHT_POST))
|
|
58
|
+
else
|
|
59
|
+
part
|
|
60
|
+
end
|
|
61
|
+
end)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A search box with suggestions for any page: :header (inline dropdown) or
|
|
65
|
+
# :palette (⌘K / Ctrl-K dialog). It posts to `url` for suggestions; Enter
|
|
66
|
+
# and "Search all" post the text to `search_url` (a full page).
|
|
67
|
+
#
|
|
68
|
+
# @param mode [Symbol] :header or :palette
|
|
69
|
+
# @param trigger [Boolean] palette only: show its button (⌘K always opens it)
|
|
70
|
+
def querylane_suggest_box(id:, url:, search_url:, mode: :header, label: t("querylane.suggest.label"),
|
|
71
|
+
placeholder: t("querylane.suggest.placeholder"), trigger: true)
|
|
72
|
+
render "querylane/suggest_box", id: id.to_s, url: url, search_url: search_url, mode: mode, label: label,
|
|
73
|
+
placeholder: placeholder, trigger: trigger
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The suggestions frame (operators, people, go-to).
|
|
77
|
+
#
|
|
78
|
+
# @param result [Querylane::Result] from querylane_suggest
|
|
79
|
+
# @param presenter [Class] a Querylane::Presenter subclass
|
|
80
|
+
def querylane_suggestions(result, id:, presenter:)
|
|
81
|
+
presenter = presenter.new(result, self, id: id)
|
|
82
|
+
groups = result.hits.any? ? presenter.groups.first(Querylane.configuration.suggest_people) : []
|
|
83
|
+
render "querylane/suggestions", result: result, presenter: presenter, groups: groups, id: id.to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The response to a group request: the "more records" frame or the preview.
|
|
87
|
+
#
|
|
88
|
+
# @param result [Querylane::Result] from querylane_related
|
|
89
|
+
# @param presenter [Class] a Querylane::Presenter subclass
|
|
90
|
+
# @param frame [String] the requesting frame id (Turbo-Frame header)
|
|
91
|
+
def querylane_related(result, id:, presenter:, frame:)
|
|
92
|
+
presenter = presenter.new(result, self, id: id)
|
|
93
|
+
if frame == "#{id}-preview"
|
|
94
|
+
render "querylane/preview", result: result, presenter: presenter, id: id.to_s
|
|
95
|
+
else
|
|
96
|
+
frame = "#{id}-related-unknown" unless frame.to_s.match?(/\A#{Regexp.escape(id.to_s)}-related-\h{16}\z/)
|
|
97
|
+
render "querylane/related", result: result, presenter: presenter, frame: frame
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Character range → UTF-16 range, for the browser.
|
|
102
|
+
#
|
|
103
|
+
# @return [Array(Integer, Integer), nil]
|
|
104
|
+
def querylane_utf16_range(text, range)
|
|
105
|
+
return unless range
|
|
106
|
+
|
|
107
|
+
start = text[0...range.begin].to_s.encode("UTF-16LE").bytesize / 2
|
|
108
|
+
[start, start + (text[range].to_s.encode("UTF-16LE").bytesize / 2)]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { paint } from "querylane/overlay.js"
|
|
3
|
+
|
|
4
|
+
// The search box: a native <input> (the only value) with a decorative overlay
|
|
5
|
+
// (operator colours, masked IDs, error underline), debounced submission into
|
|
6
|
+
// the results frame, and a revision counter that discards stale responses.
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static targets = ["input", "overlay", "revision", "box", "hint", "saved", "options"]
|
|
9
|
+
static values = { debounce: { type: Number, default: 300 }, mask: String, frame: String, min: Number }
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.errorRange = null
|
|
13
|
+
this.frame = document.getElementById(this.frameValue)
|
|
14
|
+
this.guardStaleRenders = (event) => this.guard(event)
|
|
15
|
+
this.readError = () => this.loadError()
|
|
16
|
+
this.frame?.addEventListener("turbo:before-frame-render", this.guardStaleRenders)
|
|
17
|
+
this.frame?.addEventListener("turbo:frame-load", this.readError)
|
|
18
|
+
// The frame's busy attribute (set by Turbo while it loads) shows "Searching…" in the box.
|
|
19
|
+
this.busyObserver = new MutationObserver(() => this.boxTarget.classList.toggle("ql-box-busy", this.frame.hasAttribute("busy")))
|
|
20
|
+
if (this.frame) this.busyObserver.observe(this.frame, { attributes: true, attributeFilter: ["busy"] })
|
|
21
|
+
this.loadError()
|
|
22
|
+
this.paint()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
clearTimeout(this.timer)
|
|
27
|
+
this.frame?.removeEventListener("turbo:before-frame-render", this.guardStaleRenders)
|
|
28
|
+
this.frame?.removeEventListener("turbo:frame-load", this.readError)
|
|
29
|
+
this.busyObserver.disconnect()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Every keystroke moves the revision, marks chips stale and debounces a submit.
|
|
33
|
+
typed() {
|
|
34
|
+
this.bump()
|
|
35
|
+
this.errorRange = null
|
|
36
|
+
this.frame?.setAttribute("data-querylane-stale", "")
|
|
37
|
+
this.frame?.querySelectorAll("button[name=remove]").forEach((button) => (button.disabled = true))
|
|
38
|
+
this.paint()
|
|
39
|
+
clearTimeout(this.timer)
|
|
40
|
+
if (this.composing) return
|
|
41
|
+
// Too short to search: no request; the last results stay until it is long enough.
|
|
42
|
+
const short = this.tooShort
|
|
43
|
+
if (this.hasHintTarget) this.hintTarget.hidden = !short
|
|
44
|
+
if (short) return
|
|
45
|
+
this.timer = setTimeout(() => this.element.requestSubmit(), this.debounceValue)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
compose() {
|
|
49
|
+
this.composing = true
|
|
50
|
+
clearTimeout(this.timer)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
composed() {
|
|
54
|
+
this.composing = false
|
|
55
|
+
this.typed()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
keys(event) {
|
|
59
|
+
if (event.key === "Enter" && this.tooShort) {
|
|
60
|
+
// Too short to search: Enter keeps the last results and shows the hint.
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
clearTimeout(this.timer)
|
|
63
|
+
if (this.hasHintTarget) this.hintTarget.hidden = false
|
|
64
|
+
} else if (event.key === "Enter") {
|
|
65
|
+
clearTimeout(this.timer)
|
|
66
|
+
} else if (event.key === "Escape" && this.inputTarget.value !== "") {
|
|
67
|
+
event.preventDefault()
|
|
68
|
+
this.clear()
|
|
69
|
+
} else if (event.key === "ArrowDown") {
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
this.frame?.dispatchEvent(new CustomEvent("querylane:focus-first"))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// "/" anywhere outside a field focuses the box.
|
|
76
|
+
shortcut(event) {
|
|
77
|
+
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return
|
|
78
|
+
if (event.target.closest("input, textarea, select, [contenteditable]")) return
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
this.inputTarget.focus()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clear() {
|
|
84
|
+
this.inputTarget.value = ""
|
|
85
|
+
this.typed()
|
|
86
|
+
this.inputTarget.focus()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Toggles, chip ×, Clear and the options panel's Search rewrite the query on the server: send them at a
|
|
90
|
+
// new revision; the rewrite comes back at that same revision.
|
|
91
|
+
submitted(event) {
|
|
92
|
+
clearTimeout(this.timer)
|
|
93
|
+
if (["toggle", "remove", "clear", "compose_submit", "saved", "fix", "instead", "widen", "relax"].includes(event.submitter?.name)) this.bump()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setQuery(event) {
|
|
97
|
+
if (event.detail.revision !== this.revision) return
|
|
98
|
+
this.inputTarget.value = event.detail.query
|
|
99
|
+
const end = this.inputTarget.value.length
|
|
100
|
+
this.inputTarget.setSelectionRange(end, end)
|
|
101
|
+
this.paint()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Before Turbo snapshots the page: forget everything a snapshot could keep.
|
|
105
|
+
forget() {
|
|
106
|
+
clearTimeout(this.timer)
|
|
107
|
+
this.bump()
|
|
108
|
+
this.inputTarget.value = ""
|
|
109
|
+
this.inputTarget.defaultValue = ""
|
|
110
|
+
this.inputTarget.removeAttribute("value")
|
|
111
|
+
if (this.frame) this.frame.innerHTML = ""
|
|
112
|
+
this.paint()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
paint() {
|
|
116
|
+
paint(this.overlayTarget, this.inputTarget, { mask: this.maskValue, error: this.errorRange })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// private
|
|
120
|
+
|
|
121
|
+
// Every word shorter than the definition's minimum, and no operator.
|
|
122
|
+
get tooShort() {
|
|
123
|
+
const text = this.inputTarget.value.trim()
|
|
124
|
+
if (!this.minValue || text === "" || text.includes(":")) return false
|
|
125
|
+
return text.split(/\s+/).every((word) => word.replace(/[*"]/g, "").length < this.minValue)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get revision() {
|
|
129
|
+
return Number(this.revisionTarget.value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
bump() {
|
|
133
|
+
this.revisionTarget.value = String(this.revision + 1)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Drop a response the user has typed past. Wrapping render (instead of
|
|
137
|
+
// preventDefault, which pauses Turbo until resume) keeps later renders flowing;
|
|
138
|
+
// the revision is checked again at the moment of replacement.
|
|
139
|
+
guard(event) {
|
|
140
|
+
if (event.target !== this.frame) return
|
|
141
|
+
const answered = Number(event.detail.newFrame?.dataset?.revision)
|
|
142
|
+
if (!Number.isFinite(answered)) return
|
|
143
|
+
const render = event.detail.render
|
|
144
|
+
event.detail.render = (current, next) => {
|
|
145
|
+
if (answered < this.revision) return
|
|
146
|
+
this.frame.removeAttribute("data-querylane-stale")
|
|
147
|
+
render(current, next)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// While the options panel is open, Options closes it: a plain search.
|
|
152
|
+
toggleOptions(event) {
|
|
153
|
+
// The panel in the frame is the truth (aria-expanded updates after the frame loads).
|
|
154
|
+
if (!this.frame?.querySelector(".ql-options")) return
|
|
155
|
+
event.preventDefault()
|
|
156
|
+
this.element.requestSubmit()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// After each answer: the error underline, the Saved star and the Options state.
|
|
160
|
+
loadError() {
|
|
161
|
+
const state = this.frame?.querySelector("[data-querylane-state]")
|
|
162
|
+
if (state && this.hasSavedTarget) this.savedTarget.setAttribute("aria-pressed", state.dataset.savedActive)
|
|
163
|
+
if (state && this.hasOptionsTarget) this.optionsTarget.setAttribute("aria-expanded", state.dataset.optionsOpen)
|
|
164
|
+
const holder = this.frame?.querySelector("[data-querylane-error-range]")
|
|
165
|
+
const range = holder?.dataset.querylaneErrorRange
|
|
166
|
+
this.errorRange = range ? JSON.parse(range) : null
|
|
167
|
+
this.boxTarget.classList.toggle("ql-box-invalid", Boolean(this.errorRange))
|
|
168
|
+
this.paint()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// The options panel: field edits refresh the server-composed "Writes" line
|
|
4
|
+
// (a hidden submit into its own frame, no search); Enter applies; Reset
|
|
5
|
+
// empties the fields; Show reveals a masked field; Escape cancels (a plain
|
|
6
|
+
// search, panel closed).
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static targets = ["field", "preview", "cancel", "apply"]
|
|
9
|
+
static values = { debounce: { type: Number, default: 300 } }
|
|
10
|
+
|
|
11
|
+
// Opening the panel moves focus to its first field.
|
|
12
|
+
connect() {
|
|
13
|
+
this.fieldTargets[0]?.focus()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
clearTimeout(this.timer)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
changed(event) {
|
|
21
|
+
if (!this.fieldTargets.includes(event.target)) return
|
|
22
|
+
clearTimeout(this.timer)
|
|
23
|
+
this.timer = setTimeout(() => this.previewTarget.form?.requestSubmit(this.previewTarget), this.debounceValue)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
reset() {
|
|
27
|
+
this.fieldTargets.forEach((field) => {
|
|
28
|
+
if (field.type === "checkbox") field.checked = false
|
|
29
|
+
else field.value = ""
|
|
30
|
+
})
|
|
31
|
+
if (this.fieldTargets.length) this.changed({ target: this.fieldTargets[0] })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
reveal(event) {
|
|
35
|
+
const button = event.currentTarget
|
|
36
|
+
const field = document.getElementById(button.getAttribute("aria-controls"))
|
|
37
|
+
const shown = field.classList.toggle("ql-masked") === false
|
|
38
|
+
button.setAttribute("aria-pressed", String(shown))
|
|
39
|
+
button.textContent = shown ? button.dataset.hide : button.dataset.show
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Enter in a field applies the panel (the form's default button would close it).
|
|
43
|
+
apply(event) {
|
|
44
|
+
if (!this.fieldTargets.includes(event.target)) return
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
clearTimeout(this.timer)
|
|
47
|
+
this.applyTarget.click()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cancel(event) {
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
this.cancelTarget.click()
|
|
53
|
+
}
|
|
54
|
+
}
|