@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,803 @@
1
+ /**
2
+ * Virtual Textbox - Contenteditable Query Builder with Inline Tokens
3
+ * Replaces standard input with contenteditable div for rich inline formatting
4
+ * Tokens appear as inline badges within the text flow
5
+ * Example: Type ":name" → select operator → enter value → inline [name][starts_with]["abc"]
6
+ */
7
+
8
+ class VirtualTextbox {
9
+ constructor(element, fields, options = {}) {
10
+ this.element = element; // contenteditable div
11
+ this.fields = fields;
12
+ this.options = {
13
+ enableHighlighting: true,
14
+ enableGrouping: true, // Group field+operator+value together
15
+ ...options
16
+ };
17
+
18
+ this.popup = null;
19
+ this.activeIndex = -1;
20
+ this.isOpen = false;
21
+ this.autocompleteResults = [];
22
+ this.currentQueryState = 'idle'; // 'idle' | 'expecting-operator' | 'expecting-value' | 'expecting-logical'
23
+ this.pendingToken = null; // Stores token being built (field, operator)
24
+
25
+ // Logical operators
26
+ this.logicalOperators = [
27
+ { value: 'AND', label: 'AND', shortcuts: ['and', '&&', '&'], description: 'All conditions must match' },
28
+ { value: 'OR', label: 'OR', shortcuts: ['or', '||', '|'], description: 'Any condition can match' }
29
+ ];
30
+
31
+ // Operator definitions by field type with shortcuts
32
+ this.operators = {
33
+ text: [
34
+ { value: 'equals', label: 'Equals', shortcuts: ['eq', '='], description: 'Exact match' },
35
+ { value: 'not_equals', label: 'Not Equals', shortcuts: ['neq', 'ne', '!='], description: 'Does not match' },
36
+ { value: 'starts_with', label: 'Starts With', shortcuts: ['sw', 'startswith'], description: 'Starts with text' },
37
+ { value: 'ends_with', label: 'Ends With', shortcuts: ['ew', 'endswith'], description: 'Ends with text' },
38
+ { value: 'contains', label: 'Contains', shortcuts: ['c', 'has'], description: 'Contains text' },
39
+ { value: 'not_contains', label: 'Not Contains', shortcuts: ['nc', 'notcontains'], description: 'Does not contain' }
40
+ ],
41
+ number: [
42
+ { value: '=', label: 'Equals', shortcuts: ['eq', '=='], description: 'Exact value' },
43
+ { value: '!=', label: 'Not Equals', shortcuts: ['neq', 'ne'], description: 'Different value' },
44
+ { value: '>', label: 'Greater Than', shortcuts: ['gt'], description: 'Greater than value' },
45
+ { value: '<', label: 'Less Than', shortcuts: ['lt'], description: 'Less than value' },
46
+ { value: 'between', label: 'Between', shortcuts: ['bw', 'btw'], description: 'Between two values' },
47
+ { value: '>=', label: 'Greater or Equal', shortcuts: ['gte', 'ge'], description: 'Greater or equal' },
48
+ { value: '<=', label: 'Less or Equal', shortcuts: ['lte', 'le'], description: 'Less or equal' }
49
+ ],
50
+ date: [
51
+ { value: 'equals', label: 'On Date', shortcuts: ['eq', '=', 'on'], description: 'Exact date' },
52
+ { value: 'before', label: 'Before', shortcuts: ['bf', '<'], description: 'Before date' },
53
+ { value: 'after', label: 'After', shortcuts: ['af', '>'], description: 'After date' },
54
+ { value: 'between', label: 'Between', shortcuts: ['bw', 'btw'], description: 'Date range' },
55
+ { value: 'in_last', label: 'In Last', shortcuts: ['last', 'past'], description: 'In last X days' },
56
+ { value: 'in_next', label: 'In Next', shortcuts: ['next', 'future'], description: 'In next X days' }
57
+ ]
58
+ };
59
+
60
+ this.init();
61
+ }
62
+
63
+ init() {
64
+ // Make element contenteditable if not already
65
+ if (this.element.getAttribute('contenteditable') !== 'true') {
66
+ this.element.setAttribute('contenteditable', 'true');
67
+ }
68
+
69
+ // Create autocomplete popup
70
+ this.popup = document.createElement('div');
71
+ this.popup.className = 'pa-search-autocomplete';
72
+ this.popup.style.display = 'none';
73
+ document.body.appendChild(this.popup);
74
+
75
+ // Event listeners
76
+ this.element.addEventListener('input', (e) => this.handleInput(e));
77
+ this.element.addEventListener('keydown', (e) => this.handleKeydown(e));
78
+ this.element.addEventListener('paste', (e) => this.handlePaste(e));
79
+ document.addEventListener('click', (e) => this.handleClickOutside(e));
80
+
81
+ // Initialize state
82
+ this.updateQueryState();
83
+ }
84
+
85
+ /**
86
+ * Update query state based on pending token
87
+ */
88
+ updateQueryState() {
89
+ if (!this.pendingToken) {
90
+ this.currentQueryState = 'idle';
91
+ } else if (this.pendingToken.type === 'field') {
92
+ this.currentQueryState = 'expecting-operator';
93
+ } else if (this.pendingToken.type === 'operator') {
94
+ this.currentQueryState = 'expecting-value';
95
+ } else if (this.pendingToken.type === 'value-completed') {
96
+ this.currentQueryState = 'expecting-logical';
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get current text content at cursor position
102
+ */
103
+ getCurrentWord() {
104
+ const selection = window.getSelection();
105
+ if (!selection.rangeCount) return '';
106
+
107
+ const range = selection.getRangeAt(0);
108
+ const textNode = range.startContainer;
109
+
110
+ if (textNode.nodeType !== Node.TEXT_NODE) {
111
+ return '';
112
+ }
113
+
114
+ const text = textNode.textContent;
115
+ const cursorPos = range.startOffset;
116
+
117
+ // Find word boundaries
118
+ let start = cursorPos;
119
+ while (start > 0 && !/\s/.test(text[start - 1])) {
120
+ start--;
121
+ }
122
+
123
+ let end = cursorPos;
124
+ while (end < text.length && !/\s/.test(text[end])) {
125
+ end++;
126
+ }
127
+
128
+ return text.substring(start, end);
129
+ }
130
+
131
+ /**
132
+ * Search for autocomplete suggestions
133
+ */
134
+ searchAutocomplete(query) {
135
+ if (this.currentQueryState === 'idle') {
136
+ // Search for fields (must start with :)
137
+ if (query.startsWith(':')) {
138
+ const searchTerm = query.substring(1).toLowerCase();
139
+ return this.fields.filter(field =>
140
+ field.name.toLowerCase().includes(searchTerm)
141
+ ).map(field => ({
142
+ value: field.name,
143
+ label: field.name,
144
+ description: `${field.type} field`,
145
+ type: field.type
146
+ }));
147
+ }
148
+ } else if (this.currentQueryState === 'expecting-operator') {
149
+ // Get operators for current field type
150
+ const fieldDef = this.fields.find(f => f.name === this.pendingToken.value);
151
+ if (!fieldDef) return [];
152
+
153
+ const operators = this.operators[fieldDef.type] || [];
154
+ const searchTerm = query.toLowerCase();
155
+
156
+ return operators.filter(op =>
157
+ searchTerm === '' ||
158
+ op.value.toLowerCase().includes(searchTerm) ||
159
+ op.label.toLowerCase().includes(searchTerm) ||
160
+ (op.shortcuts && op.shortcuts.some(s => s.toLowerCase().includes(searchTerm)))
161
+ );
162
+ } else if (this.currentQueryState === 'expecting-logical') {
163
+ // Show logical operators
164
+ const searchTerm = query.toLowerCase();
165
+ return this.logicalOperators.filter(op =>
166
+ searchTerm === '' ||
167
+ op.value.toLowerCase().includes(searchTerm) ||
168
+ op.label.toLowerCase().includes(searchTerm) ||
169
+ (op.shortcuts && op.shortcuts.some(s => s.toLowerCase().includes(searchTerm)))
170
+ );
171
+ }
172
+
173
+ return [];
174
+ }
175
+
176
+ /**
177
+ * Render autocomplete dropdown
178
+ */
179
+ renderAutocomplete() {
180
+ if (this.autocompleteResults.length === 0) {
181
+ this.close();
182
+ return;
183
+ }
184
+
185
+ const isFieldSearch = this.currentQueryState === 'idle';
186
+ let html = '';
187
+
188
+ this.autocompleteResults.forEach((item, index) => {
189
+ const isActive = index === this.activeIndex;
190
+ const icon = isFieldSearch ? '🔍' : '⚡';
191
+
192
+ html += `
193
+ <div class="pa-search-autocomplete__item ${isActive ? 'pa-search-autocomplete__item--active' : ''}" data-index="${index}">
194
+ <span class="pa-search-autocomplete__item-icon">${icon}</span>
195
+ <div class="pa-search-autocomplete__item-content">
196
+ <span class="pa-search-autocomplete__item-name">${item.label}</span>
197
+ <span class="pa-search-autocomplete__item-type">${item.description}</span>
198
+ </div>
199
+ <span class="pa-search-autocomplete__item-badge">${item.value}</span>
200
+ </div>
201
+ `;
202
+ });
203
+
204
+ this.popup.innerHTML = html;
205
+
206
+ // Add click handlers
207
+ this.popup.querySelectorAll('.pa-search-autocomplete__item').forEach((item, index) => {
208
+ item.addEventListener('click', () => {
209
+ this.selectAutocomplete(this.autocompleteResults[index]);
210
+ });
211
+ });
212
+
213
+ this.position();
214
+ this.open();
215
+ }
216
+
217
+ /**
218
+ * Select autocomplete item and insert token
219
+ */
220
+ selectAutocomplete(item) {
221
+ if (!item) return;
222
+
223
+ // Remove the current word being typed
224
+ this.removeCurrentWord();
225
+
226
+ // Determine token type and variant
227
+ let tokenType, variant;
228
+
229
+ if (this.currentQueryState === 'idle') {
230
+ tokenType = 'field';
231
+ variant = 'field';
232
+ this.pendingToken = { type: 'field', value: item.value, fieldType: item.type };
233
+ } else if (this.currentQueryState === 'expecting-operator') {
234
+ tokenType = 'operator';
235
+ variant = 'operator';
236
+ this.pendingToken = { type: 'operator', value: item.value, field: this.pendingToken };
237
+ } else if (this.currentQueryState === 'expecting-logical') {
238
+ tokenType = 'logical';
239
+ variant = 'logical';
240
+ // Reset to idle after logical operator
241
+ this.pendingToken = null;
242
+ }
243
+
244
+ // Insert token at cursor
245
+ this.insertToken(item.value, variant);
246
+
247
+ // Update state
248
+ this.updateQueryState();
249
+
250
+ // Clear autocomplete
251
+ this.autocompleteResults = [];
252
+ this.activeIndex = -1;
253
+ this.close();
254
+ this.element.focus();
255
+ }
256
+
257
+ /**
258
+ * Remove current word being typed (for autocomplete replacement)
259
+ */
260
+ removeCurrentWord() {
261
+ const selection = window.getSelection();
262
+ if (!selection.rangeCount) return;
263
+
264
+ const range = selection.getRangeAt(0);
265
+ const textNode = range.startContainer;
266
+
267
+ if (textNode.nodeType !== Node.TEXT_NODE) return;
268
+
269
+ const text = textNode.textContent;
270
+ const cursorPos = range.startOffset;
271
+
272
+ // Find word boundaries
273
+ let start = cursorPos;
274
+ while (start > 0 && !/\s/.test(text[start - 1])) {
275
+ start--;
276
+ }
277
+
278
+ // Create range to delete word
279
+ const deleteRange = document.createRange();
280
+ deleteRange.setStart(textNode, start);
281
+ deleteRange.setEnd(textNode, cursorPos);
282
+ deleteRange.deleteContents();
283
+ }
284
+
285
+ /**
286
+ * Insert token at cursor position
287
+ */
288
+ insertToken(value, variant) {
289
+ const selection = window.getSelection();
290
+ if (!selection.rangeCount) return;
291
+
292
+ const range = selection.getRangeAt(0);
293
+
294
+ // Map variants to color classes (no badges, just colored text)
295
+ const colorMap = {
296
+ 'field': '#1565c0', // Blue
297
+ 'operator': '#616161', // Gray
298
+ 'value': '#2e7d32', // Green
299
+ 'logical': '#e65100', // Orange
300
+ 'paren': '#7b1fa2' // Purple
301
+ };
302
+ const color = colorMap[variant] || '#000000';
303
+
304
+ // Create colored text span (non-editable to prevent cursor getting stuck)
305
+ const token = document.createElement('span');
306
+ token.style.color = color;
307
+ token.style.fontWeight = (variant === 'logical' || variant === 'field') ? 'bold' : 'normal';
308
+ token.style.cursor = 'pointer';
309
+ token.setAttribute('contenteditable', 'false');
310
+ token.setAttribute('data-token-type', variant);
311
+ token.setAttribute('data-token-value', value);
312
+ token.textContent = value;
313
+ token.className = 'pa-query-token';
314
+
315
+ // Allow editing on double-click
316
+ token.addEventListener('dblclick', (e) => {
317
+ e.preventDefault();
318
+ e.stopPropagation();
319
+ this.editToken(token);
320
+ });
321
+
322
+ // Add space after token
323
+ const space = document.createTextNode(' ');
324
+
325
+ // Insert token and space
326
+ range.deleteContents();
327
+ range.insertNode(space);
328
+ range.insertNode(token);
329
+
330
+ // Move cursor after space
331
+ range.setStartAfter(space);
332
+ range.setEndAfter(space);
333
+ selection.removeAllRanges();
334
+ selection.addRange(range);
335
+ }
336
+
337
+ /**
338
+ * Check if we're inside a quoted string
339
+ */
340
+ isInQuotedString() {
341
+ const selection = window.getSelection();
342
+ if (!selection.rangeCount) return false;
343
+
344
+ const range = selection.getRangeAt(0);
345
+ const textNode = range.startContainer;
346
+ if (textNode.nodeType !== Node.TEXT_NODE) return false;
347
+
348
+ const text = textNode.textContent;
349
+ const cursorPos = range.startOffset;
350
+
351
+ // Count quotes before cursor
352
+ let quoteCount = 0;
353
+ for (let i = 0; i < cursorPos; i++) {
354
+ if (text[i] === '"') quoteCount++;
355
+ }
356
+
357
+ // If odd number of quotes, we're inside a quoted string
358
+ return quoteCount % 2 === 1;
359
+ }
360
+
361
+ /**
362
+ * Get current word or quoted string at cursor
363
+ */
364
+ getCurrentValue() {
365
+ const selection = window.getSelection();
366
+ if (!selection.rangeCount) return {
367
+ text: "",
368
+ range: document.createRange()
369
+ };
370
+
371
+
372
+ const range = selection.getRangeAt(0);
373
+ const textNode = range.startContainer;
374
+
375
+ if (textNode.nodeType !== Node.TEXT_NODE) {
376
+ return {
377
+ text: "",
378
+ range: document.createRange()
379
+ };
380
+ }
381
+
382
+ const text = textNode.textContent;
383
+ const cursorPos = range.startOffset;
384
+
385
+ // Check if we're in a quoted string
386
+ let quoteStart = -1;
387
+ for (let i = cursorPos - 2; i >= 0; i--) {
388
+ if (text[i] === '"') {
389
+ quoteStart = i;
390
+ break;
391
+ }
392
+ }
393
+
394
+ if (quoteStart >= 0) {
395
+ // Find closing quote
396
+ let quoteEnd = text.indexOf('"', quoteStart + 1);
397
+ if (quoteEnd > quoteStart) {
398
+ // Return the quoted string with quotes
399
+
400
+ const newRange = document.createRange()
401
+ newRange.setStart(textNode, quoteStart);
402
+ newRange.setEnd(textNode, quoteEnd - quoteStart + 2);
403
+ return {
404
+ text: text.substring(quoteStart, quoteEnd + 1),
405
+ range: newRange
406
+ };
407
+ }
408
+ }
409
+
410
+ // Not in quotes, get regular word
411
+ let start = cursorPos;
412
+ while (start > 0 && !/\s/.test(text[start - 1])) {
413
+ start--;
414
+ }
415
+
416
+ let end = cursorPos;
417
+ while (end < text.length && !/\s/.test(text[end])) {
418
+ end++;
419
+ }
420
+
421
+ const newRange = document.createRange()
422
+ newRange.setStart(textNode, start);
423
+ newRange.setEnd(textNode, end - start + 1);
424
+ return {
425
+ text: text.substring(start, end),
426
+ range: newRange
427
+ };
428
+ }
429
+
430
+ /**
431
+ * Complete value token (when user types text and presses Space/Enter)
432
+ */
433
+ completeValueToken() {
434
+ const {text: currentValue, range: currentValueRange} = this.getCurrentValue();
435
+
436
+ if (currentValue && this.currentQueryState === 'expecting-value') {
437
+ // Check if it's a quoted string
438
+ let value = currentValue;
439
+ if (value.startsWith('"') && value.endsWith('"')) {
440
+ // Remove quotes for the token value
441
+ value = value.substring(1, value.length - 1);
442
+ }
443
+
444
+ // Remove the typed text
445
+ // this.removeCurrentWord();
446
+ // console.log("currentValueRange ", currentValueRange)
447
+ currentValueRange.deleteContents();
448
+
449
+ // Insert value token
450
+ this.insertToken(value, 'value');
451
+
452
+ // After value, expect logical operator
453
+ this.pendingToken = { type: 'value-completed' };
454
+ this.updateQueryState();
455
+
456
+ return true;
457
+ }
458
+
459
+ return false;
460
+ }
461
+
462
+ /**
463
+ * Handle input changes
464
+ */
465
+ handleInput(e) {
466
+ // Sync state with actual token structure in DOM
467
+ this.syncStateWithTokens();
468
+
469
+ const currentWord = this.getCurrentWord();
470
+
471
+ // Check for autocomplete trigger
472
+ if (this.currentQueryState === 'idle' || this.currentQueryState === 'expecting-operator' || this.currentQueryState === 'expecting-logical') {
473
+ this.autocompleteResults = this.searchAutocomplete(currentWord);
474
+ this.activeIndex = this.autocompleteResults.length > 0 ? 0 : -1;
475
+
476
+ if (this.autocompleteResults.length > 0) {
477
+ this.renderAutocomplete();
478
+ } else {
479
+ this.close();
480
+ }
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Sync internal state with actual tokens in DOM
486
+ * Called on input to detect token deletions
487
+ */
488
+ syncStateWithTokens() {
489
+ const tokens = Array.from(this.element.querySelectorAll('span[data-token-type]'));
490
+
491
+ if (tokens.length === 0) {
492
+ // No tokens at all - reset to idle
493
+ this.pendingToken = null;
494
+ this.updateQueryState();
495
+ return;
496
+ }
497
+
498
+ // Get the last token to determine state
499
+ const lastToken = tokens[tokens.length - 1];
500
+ const lastTokenType = lastToken.getAttribute('data-token-type');
501
+ const lastTokenValue = lastToken.textContent || lastToken.getAttribute('data-token-value');
502
+
503
+ if (lastTokenType === 'field') {
504
+ // Last token is a field - expecting operator
505
+ const fieldDef = this.fields.find(f => f.name === lastTokenValue);
506
+ this.pendingToken = {
507
+ type: 'field',
508
+ value: lastTokenValue,
509
+ fieldType: fieldDef ? fieldDef.type : 'text'
510
+ };
511
+ this.updateQueryState();
512
+ } else if (lastTokenType === 'operator') {
513
+ // Last token is an operator - expecting value
514
+ // Need to find the preceding field token
515
+ const fieldToken = tokens[tokens.length - 2];
516
+ if (fieldToken && fieldToken.getAttribute('data-token-type') === 'field') {
517
+ const fieldValue = fieldToken.textContent || fieldToken.getAttribute('data-token-value');
518
+ const fieldDef = this.fields.find(f => f.name === fieldValue);
519
+ this.pendingToken = {
520
+ type: 'operator',
521
+ value: lastTokenValue,
522
+ field: {
523
+ type: 'field',
524
+ value: fieldValue,
525
+ fieldType: fieldDef ? fieldDef.type : 'text'
526
+ }
527
+ };
528
+ this.updateQueryState();
529
+ }
530
+ } else if (lastTokenType === 'value') {
531
+ // Last token is a value - expect logical operator
532
+ this.pendingToken = { type: 'value-completed' };
533
+ this.updateQueryState();
534
+ } else if (lastTokenType === 'logical' || lastTokenType === 'paren') {
535
+ // After logical operator or opening paren - back to idle (expecting field)
536
+ this.pendingToken = null;
537
+ this.updateQueryState();
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Handle keyboard navigation and shortcuts
543
+ */
544
+ handleKeydown(e) {
545
+ // Handle parentheses insertion
546
+ if (e.key === '(' || e.key === ')') {
547
+ e.preventDefault();
548
+ const parenType = e.key === '(' ? 'paren-open' : 'paren-close';
549
+ this.insertToken(e.key, 'paren');
550
+
551
+ // Update state based on paren type
552
+ if (e.key === '(') {
553
+ this.pendingToken = null; // Expecting field after (
554
+ } else {
555
+ this.pendingToken = { type: 'value-completed' }; // Expecting logical after )
556
+ }
557
+ this.updateQueryState();
558
+ return;
559
+ }
560
+
561
+ // Handle space key - auto-complete if exactly one match
562
+ if (e.key === ' ' && (this.currentQueryState === 'expecting-operator' || this.currentQueryState === 'expecting-logical')) {
563
+ if (this.autocompleteResults.length === 1) {
564
+ e.preventDefault();
565
+ this.selectAutocomplete(this.autocompleteResults[0]);
566
+ return;
567
+ }
568
+ }
569
+
570
+ // Handle autocomplete navigation
571
+ if (this.autocompleteResults.length > 0 && this.isOpen) {
572
+ if (e.key === 'ArrowUp') {
573
+ e.preventDefault();
574
+ this.navigateAutocompletePrevious();
575
+ return;
576
+ }
577
+ if (e.key === 'ArrowDown') {
578
+ e.preventDefault();
579
+ this.navigateAutocompleteNext();
580
+ return;
581
+ }
582
+ if (e.key === 'Enter' || e.key === 'Tab') {
583
+ e.preventDefault();
584
+ this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
585
+ return;
586
+ }
587
+ // Space also accepts the selected item when autocomplete is open
588
+ if (e.key === ' ' && this.activeIndex >= 0) {
589
+ e.preventDefault();
590
+ this.selectAutocomplete(this.autocompleteResults[this.activeIndex]);
591
+ return;
592
+ }
593
+ }
594
+
595
+ // Handle value completion
596
+ if (this.currentQueryState === 'expecting-value') {
597
+ // Only complete on space if we're NOT in a quoted string AND there's actual text
598
+ if (e.key === ' ' && !this.isInQuotedString()) {
599
+ const {text: currentValue} = this.getCurrentValue();
600
+ // Only complete if there's actual text to tokenize
601
+ if (currentValue && currentValue.trim() !== '') {
602
+ e.preventDefault();
603
+ this.completeValueToken();
604
+ return;
605
+ }
606
+ // Otherwise, let the space be inserted naturally (user is starting to type)
607
+ }
608
+ // Always complete on Enter or Tab (but only if there's text)
609
+ if (e.key === 'Enter' || e.key === 'Tab') {
610
+ const {text: currentValue} = this.getCurrentValue();
611
+ if (currentValue && currentValue.trim() !== '') {
612
+ e.preventDefault();
613
+ this.completeValueToken();
614
+ }
615
+ return;
616
+ }
617
+ }
618
+
619
+ // Escape to close
620
+ if (e.key === 'Escape') {
621
+ this.close();
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Handle paste - strip formatting
627
+ */
628
+ handlePaste(e) {
629
+ e.preventDefault();
630
+ const text = (e.clipboardData || window.clipboardData).getData('text/plain');
631
+ document.execCommand('insertText', false, text);
632
+ }
633
+
634
+ /**
635
+ * Navigate autocomplete suggestions
636
+ */
637
+ navigateAutocompletePrevious() {
638
+ if (this.autocompleteResults.length === 0) return;
639
+
640
+ this.activeIndex = this.activeIndex <= 0
641
+ ? this.autocompleteResults.length - 1
642
+ : this.activeIndex - 1;
643
+
644
+ this.renderAutocomplete();
645
+ }
646
+
647
+ navigateAutocompleteNext() {
648
+ if (this.autocompleteResults.length === 0) return;
649
+
650
+ this.activeIndex = this.activeIndex >= this.autocompleteResults.length - 1
651
+ ? 0
652
+ : this.activeIndex + 1;
653
+
654
+ this.renderAutocomplete();
655
+ }
656
+
657
+ /**
658
+ * Position popup below element (at cursor position ideally)
659
+ */
660
+ position() {
661
+ const elementRect = this.element.getBoundingClientRect();
662
+
663
+ // Try to position near cursor
664
+ const selection = window.getSelection();
665
+ if (selection.rangeCount > 0) {
666
+ const range = selection.getRangeAt(0);
667
+ const rect = range.getBoundingClientRect();
668
+
669
+ if (rect.left > 0 && rect.top > 0) {
670
+ this.popup.style.position = 'absolute';
671
+ this.popup.style.left = rect.left + 'px';
672
+ this.popup.style.top = (rect.bottom + 4) + 'px';
673
+ this.popup.style.minWidth = Math.max(250, elementRect.width) + 'px';
674
+ return;
675
+ }
676
+ }
677
+
678
+ // Fallback: position below element
679
+ this.popup.style.position = 'absolute';
680
+ this.popup.style.left = elementRect.left + 'px';
681
+ this.popup.style.top = (elementRect.bottom + 4) + 'px';
682
+ this.popup.style.minWidth = Math.max(250, elementRect.width) + 'px';
683
+ }
684
+
685
+ /**
686
+ * Open popup
687
+ */
688
+ open() {
689
+ this.popup.style.display = 'block';
690
+ this.isOpen = true;
691
+ }
692
+
693
+ /**
694
+ * Close popup
695
+ */
696
+ close() {
697
+ this.popup.style.display = 'none';
698
+ this.isOpen = false;
699
+ this.activeIndex = -1;
700
+ this.autocompleteResults = [];
701
+ }
702
+
703
+ /**
704
+ * Handle click outside
705
+ */
706
+ handleClickOutside(e) {
707
+ if (this.isOpen && !this.popup.contains(e.target) && e.target !== this.element && !this.element.contains(e.target)) {
708
+ this.close();
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Get parsed query from tokens
714
+ */
715
+ getQuery() {
716
+ const tokens = this.element.querySelectorAll('span[data-token-type]');
717
+ const query = [];
718
+
719
+ tokens.forEach(token => {
720
+ query.push({
721
+ type: token.getAttribute('data-token-type'),
722
+ value: token.textContent || token.getAttribute('data-token-value')
723
+ });
724
+ });
725
+
726
+ return query;
727
+ }
728
+
729
+ /**
730
+ * Edit a token (make it temporarily editable)
731
+ */
732
+ editToken(token) {
733
+ const originalValue = token.textContent;
734
+ const originalColor = token.style.color;
735
+
736
+ // Make it editable
737
+ token.setAttribute('contenteditable', 'true');
738
+ token.style.backgroundColor = '#fff9e6';
739
+ token.style.padding = '2px 4px';
740
+ token.style.borderRadius = '3px';
741
+
742
+ // Select all text
743
+ const range = document.createRange();
744
+ range.selectNodeContents(token);
745
+ const selection = window.getSelection();
746
+ selection.removeAllRanges();
747
+ selection.addRange(range);
748
+
749
+ // Focus it
750
+ token.focus();
751
+
752
+ // Handle blur (when user clicks away or presses enter)
753
+ const finishEdit = () => {
754
+ token.setAttribute('contenteditable', 'false');
755
+ token.style.backgroundColor = '';
756
+ token.style.padding = '';
757
+ token.style.borderRadius = '';
758
+
759
+ const newValue = token.textContent.trim();
760
+ if (newValue === '') {
761
+ // Remove token if empty
762
+ token.remove();
763
+ } else {
764
+ // Update the data attribute
765
+ token.setAttribute('data-token-value', newValue);
766
+ }
767
+
768
+ // Trigger input event to update tree
769
+ this.element.dispatchEvent(new Event('input', { bubbles: true }));
770
+ };
771
+
772
+ token.addEventListener('blur', finishEdit, { once: true });
773
+ token.addEventListener('keydown', (e) => {
774
+ if (e.key === 'Enter') {
775
+ e.preventDefault();
776
+ token.blur();
777
+ } else if (e.key === 'Escape') {
778
+ e.preventDefault();
779
+ token.textContent = originalValue;
780
+ token.blur();
781
+ }
782
+ });
783
+ }
784
+
785
+ /**
786
+ * Clear all content
787
+ */
788
+ clear() {
789
+ this.element.innerHTML = '';
790
+ this.pendingToken = null;
791
+ this.updateQueryState();
792
+ }
793
+
794
+ /**
795
+ * Destroy instance
796
+ */
797
+ destroy() {
798
+ if (this.popup && this.popup.parentNode) {
799
+ this.popup.parentNode.removeChild(this.popup);
800
+ }
801
+ this.element.removeAttribute('contenteditable');
802
+ }
803
+ }