@keenmate/pure-admin-core 2.9.0-rc03 → 2.9.0-rc05

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 (35) hide show
  1. package/README.md +55 -13
  2. package/dist/css/main.css +554 -7
  3. package/package.json +3 -1
  4. package/snippets/buttons.html +51 -0
  5. package/snippets/cards.html +132 -47
  6. package/snippets/comparison.html +26 -22
  7. package/snippets/manifest.json +180 -115
  8. package/snippets/range-group.html +125 -0
  9. package/snippets/splitter.html +44 -38
  10. package/snippets/statistics.html +31 -0
  11. package/src/js/btn-split-auto-absorb.js +327 -0
  12. package/src/js/command-palette.js +472 -0
  13. package/src/js/file-selector.js +1275 -0
  14. package/src/js/internal/logging.js +121 -0
  15. package/src/js/logic-tree-renderer.js +303 -0
  16. package/src/js/modal-dialogs.js +460 -0
  17. package/src/js/overflow.js +371 -0
  18. package/src/js/pa-stat-fit.js +184 -0
  19. package/src/js/range-group.js +663 -0
  20. package/src/js/search-autocomplete-v2.js +907 -0
  21. package/src/js/search-autocomplete.js +434 -0
  22. package/src/js/settings-panel.js +245 -0
  23. package/src/js/split-button.js +141 -0
  24. package/src/js/splitter.js +1323 -0
  25. package/src/js/toast-service.js +302 -0
  26. package/src/js/tooltips-popovers.js +275 -0
  27. package/src/js/virtual-scroll.js +143 -0
  28. package/src/js/virtual-textbox.js +803 -0
  29. package/src/scss/_core.scss +7 -0
  30. package/src/scss/core-components/_buttons.scss +44 -0
  31. package/src/scss/core-components/_cards.scss +95 -6
  32. package/src/scss/core-components/_overflow.scss +50 -0
  33. package/src/scss/core-components/_range-group.scss +474 -0
  34. package/src/scss/core-components/_statistics.scss +163 -0
  35. package/src/scss/variables/_components.scss +41 -2
@@ -0,0 +1,907 @@
1
+ /**
2
+ * Inline Query Editor with Syntax Highlighting (V2)
3
+ * Provides inline editable query string with real-time syntax highlighting
4
+ * User can type: :name startswith abcd :price between 1 to 3
5
+ * Different parts get colored backgrounds (fields=blue, operators=gray, values=green)
6
+ */
7
+
8
+ class InlineQueryEditor {
9
+ constructor(containerElement, fields, options = {}) {
10
+ this.container = containerElement;
11
+ this.fields = fields;
12
+ this.popup = null;
13
+ this.highlightLayer = null;
14
+ this.inputLayer = null;
15
+ this.activeIndex = -1;
16
+ this.isOpen = false;
17
+ this.autocompleteResults = [];
18
+ this.cursorPosition = 0;
19
+
20
+ // Operator definitions by field type with shortcuts
21
+ this.operators = {
22
+ text: [
23
+ { value: 'equals', label: 'Equals', shortcuts: ['eq', '=', 'is'], description: 'Exact match' },
24
+ { value: 'not_equals', label: 'Not Equals', shortcuts: ['neq', 'ne', '!=', 'not'], description: 'Does not match' },
25
+ { value: 'starts_with', label: 'Starts With', shortcuts: ['sw', 'startswith', 'begins'], description: 'Starts with text' },
26
+ { value: 'ends_with', label: 'Ends With', shortcuts: ['ew', 'endswith'], description: 'Ends with text' },
27
+ { value: 'contains', label: 'Contains', shortcuts: ['c', 'has', 'includes'], description: 'Contains text' },
28
+ { value: 'not_contains', label: 'Not Contains', shortcuts: ['nc', 'notcontains'], description: 'Does not contain' }
29
+ ],
30
+ number: [
31
+ { value: '=', label: 'Equals', shortcuts: ['eq', '==', 'is'], description: 'Exact value' },
32
+ { value: '!=', label: 'Not Equals', shortcuts: ['neq', 'ne', 'not'], description: 'Different value' },
33
+ { value: '>', label: 'Greater Than', shortcuts: ['gt', 'over', 'above'], description: 'Greater than value' },
34
+ { value: '<', label: 'Less Than', shortcuts: ['lt', 'under', 'below'], description: 'Less than value' },
35
+ { value: 'between', label: 'Between', shortcuts: ['bw', 'btw', 'from'], description: 'Between two values' },
36
+ { value: '>=', label: 'Greater or Equal', shortcuts: ['gte', 'ge', 'atleast', 'min'], description: 'Greater or equal' },
37
+ { value: '<=', label: 'Less or Equal', shortcuts: ['lte', 'le', 'atmost', 'max'], description: 'Less or equal' }
38
+ ],
39
+ date: [
40
+ { value: 'equals', label: 'On Date', shortcuts: ['eq', '=', 'on', 'is'], description: 'Exact date' },
41
+ { value: 'before', label: 'Before', shortcuts: ['bf', '<', 'until'], description: 'Before date' },
42
+ { value: 'after', label: 'After', shortcuts: ['af', '>', 'since', 'from'], description: 'After date' },
43
+ { value: 'between', label: 'Between', shortcuts: ['bw', 'btw'], description: 'Date range' },
44
+ { value: 'in_last', label: 'In Last', shortcuts: ['last', 'past', 'recent'], description: 'In last X days' },
45
+ { value: 'in_next', label: 'In Next', shortcuts: ['next', 'future', 'upcoming'], description: 'In next X days' }
46
+ ]
47
+ };
48
+
49
+ // Keywords that are part of query syntax but not values
50
+ this.keywords = ['to', 'and', 'or', '(', ')'];
51
+
52
+ this.init();
53
+ }
54
+
55
+ init() {
56
+ // Create dual-layer structure
57
+ this.container.classList.add('pa-inline-query-editor');
58
+ this.container.innerHTML = `
59
+ <div class="pa-inline-query-editor__layers">
60
+ <div class="pa-inline-query-editor__highlights" aria-hidden="true"></div>
61
+ <textarea class="pa-inline-query-editor__input"
62
+ placeholder="Type : to search fields..."
63
+ rows="1"
64
+ spellcheck="false"></textarea>
65
+ </div>
66
+ `;
67
+
68
+ this.highlightLayer = this.container.querySelector('.pa-inline-query-editor__highlights');
69
+ this.inputLayer = this.container.querySelector('.pa-inline-query-editor__input');
70
+
71
+ // Create popup element
72
+ this.popup = document.createElement('div');
73
+ this.popup.className = 'pa-inline-query-autocomplete';
74
+ this.popup.style.display = 'none';
75
+ document.body.appendChild(this.popup);
76
+
77
+ // Event listeners
78
+ this.inputLayer.addEventListener('input', (e) => this.handleInput(e));
79
+ this.inputLayer.addEventListener('keydown', (e) => this.handleKeydown(e));
80
+ this.inputLayer.addEventListener('scroll', () => this.syncScroll());
81
+ this.inputLayer.addEventListener('click', () => this.updateCursorPosition());
82
+ this.inputLayer.addEventListener('keyup', () => this.updateCursorPosition());
83
+ document.addEventListener('click', (e) => this.handleClickOutside(e));
84
+
85
+ // Auto-grow textarea
86
+ this.inputLayer.addEventListener('input', () => this.autoGrow());
87
+ }
88
+
89
+ /**
90
+ * Auto-grow textarea height
91
+ */
92
+ autoGrow() {
93
+ this.inputLayer.style.height = 'auto';
94
+ this.inputLayer.style.height = this.inputLayer.scrollHeight + 'px';
95
+ this.highlightLayer.style.height = this.inputLayer.scrollHeight + 'px';
96
+ }
97
+
98
+ /**
99
+ * Sync scroll position between input and highlight layers
100
+ */
101
+ syncScroll() {
102
+ this.highlightLayer.scrollTop = this.inputLayer.scrollTop;
103
+ this.highlightLayer.scrollLeft = this.inputLayer.scrollLeft;
104
+ }
105
+
106
+ /**
107
+ * Update cursor position
108
+ */
109
+ updateCursorPosition() {
110
+ this.cursorPosition = this.inputLayer.selectionStart;
111
+ }
112
+
113
+ /**
114
+ * Parse query string into tokens with types
115
+ */
116
+ parseQuery(queryString) {
117
+ const tokens = [];
118
+ let currentPos = 0;
119
+ const text = queryString;
120
+
121
+ // Get all field names and operators for matching
122
+ const allFieldNames = this.fields.map(f => f.name);
123
+ const allOperators = [];
124
+ Object.values(this.operators).forEach(ops => {
125
+ ops.forEach(op => {
126
+ allOperators.push(op.value);
127
+ if (op.shortcuts) {
128
+ op.shortcuts.forEach(s => allOperators.push(s));
129
+ }
130
+ });
131
+ });
132
+ // Sort operators by length (longest first) to match "starts_with" before "starts"
133
+ allOperators.sort((a, b) => b.length - a.length);
134
+
135
+ while (currentPos < text.length) {
136
+ const remaining = text.substring(currentPos);
137
+
138
+ // Check if we're at the start or after whitespace/parenthesis for field matching
139
+ const prevChar = currentPos > 0 ? text[currentPos - 1] : ' ';
140
+ const isFieldStart = prevChar === ' ' || prevChar === '(' || currentPos === 0;
141
+
142
+ // Match field (starts with : only at start or after whitespace)
143
+ if (isFieldStart) {
144
+ const fieldMatch = remaining.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)/);
145
+ if (fieldMatch) {
146
+ const fieldName = fieldMatch[1];
147
+ tokens.push({
148
+ type: 'field',
149
+ value: ':' + fieldName,
150
+ start: currentPos,
151
+ end: currentPos + fieldMatch[0].length,
152
+ valid: allFieldNames.includes(fieldName)
153
+ });
154
+ currentPos += fieldMatch[0].length;
155
+ continue;
156
+ }
157
+ }
158
+
159
+ // Match operator (sorted by length, longest first)
160
+ let operatorMatched = false;
161
+ for (const op of allOperators) {
162
+ const opRegex = new RegExp('^' + this.escapeRegex(op) + '(?=\\s|\\)|$)', 'i');
163
+ const opMatch = remaining.match(opRegex);
164
+ if (opMatch) {
165
+ tokens.push({
166
+ type: 'operator',
167
+ value: opMatch[0],
168
+ start: currentPos,
169
+ end: currentPos + opMatch[0].length
170
+ });
171
+ currentPos += opMatch[0].length;
172
+ operatorMatched = true;
173
+ break;
174
+ }
175
+ }
176
+ if (operatorMatched) continue;
177
+
178
+ // Match keyword - parentheses don't need trailing whitespace
179
+ const keywordMatch = remaining.match(/^(\(|\))/);
180
+ if (keywordMatch) {
181
+ tokens.push({
182
+ type: 'keyword',
183
+ value: keywordMatch[0],
184
+ start: currentPos,
185
+ end: currentPos + keywordMatch[0].length
186
+ });
187
+ currentPos += keywordMatch[0].length;
188
+ continue;
189
+ }
190
+
191
+ // Match logical keywords (to, and, or) - these need trailing whitespace or end
192
+ const logicalKeywordMatch = remaining.match(/^(to|and|or)(?=\s|$)/i);
193
+ if (logicalKeywordMatch) {
194
+ tokens.push({
195
+ type: 'keyword',
196
+ value: logicalKeywordMatch[0],
197
+ start: currentPos,
198
+ end: currentPos + logicalKeywordMatch[0].length
199
+ });
200
+ currentPos += logicalKeywordMatch[0].length;
201
+ continue;
202
+ }
203
+
204
+ // Match whitespace
205
+ const whitespaceMatch = remaining.match(/^\s+/);
206
+ if (whitespaceMatch) {
207
+ tokens.push({
208
+ type: 'whitespace',
209
+ value: whitespaceMatch[0],
210
+ start: currentPos,
211
+ end: currentPos + whitespaceMatch[0].length
212
+ });
213
+ currentPos += whitespaceMatch[0].length;
214
+ continue;
215
+ }
216
+
217
+ // Match value with optional time unit suffix (30days, 5weeks, 2months, 1year)
218
+ const valueWithUnitMatch = remaining.match(/^(\d+)(days?|weeks?|months?|years?)(?=\s|\)|$)/i);
219
+ if (valueWithUnitMatch) {
220
+ tokens.push({
221
+ type: 'value',
222
+ value: valueWithUnitMatch[0],
223
+ start: currentPos,
224
+ end: currentPos + valueWithUnitMatch[0].length
225
+ });
226
+ currentPos += valueWithUnitMatch[0].length;
227
+ continue;
228
+ }
229
+
230
+ // Match value (everything else that's not whitespace or parentheses)
231
+ const valueMatch = remaining.match(/^[^\s()]+/);
232
+ if (valueMatch) {
233
+ tokens.push({
234
+ type: 'value',
235
+ value: valueMatch[0],
236
+ start: currentPos,
237
+ end: currentPos + valueMatch[0].length
238
+ });
239
+ currentPos += valueMatch[0].length;
240
+ continue;
241
+ }
242
+
243
+ // Safety: advance one character if nothing matched
244
+ currentPos++;
245
+ }
246
+
247
+ return tokens;
248
+ }
249
+
250
+ /**
251
+ * Escape special regex characters
252
+ */
253
+ escapeRegex(str) {
254
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
255
+ }
256
+
257
+ /**
258
+ * Render syntax highlighting
259
+ */
260
+ renderHighlights() {
261
+ const queryString = this.inputLayer.value;
262
+ const tokens = this.parseQuery(queryString);
263
+
264
+ let html = '';
265
+
266
+ tokens.forEach(token => {
267
+ const escapedValue = this.escapeHtml(token.value);
268
+
269
+ if (token.type === 'field') {
270
+ const validClass = token.valid ? '' : ' pa-inline-query-token--invalid';
271
+ html += `<span class="pa-inline-query-token pa-inline-query-token--field${validClass}">${escapedValue}</span>`;
272
+ } else if (token.type === 'operator') {
273
+ html += `<span class="pa-inline-query-token pa-inline-query-token--operator">${escapedValue}</span>`;
274
+ } else if (token.type === 'value') {
275
+ html += `<span class="pa-inline-query-token pa-inline-query-token--value">${escapedValue}</span>`;
276
+ } else if (token.type === 'keyword') {
277
+ html += `<span class="pa-inline-query-token pa-inline-query-token--keyword">${escapedValue}</span>`;
278
+ } else if (token.type === 'whitespace') {
279
+ html += escapedValue;
280
+ } else {
281
+ html += escapedValue;
282
+ }
283
+ });
284
+
285
+ // Add trailing space to prevent layout collapse
286
+ html += ' ';
287
+
288
+ this.highlightLayer.innerHTML = html;
289
+ }
290
+
291
+ /**
292
+ * Escape HTML
293
+ */
294
+ escapeHtml(text) {
295
+ const div = document.createElement('div');
296
+ div.textContent = text;
297
+ return div.innerHTML;
298
+ }
299
+
300
+ /**
301
+ * Get context at cursor position (what type of suggestion to show)
302
+ */
303
+ getCursorContext() {
304
+ const queryString = this.inputLayer.value;
305
+ const cursorPos = this.cursorPosition;
306
+ const tokens = this.parseQuery(queryString);
307
+
308
+ // Find token at cursor
309
+ const currentToken = tokens.find(t => cursorPos >= t.start && cursorPos <= t.end);
310
+ const prevToken = tokens.filter(t => t.end <= cursorPos).pop();
311
+
312
+ // If cursor is in a field token or after :
313
+ if (currentToken && currentToken.type === 'field') {
314
+ return { type: 'field', partial: currentToken.value };
315
+ }
316
+
317
+ // If cursor is right after : (incomplete field) - but only at start or after whitespace/parenthesis
318
+ const beforeCursor = queryString.substring(0, cursorPos);
319
+ if (beforeCursor.endsWith(':')) {
320
+ const beforeColon = cursorPos > 1 ? queryString[cursorPos - 2] : ' ';
321
+ if (beforeColon === ' ' || beforeColon === '(' || cursorPos === 1) {
322
+ return { type: 'field', partial: ':' };
323
+ }
324
+ }
325
+
326
+ // Find the field before the current position
327
+ // We need to look backwards from the current token to find a field
328
+ let fieldToken = null;
329
+ for (let i = tokens.length - 1; i >= 0; i--) {
330
+ const token = tokens[i];
331
+ // Skip the current token if it's an operator or value (we're editing it)
332
+ if (currentToken && token.start >= currentToken.start) {
333
+ continue;
334
+ }
335
+ // Found a field token before current position
336
+ if (token.type === 'field' && token.valid) {
337
+ fieldToken = token;
338
+ break;
339
+ }
340
+ // If we hit another operator or value before finding a field, stop
341
+ if (token.type === 'operator' || token.type === 'value') {
342
+ break;
343
+ }
344
+ }
345
+
346
+ if (fieldToken) {
347
+ // Get the field definition
348
+ const fieldName = fieldToken.value.substring(1);
349
+ const fieldDef = this.fields.find(f => f.name === fieldName);
350
+
351
+ // Check if cursor is in operator position (typing or inside operator)
352
+ if (currentToken && currentToken.type === 'operator') {
353
+ return { type: 'operator', fieldType: fieldDef.type, partial: currentToken.value };
354
+ }
355
+
356
+ // Check if cursor is in a value token that might actually be an incomplete operator
357
+ if (currentToken && currentToken.type === 'value') {
358
+ // This could be an incomplete operator shortcut, show operator suggestions
359
+ return { type: 'operator', fieldType: fieldDef.type, partial: currentToken.value };
360
+ }
361
+
362
+ // If cursor is right after field (no token at cursor), show all operators
363
+ if (!currentToken || currentToken.type === 'whitespace') {
364
+ return { type: 'operator', fieldType: fieldDef.type, partial: '' };
365
+ }
366
+ }
367
+
368
+ return { type: 'none' };
369
+ }
370
+
371
+ /**
372
+ * Search for autocomplete suggestions
373
+ */
374
+ searchAutocomplete(context) {
375
+ if (context.type === 'field') {
376
+ const searchTerm = context.partial.startsWith(':')
377
+ ? context.partial.substring(1).toLowerCase()
378
+ : context.partial.toLowerCase();
379
+
380
+ return this.fields.filter(field =>
381
+ field.name.toLowerCase().includes(searchTerm)
382
+ ).map(field => ({
383
+ value: field.name,
384
+ label: field.name,
385
+ description: `${field.type} field`,
386
+ type: field.type,
387
+ insertValue: ':' + field.name
388
+ }));
389
+ } else if (context.type === 'operator') {
390
+ const operators = this.operators[context.fieldType] || [];
391
+ const searchTerm = context.partial.toLowerCase();
392
+
393
+ return operators.filter(op =>
394
+ searchTerm === '' ||
395
+ op.value.toLowerCase().includes(searchTerm) ||
396
+ op.label.toLowerCase().includes(searchTerm) ||
397
+ (op.shortcuts && op.shortcuts.some(s => s.toLowerCase().includes(searchTerm)))
398
+ ).map(op => ({
399
+ ...op,
400
+ insertValue: op.value
401
+ }));
402
+ }
403
+
404
+ return [];
405
+ }
406
+
407
+ /**
408
+ * Handle input changes
409
+ */
410
+ handleInput(e) {
411
+ this.updateCursorPosition();
412
+ this.renderHighlights();
413
+ this.syncScroll();
414
+
415
+ // Check for autocomplete
416
+ const context = this.getCursorContext();
417
+ if (context.type !== 'none') {
418
+ this.autocompleteResults = this.searchAutocomplete(context);
419
+ this.activeIndex = this.autocompleteResults.length > 0 ? 0 : -1;
420
+
421
+ if (this.autocompleteResults.length > 0) {
422
+ this.renderAutocomplete();
423
+ } else {
424
+ this.close();
425
+ }
426
+ } else {
427
+ this.close();
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Render autocomplete dropdown
433
+ */
434
+ renderAutocomplete() {
435
+ if (this.autocompleteResults.length === 0) {
436
+ this.close();
437
+ return;
438
+ }
439
+
440
+ const context = this.getCursorContext();
441
+ const isFieldSearch = context.type === 'field';
442
+ let html = '';
443
+
444
+ this.autocompleteResults.forEach((item, index) => {
445
+ const isActive = index === this.activeIndex;
446
+ const icon = isFieldSearch ? '🔍' : '⚡';
447
+
448
+ html += `
449
+ <div class="pa-inline-query-autocomplete__item ${isActive ? 'pa-inline-query-autocomplete__item--active' : ''}" data-index="${index}">
450
+ <span class="pa-inline-query-autocomplete__item-icon">${icon}</span>
451
+ <div class="pa-inline-query-autocomplete__item-content">
452
+ <span class="pa-inline-query-autocomplete__item-name">${item.label}</span>
453
+ <span class="pa-inline-query-autocomplete__item-type">${item.description}</span>
454
+ </div>
455
+ <span class="pa-inline-query-autocomplete__item-badge">${item.value}</span>
456
+ </div>
457
+ `;
458
+ });
459
+
460
+ this.popup.innerHTML = html;
461
+
462
+ // Add click handlers
463
+ this.popup.querySelectorAll('.pa-inline-query-autocomplete__item').forEach((item, index) => {
464
+ item.addEventListener('click', () => {
465
+ this.selectAutocomplete(this.autocompleteResults[index]);
466
+ });
467
+ });
468
+
469
+ this.position();
470
+ this.open();
471
+ }
472
+
473
+ /**
474
+ * Select autocomplete item
475
+ */
476
+ selectAutocomplete(item) {
477
+ if (!item) return;
478
+
479
+ const context = this.getCursorContext();
480
+ const queryString = this.inputLayer.value;
481
+ const tokens = this.parseQuery(queryString);
482
+ const cursorPos = this.cursorPosition;
483
+
484
+ let newValue = queryString;
485
+ let newCursorPos = cursorPos;
486
+
487
+ if (context.type === 'field') {
488
+ // Replace the partial field with selected field
489
+ const currentToken = tokens.find(t => t.type === 'field' && cursorPos >= t.start && cursorPos <= t.end);
490
+
491
+ if (currentToken) {
492
+ // Replace existing field
493
+ newValue = queryString.substring(0, currentToken.start) + item.insertValue + ' ' + queryString.substring(currentToken.end);
494
+ newCursorPos = currentToken.start + item.insertValue.length + 1;
495
+ } else {
496
+ // Insert after :
497
+ const colonPos = queryString.lastIndexOf(':', cursorPos);
498
+ if (colonPos !== -1) {
499
+ newValue = queryString.substring(0, colonPos) + item.insertValue + ' ' + queryString.substring(cursorPos);
500
+ newCursorPos = colonPos + item.insertValue.length + 1;
501
+ }
502
+ }
503
+ } else if (context.type === 'operator') {
504
+ // Find the position to insert operator
505
+ const currentToken = tokens.find(t => cursorPos >= t.start && cursorPos <= t.end);
506
+
507
+ if (currentToken && (currentToken.type === 'operator' || currentToken.type === 'value')) {
508
+ // Replace existing operator or value token (incomplete operator)
509
+ newValue = queryString.substring(0, currentToken.start) + item.insertValue + ' ' + queryString.substring(currentToken.end);
510
+ newCursorPos = currentToken.start + item.insertValue.length + 1;
511
+ } else {
512
+ // Insert at cursor
513
+ newValue = queryString.substring(0, cursorPos) + item.insertValue + ' ' + queryString.substring(cursorPos);
514
+ newCursorPos = cursorPos + item.insertValue.length + 1;
515
+ }
516
+ }
517
+
518
+ // Update input
519
+ this.inputLayer.value = newValue;
520
+ this.inputLayer.setSelectionRange(newCursorPos, newCursorPos);
521
+ this.cursorPosition = newCursorPos;
522
+
523
+ // Update UI
524
+ this.renderHighlights();
525
+ this.close();
526
+ this.inputLayer.focus();
527
+ this.autoGrow();
528
+
529
+ // If we just selected a field, trigger operator autocomplete
530
+ if (context.type === 'field') {
531
+ setTimeout(() => {
532
+ this.updateCursorPosition();
533
+ const newContext = this.getCursorContext();
534
+ if (newContext.type === 'operator') {
535
+ this.autocompleteResults = this.searchAutocomplete(newContext);
536
+ this.activeIndex = this.autocompleteResults.length > 0 ? 0 : -1;
537
+ if (this.autocompleteResults.length > 0) {
538
+ this.renderAutocomplete();
539
+ }
540
+ }
541
+ }, 0);
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Handle keyboard navigation
547
+ */
548
+ handleKeydown(e) {
549
+ // Handle autocomplete navigation
550
+ if (this.autocompleteResults.length > 0 && this.isOpen) {
551
+ if (e.key === 'ArrowUp') {
552
+ e.preventDefault();
553
+ this.navigateAutocompletePrevious();
554
+ return;
555
+ }
556
+ if (e.key === 'ArrowDown') {
557
+ e.preventDefault();
558
+ this.navigateAutocompleteNext();
559
+ return;
560
+ }
561
+ if (e.key === 'Tab') {
562
+ e.preventDefault();
563
+ this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
564
+ return;
565
+ }
566
+ if (e.key === 'Enter' && this.activeIndex >= 0) {
567
+ e.preventDefault();
568
+ this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
569
+ return;
570
+ }
571
+ }
572
+
573
+ // Escape to close
574
+ if (e.key === 'Escape') {
575
+ this.close();
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Navigate autocomplete suggestions
581
+ */
582
+ navigateAutocompletePrevious() {
583
+ if (this.autocompleteResults.length === 0) return;
584
+
585
+ this.activeIndex = this.activeIndex <= 0
586
+ ? this.autocompleteResults.length - 1
587
+ : this.activeIndex - 1;
588
+
589
+ this.renderAutocomplete();
590
+ }
591
+
592
+ navigateAutocompleteNext() {
593
+ if (this.autocompleteResults.length === 0) return;
594
+
595
+ this.activeIndex = this.activeIndex >= this.autocompleteResults.length - 1
596
+ ? 0
597
+ : this.activeIndex + 1;
598
+
599
+ this.renderAutocomplete();
600
+ }
601
+
602
+ /**
603
+ * Position popup below input
604
+ */
605
+ position() {
606
+ const inputRect = this.inputLayer.getBoundingClientRect();
607
+ this.popup.style.position = 'absolute';
608
+ this.popup.style.left = inputRect.left + 'px';
609
+ this.popup.style.top = (inputRect.bottom + 4) + 'px';
610
+ this.popup.style.minWidth = Math.max(inputRect.width, 250) + 'px';
611
+ }
612
+
613
+ /**
614
+ * Open popup
615
+ */
616
+ open() {
617
+ this.popup.style.display = 'block';
618
+ this.isOpen = true;
619
+ }
620
+
621
+ /**
622
+ * Close popup
623
+ */
624
+ close() {
625
+ this.popup.style.display = 'none';
626
+ this.isOpen = false;
627
+ this.activeIndex = -1;
628
+ this.autocompleteResults = [];
629
+ }
630
+
631
+ /**
632
+ * Handle click outside
633
+ */
634
+ handleClickOutside(e) {
635
+ if (this.isOpen && !this.popup.contains(e.target) && !this.container.contains(e.target)) {
636
+ this.close();
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Get parsed query (for external use)
642
+ */
643
+ getParsedQuery() {
644
+ return this.parseQuery(this.inputLayer.value);
645
+ }
646
+
647
+ /**
648
+ * Convert parsed tokens to structured query for server (JSON:API format)
649
+ * Returns nested filter object with logical operators
650
+ */
651
+ toServerQuery() {
652
+ const tokens = this.parseQuery(this.inputLayer.value);
653
+
654
+ // Build expression tree from tokens
655
+ const filter = this.buildFilterTree(tokens, 0).filter;
656
+
657
+ return {
658
+ filter: filter,
659
+ raw: this.inputLayer.value
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Build nested filter tree from flat token list
665
+ * Handles parentheses and logical operators with proper precedence
666
+ */
667
+ buildFilterTree(tokens, startIndex) {
668
+ const conditions = [];
669
+ let currentLogic = null; // null, 'and', or 'or'
670
+ let i = startIndex;
671
+
672
+ while (i < tokens.length) {
673
+ const token = tokens[i];
674
+
675
+ // Skip whitespace
676
+ if (token.type === 'whitespace') {
677
+ i++;
678
+ continue;
679
+ }
680
+
681
+ // Handle opening parenthesis - recursively parse group
682
+ if (token.type === 'keyword' && token.value === '(') {
683
+ const result = this.buildFilterTree(tokens, i + 1);
684
+ conditions.push(result.filter);
685
+ i = result.endIndex + 1; // Skip past closing parenthesis
686
+ continue;
687
+ }
688
+
689
+ // Handle closing parenthesis - return current level
690
+ if (token.type === 'keyword' && token.value === ')') {
691
+ return {
692
+ filter: this.combineConditions(conditions, currentLogic),
693
+ endIndex: i
694
+ };
695
+ }
696
+
697
+ // Handle logical operators (AND, OR)
698
+ if (token.type === 'keyword' && (token.value.toLowerCase() === 'and' || token.value.toLowerCase() === 'or')) {
699
+ const logic = token.value.toLowerCase();
700
+
701
+ if (currentLogic === null) {
702
+ currentLogic = logic;
703
+ } else if (currentLogic !== logic) {
704
+ // Mixed operators at same level - need to restructure
705
+ // Combine existing conditions with current logic, then switch
706
+ const combined = this.combineConditions(conditions, currentLogic);
707
+ conditions.length = 0; // Clear array
708
+ conditions.push(combined);
709
+ currentLogic = logic;
710
+ }
711
+
712
+ i++;
713
+ continue;
714
+ }
715
+
716
+ // Handle field + operator + value
717
+ if (token.type === 'field' && token.valid) {
718
+ const field = token.value.substring(1); // Remove leading :
719
+ i++;
720
+
721
+ // Skip whitespace after field
722
+ while (i < tokens.length && tokens[i].type === 'whitespace') {
723
+ i++;
724
+ }
725
+
726
+ // Get operator
727
+ if (i < tokens.length && tokens[i].type === 'operator') {
728
+ const operator = this.normalizeOperator(tokens[i].value);
729
+ i++;
730
+
731
+ // Skip whitespace after operator
732
+ while (i < tokens.length && tokens[i].type === 'whitespace') {
733
+ i++;
734
+ }
735
+
736
+ // Get value(s)
737
+ const values = [];
738
+ while (i < tokens.length && (tokens[i].type === 'value' || tokens[i].type === 'keyword')) {
739
+ if (tokens[i].type === 'value') {
740
+ // Remove surrounding quotes if present
741
+ let value = tokens[i].value;
742
+ if ((value.startsWith("'") && value.endsWith("'")) ||
743
+ (value.startsWith('"') && value.endsWith('"'))) {
744
+ value = value.slice(1, -1);
745
+ }
746
+ values.push(value);
747
+ } else if (tokens[i].type === 'keyword' && tokens[i].value.toLowerCase() === 'to') {
748
+ // 'to' keyword in 'between' queries, skip it
749
+ i++;
750
+ while (i < tokens.length && tokens[i].type === 'whitespace') {
751
+ i++;
752
+ }
753
+ continue;
754
+ } else {
755
+ // Stop at logical operators or parentheses
756
+ break;
757
+ }
758
+ i++;
759
+
760
+ // Skip whitespace between values
761
+ while (i < tokens.length && tokens[i].type === 'whitespace') {
762
+ i++;
763
+ }
764
+
765
+ // Stop if we hit another field, operator, or keyword
766
+ if (i < tokens.length &&
767
+ (tokens[i].type === 'field' ||
768
+ tokens[i].type === 'operator' ||
769
+ (tokens[i].type === 'keyword' &&
770
+ (tokens[i].value.toLowerCase() === 'and' ||
771
+ tokens[i].value.toLowerCase() === 'or' ||
772
+ tokens[i].value === ')')))) {
773
+ break;
774
+ }
775
+ }
776
+
777
+ // Create filter condition in JSON:API format
778
+ const value = values.length === 1 ? values[0] : values;
779
+ const condition = {
780
+ [field]: { [operator]: value }
781
+ };
782
+
783
+ conditions.push(condition);
784
+ continue;
785
+ }
786
+ }
787
+
788
+ // Unknown or invalid token, skip
789
+ i++;
790
+ }
791
+
792
+ // Return combined result
793
+ return {
794
+ filter: this.combineConditions(conditions, currentLogic),
795
+ endIndex: i
796
+ };
797
+ }
798
+
799
+ /**
800
+ * Combine multiple conditions with a logical operator
801
+ */
802
+ combineConditions(conditions, logic) {
803
+ if (conditions.length === 0) {
804
+ return {};
805
+ }
806
+
807
+ if (conditions.length === 1) {
808
+ return conditions[0];
809
+ }
810
+
811
+ // Multiple conditions - wrap in logical operator
812
+ return {
813
+ [logic || 'and']: conditions
814
+ };
815
+ }
816
+
817
+ /**
818
+ * Normalize operator names to JSON:API standard format
819
+ */
820
+ normalizeOperator(operator) {
821
+ const normalized = operator.toLowerCase();
822
+
823
+ // Map common operators to standard names
824
+ const operatorMap = {
825
+ '=': 'eq',
826
+ '==': 'eq',
827
+ 'is': 'eq',
828
+ 'equals': 'eq',
829
+ '!=': 'ne',
830
+ 'not_equals': 'ne',
831
+ 'not': 'ne',
832
+ 'neq': 'ne',
833
+ '>': 'gt',
834
+ 'greater_than': 'gt',
835
+ 'over': 'gt',
836
+ 'above': 'gt',
837
+ '<': 'lt',
838
+ 'less_than': 'lt',
839
+ 'under': 'lt',
840
+ 'below': 'lt',
841
+ '>=': 'gte',
842
+ 'greater_or_equal': 'gte',
843
+ 'atleast': 'gte',
844
+ 'min': 'gte',
845
+ '<=': 'lte',
846
+ 'less_or_equal': 'lte',
847
+ 'atmost': 'lte',
848
+ 'max': 'lte',
849
+ 'starts_with': 'startswith',
850
+ 'sw': 'startswith',
851
+ 'begins': 'startswith',
852
+ 'ends_with': 'endswith',
853
+ 'ew': 'endswith',
854
+ 'contains': 'contains',
855
+ 'c': 'contains',
856
+ 'has': 'contains',
857
+ 'includes': 'contains',
858
+ 'not_contains': 'notcontains',
859
+ 'nc': 'notcontains',
860
+ 'between': 'between',
861
+ 'bw': 'between',
862
+ 'btw': 'between',
863
+ 'from': 'between',
864
+ 'before': 'before',
865
+ 'bf': 'before',
866
+ 'until': 'before',
867
+ 'after': 'after',
868
+ 'af': 'after',
869
+ 'since': 'after',
870
+ 'in_last': 'inlast',
871
+ 'last': 'inlast',
872
+ 'past': 'inlast',
873
+ 'recent': 'inlast',
874
+ 'in_next': 'innext',
875
+ 'next': 'innext',
876
+ 'future': 'innext',
877
+ 'upcoming': 'innext'
878
+ };
879
+
880
+ return operatorMap[normalized] || normalized;
881
+ }
882
+
883
+ /**
884
+ * Get query string
885
+ */
886
+ getValue() {
887
+ return this.inputLayer.value;
888
+ }
889
+
890
+ /**
891
+ * Set query string
892
+ */
893
+ setValue(value) {
894
+ this.inputLayer.value = value;
895
+ this.renderHighlights();
896
+ this.autoGrow();
897
+ }
898
+
899
+ /**
900
+ * Destroy instance
901
+ */
902
+ destroy() {
903
+ if (this.popup && this.popup.parentNode) {
904
+ this.popup.parentNode.removeChild(this.popup);
905
+ }
906
+ }
907
+ }