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,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
+ }