rich-input 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1003 @@
1
+ /**
2
+ * <rich-input> Custom Element
3
+ * Keyword-based autocomplete input field powered by OpaqueRange and Custom Highlight API.
4
+ */
5
+
6
+ import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } from '../utils/query-parser.js';
7
+ import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from '../utils/highlights.js';
8
+ import { getCaretCoordinates, positionPopover } from '../utils/positioning.js';
9
+
10
+ const TEMPLATE = document.createElement('template');
11
+ TEMPLATE.innerHTML = `
12
+ <style>
13
+ :host {
14
+ display: inline-block;
15
+ position: relative;
16
+ font-family: inherit;
17
+ width: 100%;
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ :host([hidden]),
22
+ [hidden] {
23
+ display: none !important;
24
+ }
25
+
26
+ *, *::before, *::after {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ .wrapper {
31
+ position: relative;
32
+ width: 100%;
33
+ }
34
+
35
+ .control {
36
+ display: flex;
37
+ align-items: center;
38
+ position: relative;
39
+ width: 100%;
40
+ background-color: var(--ri-input-bg, var(--rs-input-bg, #ffffff));
41
+ border: 1px solid var(--ri-input-border, var(--rs-input-border, #d1d5db));
42
+ border-radius: var(--ri-input-radius, var(--rs-input-radius, 8px));
43
+ padding: 0 0.75rem;
44
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
45
+ }
46
+
47
+ .control:focus-within {
48
+ border-color: var(--ri-primary, var(--rs-primary, #2563eb));
49
+ box-shadow: 0 0 0 3px var(--ri-focus-ring, var(--rs-focus-ring, rgba(37, 99, 235, 0.2)));
50
+ }
51
+
52
+ :host([disabled]) .control {
53
+ background-color: var(--ri-disabled-bg, var(--rs-disabled-bg, #f3f4f6));
54
+ border-color: var(--ri-disabled-border, var(--rs-disabled-border, #e5e7eb));
55
+ opacity: 0.7;
56
+ cursor: not-allowed;
57
+ }
58
+
59
+ slot[name="leading"] {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ flex-shrink: 0;
64
+ margin-right: 0.5rem;
65
+ color: var(--ri-icon-color, var(--rs-icon-color, #9ca3af));
66
+ }
67
+
68
+ .search-icon {
69
+ width: 1.125rem;
70
+ height: 1.125rem;
71
+ color: inherit;
72
+ pointer-events: none;
73
+ }
74
+
75
+ ::slotted([slot="trailing"]) {
76
+ flex-shrink: 0;
77
+ margin-left: 0.375rem;
78
+ }
79
+
80
+ .search-input {
81
+ flex: 1;
82
+ width: 100%;
83
+ min-width: 0;
84
+ border: none;
85
+ outline: none;
86
+ background: transparent;
87
+ font-family: inherit;
88
+ font-size: 0.95rem;
89
+ line-height: 1.5;
90
+ padding: 0.625rem 0;
91
+ color: var(--ri-input-color, var(--rs-input-color, #111827));
92
+ }
93
+
94
+ .search-input::placeholder {
95
+ color: var(--ri-placeholder-color, var(--rs-placeholder-color, #9ca3af));
96
+ }
97
+
98
+ .clear-button {
99
+ flex-shrink: 0;
100
+ display: inline-flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ width: 1.5rem;
104
+ height: 1.5rem;
105
+ padding: 0;
106
+ margin-left: 0.375rem;
107
+ border: none;
108
+ border-radius: 9999px;
109
+ background: var(--ri-clear-bg, var(--rs-clear-bg, #e5e7eb));
110
+ color: var(--ri-clear-color, var(--rs-clear-color, #4b5563));
111
+ cursor: pointer;
112
+ transition: background-color 0.15s ease, color 0.15s ease;
113
+ }
114
+
115
+ .clear-button[hidden] {
116
+ display: none !important;
117
+ }
118
+
119
+ .clear-button:hover {
120
+ background: var(--ri-clear-hover-bg, var(--rs-clear-hover-bg, #d1d5db));
121
+ color: var(--ri-clear-hover-color, var(--rs-clear-hover-color, #111827));
122
+ }
123
+
124
+ .clear-button svg {
125
+ width: 1rem;
126
+ height: 1rem;
127
+ }
128
+
129
+ /* HTML Popover API styles */
130
+ .popover {
131
+ position: fixed;
132
+ inset: auto;
133
+ margin: 0;
134
+ padding: 0;
135
+ min-width: 280px;
136
+ max-width: 420px;
137
+ max-height: 290px;
138
+ overflow-y: auto;
139
+ background-color: var(--ri-popover-bg, var(--rs-popover-bg, #ffffff));
140
+ border: 1px solid var(--ri-popover-border, var(--rs-popover-border, #e2e8f0));
141
+ border-radius: var(--ri-popover-radius, var(--rs-popover-radius, 8px));
142
+ box-shadow: var(--ri-popover-shadow, var(--rs-popover-shadow, 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)));
143
+ z-index: 10000;
144
+ font-family: system-ui, -apple-system, sans-serif;
145
+ }
146
+
147
+ .popover:not(:popover-open) {
148
+ display: none;
149
+ }
150
+
151
+ .popover-header {
152
+ display: flex;
153
+ justify-content: space-between;
154
+ align-items: center;
155
+ padding: 0.5rem 0.75rem;
156
+ border-bottom: 1px solid var(--ri-popover-border, var(--rs-popover-border, #e2e8f0));
157
+ background-color: var(--ri-header-bg, var(--rs-header-bg, #f8fafc));
158
+ font-size: 0.75rem;
159
+ font-weight: 600;
160
+ text-transform: uppercase;
161
+ letter-spacing: 0.05em;
162
+ color: var(--ri-header-color, var(--rs-header-color, #64748b));
163
+ }
164
+
165
+ .suggestions-list {
166
+ list-style: none;
167
+ margin: 0;
168
+ padding: 0.25rem 0;
169
+ }
170
+
171
+ .suggestion-item {
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 0.625rem;
175
+ padding: 0.5rem 0.75rem;
176
+ cursor: pointer;
177
+ font-size: 0.875rem;
178
+ line-height: 1.4;
179
+ transition: background-color 0.1s ease;
180
+ user-select: none;
181
+ color: var(--ri-item-title-color, var(--rs-item-title-color, #0f172a));
182
+ }
183
+
184
+ .suggestion-item:hover,
185
+ .suggestion-item.active {
186
+ background-color: var(--ri-item-active-bg, var(--rs-item-active-bg, #f1f5f9));
187
+ }
188
+
189
+ .suggestion-content {
190
+ flex: 1;
191
+ min-width: 0;
192
+ display: flex;
193
+ align-items: baseline;
194
+ gap: 0.5rem;
195
+ }
196
+
197
+ .suggestion-title {
198
+ font-weight: 600;
199
+ color: var(--ri-item-title-color, var(--rs-item-title-color, #0f172a));
200
+ white-space: nowrap;
201
+ overflow: hidden;
202
+ text-overflow: ellipsis;
203
+ }
204
+
205
+ .suggestion-desc {
206
+ font-size: 0.75rem;
207
+ color: var(--ri-item-desc-color, var(--rs-item-desc-color, #64748b));
208
+ white-space: nowrap;
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ }
212
+
213
+ .suggestion-image {
214
+ object-fit: cover;
215
+ flex-shrink: 0;
216
+ pointer-events: none;
217
+ vertical-align: middle;
218
+ }
219
+
220
+ /* Visually hide datalists in slot */
221
+ ::slotted(datalist) {
222
+ display: none !important;
223
+ }
224
+ </style>
225
+
226
+ <div part="wrapper" class="wrapper">
227
+ <div part="control" class="control">
228
+ <slot name="leading">
229
+ <svg part="icon" class="search-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
230
+ <path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
231
+ </svg>
232
+ </slot>
233
+ <input
234
+ part="input"
235
+ class="search-input"
236
+ type="text"
237
+ autocomplete="off"
238
+ spellcheck="false"
239
+ role="combobox"
240
+ aria-autocomplete="list"
241
+ aria-expanded="false"
242
+ aria-haspopup="listbox"
243
+ />
244
+ <button part="clear-button" type="button" class="clear-button" aria-label="Clear search query" hidden>
245
+ <svg viewBox="0 0 20 20" fill="currentColor">
246
+ <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
247
+ </svg>
248
+ </button>
249
+ <slot name="trailing"></slot>
250
+ </div>
251
+
252
+ <div
253
+ part="popover suggestions"
254
+ class="popover"
255
+ popover="manual"
256
+ role="listbox"
257
+ aria-label="Search suggestions"
258
+ >
259
+ <div part="suggestions-header" class="popover-header">
260
+ <span part="suggestions-title" class="popover-title">Suggestions</span>
261
+ </div>
262
+ <ul part="suggestions-list" class="suggestions-list" role="presentation"></ul>
263
+ </div>
264
+
265
+ <slot style="display: none;"></slot>
266
+ </div>
267
+ `;
268
+
269
+ export class RichInput extends HTMLElement {
270
+ static formAssociated = true;
271
+
272
+ static get observedAttributes() {
273
+ return [
274
+ 'value',
275
+ 'placeholder',
276
+ 'disabled',
277
+ 'name',
278
+ 'readonly',
279
+ 'autofocus',
280
+ 'required',
281
+ 'highlight-quotes',
282
+ ];
283
+ }
284
+
285
+ constructor() {
286
+ super();
287
+
288
+ this._internals = this.attachInternals ? this.attachInternals() : null;
289
+ this.attachShadow({ mode: 'open' });
290
+ this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true));
291
+
292
+ this._input = this.shadowRoot.querySelector('.search-input');
293
+ this._popover = this.shadowRoot.querySelector('.popover');
294
+ this._popoverTitle = this.shadowRoot.querySelector('.popover-title');
295
+ this._suggestionsList = this.shadowRoot.querySelector('.suggestions-list');
296
+ this._clearBtn = this.shadowRoot.querySelector('.clear-button');
297
+ this._slot = this.shadowRoot.querySelector('slot:not([name])');
298
+
299
+ this._configuredKeywords = new Map();
300
+ this._activeSuggestions = [];
301
+ this._selectedIndex = -1;
302
+ this._context = null;
303
+ this._ownedRanges = [];
304
+ this._activeKeywordHighlightMap = new Map();
305
+
306
+ // Bound listeners for easy cleanup
307
+ this._onInput = this._onInput.bind(this);
308
+ this._onKeyDown = this._onKeyDown.bind(this);
309
+ this._onKeyUp = this._onKeyUp.bind(this);
310
+ this._onFocus = this._onFocus.bind(this);
311
+ this._onBlur = this._onBlur.bind(this);
312
+ this._onClick = this._onClick.bind(this);
313
+ this._onClearClick = this._onClearClick.bind(this);
314
+ this._onSlotChange = this._onSlotChange.bind(this);
315
+ this._onGlobalClick = this._onGlobalClick.bind(this);
316
+ this._onGlobalResizeOrScroll = this._onGlobalResizeOrScroll.bind(this);
317
+ }
318
+
319
+ connectedCallback() {
320
+ highlightManager.register(this);
321
+
322
+ // Parse initial datalists
323
+ this._loadDatalists();
324
+
325
+ // Listen to changes on light DOM datalists
326
+ this._slot.addEventListener('slotchange', this._onSlotChange);
327
+ this._mutationObserver = new MutationObserver(() => {
328
+ this._loadDatalists();
329
+ this.updateHighlights();
330
+ });
331
+ this._mutationObserver.observe(this, { childList: true, subtree: true, attributes: true, characterData: true });
332
+
333
+ // Events on input
334
+ this._input.addEventListener('input', this._onInput);
335
+ this._input.addEventListener('keydown', this._onKeyDown);
336
+ this._input.addEventListener('keyup', this._onKeyUp);
337
+ this._input.addEventListener('focus', this._onFocus);
338
+ this._input.addEventListener('blur', this._onBlur);
339
+ this._input.addEventListener('click', this._onClick);
340
+ this._clearBtn.addEventListener('click', this._onClearClick);
341
+
342
+ // Global events
343
+ document.addEventListener('click', this._onGlobalClick);
344
+ window.addEventListener('resize', this._onGlobalResizeOrScroll);
345
+ window.addEventListener('scroll', this._onGlobalResizeOrScroll, { passive: true });
346
+
347
+ // Sync initial attributes
348
+ if (this.hasAttribute('value')) {
349
+ this._input.value = this.getAttribute('value');
350
+ }
351
+ if (this.hasAttribute('placeholder')) {
352
+ this._input.placeholder = this.getAttribute('placeholder');
353
+ }
354
+ if (this.hasAttribute('disabled')) {
355
+ this._input.disabled = true;
356
+ }
357
+ if (this.hasAttribute('name') && this._internals) {
358
+ this._internals.setFormValue(this._input.value);
359
+ }
360
+
361
+ this._updateClearButton();
362
+ this.updateHighlights();
363
+ }
364
+
365
+ disconnectedCallback() {
366
+ highlightManager.unregister(this);
367
+ this._disconnectOwnedRanges();
368
+
369
+ if (this._mutationObserver) {
370
+ this._mutationObserver.disconnect();
371
+ }
372
+
373
+ this._slot.removeEventListener('slotchange', this._onSlotChange);
374
+ this._input.removeEventListener('input', this._onInput);
375
+ this._input.removeEventListener('keydown', this._onKeyDown);
376
+ this._input.removeEventListener('keyup', this._onKeyUp);
377
+ this._input.removeEventListener('focus', this._onFocus);
378
+ this._input.removeEventListener('blur', this._onBlur);
379
+ this._input.removeEventListener('click', this._onClick);
380
+ this._clearBtn.removeEventListener('click', this._onClearClick);
381
+
382
+ document.removeEventListener('click', this._onGlobalClick);
383
+ window.removeEventListener('resize', this._onGlobalResizeOrScroll);
384
+ window.removeEventListener('scroll', this._onGlobalResizeOrScroll);
385
+
386
+ this.hideSuggestions();
387
+ }
388
+
389
+ attributeChangedCallback(name, oldValue, newValue) {
390
+ if (oldValue === newValue) return;
391
+
392
+ if (name === 'value') {
393
+ if (this._input.value !== (newValue || '')) {
394
+ this._input.value = newValue || '';
395
+ this._updateClearButton();
396
+ this.updateHighlights();
397
+ if (this._internals) {
398
+ this._internals.setFormValue(this._input.value);
399
+ }
400
+ }
401
+ } else if (name === 'placeholder') {
402
+ this._input.placeholder = newValue || '';
403
+ } else if (name === 'disabled') {
404
+ this._input.disabled = newValue !== null;
405
+ } else if (name === 'readonly') {
406
+ this._input.readOnly = newValue !== null;
407
+ } else if (name === 'autofocus') {
408
+ this._input.autofocus = newValue !== null;
409
+ } else if (name === 'required') {
410
+ this._input.required = newValue !== null;
411
+ } else if (name === 'name') {
412
+ if (this._internals) {
413
+ this._internals.setFormValue(this._input.value);
414
+ }
415
+ } else if (name === 'highlight-quotes') {
416
+ this.updateHighlights();
417
+ }
418
+ }
419
+
420
+ // --- Form Associated Lifecycle ---
421
+ formResetCallback() {
422
+ this.value = this.getAttribute('value') || '';
423
+ }
424
+
425
+ formDisabledCallback(disabled) {
426
+ this._input.disabled = disabled;
427
+ }
428
+
429
+ checkValidity() {
430
+ return this._internals ? this._internals.checkValidity() : this._input.checkValidity();
431
+ }
432
+
433
+ reportValidity() {
434
+ return this._internals ? this._internals.reportValidity() : this._input.reportValidity();
435
+ }
436
+
437
+ get validity() {
438
+ return this._internals ? this._internals.validity : this._input.validity;
439
+ }
440
+
441
+ get validationMessage() {
442
+ return this._internals ? this._internals.validationMessage : this._input.validationMessage;
443
+ }
444
+
445
+ // --- Public Properties ---
446
+ get value() {
447
+ return this._input ? this._input.value : '';
448
+ }
449
+
450
+ set value(val) {
451
+ const nextVal = String(val ?? '');
452
+ if (this._input.value !== nextVal) {
453
+ this._input.value = nextVal;
454
+ this._updateClearButton();
455
+ this.updateHighlights();
456
+ if (this._internals) {
457
+ this._internals.setFormValue(nextVal);
458
+ }
459
+ }
460
+ }
461
+
462
+ get placeholder() {
463
+ return this._input.placeholder;
464
+ }
465
+
466
+ set placeholder(val) {
467
+ this._input.placeholder = val;
468
+ }
469
+
470
+ get disabled() {
471
+ return this._input.disabled;
472
+ }
473
+
474
+ set disabled(val) {
475
+ this._input.disabled = Boolean(val);
476
+ if (val) this.setAttribute('disabled', '');
477
+ else this.removeAttribute('disabled');
478
+ }
479
+
480
+ get name() {
481
+ return this.getAttribute('name') || '';
482
+ }
483
+
484
+ set name(val) {
485
+ if (val) this.setAttribute('name', val);
486
+ else this.removeAttribute('name');
487
+ }
488
+
489
+ get selectionStart() {
490
+ return this._input.selectionStart;
491
+ }
492
+
493
+ set selectionStart(val) {
494
+ this._input.selectionStart = val;
495
+ }
496
+
497
+ get selectionEnd() {
498
+ return this._input.selectionEnd;
499
+ }
500
+
501
+ set selectionEnd(val) {
502
+ this._input.selectionEnd = val;
503
+ }
504
+
505
+ get selectionDirection() {
506
+ return this._input.selectionDirection;
507
+ }
508
+
509
+ set selectionDirection(val) {
510
+ this._input.selectionDirection = val;
511
+ }
512
+
513
+ // --- Public Methods ---
514
+ focus(options) {
515
+ this._input.focus(options);
516
+ }
517
+
518
+ blur() {
519
+ this._input.blur();
520
+ }
521
+
522
+ select() {
523
+ this._input.select();
524
+ }
525
+
526
+ setSelectionRange(start, end, direction) {
527
+ this._input.setSelectionRange(start, end, direction);
528
+ }
529
+
530
+ getParsedQuery() {
531
+ return parseSearchQuery(this._input.value);
532
+ }
533
+
534
+ getKeywords() {
535
+ return Array.from(this._configuredKeywords.values());
536
+ }
537
+
538
+ getCurrentOpaqueRange() {
539
+ return this._getCurrentOpaqueRange();
540
+ }
541
+
542
+ // --- Datalist Configuration Discovery ---
543
+ _loadDatalists() {
544
+ const datalists = this.querySelectorAll('datalist');
545
+ const newMap = new Map();
546
+
547
+ for (const dl of datalists) {
548
+ const id = dl.id ? dl.id.trim() : null;
549
+ if (!id) continue;
550
+
551
+ const idLower = id.toLowerCase();
552
+ const rawLabel = dl.hasAttribute('label') ? dl.getAttribute('label').trim() : null;
553
+ const label = rawLabel || id.charAt(0).toUpperCase() + id.slice(1);
554
+ const dataType = dl.dataset.type || dl.getAttribute('type') || 'string';
555
+
556
+ const options = [];
557
+ const optElements = dl.querySelectorAll('option');
558
+
559
+ for (const opt of optElements) {
560
+ const textContent = opt.textContent.trim();
561
+ const value = opt.hasAttribute('value') ? opt.getAttribute('value') : textContent;
562
+ const optLabel = opt.getAttribute('label') || textContent || value;
563
+ const imgEl = opt.querySelector('img');
564
+ if (value) {
565
+ options.push({
566
+ value,
567
+ label: optLabel,
568
+ text: textContent || value,
569
+ element: opt,
570
+ image: imgEl ? imgEl.cloneNode(true) : null,
571
+ });
572
+ }
573
+ }
574
+
575
+ newMap.set(idLower, {
576
+ id,
577
+ idLower,
578
+ label,
579
+ rawLabel,
580
+ dataType,
581
+ options,
582
+ });
583
+
584
+ highlightManager.recordKeyword(idLower);
585
+ }
586
+
587
+ this._configuredKeywords = newMap;
588
+ }
589
+
590
+ _onSlotChange() {
591
+ this._loadDatalists();
592
+ this.updateHighlights();
593
+ }
594
+
595
+ // --- Highlighting with OpaqueRange & Custom Highlight API ---
596
+ _disconnectOwnedRanges() {
597
+ for (const r of this._ownedRanges) {
598
+ try {
599
+ r.disconnect();
600
+ } catch (e) {}
601
+ }
602
+ this._ownedRanges = [];
603
+ this._activeKeywordHighlightMap.clear();
604
+ }
605
+
606
+ updateHighlights() {
607
+ this._disconnectOwnedRanges();
608
+
609
+ if (!isOpaqueRangeSupported || !isHighlightSupported) {
610
+ return;
611
+ }
612
+
613
+ const text = this._input.value;
614
+ if (!text) {
615
+ highlightManager.syncAll();
616
+ return;
617
+ }
618
+
619
+ const tokens = parseSearchTokens(text);
620
+ const highlightQuotes = this.getAttribute('highlight-quotes') !== 'exclude';
621
+
622
+ for (const token of tokens) {
623
+ if (token.type === 'keyword' && this._configuredKeywords.has(token.keywordLower)) {
624
+ const kw = token.keywordLower;
625
+
626
+ if (!this._activeKeywordHighlightMap.has(kw)) {
627
+ this._activeKeywordHighlightMap.set(kw, {
628
+ valueRanges: [],
629
+ keywordRanges: [],
630
+ });
631
+ }
632
+
633
+ const bucket = this._activeKeywordHighlightMap.get(kw);
634
+
635
+ // 1. Value range
636
+ const start = highlightQuotes ? token.valueStart : token.innerStart;
637
+ const end = highlightQuotes ? token.valueEnd : token.innerEnd;
638
+
639
+ if (end >= start && end <= text.length) {
640
+ try {
641
+ const valRange = this._input.createValueRange(start, end);
642
+ this._ownedRanges.push(valRange);
643
+ bucket.valueRanges.push(valRange);
644
+ } catch (e) {
645
+ console.warn('[rich-input] Range creation error:', e);
646
+ }
647
+ }
648
+
649
+ // 2. Keyword prefix range (for ::highlight(rich-input-keyword))
650
+ if (token.keywordEnd > token.keywordStart && token.keywordEnd <= text.length) {
651
+ try {
652
+ const kwRange = this._input.createValueRange(token.keywordStart, token.keywordEnd);
653
+ this._ownedRanges.push(kwRange);
654
+ bucket.keywordRanges.push(kwRange);
655
+ } catch (e) {}
656
+ }
657
+ }
658
+ }
659
+
660
+ highlightManager.syncAll();
661
+ }
662
+
663
+ getActiveHighlightRanges() {
664
+ return this._activeKeywordHighlightMap;
665
+ }
666
+
667
+ // --- Suggestions Popover Handling ---
668
+ _onInput(e) {
669
+ this._updateClearButton();
670
+ this.updateHighlights();
671
+ this.updateSuggestions('input');
672
+
673
+ if (this._internals) {
674
+ this._internals.setFormValue(this._input.value);
675
+ }
676
+
677
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
678
+ }
679
+
680
+ _onKeyDown(e) {
681
+ if (this._isPopoverOpen()) {
682
+ if (e.key === 'ArrowDown') {
683
+ e.preventDefault();
684
+ this._moveSelection(1);
685
+ return;
686
+ } else if (e.key === 'ArrowUp') {
687
+ e.preventDefault();
688
+ this._moveSelection(-1);
689
+ return;
690
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
691
+ if (this._selectedIndex >= 0 && this._selectedIndex < this._activeSuggestions.length) {
692
+ e.preventDefault();
693
+ e.stopPropagation();
694
+ this._applySelectedSuggestion();
695
+ return;
696
+ }
697
+ } else if (e.key === 'Escape') {
698
+ e.preventDefault();
699
+ this.hideSuggestions();
700
+ return;
701
+ }
702
+ } else {
703
+ if (e.key === 'ArrowDown') {
704
+ // Open suggestions on Down Arrow
705
+ e.preventDefault();
706
+ this.updateSuggestions('arrow');
707
+ return;
708
+ }
709
+ }
710
+
711
+ // Enter when popover closed triggers search/change
712
+ if (e.key === 'Enter') {
713
+ this.dispatchEvent(new CustomEvent('search', {
714
+ bubbles: true,
715
+ composed: true,
716
+ detail: { value: this._input.value, parsed: this.getParsedQuery() }
717
+ }));
718
+ }
719
+ }
720
+
721
+ _onKeyUp(e) {
722
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
723
+ this.updateSuggestions('caret');
724
+ }
725
+ }
726
+
727
+ _onFocus() {
728
+ // Optionally open suggestions if context matches
729
+ if (this._input.value.length > 0) {
730
+ this.updateSuggestions('focus');
731
+ }
732
+ }
733
+
734
+ _onBlur() {
735
+ // Delay closing so click events on popover items can fire
736
+ setTimeout(() => {
737
+ this.hideSuggestions();
738
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
739
+ }, 150);
740
+ }
741
+
742
+ _onClick() {
743
+ this.updateSuggestions('click');
744
+ }
745
+
746
+ _onClearClick() {
747
+ this.value = '';
748
+ this._input.focus();
749
+ this.hideSuggestions();
750
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
751
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
752
+ }
753
+
754
+ _onGlobalClick(e) {
755
+ if (!this.contains(e.target) && !this.shadowRoot.contains(e.target)) {
756
+ this.hideSuggestions();
757
+ }
758
+ }
759
+
760
+ _getCurrentOpaqueRange() {
761
+ if (!this._context || !isOpaqueRangeSupported) return null;
762
+
763
+ const { mode, token, caretPos } = this._context;
764
+
765
+ // 1. If in value mode, find the value's OpaqueRange in active keyword highlights
766
+ if (mode === 'value' && token) {
767
+ const kwData = this._activeKeywordHighlightMap.get(token.keywordLower);
768
+ if (kwData?.valueRanges?.length) {
769
+ const matched = kwData.valueRanges.find(r =>
770
+ r.startOffset === token.valueStart ||
771
+ (r.startOffset <= caretPos && r.endOffset >= token.valueStart)
772
+ );
773
+ if (matched) return matched;
774
+ }
775
+ }
776
+
777
+ // 2. If in keyword mode editing an existing keyword token, find keyword's OpaqueRange
778
+ if (mode === 'keyword' && token && token.type === 'keyword') {
779
+ const kwData = this._activeKeywordHighlightMap.get(token.keywordLower);
780
+ if (kwData?.keywordRanges?.length) {
781
+ const targetStart = token.keywordStart ?? token.start;
782
+ const matched = kwData.keywordRanges.find(r =>
783
+ r.startOffset === targetStart ||
784
+ (r.startOffset <= caretPos && r.endOffset >= targetStart)
785
+ );
786
+ if (matched) return matched;
787
+ }
788
+ }
789
+
790
+ // 3. Fallback to any owned range matching token.valueStart or token.start
791
+ if (token) {
792
+ const targetStart = mode === 'value' ? token.valueStart : (token.keywordStart ?? token.start);
793
+ const matched = this._ownedRanges.find(r => r.startOffset === targetStart);
794
+ if (matched) return matched;
795
+ }
796
+
797
+ return null;
798
+ }
799
+
800
+ _onGlobalResizeOrScroll() {
801
+ if (this._isPopoverOpen() && this._context) {
802
+ const currentRange = this._getCurrentOpaqueRange();
803
+ const anchor = currentRange || (this._context.replaceStart !== undefined ? this._context.replaceStart : this._context.caretPos);
804
+ const anchorCoords = getCaretCoordinates(this._input, anchor);
805
+ positionPopover(this._popover, anchorCoords, this._input);
806
+ }
807
+ }
808
+
809
+ _updateClearButton() {
810
+ if (!this._clearBtn || !this._input) return;
811
+ this._clearBtn.hidden = this._input.value.length === 0;
812
+ }
813
+
814
+ _isPopoverOpen() {
815
+ try {
816
+ return this._popover && this._popover.matches(':popover-open');
817
+ } catch (e) {
818
+ return false;
819
+ }
820
+ }
821
+
822
+ updateSuggestions(trigger = 'input') {
823
+ const caretPos = this._input.selectionStart;
824
+ const context = getCaretContext(this._input.value, caretPos, this._configuredKeywords);
825
+
826
+ // If trigger is arrow and context is 'none', allow suggesting all keywords
827
+ if (trigger === 'arrow' && context.mode === 'none') {
828
+ context.mode = 'keyword';
829
+ context.query = '';
830
+ context.replaceStart = caretPos;
831
+ context.replaceEnd = caretPos;
832
+ }
833
+
834
+ this._context = context;
835
+ const suggestions = getSuggestions(context, this._configuredKeywords);
836
+
837
+ if (suggestions.length === 0) {
838
+ this.hideSuggestions();
839
+ return;
840
+ }
841
+
842
+ this._activeSuggestions = suggestions;
843
+ this._selectedIndex = 0; // Pre-select first item for quick Enter/Tab
844
+ this._renderSuggestions();
845
+
846
+ // Show popover
847
+ if (!this._isPopoverOpen()) {
848
+ try {
849
+ this._popover.showPopover();
850
+ this._input.setAttribute('aria-expanded', 'true');
851
+ } catch (e) {}
852
+ }
853
+
854
+ // Position popover at the start of the current OpaqueRange
855
+ const currentRange = this._getCurrentOpaqueRange();
856
+ const anchor = currentRange || (context.replaceStart !== undefined ? context.replaceStart : context.caretPos);
857
+ const anchorCoords = getCaretCoordinates(this._input, anchor);
858
+ positionPopover(this._popover, anchorCoords, this._input);
859
+ }
860
+
861
+ hideSuggestions() {
862
+ if (this._isPopoverOpen()) {
863
+ try {
864
+ this._popover.hidePopover();
865
+ } catch (e) {}
866
+ }
867
+ this._input.setAttribute('aria-expanded', 'false');
868
+ this._input.removeAttribute('aria-activedescendant');
869
+ this._activeSuggestions = [];
870
+ this._selectedIndex = -1;
871
+ }
872
+
873
+ _renderSuggestions() {
874
+ if (this._popoverTitle) {
875
+ let title = 'Suggestions';
876
+ if (this._context?.mode === 'value' && this._context.keywordLower) {
877
+ const kwConfig = this._configuredKeywords.get(this._context.keywordLower);
878
+ if (kwConfig?.rawLabel) {
879
+ title = kwConfig.rawLabel;
880
+ }
881
+ }
882
+ this._popoverTitle.textContent = title;
883
+ }
884
+
885
+ this._suggestionsList.replaceChildren();
886
+
887
+ this._activeSuggestions.forEach((sug, idx) => {
888
+ const li = document.createElement('li');
889
+ li.className = `suggestion-item${idx === this._selectedIndex ? ' active' : ''}`;
890
+ li.id = `ri-opt-${idx}`;
891
+ li.setAttribute('role', 'option');
892
+ li.setAttribute('aria-selected', idx === this._selectedIndex ? 'true' : 'false');
893
+ li.setAttribute('part', `suggestion-item${idx === this._selectedIndex ? ' suggestion-item-active' : ''}`);
894
+
895
+ if (sug.image) {
896
+ const img = sug.image.cloneNode(true);
897
+ img.className = 'suggestion-image';
898
+ const currentPart = img.getAttribute('part');
899
+ img.setAttribute('part', currentPart ? `${currentPart} suggestion-image` : 'suggestion-image');
900
+ li.appendChild(img);
901
+ }
902
+
903
+ const content = document.createElement('div');
904
+ content.className = 'suggestion-content';
905
+ content.setAttribute('part', 'suggestion-content');
906
+
907
+ const title = document.createElement('span');
908
+ title.className = 'suggestion-title';
909
+ title.setAttribute('part', sug.type === 'keyword' ? 'suggestion-keyword' : 'suggestion-value');
910
+ title.textContent = sug.display;
911
+
912
+ const desc = document.createElement('span');
913
+ desc.className = 'suggestion-desc';
914
+ desc.setAttribute('part', 'suggestion-label');
915
+ desc.textContent = (sug.description && sug.description !== sug.display) ? sug.description : '';
916
+
917
+ content.appendChild(title);
918
+ if (desc.textContent) content.appendChild(desc);
919
+
920
+ li.appendChild(content);
921
+
922
+ li.addEventListener('mousedown', (e) => {
923
+ e.preventDefault(); // Prevent input blur
924
+ });
925
+
926
+ li.addEventListener('click', () => {
927
+ this._selectedIndex = idx;
928
+ this._applySelectedSuggestion();
929
+ });
930
+
931
+ li.addEventListener('pointerenter', () => {
932
+ this._selectedIndex = idx;
933
+ this._updateActiveSuggestion();
934
+ });
935
+
936
+ this._suggestionsList.appendChild(li);
937
+ });
938
+
939
+ this._updateActiveSuggestion();
940
+ }
941
+
942
+ _moveSelection(delta) {
943
+ if (this._activeSuggestions.length === 0) return;
944
+ const len = this._activeSuggestions.length;
945
+ this._selectedIndex = (this._selectedIndex + delta + len) % len;
946
+ this._updateActiveSuggestion();
947
+
948
+ // Scroll active item into view
949
+ const activeEl = this._suggestionsList.children[this._selectedIndex];
950
+ if (activeEl) {
951
+ activeEl.scrollIntoView({ block: 'nearest' });
952
+ }
953
+ }
954
+
955
+ _updateActiveSuggestion() {
956
+ const items = this._suggestionsList.children;
957
+ for (let i = 0; i < items.length; i++) {
958
+ const isAct = i === this._selectedIndex;
959
+ items[i].classList.toggle('active', isAct);
960
+ items[i].setAttribute('aria-selected', isAct ? 'true' : 'false');
961
+ if (isAct) {
962
+ this._input.setAttribute('aria-activedescendant', items[i].id);
963
+ }
964
+ }
965
+ }
966
+
967
+ _applySelectedSuggestion() {
968
+ if (this._selectedIndex < 0 || this._selectedIndex >= this._activeSuggestions.length) return;
969
+
970
+ const suggestion = this._activeSuggestions[this._selectedIndex];
971
+ const { newValue, newCaret } = applySuggestion(this._input.value, suggestion, this._context);
972
+
973
+ this._input.value = newValue;
974
+ this._input.setSelectionRange(newCaret, newCaret);
975
+ this._updateClearButton();
976
+ this.updateHighlights();
977
+
978
+ if (this._internals) {
979
+ this._internals.setFormValue(this._input.value);
980
+ }
981
+
982
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
983
+ const selectDetail = {
984
+ type: suggestion.type,
985
+ keyword: suggestion.keyword || suggestion.id,
986
+ value: suggestion.value || null,
987
+ label: suggestion.label || null,
988
+ query: newValue,
989
+ };
990
+ this.dispatchEvent(new CustomEvent('rich-input-select', {
991
+ bubbles: true,
992
+ composed: true,
993
+ detail: selectDetail,
994
+ }));
995
+ // If a keyword was selected (e.g. `mix:`), immediately show value suggestions
996
+ if (suggestion.type === 'keyword') {
997
+ this.updateSuggestions('keyword-selected');
998
+ } else {
999
+ this.hideSuggestions();
1000
+ }
1001
+ }
1002
+ }
1003
+