@aquera/nile-visualization 2.9.2 → 2.9.4
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.
- package/dist/src/nile-chart/nile-chart.css.js +6 -0
- package/dist/src/nile-filter-chart/nile-filter-chart.css.js +274 -4
- package/dist/src/nile-filter-chart/nile-filter-chart.d.ts +59 -206
- package/dist/src/nile-filter-chart/nile-filter-chart.js +330 -436
- package/dist/src/nile-filter-chart/utils/badge.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/badge.js +33 -0
- package/dist/src/nile-filter-chart/utils/comparison.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/comparison.js +24 -0
- package/dist/src/nile-filter-chart/utils/dropdown.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/dropdown.js +24 -0
- package/dist/src/nile-filter-chart/utils/preset.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/preset.js +16 -0
- package/dist/src/nile-filter-chart/utils/prompt.d.ts +12 -0
- package/dist/src/nile-filter-chart/utils/prompt.js +676 -0
- package/dist/src/nile-filter-chart/utils/radio.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/radio.js +13 -0
- package/dist/src/nile-filter-chart/utils/search.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/search.js +12 -0
- package/dist/src/nile-filter-chart/utils/segmented.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/segmented.js +15 -0
- package/dist/src/nile-filter-chart/utils/threshold.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/threshold.js +58 -0
- package/dist/src/nile-filter-chart/utils/toggle.d.ts +3 -0
- package/dist/src/nile-filter-chart/utils/toggle.js +19 -0
- package/dist/src/nile-filter-chart/utils/types.d.ts +334 -0
- package/dist/src/nile-filter-chart/utils/types.js +2 -0
- package/dist/src/nile-kpi-chart/nile-kpi-chart.css.js +7 -4
- 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
|