@aquera/nile-visualization 2.9.2 → 2.9.3

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 (27) hide show
  1. package/dist/src/nile-chart/nile-chart.css.js +6 -0
  2. package/dist/src/nile-filter-chart/nile-filter-chart.css.js +274 -4
  3. package/dist/src/nile-filter-chart/nile-filter-chart.d.ts +59 -206
  4. package/dist/src/nile-filter-chart/nile-filter-chart.js +330 -436
  5. package/dist/src/nile-filter-chart/utils/badge.d.ts +3 -0
  6. package/dist/src/nile-filter-chart/utils/badge.js +33 -0
  7. package/dist/src/nile-filter-chart/utils/comparison.d.ts +3 -0
  8. package/dist/src/nile-filter-chart/utils/comparison.js +24 -0
  9. package/dist/src/nile-filter-chart/utils/dropdown.d.ts +3 -0
  10. package/dist/src/nile-filter-chart/utils/dropdown.js +24 -0
  11. package/dist/src/nile-filter-chart/utils/preset.d.ts +3 -0
  12. package/dist/src/nile-filter-chart/utils/preset.js +16 -0
  13. package/dist/src/nile-filter-chart/utils/prompt.d.ts +12 -0
  14. package/dist/src/nile-filter-chart/utils/prompt.js +676 -0
  15. package/dist/src/nile-filter-chart/utils/radio.d.ts +3 -0
  16. package/dist/src/nile-filter-chart/utils/radio.js +13 -0
  17. package/dist/src/nile-filter-chart/utils/search.d.ts +3 -0
  18. package/dist/src/nile-filter-chart/utils/search.js +12 -0
  19. package/dist/src/nile-filter-chart/utils/segmented.d.ts +3 -0
  20. package/dist/src/nile-filter-chart/utils/segmented.js +15 -0
  21. package/dist/src/nile-filter-chart/utils/threshold.d.ts +3 -0
  22. package/dist/src/nile-filter-chart/utils/threshold.js +58 -0
  23. package/dist/src/nile-filter-chart/utils/toggle.d.ts +3 -0
  24. package/dist/src/nile-filter-chart/utils/toggle.js +19 -0
  25. package/dist/src/nile-filter-chart/utils/types.d.ts +334 -0
  26. package/dist/src/nile-filter-chart/utils/types.js +2 -0
  27. package/package.json +1 -1
@@ -0,0 +1,676 @@
1
+ import { html, nothing } from 'lit';
2
+ const PUNCT_TOKENS = new Set(['(', ')', '[', ']', ',']);
3
+ /**
4
+ * Splits the input into the tokens analyzePromptCtx reasons about:
5
+ * - whitespace-delimited words (numbers, identifiers, operators)
6
+ * - double / single-quoted strings (atomic so `"Birat Datta"` is one token)
7
+ * - parens, brackets, commas (each is its own atomic token — needed so
8
+ * `status in [` produces three tokens, not one fused word)
9
+ */
10
+ function splitRespectingQuotes(input) {
11
+ const tokens = [];
12
+ const n = input.length;
13
+ let i = 0;
14
+ while (i < n) {
15
+ while (i < n && /\s/.test(input[i]))
16
+ i++;
17
+ if (i >= n)
18
+ break;
19
+ const c = input[i];
20
+ if (PUNCT_TOKENS.has(c)) {
21
+ tokens.push(c);
22
+ i++;
23
+ continue;
24
+ }
25
+ let j = i;
26
+ if (c === '"' || c === "'") {
27
+ const quote = c;
28
+ j = i + 1;
29
+ while (j < n && input[j] !== quote) {
30
+ if (input[j] === '\\' && j + 1 < n)
31
+ j++;
32
+ j++;
33
+ }
34
+ if (j < n)
35
+ j++; // include closing quote
36
+ }
37
+ else {
38
+ while (j < n && !/\s/.test(input[j]) && !PUNCT_TOKENS.has(input[j]) && input[j] !== '"' && input[j] !== "'")
39
+ j++;
40
+ }
41
+ tokens.push(input.slice(i, j));
42
+ i = j;
43
+ }
44
+ return tokens;
45
+ }
46
+ /**
47
+ * Walks the input from the right and decides which suggestion bucket should
48
+ * show. Layered routing:
49
+ *
50
+ * 1. Inside an unmatched `[` → `value` (array element) scoped to the field
51
+ * sitting before the `[` (so `status in [Op…` filters status values).
52
+ * 2. After `order by …` → sort-field on the empty slot, `sort-direction`
53
+ * after a field, sort-field again after a comma.
54
+ * 3. Directly after `(` or `,` (when not inside an array) → `field`.
55
+ * This is what makes function args (`contains(|`, `contains(name, |`)
56
+ * and sub-expressions (`(|`) suggest attributes.
57
+ * 4. Otherwise the original four-state logic: field / operator / value /
58
+ * connector — based on the last completed token.
59
+ */
60
+ function analyzePromptCtx(input, fieldNames, operatorTokens, connectorTokens) {
61
+ const tokens = splitRespectingQuotes(input);
62
+ const lastToken = tokens[tokens.length - 1];
63
+ const hasTrailingSpace = input.length > 0 && /\s$/.test(input);
64
+ // Punctuation tokens are always "completed" — typing `[` ends the previous
65
+ // partial and starts the inside-array context, even with no space after.
66
+ const lastIsPunct = !!lastToken && PUNCT_TOKENS.has(lastToken);
67
+ const isCompleted = hasTrailingSpace || lastIsPunct;
68
+ const partial = isCompleted ? '' : (lastToken ?? '');
69
+ const completed = isCompleted ? tokens : tokens.slice(0, -1);
70
+ const before = isCompleted ? input : input.slice(0, input.length - partial.length);
71
+ const isField = (t) => !!t && fieldNames.has(t);
72
+ const isOp = (t) => !!t && operatorTokens.has(t);
73
+ const isConn = (t) => !!t && connectorTokens.has(t.toLowerCase());
74
+ // Bracket scan — find the deepest unmatched `[` and the attribute it
75
+ // belongs to (the nearest field token to its left, scanning across
76
+ // intermediate tokens like `in` / `not in`).
77
+ const bracketStack = [];
78
+ for (let i = 0; i < completed.length; i++) {
79
+ const t = completed[i];
80
+ if (t === '[') {
81
+ let attribute;
82
+ for (let j = i - 1; j >= 0; j--) {
83
+ if (isField(completed[j])) {
84
+ attribute = completed[j];
85
+ break;
86
+ }
87
+ }
88
+ bracketStack.push({ attribute });
89
+ }
90
+ else if (t === ']') {
91
+ bracketStack.pop();
92
+ }
93
+ }
94
+ const inArray = bracketStack.length > 0;
95
+ const arrayAttribute = inArray ? bracketStack[bracketStack.length - 1].attribute : undefined;
96
+ // Last `order by` pair — `order` then `by` (case-insensitive, adjacent).
97
+ let orderByEnd = -1;
98
+ for (let i = completed.length - 1; i >= 1; i--) {
99
+ if (completed[i].toLowerCase() === 'by' && completed[i - 1].toLowerCase() === 'order') {
100
+ orderByEnd = i;
101
+ break;
102
+ }
103
+ }
104
+ const inSortClause = orderByEnd >= 0;
105
+ const last1 = completed[completed.length - 1];
106
+ const last2 = completed[completed.length - 2];
107
+ // 1. Array-element wins over everything else — inside `[ … ]` the user
108
+ // is typing array values regardless of what came before.
109
+ if (inArray) {
110
+ return { position: 'value', partial, before, attribute: arrayAttribute };
111
+ }
112
+ // 2. Sort sub-routing.
113
+ if (inSortClause) {
114
+ if (orderByEnd === completed.length - 1) {
115
+ return { position: 'field', partial, before }; // `order by |`
116
+ }
117
+ if (orderByEnd === completed.length - 2 && isField(last1)) {
118
+ return { position: 'sort-direction', partial, before }; // `order by status |`
119
+ }
120
+ if (last1 === ',') {
121
+ return { position: 'field', partial, before }; // `order by a, |`
122
+ }
123
+ }
124
+ // 3. After `(` (sub-expression / function start) or `,` (function args).
125
+ if (last1 === '(' || last1 === ',') {
126
+ return { position: 'field', partial, before };
127
+ }
128
+ // 4. Standard routing.
129
+ if (!last1 || isConn(last1))
130
+ return { position: 'field', partial, before };
131
+ if (isField(last1))
132
+ return { position: 'operator', partial, before };
133
+ if (isOp(last1)) {
134
+ const attribute = isField(last2) ? last2 : undefined;
135
+ return { position: 'value', partial, before, attribute };
136
+ }
137
+ if (isOp(last2))
138
+ return { position: 'connector', partial, before };
139
+ return { position: 'field', partial, before };
140
+ }
141
+ const BUILT_IN_FILTREX_FNS = {
142
+ contains: (s, sub) => String(s ?? '').includes(String(sub ?? '')),
143
+ startsWith: (s, p) => String(s ?? '').startsWith(String(p ?? '')),
144
+ endsWith: (s, p) => String(s ?? '').endsWith(String(p ?? '')),
145
+ lower: (s) => String(s ?? '').toLowerCase(),
146
+ upper: (s) => String(s ?? '').toUpperCase(),
147
+ len: (s) => (s == null ? 0 : Array.isArray(s) ? s.length : String(s).length),
148
+ isEmpty: (s) => s == null || s === '' || (Array.isArray(s) && s.length === 0),
149
+ isNotEmpty: (s) => !(s == null || s === '' || (Array.isArray(s) && s.length === 0)),
150
+ between: (n, lo, hi) => Number(n) >= Number(lo) && Number(n) <= Number(hi),
151
+ year: (d) => new Date(d).getFullYear(),
152
+ month: (d) => new Date(d).getMonth() + 1,
153
+ day: (d) => new Date(d).getDate(),
154
+ daysAgo: (d) => (Date.now() - +new Date(d)) / 86400000,
155
+ matches: (s, re) => new RegExp(String(re ?? '')).test(String(s ?? '')),
156
+ coalesce: (...xs) => xs.find(x => x != null && x !== '') ?? null,
157
+ };
158
+ let filtrexLoading = null;
159
+ async function getFiltrex() {
160
+ if (!filtrexLoading) {
161
+ filtrexLoading = import('filtrex').then(m => {
162
+ const mod = m.default ?? m;
163
+ if (typeof mod.compileExpression !== 'function') {
164
+ throw new Error('filtrex: compileExpression export not found');
165
+ }
166
+ return mod;
167
+ });
168
+ }
169
+ return filtrexLoading;
170
+ }
171
+ let jsepLoading = null;
172
+ async function getJsep() {
173
+ if (!jsepLoading) {
174
+ jsepLoading = import('jsep').then(m => {
175
+ const candidate = m.default ?? m;
176
+ if (typeof candidate !== 'function') {
177
+ throw new Error('jsep: function export not found');
178
+ }
179
+ const fn = candidate;
180
+ try {
181
+ fn.addBinaryOp?.('and', 2);
182
+ fn.addBinaryOp?.('or', 1);
183
+ fn.addBinaryOp?.('in', 6);
184
+ fn.addUnaryOp?.('not');
185
+ }
186
+ catch {
187
+ /* parser already augmented */
188
+ }
189
+ return fn;
190
+ });
191
+ }
192
+ return jsepLoading;
193
+ }
194
+ /**
195
+ * Parse + compile a strict-mode prompt expression. Resolves with both the
196
+ * parsed AST (jsep) and the compiled filtrex predicate, or rejects with
197
+ * a parse / compile Error.
198
+ */
199
+ export async function validatePromptExpression(value, ctrl) {
200
+ const ql = ctrl.queryLanguage;
201
+ const jsep = await getJsep();
202
+ // jsep first — its syntax errors are more readable than filtrex's.
203
+ const ast = jsep(value);
204
+ const filtrex = await getFiltrex();
205
+ const extraFunctions = {
206
+ ...BUILT_IN_FILTREX_FNS,
207
+ ...(ql?.extraFunctions ?? {}),
208
+ };
209
+ // Pick a customProp resolver. Priority: explicit fn > combined flags > none.
210
+ let customProp = ql?.customProp;
211
+ if (!customProp) {
212
+ if (ql?.dotAccess && ql?.optionalChaining)
213
+ customProp = filtrex.useDotAccessOperatorAndOptionalChaining;
214
+ else if (ql?.dotAccess)
215
+ customProp = filtrex.useDotAccessOperator;
216
+ else if (ql?.optionalChaining)
217
+ customProp = filtrex.useOptionalChaining;
218
+ }
219
+ const opts = { extraFunctions };
220
+ if (ql?.constants)
221
+ opts.constants = ql.constants;
222
+ if (ql?.operators)
223
+ opts.operators = ql.operators;
224
+ if (customProp)
225
+ opts.customProp = customProp;
226
+ const evaluate = filtrex.compileExpression(value, opts);
227
+ return { ast, evaluate };
228
+ }
229
+ /**
230
+ * Renders one segment of the Basic / NQL toggle. Defaults match the Jira-style
231
+ * layout: Basic shows "Basic" text; NQL shows "JQL" text. Either segment can
232
+ * be overridden with a label, an icon, or both.
233
+ */
234
+ function renderModeToggleSegment(segValue, cfg) {
235
+ const defaults = segValue === 'basic'
236
+ ? { label: 'Basic', icon: undefined, aria: 'Basic mode' }
237
+ : { label: 'JQL', icon: undefined, aria: 'JQL / expression mode' };
238
+ const label = cfg?.label ?? defaults.label;
239
+ const icon = cfg?.icon ?? defaults.icon;
240
+ const aria = cfg?.ariaLabel ?? label ?? defaults.aria;
241
+ return html `
242
+ <nile-button-toggle value="${segValue}" aria-label="${aria}">
243
+ ${icon ? html `<nile-icon class="fc-prompt__mode-icon" name="${icon}" size="14"></nile-icon>` : nothing}
244
+ ${label ? html `<span>${label}</span>` : nothing}
245
+ </nile-button-toggle>
246
+ `;
247
+ }
248
+ function renderModeToggle(host, ctrl, mode) {
249
+ const toggleCfg = ctrl.queryLanguage?.toggle;
250
+ return html `
251
+ <nile-button-toggle-group
252
+ class="fc-prompt__mode"
253
+ aria-label="Query mode"
254
+ .value="${mode}"
255
+ @nile-change="${(e) => {
256
+ e.stopPropagation();
257
+ const next = e.detail?.value;
258
+ if (next === 'basic' || next === 'nql')
259
+ host.setPromptMode(ctrl, next);
260
+ }}"
261
+ >
262
+ ${renderModeToggleSegment('nql', toggleCfg?.nql)}
263
+ ${renderModeToggleSegment('basic', toggleCfg?.basic)}
264
+ </nile-button-toggle-group>
265
+ `;
266
+ }
267
+ export function renderPrompt(host, ctrl) {
268
+ const value = String(host.selectedValues.get(ctrl.id) ?? '');
269
+ const animated = host.promptPlaceholder.get(ctrl.id) ?? '';
270
+ const error = host.promptErrors.get(ctrl.id);
271
+ const mode = host.promptModes.get(ctrl.id);
272
+ // Toggle shows only when the consumer explicitly opted into a hybrid surface —
273
+ // the standalone `expression` variant is filtrex-only by design and never
274
+ // renders a Basic / JQL switch even though queryLanguage is enabled.
275
+ const showToggle = !!ctrl.queryLanguage?.enabled && !!mode && ctrl.variant !== 'expression';
276
+ // Suggestions + inline syntax highlighting are filtrex-mode-only:
277
+ // - `expression` variant → always on
278
+ // - `hybrid` variant → only when the toggle is on Expression (`nql`)
279
+ // - `prompt` variant → never (plain free text)
280
+ const isFiltrexMode = ctrl.variant === 'expression'
281
+ || (ctrl.variant === 'hybrid' && mode === 'nql');
282
+ const styleParts = [];
283
+ if (ctrl.gradientColors && ctrl.gradientColors.length > 0) {
284
+ const dir = ctrl.gradientDirection ?? '90deg';
285
+ styleParts.push(`--fc-prompt-gradient: linear-gradient(${dir}, ${ctrl.gradientColors.join(', ')})`);
286
+ }
287
+ else if (ctrl.gradientDirection) {
288
+ // Direction-only override: keep the default palette but rotate it.
289
+ styleParts.push(`--fc-prompt-gradient: linear-gradient(${ctrl.gradientDirection},` +
290
+ ' #2563eb 0%, #3b82f6 18%, #06b6d4 36%, #6366f1 54%,' +
291
+ ' #8b5cf6 72%, #2563eb 100%)');
292
+ }
293
+ if (typeof ctrl.gradientSpeedMs === 'number' && ctrl.gradientSpeedMs > 0) {
294
+ styleParts.push(`--fc-prompt-gradient-speed: ${ctrl.gradientSpeedMs}ms`);
295
+ }
296
+ const inlineStyle = styleParts.length ? styleParts.join('; ') + ';' : '';
297
+ // Suggestion catalog: prefer the control's own `suggestions` if the consumer
298
+ // shipped one, otherwise fall back to `host.config.aq.autocomplete` (the
299
+ // single backend-supplied catalog every prompt-like control draws from).
300
+ const aqItems = host
301
+ .config?.aq?.autocomplete;
302
+ const userSuggestions = Array.isArray(ctrl.suggestions)
303
+ ? ctrl.suggestions
304
+ : (Array.isArray(aqItems) ? aqItems : []);
305
+ // The SuggestionItem shape is `{ id?, label, type }`. `type` drives routing
306
+ // and colour. `label` is the display text. `id` is overloaded:
307
+ // - on attributes / operators / connectors / keywords / functions → the
308
+ // technical token inserted on pick (falls back to `label` when omitted)
309
+ // - on values → the parent attribute's `id` (matched against ctx.attribute
310
+ // to scope the value to a single field). NOT inserted on pick.
311
+ const tokenOf = (s) => s.id ?? s.label;
312
+ const fieldNames = new Set(userSuggestions.filter(s => s.type === 'attribute').map(tokenOf));
313
+ const operatorTokens = new Set(userSuggestions.filter(s => s.type === 'operator').map(tokenOf));
314
+ // Connectors (and/or/not/in) live in the new `connector` bucket. `clause`
315
+ // is the deprecated alias — accept it so existing demos still work.
316
+ const connectorTokens = new Set(userSuggestions
317
+ .filter(s => s.type === 'connector' || s.type === 'clause')
318
+ .map(s => tokenOf(s).toLowerCase()));
319
+ // Decide what kind of token comes next, then pre-filter the suggestion list
320
+ // to that context. The combobox's own substring filter is bypassed below
321
+ // (via `getSearchText`) so this pre-filter is the single source of truth.
322
+ const ctx = analyzePromptCtx(value, fieldNames, operatorTokens, connectorTokens);
323
+ const candidates = (() => {
324
+ switch (ctx.position) {
325
+ case 'operator':
326
+ return userSuggestions.filter(s => s.type === 'operator');
327
+ case 'value':
328
+ // For values, `id` is the parent attribute's id. A value with no `id`
329
+ // is cross-attribute (e.g. true / false / null) and shows everywhere.
330
+ return userSuggestions.filter(s => s.type === 'value' && (!s.id || s.id === ctx.attribute));
331
+ case 'connector':
332
+ // `clause` is deprecated but routes here too. `keyword` (ORDER BY etc.)
333
+ // also lives at end-of-predicate, so surface both.
334
+ return userSuggestions.filter(s => s.type === 'connector' || s.type === 'keyword' || s.type === 'clause');
335
+ case 'sort-direction':
336
+ // Only keywords show after `order by <attr>`. We drop ORDER BY itself
337
+ // here (cheap heuristic: filter out labels containing 'order') so the
338
+ // dropdown doesn't suggest nesting another sort clause.
339
+ return userSuggestions.filter(s => s.type === 'keyword' && !s.label.toLowerCase().includes('order'));
340
+ case 'field':
341
+ default:
342
+ return userSuggestions.filter(s => s.type === 'attribute'
343
+ || s.type === 'function'
344
+ || s.type === 'snippet'
345
+ || !s.type);
346
+ }
347
+ })();
348
+ const partialLower = ctx.partial.toLowerCase();
349
+ const suggestionData = ctx.partial
350
+ ? candidates.filter(s => s.label.toLowerCase().includes(partialLower))
351
+ : [...candidates];
352
+ // Highlighted suggestion index for keyboard nav. Clamp to bounds so a stale
353
+ // index from a previous candidate set never points past the current list.
354
+ const rawActive = host.promptActiveIndex.get(ctrl.id) ?? -1;
355
+ const activeIdx = rawActive >= 0 && rawActive < suggestionData.length ? rawActive : -1;
356
+ // Bypass the combobox's own substring filter by stuffing the current input
357
+ // into searchText so the substring check always passes; our pre-filter
358
+ // (context + partial) is the source of truth for what shows.
359
+ //
360
+ // Each row is wrapped in a coloured <span> keyed by `type`. Colours are
361
+ // INLINE on the span (not class-based) because the combobox's listbox is
362
+ // ── Plain-input + custom dropdown ────────────────────────────────────
363
+ // Native <input> for the typed value; suggestions render as a sibling div
364
+ // beneath it. Mousedown on a suggestion fires before the input's blur, so
365
+ // we preventDefault to keep focus and update the value via our token-merge.
366
+ //
367
+ // Suggestion rows are intentionally NOT coloured by type — they're plain
368
+ // text rows so the dropdown stays neutral. Colour only paints the typed
369
+ // expression itself (the inline syntax highlighter below).
370
+ // Inline syntax highlighter — paints the typed expression in the input's
371
+ // overlay div using nile-color CSS variables (with sensible fallbacks).
372
+ // The mapping reuses the same five token colours as the spec.
373
+ const TOKEN_COLOR = {
374
+ string: 'var(--nile-colors-green-600, var(--ng-colors-text-success-primary-600, #16a34a))',
375
+ number: 'var(--nile-colors-green-600, var(--ng-colors-text-success-primary-600, #16a34a))',
376
+ operator: 'var(--nile-colors-green-600, var(--ng-colors-text-success-primary-600, #16a34a))',
377
+ keyword: 'var(--nile-colors-pink-600, var(--ng-colors-text-error-primary-600, #db2777))',
378
+ function: 'var(--nile-colors-violet-600, var(--ng-colors-text-brand-secondary-700, #7c3aed))',
379
+ identifier: 'var(--nile-colors-dark-900, var(--ng-colors-text-primary-900, #1f2937))',
380
+ punctuation: 'var(--nile-colors-neutral-700, var(--ng-colors-text-tertiary-600, #6b7280))',
381
+ whitespace: 'transparent',
382
+ };
383
+ const KEYWORDS = new Set([
384
+ 'and', 'or', 'not', 'in', 'mod', 'of',
385
+ 'if', 'then', 'else',
386
+ 'order', 'by', 'asc', 'desc',
387
+ ]);
388
+ const tokenize = (src) => {
389
+ const out = [];
390
+ const n = src.length;
391
+ let i = 0;
392
+ while (i < n) {
393
+ const c = src[i];
394
+ // Whitespace
395
+ if (/\s/.test(c)) {
396
+ let j = i;
397
+ while (j < n && /\s/.test(src[j]))
398
+ j++;
399
+ out.push({ text: src.slice(i, j), type: 'whitespace' });
400
+ i = j;
401
+ continue;
402
+ }
403
+ // Double-quoted string
404
+ if (c === '"') {
405
+ let j = i + 1;
406
+ while (j < n && src[j] !== '"') {
407
+ if (src[j] === '\\' && j + 1 < n)
408
+ j++;
409
+ j++;
410
+ }
411
+ if (j < n)
412
+ j++;
413
+ out.push({ text: src.slice(i, j), type: 'string' });
414
+ i = j;
415
+ continue;
416
+ }
417
+ // Single-quoted identifier
418
+ if (c === "'") {
419
+ let j = i + 1;
420
+ while (j < n && src[j] !== "'")
421
+ j++;
422
+ if (j < n)
423
+ j++;
424
+ out.push({ text: src.slice(i, j), type: 'identifier' });
425
+ i = j;
426
+ continue;
427
+ }
428
+ // Number
429
+ if (/\d/.test(c) || (c === '.' && /\d/.test(src[i + 1] ?? ''))) {
430
+ let j = i;
431
+ while (j < n && /[\d.]/.test(src[j]))
432
+ j++;
433
+ out.push({ text: src.slice(i, j), type: 'number' });
434
+ i = j;
435
+ continue;
436
+ }
437
+ // Operators (longest match)
438
+ const ops = ['<=', '>=', '==', '!=', '~=', '<', '>', '=', '+', '-', '*', '/', '^', '?', ':'];
439
+ const op = ops.find(o => src.startsWith(o, i));
440
+ if (op) {
441
+ out.push({ text: op, type: 'operator' });
442
+ i += op.length;
443
+ continue;
444
+ }
445
+ // Identifier / keyword / function
446
+ if (/[A-Za-z_]/.test(c)) {
447
+ let j = i;
448
+ while (j < n && /[\w.]/.test(src[j]))
449
+ j++;
450
+ const word = src.slice(i, j);
451
+ const next = src[j];
452
+ const type = KEYWORDS.has(word.toLowerCase())
453
+ ? 'keyword'
454
+ : (next === '(' || next === '[') ? 'function' : 'identifier';
455
+ out.push({ text: word, type });
456
+ i = j;
457
+ continue;
458
+ }
459
+ // Punctuation (parens, brackets, comma, etc.)
460
+ out.push({ text: c, type: 'punctuation' });
461
+ i++;
462
+ }
463
+ return out;
464
+ };
465
+ const tokens = tokenize(value);
466
+ const onInput = (e) => {
467
+ const inputEl = e.target;
468
+ const next = inputEl.value;
469
+ const atEnd = () => inputEl.selectionStart === inputEl.value.length;
470
+ if (atEnd()) {
471
+ const s = inputEl.selectionStart ?? next.length;
472
+ inputEl.setSelectionRange(s, s);
473
+ }
474
+ // Typing changes which suggestions match — clear any highlighted item so
475
+ // the user starts fresh. They can press ArrowDown to highlight the first.
476
+ host.setPromptActiveIndex(ctrl.id, -1);
477
+ host.handlePromptInput(ctrl, next);
478
+ requestAnimationFrame(() => {
479
+ if (inputEl.isConnected && atEnd()) {
480
+ // Over-scroll past max so the browser pins to the absolute right edge.
481
+ inputEl.scrollLeft = inputEl.scrollWidth + 100;
482
+ }
483
+ });
484
+ };
485
+ const pickItem = (item, inputEl) => {
486
+ const tokenLike = item.type === 'attribute'
487
+ || item.type === 'operator'
488
+ || item.type === 'connector'
489
+ || item.type === 'keyword'
490
+ || item.type === 'clause';
491
+ let next;
492
+ if (tokenLike) {
493
+ const beforeTrimmed = ctx.before.replace(/\s+$/, '');
494
+ const sep = beforeTrimmed.length > 0 ? ' ' : '';
495
+ next = beforeTrimmed + sep + tokenOf(item) + ' ';
496
+ }
497
+ else if (item.type === 'value') {
498
+ const beforeTrimmed = ctx.before.replace(/\s+$/, '');
499
+ const sep = beforeTrimmed.length > 0 ? ' ' : '';
500
+ // In filtrex, bare identifiers reference properties — so a value like
501
+ // `Open` MUST be quoted as `"Open"` to parse as the string literal.
502
+ // Pass-through cases: numbers, booleans, null, and labels the consumer
503
+ // already wrapped in quotes.
504
+ const raw = item.label;
505
+ const alreadyQuoted = (raw.startsWith('"') && raw.endsWith('"'))
506
+ || (raw.startsWith("'") && raw.endsWith("'"));
507
+ const isNumeric = /^-?\d+(\.\d+)?$/.test(raw);
508
+ const isLiteral = raw === 'true' || raw === 'false' || raw === 'null';
509
+ const inserted = alreadyQuoted || isNumeric || isLiteral
510
+ ? raw
511
+ : `"${raw.replace(/"/g, '\\"')}"`;
512
+ next = beforeTrimmed + sep + inserted + ' ';
513
+ }
514
+ else if (item.type === 'function') {
515
+ const beforeTrimmed = ctx.before.replace(/\s+$/, '');
516
+ const sep = beforeTrimmed.length > 0 ? ' ' : '';
517
+ // Function labels may already include their own parens (e.g. "abs(value)").
518
+ // Only append "(" for bare names.
519
+ const tail = item.label.includes('(') ? '' : '(';
520
+ next = beforeTrimmed + sep + item.label + tail;
521
+ }
522
+ else {
523
+ next = item.label;
524
+ }
525
+ host.handlePromptInput(ctrl, next);
526
+ host.submitPrompt(ctrl);
527
+ // Clear the keyboard cursor — picking commits to a value, so the new
528
+ // candidate set should start with no row pre-highlighted. Avoids the
529
+ // confusing "stuck on the old index" state after a pick.
530
+ host.setPromptActiveIndex(ctrl.id, -1);
531
+ // Imperatively sync the input for immediate visual feedback.
532
+ inputEl.value = next;
533
+ // Defer focus + caret-to-end to AFTER Lit's microtask-scheduled re-render.
534
+ // If we focus synchronously, Lit's subsequent re-render can detach /
535
+ // reattach DOM inside the shadow root and clear shadowRoot.activeElement.
536
+ // Re-query by id in case the element was replaced; stable id makes this
537
+ // safe even when the original `inputEl` reference is now stale.
538
+ const root = inputEl.getRootNode();
539
+ const inputId = `fc-prompt-${ctrl.id}`;
540
+ requestAnimationFrame(() => {
541
+ const inp = root.querySelector(`#${CSS.escape(inputId)}`);
542
+ if (!inp)
543
+ return;
544
+ inp.focus({ preventScroll: true });
545
+ inp.setSelectionRange(inp.value.length, inp.value.length);
546
+ // After focus + caret-to-end, snap scroll to right so the new caret
547
+ // position is visible (mirrors what onInput does for typing).
548
+ inp.scrollLeft = inp.scrollWidth;
549
+ });
550
+ };
551
+ const onKeyDown = (e) => {
552
+ const inputEl = e.currentTarget;
553
+ const len = suggestionData.length;
554
+ // Read fresh from host (don't rely on the closure-captured `activeIdx`,
555
+ // which is from this render — fine in theory but defensive against any
556
+ // Lit re-binding edge case).
557
+ const rawActive = host.promptActiveIndex.get(ctrl.id) ?? -1;
558
+ const current = rawActive >= 0 && rawActive < len ? rawActive : -1;
559
+ // Arrow nav only kicks in when there's a non-empty suggestion list. With
560
+ // no list, ArrowUp/Down behave normally (caret movement inside the input).
561
+ if (len > 0 && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
562
+ e.preventDefault();
563
+ e.stopPropagation(); // avoid any parent (combobox / scroll-trap) intercept
564
+ const next = e.key === 'ArrowDown'
565
+ ? (current < 0 ? 0 : (current + 1) % len)
566
+ : (current <= 0 ? len - 1 : current - 1);
567
+ host.setPromptActiveIndex(ctrl.id, next);
568
+ // After Lit applies the active class, scroll the now-active row into
569
+ // view (the dropdown caps at 280px with overflow-y: auto).
570
+ const root = inputEl.getRootNode();
571
+ requestAnimationFrame(() => {
572
+ const activeBtn = root.querySelector('.fc-prompt__suggestion--active');
573
+ activeBtn?.scrollIntoView({ block: 'nearest' });
574
+ });
575
+ return;
576
+ }
577
+ if (e.key === 'Enter') {
578
+ e.preventDefault();
579
+ e.stopPropagation();
580
+ // If a suggestion is highlighted, pick it; otherwise submit the prompt.
581
+ if (current >= 0 && current < len) {
582
+ pickItem(suggestionData[current], inputEl);
583
+ }
584
+ else {
585
+ host.submitPrompt(ctrl);
586
+ }
587
+ return;
588
+ }
589
+ if (e.key === 'Escape' && current >= 0) {
590
+ e.preventDefault();
591
+ e.stopPropagation();
592
+ host.setPromptActiveIndex(ctrl.id, -1);
593
+ }
594
+ };
595
+ // The native <input> scrolls horizontally to keep the caret visible, but
596
+ // the syntax-highlight overlay (`.fc-prompt__highlight`) is `overflow:
597
+ // hidden` and would otherwise clip at the right edge — leaving the typed
598
+ // text invisible past the field width. Mirror the input's scrollLeft onto
599
+ // the overlay so both layers stay aligned.
600
+ const onScroll = (e) => {
601
+ const inputEl = e.currentTarget;
602
+ const highlightEl = inputEl.parentElement?.querySelector('.fc-prompt__highlight');
603
+ if (highlightEl)
604
+ highlightEl.scrollLeft = inputEl.scrollLeft;
605
+ };
606
+ const classes = [
607
+ 'fc-prompt',
608
+ ctrl.noAiBorder ? 'fc-prompt--no-ai-border' : '',
609
+ error ? 'fc-prompt--error' : '',
610
+ ].filter(Boolean).join(' ');
611
+ return html `
612
+ <div class="fc-prompt-row">
613
+ <div class="${classes} fc-prompt--row-input" part="filter-prompt" style="${inlineStyle}">
614
+ <div class="fc-prompt__inner">
615
+ <div class="fc-prompt__field${isFiltrexMode ? ' fc-prompt__field--highlight' : ''}" part="filter-prompt-field">
616
+ ${isFiltrexMode ? html `
617
+ <div class="fc-prompt__highlight" aria-hidden="true">${tokens.map((tok) => {
618
+ const color = TOKEN_COLOR[tok.type] ?? 'inherit';
619
+ return html `<span style="color:${color};${tok.type === 'keyword' ? 'font-weight:600;' : ''}">${tok.text}</span>`;
620
+ })}</div>
621
+ ` : nothing}
622
+ <input
623
+ type="text"
624
+ class="fc-prompt__input"
625
+ id="fc-prompt-${ctrl.id}"
626
+ name="${ctrl.id}"
627
+ part="filter-prompt-input"
628
+ autocomplete="off"
629
+ spellcheck="false"
630
+ aria-invalid="${error ? 'true' : 'false'}"
631
+ placeholder="${animated || (ctrl.placeholders?.[0] ?? '')}"
632
+ .value="${value}"
633
+ @input="${onInput}"
634
+ @keydown="${onKeyDown}"
635
+ @scroll="${onScroll}"
636
+ />
637
+ </div>
638
+ ${isFiltrexMode && suggestionData.length > 0 ? html `
639
+ <div class="fc-prompt__suggestions" part="filter-prompt-suggestions">
640
+ ${suggestionData.map((item, idx) => html `
641
+ <button
642
+ type="button"
643
+ class="fc-prompt__suggestion${idx === activeIdx ? ' fc-prompt__suggestion--active' : ''}"
644
+ @mouseenter="${() => {
645
+ if (idx !== activeIdx)
646
+ host.setPromptActiveIndex(ctrl.id, idx);
647
+ }}"
648
+ @mousedown="${(e) => {
649
+ // Prevent focus loss on the input so we can update its
650
+ // value imperatively after the merge.
651
+ e.preventDefault();
652
+ const inputEl = e.currentTarget
653
+ .closest('.fc-prompt__inner')
654
+ ?.querySelector('input.fc-prompt__input');
655
+ if (inputEl)
656
+ pickItem(item, inputEl);
657
+ }}"
658
+ >
659
+ <span class="fc-prompt__suggestion-label">${item.label}</span>
660
+ ${item.type ? html `<span class="fc-prompt__suggestion-tag">${item.type}</span>` : nothing}
661
+ </button>
662
+ `)}
663
+ </div>
664
+ ` : nothing}
665
+ ${showToggle ? renderModeToggle(host, ctrl, mode) : nothing}
666
+ </div>
667
+ ${error ? html `
668
+ <div class="fc-prompt__error" role="alert">
669
+ <span class="fc-prompt__error-icon" aria-hidden="true">!</span>
670
+ <span class="fc-prompt__error-msg">${error}</span>
671
+ </div>
672
+ ` : nothing}
673
+ </div>
674
+ </div>`;
675
+ }
676
+ //# sourceMappingURL=prompt.js.map
@@ -0,0 +1,3 @@
1
+ import { TemplateResult } from 'lit';
2
+ import type { FilterChartHost, NormalizedFilterControl } from './types.js';
3
+ export declare function renderRadio(host: FilterChartHost, ctrl: NormalizedFilterControl): TemplateResult;