rich-input 1.2.2 → 1.4.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.
@@ -3,11 +3,13 @@
3
3
  * Keyword-based autocomplete input field powered by OpaqueRange and Custom Highlight API.
4
4
  */
5
5
 
6
- import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion, isDatalistValue } from '../utils/query-parser.js';
6
+ import { DEFAULT_OPERATORS, normalizeOperators, DEFAULT_COMBINATORS, normalizeCombinators, DEFAULT_DELIMITERS, normalizeDelimiters, parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion, isDatalistValue } from '../utils/query-parser.js';
7
7
  import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from '../utils/highlights.js';
8
8
  import { getCaretCoordinates, positionPopover } from '../utils/positioning.js';
9
9
  import { setupContentEditableAdapter, isContentEditableFallbackActive } from '../utils/contenteditable-adapter.js';
10
10
 
11
+ const KEYWORD_SUGGESTION_DELAY = 300;
12
+
11
13
  const TEMPLATE = document.createElement('template');
12
14
  TEMPLATE.innerHTML = `
13
15
  <style>
@@ -57,25 +59,35 @@ TEMPLATE.innerHTML = `
57
59
  cursor: not-allowed;
58
60
  }
59
61
 
60
- slot[name="leading"] {
62
+ .search-icon {
61
63
  display: inline-flex;
62
64
  align-items: center;
63
65
  justify-content: center;
64
66
  flex-shrink: 0;
67
+ width: 1.125rem;
68
+ height: 1.125rem;
65
69
  margin-right: 0.5rem;
66
70
  color: var(--ri-icon-color, var(--rs-icon-color, #9ca3af));
71
+ background-image: var(--_icon-default-bg, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%239ca3af'%3E%3Cpath 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'/%3E%3C/svg%3E"));
72
+ background-repeat: no-repeat;
73
+ background-position: center;
74
+ background-size: contain;
75
+ font-size: 1rem;
76
+ line-height: 1;
77
+ pointer-events: none;
78
+ user-select: none;
67
79
  }
68
80
 
69
- .search-icon {
70
- width: 1.125rem;
71
- height: 1.125rem;
72
- color: inherit;
73
- pointer-events: none;
81
+ .search-icon[data-has-content] {
82
+ background-image: none;
74
83
  }
75
84
 
76
- ::slotted([slot="trailing"]) {
77
- flex-shrink: 0;
78
- margin-left: 0.375rem;
85
+ .search-icon[data-content-none] {
86
+ display: none;
87
+ }
88
+
89
+ .search-icon::before {
90
+ content: var(--_icon-content, none);
79
91
  }
80
92
 
81
93
  .search-input {
@@ -113,14 +125,32 @@ TEMPLATE.innerHTML = `
113
125
  display: inline-block;
114
126
  }
115
127
 
128
+ /* Generic prefix highlight for operators */
129
+ :host::highlight(rich-input-operator) {
130
+ color: var(--ri-operator-color, var(--ri-keyword-color, #64748b));
131
+ text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
132
+ }
133
+
134
+ /* Generic highlight for combinators */
135
+ :host::highlight(rich-input-combinator) {
136
+ color: var(--ri-combinator-color, var(--ri-operator-color, var(--ri-keyword-color, #64748b)));
137
+ text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
138
+ }
139
+
140
+ /* Generic highlight for delimiters */
141
+ :host::highlight(rich-input-delimiter) {
142
+ color: var(--ri-delimiter-color, var(--ri-operator-color, var(--ri-keyword-color, #64748b)));
143
+ text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
144
+ }
145
+
116
146
  /* Generic prefix highlight for keywords */
117
- ::highlight(rich-input-keyword) {
147
+ :host::highlight(rich-input-keyword) {
118
148
  color: var(--ri-keyword-color, #64748b);
119
149
  text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
120
150
  }
121
151
 
122
152
  /* Squiggly line underneath invalid keyword values */
123
- ::highlight(rich-input-invalid) {
153
+ :host::highlight(rich-input-invalid) {
124
154
  text-decoration: underline wavy var(--ri-invalid-color, var(--rs-invalid-color, #ef4444));
125
155
  -webkit-text-decoration: underline wavy var(--ri-invalid-color, var(--rs-invalid-color, #ef4444));
126
156
  text-decoration-line: underline;
@@ -132,12 +162,6 @@ TEMPLATE.innerHTML = `
132
162
  text-decoration-skip-ink: none;
133
163
  }
134
164
 
135
- /* Visually hide datalists and custom style tags in slot */
136
- ::slotted(datalist),
137
- ::slotted(style) {
138
- display: none !important;
139
- }
140
-
141
165
  .clear-button {
142
166
  flex-shrink: 0;
143
167
  display: inline-flex;
@@ -180,6 +204,7 @@ TEMPLATE.innerHTML = `
180
204
  max-height: 290px;
181
205
  overflow-y: auto;
182
206
  background-color: var(--ri-popover-bg, var(--rs-popover-bg, #ffffff));
207
+ color: var(--ri-item-title-color, var(--rs-item-title-color, #0f172a));
183
208
  border: 1px solid var(--ri-popover-border, var(--rs-popover-border, #e2e8f0));
184
209
  border-radius: var(--ri-popover-radius, var(--rs-popover-radius, 8px));
185
210
  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)));
@@ -221,7 +246,11 @@ TEMPLATE.innerHTML = `
221
246
  line-height: 1.4;
222
247
  transition: background-color 0.1s ease;
223
248
  user-select: none;
224
- color: var(--ri-item-title-color, var(--rs-item-title-color, #0f172a));
249
+ color: var(--ri-item-title-color, var(--rs-item-title-color, inherit));
250
+ }
251
+
252
+ .suggestion-item.selected {
253
+ background-color: var(--ri-item-selected-bg, rgba(37, 99, 235, 0.06));
225
254
  }
226
255
 
227
256
  .suggestion-item:hover,
@@ -239,7 +268,7 @@ TEMPLATE.innerHTML = `
239
268
 
240
269
  .suggestion-title {
241
270
  font-weight: 600;
242
- color: var(--ri-item-title-color, var(--rs-item-title-color, #0f172a));
271
+ color: var(--ri-item-title-color, var(--rs-item-title-color, inherit));
243
272
  white-space: nowrap;
244
273
  overflow: hidden;
245
274
  text-overflow: ellipsis;
@@ -260,19 +289,16 @@ TEMPLATE.innerHTML = `
260
289
  vertical-align: middle;
261
290
  }
262
291
 
263
- /* Visually hide datalists in slot */
264
- ::slotted(datalist) {
292
+ /* Visually hide datalists and custom style tags in slot */
293
+ ::slotted(datalist),
294
+ ::slotted(style) {
265
295
  display: none !important;
266
296
  }
267
297
  </style>
268
298
 
269
299
  <div part="wrapper" class="wrapper">
270
300
  <div part="control" class="control">
271
- <slot name="leading">
272
- <svg part="icon" class="search-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
273
- <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" />
274
- </svg>
275
- </slot>
301
+ <span part="icon" class="search-icon" aria-hidden="true"></span>
276
302
  <input
277
303
  part="input"
278
304
  class="search-input"
@@ -290,7 +316,6 @@ TEMPLATE.innerHTML = `
290
316
  <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"/>
291
317
  </svg>
292
318
  </button>
293
- <slot name="trailing"></slot>
294
319
  </div>
295
320
 
296
321
  <div
@@ -356,6 +381,79 @@ function syncDocumentHighlightStyles(shadowRoot) {
356
381
  export class RichInput extends HTMLElement {
357
382
  static formAssociated = true;
358
383
 
384
+ static _globalOperators = [...DEFAULT_OPERATORS];
385
+ static _globalCombinators = [...DEFAULT_COMBINATORS];
386
+ static _globalDelimiters = [...DEFAULT_DELIMITERS];
387
+
388
+ /**
389
+ * Global default operators inherited by newly created <rich-input> instances.
390
+ */
391
+ static get operators() {
392
+ return [...RichInput._globalOperators];
393
+ }
394
+
395
+ static set operators(val) {
396
+ if (val === null || val === undefined) {
397
+ RichInput._globalOperators = [...DEFAULT_OPERATORS];
398
+ } else {
399
+ RichInput._globalOperators = normalizeOperators(val);
400
+ }
401
+ }
402
+
403
+ static get defaultOperators() {
404
+ return RichInput.operators;
405
+ }
406
+
407
+ static set defaultOperators(val) {
408
+ RichInput.operators = val;
409
+ }
410
+
411
+ /**
412
+ * Global default combinators inherited by newly created <rich-input> instances.
413
+ */
414
+ static get combinators() {
415
+ return [...RichInput._globalCombinators];
416
+ }
417
+
418
+ static set combinators(val) {
419
+ if (val === null || val === undefined) {
420
+ RichInput._globalCombinators = [...DEFAULT_COMBINATORS];
421
+ } else {
422
+ RichInput._globalCombinators = normalizeCombinators(val);
423
+ }
424
+ }
425
+
426
+ static get defaultCombinators() {
427
+ return RichInput.combinators;
428
+ }
429
+
430
+ static set defaultCombinators(val) {
431
+ RichInput.combinators = val;
432
+ }
433
+
434
+ /**
435
+ * Global default delimiters inherited by newly created <rich-input> instances.
436
+ */
437
+ static get delimiters() {
438
+ return [...RichInput._globalDelimiters];
439
+ }
440
+
441
+ static set delimiters(val) {
442
+ if (val === null || val === undefined) {
443
+ RichInput._globalDelimiters = [...DEFAULT_DELIMITERS];
444
+ } else {
445
+ RichInput._globalDelimiters = normalizeDelimiters(val);
446
+ }
447
+ }
448
+
449
+ static get defaultDelimiters() {
450
+ return RichInput.delimiters;
451
+ }
452
+
453
+ static set defaultDelimiters(val) {
454
+ RichInput.delimiters = val;
455
+ }
456
+
359
457
  static get observedAttributes() {
360
458
  return [
361
459
  'value',
@@ -366,6 +464,9 @@ export class RichInput extends HTMLElement {
366
464
  'autofocus',
367
465
  'required',
368
466
  'highlight-quotes',
467
+ 'operators',
468
+ 'combinators',
469
+ 'delimiters',
369
470
  'aria-label',
370
471
  'aria-labelledby',
371
472
  ];
@@ -403,14 +504,26 @@ export class RichInput extends HTMLElement {
403
504
  this._popoverTitle = this.shadowRoot.querySelector('.popover-title');
404
505
  this._suggestionsList = this.shadowRoot.querySelector('.suggestions-list');
405
506
  this._clearBtn = this.shadowRoot.querySelector('.clear-button');
507
+ this._icon = this.shadowRoot.querySelector('.search-icon');
406
508
  this._slot = this.shadowRoot.querySelector('slot:not([name])');
509
+ this._lastIconColor = null;
407
510
 
408
511
  this._configuredKeywords = new Map();
512
+ this._operators = [...RichInput.operators];
513
+ this._settingOperatorsAttribute = false;
514
+ this._combinators = [...RichInput.combinators];
515
+ this._settingCombinatorsAttribute = false;
516
+ this._delimiters = [...RichInput.delimiters];
517
+ this._settingDelimitersAttribute = false;
409
518
  this._activeSuggestions = [];
410
519
  this._selectedIndex = -1;
411
520
  this._context = null;
521
+ this._suggestionTimeout = null;
412
522
  this._ownedRanges = [];
413
523
  this._invalidRanges = [];
524
+ this._operatorRanges = [];
525
+ this._combinatorRanges = [];
526
+ this._delimiterRanges = [];
414
527
  this._activeKeywordHighlightMap = new Map();
415
528
  this._isFocused = false;
416
529
  this._lastCaretPosition = -1;
@@ -432,6 +545,10 @@ export class RichInput extends HTMLElement {
432
545
 
433
546
  connectedCallback() {
434
547
  this._syncInjectedStyles();
548
+ this._syncIconStyle();
549
+ if (typeof requestAnimationFrame === 'function') {
550
+ requestAnimationFrame(() => this._syncIconStyle());
551
+ }
435
552
  syncDocumentHighlightStyles(this.shadowRoot);
436
553
  highlightManager.register(this);
437
554
 
@@ -448,9 +565,15 @@ export class RichInput extends HTMLElement {
448
565
 
449
566
  // Listen to changes on light DOM datalists and style tags
450
567
  this._slot.addEventListener('slotchange', this._onSlotChange);
451
- this._mutationObserver = new MutationObserver(() => {
568
+ this._mutationObserver = new MutationObserver((mutations) => {
569
+ this._syncIconStyle();
570
+ const hasLightDomChange = mutations.some(
571
+ (m) => !(m.target === this && m.type === 'attributes')
572
+ );
573
+ if (!hasLightDomChange) return;
452
574
  this._loadDatalists();
453
575
  this._syncInjectedStyles();
576
+ this._syncIconStyle();
454
577
  this.updateHighlights();
455
578
  });
456
579
  this._mutationObserver.observe(this, { childList: true, subtree: true, attributes: true, characterData: true });
@@ -472,6 +595,48 @@ export class RichInput extends HTMLElement {
472
595
  window.addEventListener('scroll', this._onGlobalResizeOrScroll, { passive: true });
473
596
 
474
597
  // Sync initial attributes
598
+ if (this.hasAttribute('operators')) {
599
+ const rawOps = this.getAttribute('operators');
600
+ this._operators = normalizeOperators(rawOps);
601
+ const normalizedAttr = this._operators.join(' ');
602
+ if (rawOps !== normalizedAttr) {
603
+ this._settingOperatorsAttribute = true;
604
+ this.setAttribute('operators', normalizedAttr);
605
+ this._settingOperatorsAttribute = false;
606
+ }
607
+ } else if (this._operators.length > 0) {
608
+ this._settingOperatorsAttribute = true;
609
+ this.setAttribute('operators', this._operators.join(' '));
610
+ this._settingOperatorsAttribute = false;
611
+ }
612
+ if (this.hasAttribute('combinators')) {
613
+ const rawCombs = this.getAttribute('combinators');
614
+ this._combinators = normalizeCombinators(rawCombs);
615
+ const normalizedAttr = this._combinators.join(' ');
616
+ if (rawCombs !== normalizedAttr) {
617
+ this._settingCombinatorsAttribute = true;
618
+ this.setAttribute('combinators', normalizedAttr);
619
+ this._settingCombinatorsAttribute = false;
620
+ }
621
+ } else if (this._combinators.length > 0) {
622
+ this._settingCombinatorsAttribute = true;
623
+ this.setAttribute('combinators', this._combinators.join(' '));
624
+ this._settingCombinatorsAttribute = false;
625
+ }
626
+ if (this.hasAttribute('delimiters')) {
627
+ const rawDelims = this.getAttribute('delimiters');
628
+ this._delimiters = normalizeDelimiters(rawDelims);
629
+ const normalizedAttr = this._delimiters.join(' ');
630
+ if (rawDelims !== normalizedAttr) {
631
+ this._settingDelimitersAttribute = true;
632
+ this.setAttribute('delimiters', normalizedAttr);
633
+ this._settingDelimitersAttribute = false;
634
+ }
635
+ } else if (this._delimiters.length > 0) {
636
+ this._settingDelimitersAttribute = true;
637
+ this.setAttribute('delimiters', this._delimiters.join(' '));
638
+ this._settingDelimitersAttribute = false;
639
+ }
475
640
  if (this.hasAttribute('value')) {
476
641
  this._input.value = this.getAttribute('value');
477
642
  }
@@ -547,6 +712,72 @@ export class RichInput extends HTMLElement {
547
712
  }
548
713
  } else if (name === 'highlight-quotes') {
549
714
  this.updateHighlights();
715
+ } else if (name === 'operators') {
716
+ if (this._settingOperatorsAttribute) return;
717
+ if (newValue === null) {
718
+ this._operators = [...RichInput.operators];
719
+ if (this._operators.length > 0) {
720
+ this._settingOperatorsAttribute = true;
721
+ this.setAttribute('operators', this._operators.join(' '));
722
+ this._settingOperatorsAttribute = false;
723
+ }
724
+ } else {
725
+ this._operators = normalizeOperators(newValue);
726
+ const normalizedAttr = this._operators.join(' ');
727
+ if (newValue !== normalizedAttr) {
728
+ this._settingOperatorsAttribute = true;
729
+ this.setAttribute('operators', normalizedAttr);
730
+ this._settingOperatorsAttribute = false;
731
+ }
732
+ }
733
+ this.updateHighlights();
734
+ if (this._isPopoverOpen()) {
735
+ this.updateSuggestions('operators-changed');
736
+ }
737
+ } else if (name === 'combinators') {
738
+ if (this._settingCombinatorsAttribute) return;
739
+ if (newValue === null) {
740
+ this._combinators = [...RichInput.combinators];
741
+ if (this._combinators.length > 0) {
742
+ this._settingCombinatorsAttribute = true;
743
+ this.setAttribute('combinators', this._combinators.join(' '));
744
+ this._settingCombinatorsAttribute = false;
745
+ }
746
+ } else {
747
+ this._combinators = normalizeCombinators(newValue);
748
+ const normalizedAttr = this._combinators.join(' ');
749
+ if (newValue !== normalizedAttr) {
750
+ this._settingCombinatorsAttribute = true;
751
+ this.setAttribute('combinators', normalizedAttr);
752
+ this._settingCombinatorsAttribute = false;
753
+ }
754
+ }
755
+ this.updateHighlights();
756
+ if (this._isPopoverOpen()) {
757
+ this.updateSuggestions('combinators-changed');
758
+ }
759
+ } else if (name === 'delimiters') {
760
+ if (this._settingDelimitersAttribute) return;
761
+ if (newValue === null) {
762
+ this._delimiters = [...RichInput.delimiters];
763
+ if (this._delimiters.length > 0) {
764
+ this._settingDelimitersAttribute = true;
765
+ this.setAttribute('delimiters', this._delimiters.join(' '));
766
+ this._settingDelimitersAttribute = false;
767
+ }
768
+ } else {
769
+ this._delimiters = normalizeDelimiters(newValue);
770
+ const normalizedAttr = this._delimiters.join(' ');
771
+ if (newValue !== normalizedAttr) {
772
+ this._settingDelimitersAttribute = true;
773
+ this.setAttribute('delimiters', normalizedAttr);
774
+ this._settingDelimitersAttribute = false;
775
+ }
776
+ }
777
+ this.updateHighlights();
778
+ if (this._isPopoverOpen()) {
779
+ this.updateSuggestions('delimiters-changed');
780
+ }
550
781
  }
551
782
  }
552
783
 
@@ -662,6 +893,108 @@ export class RichInput extends HTMLElement {
662
893
  this._input.selectionDirection = val;
663
894
  }
664
895
 
896
+ get operators() {
897
+ return [...this._operators];
898
+ }
899
+
900
+ set operators(val) {
901
+ if (val === null || val === undefined) {
902
+ this._operators = [...RichInput.operators];
903
+ this._settingOperatorsAttribute = true;
904
+ if (this._operators.length > 0) {
905
+ this.setAttribute('operators', this._operators.join(' '));
906
+ } else {
907
+ this.removeAttribute('operators');
908
+ }
909
+ this._settingOperatorsAttribute = false;
910
+ } else {
911
+ this._operators = normalizeOperators(val);
912
+ this._settingOperatorsAttribute = true;
913
+ this.setAttribute('operators', this._operators.join(' '));
914
+ this._settingOperatorsAttribute = false;
915
+ }
916
+ this.updateHighlights();
917
+ if (this._isPopoverOpen()) {
918
+ this.updateSuggestions('operators-changed');
919
+ }
920
+ }
921
+
922
+ getOperators() {
923
+ return this.operators;
924
+ }
925
+
926
+ setOperators(val) {
927
+ this.operators = val;
928
+ }
929
+
930
+ get combinators() {
931
+ return [...this._combinators];
932
+ }
933
+
934
+ set combinators(val) {
935
+ if (val === null || val === undefined) {
936
+ this._combinators = [...RichInput.combinators];
937
+ this._settingCombinatorsAttribute = true;
938
+ if (this._combinators.length > 0) {
939
+ this.setAttribute('combinators', this._combinators.join(' '));
940
+ } else {
941
+ this.removeAttribute('combinators');
942
+ }
943
+ this._settingCombinatorsAttribute = false;
944
+ } else {
945
+ this._combinators = normalizeCombinators(val);
946
+ this._settingCombinatorsAttribute = true;
947
+ this.setAttribute('combinators', this._combinators.join(' '));
948
+ this._settingCombinatorsAttribute = false;
949
+ }
950
+ this.updateHighlights();
951
+ if (this._isPopoverOpen()) {
952
+ this.updateSuggestions('combinators-changed');
953
+ }
954
+ }
955
+
956
+ getCombinators() {
957
+ return this.combinators;
958
+ }
959
+
960
+ setCombinators(val) {
961
+ this.combinators = val;
962
+ }
963
+
964
+ get delimiters() {
965
+ return [...this._delimiters];
966
+ }
967
+
968
+ set delimiters(val) {
969
+ if (val === null || val === undefined) {
970
+ this._delimiters = [...RichInput.delimiters];
971
+ this._settingDelimitersAttribute = true;
972
+ if (this._delimiters.length > 0) {
973
+ this.setAttribute('delimiters', this._delimiters.join(' '));
974
+ } else {
975
+ this.removeAttribute('delimiters');
976
+ }
977
+ this._settingDelimitersAttribute = false;
978
+ } else {
979
+ this._delimiters = normalizeDelimiters(val);
980
+ this._settingDelimitersAttribute = true;
981
+ this.setAttribute('delimiters', this._delimiters.join(' '));
982
+ this._settingDelimitersAttribute = false;
983
+ }
984
+ this.updateHighlights();
985
+ if (this._isPopoverOpen()) {
986
+ this.updateSuggestions('delimiters-changed');
987
+ }
988
+ }
989
+
990
+ getDelimiters() {
991
+ return this.delimiters;
992
+ }
993
+
994
+ setDelimiters(val) {
995
+ this.delimiters = val;
996
+ }
997
+
665
998
  // --- Public Methods ---
666
999
  focus(options) {
667
1000
  this._isFocused = true;
@@ -682,7 +1015,7 @@ export class RichInput extends HTMLElement {
682
1015
  }
683
1016
 
684
1017
  getParsedQuery() {
685
- return parseSearchQuery(this._input.value);
1018
+ return parseSearchQuery(this._input.value, this.operators, this.combinators, this.delimiters);
686
1019
  }
687
1020
 
688
1021
  getKeywords() {
@@ -769,9 +1102,66 @@ export class RichInput extends HTMLElement {
769
1102
  _onSlotChange() {
770
1103
  this._loadDatalists();
771
1104
  this._syncInjectedStyles();
1105
+ this._syncIconStyle();
772
1106
  this.updateHighlights();
773
1107
  }
774
1108
 
1109
+ _syncIconStyle() {
1110
+ if (!this._icon || typeof window === 'undefined' || typeof getComputedStyle !== 'function') return;
1111
+
1112
+ const cs = getComputedStyle(this._icon);
1113
+ const content = cs.content;
1114
+ const color = cs.color || '#9ca3af';
1115
+
1116
+ if (this._lastIconColor !== color) {
1117
+ this._lastIconColor = color;
1118
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${color}"><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"/></svg>`;
1119
+ this._icon.style.setProperty('--_icon-default-bg', `url("data:image/svg+xml,${encodeURIComponent(svg)}")`);
1120
+ }
1121
+
1122
+ if (content === 'none') {
1123
+ if (!this._icon.hasAttribute('data-has-content')) {
1124
+ this._icon.setAttribute('data-has-content', '');
1125
+ }
1126
+ if (!this._icon.hasAttribute('data-content-none')) {
1127
+ this._icon.setAttribute('data-content-none', '');
1128
+ }
1129
+ this._icon.style.removeProperty('--_icon-content');
1130
+ return;
1131
+ }
1132
+
1133
+ if (this._icon.hasAttribute('data-content-none')) {
1134
+ this._icon.removeAttribute('data-content-none');
1135
+ }
1136
+
1137
+ if (content && content !== 'normal') {
1138
+ if (!this._icon.hasAttribute('data-has-content')) {
1139
+ this._icon.setAttribute('data-has-content', '');
1140
+ }
1141
+ if (content.startsWith('"') || content.startsWith("'")) {
1142
+ if (this._icon.style.getPropertyValue('--_icon-content') !== content) {
1143
+ this._icon.style.setProperty('--_icon-content', content);
1144
+ }
1145
+ } else {
1146
+ this._icon.style.removeProperty('--_icon-content');
1147
+ }
1148
+ return;
1149
+ }
1150
+
1151
+ if (this._icon.style.getPropertyValue('--_icon-content')) {
1152
+ this._icon.style.removeProperty('--_icon-content');
1153
+ }
1154
+
1155
+ const beforeContent = getComputedStyle(this._icon, '::before').content;
1156
+ if (beforeContent && beforeContent !== 'none' && beforeContent !== 'normal') {
1157
+ if (!this._icon.hasAttribute('data-has-content')) {
1158
+ this._icon.setAttribute('data-has-content', '');
1159
+ }
1160
+ } else if (this._icon.hasAttribute('data-has-content')) {
1161
+ this._icon.removeAttribute('data-has-content');
1162
+ }
1163
+ }
1164
+
775
1165
  // --- Public Properties ---
776
1166
  get inputElement() {
777
1167
  return this._input;
@@ -786,6 +1176,9 @@ export class RichInput extends HTMLElement {
786
1176
  }
787
1177
  this._ownedRanges = [];
788
1178
  this._invalidRanges = [];
1179
+ this._operatorRanges = [];
1180
+ this._combinatorRanges = [];
1181
+ this._delimiterRanges = [];
789
1182
  this._activeKeywordHighlightMap.clear();
790
1183
  }
791
1184
 
@@ -808,7 +1201,7 @@ export class RichInput extends HTMLElement {
808
1201
  return;
809
1202
  }
810
1203
 
811
- const tokens = parseSearchTokens(text);
1204
+ const tokens = parseSearchTokens(text, this.operators, this.combinators, this.delimiters);
812
1205
  const highlightQuotes = this.getAttribute('highlight-quotes') !== 'exclude';
813
1206
 
814
1207
  const isFocused = this._isFocused;
@@ -832,6 +1225,15 @@ export class RichInput extends HTMLElement {
832
1225
 
833
1226
  const bucket = this._activeKeywordHighlightMap.get(kw);
834
1227
 
1228
+ // 0. Operator prefix range (for ::highlight(rich-input-operator))
1229
+ if (token.operator && token.operatorEnd > token.operatorStart && token.operatorEnd <= text.length) {
1230
+ try {
1231
+ const opRange = this._input.createValueRange(token.operatorStart, token.operatorEnd);
1232
+ this._ownedRanges.push(opRange);
1233
+ this._operatorRanges.push(opRange);
1234
+ } catch (e) {}
1235
+ }
1236
+
835
1237
  // 1. Value range
836
1238
  const start = highlightQuotes ? token.valueStart : token.innerStart;
837
1239
  const end = highlightQuotes ? token.valueEnd : token.innerEnd;
@@ -890,6 +1292,39 @@ export class RichInput extends HTMLElement {
890
1292
  }
891
1293
  }
892
1294
  }
1295
+ } else if (token.type === 'combinator') {
1296
+ if (token.end > token.start && token.end <= text.length) {
1297
+ try {
1298
+ const combRange = this._input.createValueRange(token.start, token.end);
1299
+ this._ownedRanges.push(combRange);
1300
+ this._combinatorRanges.push(combRange);
1301
+ } catch (e) {}
1302
+ }
1303
+ } else if (token.type === 'delimiter') {
1304
+ if (token.end > token.start && token.end <= text.length) {
1305
+ try {
1306
+ const delimRange = this._input.createValueRange(token.start, token.end);
1307
+ this._ownedRanges.push(delimRange);
1308
+ this._delimiterRanges.push(delimRange);
1309
+ } catch (e) {}
1310
+ }
1311
+ } else if (token.type === 'text' && token.operator) {
1312
+ // Highlight operator while typing an operator prefix before colon (e.g. "-" or "-sty")
1313
+ const remainder = token.raw.slice(token.operator.length).toLowerCase();
1314
+ const matchesKeywordPrefix =
1315
+ remainder.length === 0 ||
1316
+ Array.from(this._configuredKeywords.keys()).some((k) => k.startsWith(remainder));
1317
+ if (
1318
+ matchesKeywordPrefix &&
1319
+ token.operatorEnd > token.operatorStart &&
1320
+ token.operatorEnd <= text.length
1321
+ ) {
1322
+ try {
1323
+ const opRange = this._input.createValueRange(token.operatorStart, token.operatorEnd);
1324
+ this._ownedRanges.push(opRange);
1325
+ this._operatorRanges.push(opRange);
1326
+ } catch (e) {}
1327
+ }
893
1328
  }
894
1329
  }
895
1330
 
@@ -900,6 +1335,18 @@ export class RichInput extends HTMLElement {
900
1335
  return this._activeKeywordHighlightMap;
901
1336
  }
902
1337
 
1338
+ getActiveOperatorRanges() {
1339
+ return this._operatorRanges;
1340
+ }
1341
+
1342
+ getActiveCombinatorRanges() {
1343
+ return this._combinatorRanges;
1344
+ }
1345
+
1346
+ getActiveDelimiterRanges() {
1347
+ return this._delimiterRanges;
1348
+ }
1349
+
903
1350
  getActiveInvalidRanges() {
904
1351
  return this._invalidRanges;
905
1352
  }
@@ -949,8 +1396,9 @@ export class RichInput extends HTMLElement {
949
1396
  }
950
1397
  }
951
1398
 
952
- // Enter when popover closed triggers search/change
1399
+ // Enter when popover closed (or no suggestion selected) triggers search/change
953
1400
  if (e.key === 'Enter') {
1401
+ this.hideSuggestions();
954
1402
  this.dispatchEvent(new CustomEvent('search', {
955
1403
  bubbles: true,
956
1404
  composed: true,
@@ -971,15 +1419,16 @@ export class RichInput extends HTMLElement {
971
1419
  this._isFocused = true;
972
1420
  this._lastCaretPosition = this._input.selectionStart;
973
1421
  this.updateHighlights();
974
- // Optionally open suggestions if context matches
975
- if (this._input.value.length > 0) {
976
- this.updateSuggestions('focus');
977
- }
1422
+ this.updateSuggestions('focus');
978
1423
  }
979
1424
 
980
1425
  _onBlur() {
981
1426
  this._isFocused = false;
982
1427
  this._lastCaretPosition = -1;
1428
+ if (this._suggestionTimeout) {
1429
+ clearTimeout(this._suggestionTimeout);
1430
+ this._suggestionTimeout = null;
1431
+ }
983
1432
  this.updateHighlights();
984
1433
  // Delay closing so click events on popover items can fire
985
1434
  setTimeout(() => {
@@ -1012,7 +1461,7 @@ export class RichInput extends HTMLElement {
1012
1461
  _onClearClick() {
1013
1462
  this.value = '';
1014
1463
  this._input.focus();
1015
- this.hideSuggestions();
1464
+ this.updateSuggestions('clear');
1016
1465
  this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1017
1466
  this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
1018
1467
  }
@@ -1087,8 +1536,13 @@ export class RichInput extends HTMLElement {
1087
1536
  }
1088
1537
 
1089
1538
  updateSuggestions(trigger = 'input') {
1539
+ if (this._suggestionTimeout) {
1540
+ clearTimeout(this._suggestionTimeout);
1541
+ this._suggestionTimeout = null;
1542
+ }
1543
+
1090
1544
  const caretPos = this._input.selectionStart;
1091
- const context = getCaretContext(this._input.value, caretPos, this._configuredKeywords);
1545
+ const context = getCaretContext(this._input.value, caretPos, this._configuredKeywords, this.operators, this.combinators, this.delimiters);
1092
1546
 
1093
1547
  // If trigger is arrow and context is 'none', allow suggesting all keywords
1094
1548
  if (trigger === 'arrow' && context.mode === 'none') {
@@ -1099,15 +1553,69 @@ export class RichInput extends HTMLElement {
1099
1553
  }
1100
1554
 
1101
1555
  this._context = context;
1102
- const suggestions = getSuggestions(context, this._configuredKeywords);
1556
+ const suggestions = getSuggestions(context, this._configuredKeywords, this.combinators);
1103
1557
 
1104
1558
  if (suggestions.length === 0) {
1105
1559
  this.hideSuggestions();
1106
1560
  return;
1107
1561
  }
1108
1562
 
1563
+ // Value suggestions always show immediately.
1564
+ // Keyword suggestions show immediately when:
1565
+ // - focusing/clearing an empty field
1566
+ // - typing characters that match a keyword (Boolean(context.query))
1567
+ // - typing an operator (Boolean(context.operator))
1568
+ // - explicitly triggered via the Down Arrow key
1569
+ // In other cases (at the start of a new token after a space when the field is not empty),
1570
+ // show after a small delay to prevent constant popups when typing regular sentences with spaces.
1571
+ const isEmptyField = this._input.value.length === 0;
1572
+ const shouldShowImmediately =
1573
+ context.mode === 'value' ||
1574
+ isEmptyField ||
1575
+ Boolean(context.query) ||
1576
+ Boolean(context.operator) ||
1577
+ trigger === 'arrow';
1578
+
1579
+ if (shouldShowImmediately) {
1580
+ this._showSuggestions(suggestions, context, trigger);
1581
+ } else {
1582
+ if (this._isPopoverOpen()) {
1583
+ this.hideSuggestions();
1584
+ }
1585
+ this._suggestionTimeout = setTimeout(() => {
1586
+ this._suggestionTimeout = null;
1587
+ const isFocused =
1588
+ this._isFocused ||
1589
+ this.shadowRoot?.activeElement === this._input ||
1590
+ document.activeElement === this ||
1591
+ document.activeElement === this._input ||
1592
+ Boolean(this.matches?.(':focus-within'));
1593
+ if (!isFocused) return;
1594
+
1595
+ const currentCaretPos = this._input.selectionStart;
1596
+ const currentContext = getCaretContext(this._input.value, currentCaretPos, this._configuredKeywords, this.operators, this.combinators, this.delimiters);
1597
+ this._context = currentContext;
1598
+ const currentSuggestions = getSuggestions(currentContext, this._configuredKeywords, this.combinators);
1599
+ if (currentSuggestions.length === 0) {
1600
+ this.hideSuggestions();
1601
+ return;
1602
+ }
1603
+ this._showSuggestions(currentSuggestions, currentContext, trigger);
1604
+ }, KEYWORD_SUGGESTION_DELAY);
1605
+ }
1606
+ }
1607
+
1608
+ _showSuggestions(suggestions, context, trigger) {
1109
1609
  this._activeSuggestions = suggestions;
1110
- this._selectedIndex = 0; // Pre-select first item for quick Enter/Tab
1610
+
1611
+ // Pre-select first item for quick Enter/Tab when filtering or in value mode or triggered via Down Arrow,
1612
+ // but leave unselected (-1) when showing full keyword list at the start of a new token so Enter/Tab aren't hijacked.
1613
+ const shouldPreselect =
1614
+ context.mode === 'value' ||
1615
+ Boolean(context.query) ||
1616
+ trigger === 'arrow';
1617
+ const existingSelectedIdx = suggestions.findIndex((s) => this._isSuggestionSelected(s));
1618
+ this._selectedIndex = existingSelectedIdx !== -1 ? existingSelectedIdx : (shouldPreselect ? 0 : -1); // Highlight matching item or first item for quick Enter/Tab
1111
1619
  this._renderSuggestions();
1112
1620
 
1113
1621
  // Show popover
@@ -1126,6 +1634,10 @@ export class RichInput extends HTMLElement {
1126
1634
  }
1127
1635
 
1128
1636
  hideSuggestions() {
1637
+ if (this._suggestionTimeout) {
1638
+ clearTimeout(this._suggestionTimeout);
1639
+ this._suggestionTimeout = null;
1640
+ }
1129
1641
  if (this._isPopoverOpen()) {
1130
1642
  try {
1131
1643
  this._popover.hidePopover();
@@ -1137,6 +1649,35 @@ export class RichInput extends HTMLElement {
1137
1649
  this._selectedIndex = -1;
1138
1650
  }
1139
1651
 
1652
+ _isSuggestionSelected(sug) {
1653
+ if (!this._context || !sug) return false;
1654
+
1655
+ if (sug.type === 'value' && this._context.mode === 'value' && this._context.token) {
1656
+ const currentVal = (this._context.token.innerValue ?? '').trim().toLowerCase();
1657
+ if (!currentVal) return false;
1658
+ const sugVal = (sug.value ?? '').trim().toLowerCase();
1659
+ const sugLabel = (sug.label ?? '').trim().toLowerCase();
1660
+ const sugDisplay = (sug.display ?? '').trim().toLowerCase();
1661
+ return currentVal === sugVal || currentVal === sugLabel || currentVal === sugDisplay;
1662
+ }
1663
+
1664
+ if (sug.type === 'keyword' && this._context.mode === 'keyword' && this._context.token?.type === 'keyword') {
1665
+ const currentKw = (this._context.token.keywordLower ?? '').trim().toLowerCase();
1666
+ if (!currentKw) return false;
1667
+ const sugId = (sug.id ?? '').trim().toLowerCase();
1668
+ return currentKw === sugId;
1669
+ }
1670
+
1671
+ if (sug.type === 'combinator' && this._context.mode === 'keyword' && this._context.token?.type === 'combinator') {
1672
+ const currentComb = (this._context.token.combinator ?? '').trim().toLowerCase();
1673
+ if (!currentComb) return false;
1674
+ const sugComb = (sug.combinator ?? sug.value ?? '').trim().toLowerCase();
1675
+ return currentComb === sugComb;
1676
+ }
1677
+
1678
+ return false;
1679
+ }
1680
+
1140
1681
  _renderSuggestions() {
1141
1682
  if (this._popoverTitle) {
1142
1683
  let title = 'Suggestions';
@@ -1152,12 +1693,17 @@ export class RichInput extends HTMLElement {
1152
1693
  this._suggestionsList.replaceChildren();
1153
1694
 
1154
1695
  this._activeSuggestions.forEach((sug, idx) => {
1696
+ const isAct = idx === this._selectedIndex;
1697
+ const isSel = this._isSuggestionSelected(sug);
1155
1698
  const li = document.createElement('li');
1156
- li.className = `suggestion-item${idx === this._selectedIndex ? ' active' : ''}`;
1699
+ li.className = `suggestion-item${isAct ? ' active' : ''}${isSel ? ' selected' : ''}`;
1157
1700
  li.id = `ri-opt-${idx}`;
1158
1701
  li.setAttribute('role', 'option');
1159
- li.setAttribute('aria-selected', idx === this._selectedIndex ? 'true' : 'false');
1160
- li.setAttribute('part', `suggestion-item${idx === this._selectedIndex ? ' suggestion-item-active' : ''}`);
1702
+ li.setAttribute('aria-selected', isSel ? 'true' : 'false');
1703
+ const parts = ['suggestion-item'];
1704
+ if (isAct) parts.push('suggestion-item-active');
1705
+ if (isSel) parts.push('suggestion-item-selected');
1706
+ li.setAttribute('part', parts.join(' '));
1161
1707
 
1162
1708
  if (sug.image) {
1163
1709
  const img = sug.image.cloneNode(true);
@@ -1173,7 +1719,13 @@ export class RichInput extends HTMLElement {
1173
1719
 
1174
1720
  const title = document.createElement('span');
1175
1721
  title.className = 'suggestion-title';
1176
- title.setAttribute('part', sug.type === 'keyword' ? 'suggestion-keyword' : 'suggestion-value');
1722
+ const titlePart =
1723
+ sug.type === 'keyword'
1724
+ ? 'suggestion-keyword'
1725
+ : sug.type === 'combinator'
1726
+ ? 'suggestion-combinator'
1727
+ : 'suggestion-value';
1728
+ title.setAttribute('part', titlePart);
1177
1729
  title.textContent = sug.display;
1178
1730
 
1179
1731
  const desc = document.createElement('span');
@@ -1209,7 +1761,11 @@ export class RichInput extends HTMLElement {
1209
1761
  _moveSelection(delta) {
1210
1762
  if (this._activeSuggestions.length === 0) return;
1211
1763
  const len = this._activeSuggestions.length;
1212
- this._selectedIndex = (this._selectedIndex + delta + len) % len;
1764
+ if (this._selectedIndex === -1) {
1765
+ this._selectedIndex = delta > 0 ? 0 : len - 1;
1766
+ } else {
1767
+ this._selectedIndex = (this._selectedIndex + delta + len) % len;
1768
+ }
1213
1769
  this._updateActiveSuggestion();
1214
1770
 
1215
1771
  // Scroll active item into view
@@ -1223,9 +1779,14 @@ export class RichInput extends HTMLElement {
1223
1779
  const items = this._suggestionsList.children;
1224
1780
  for (let i = 0; i < items.length; i++) {
1225
1781
  const isAct = i === this._selectedIndex;
1782
+ const isSel = this._isSuggestionSelected(this._activeSuggestions[i]);
1226
1783
  items[i].classList.toggle('active', isAct);
1227
- items[i].setAttribute('aria-selected', isAct ? 'true' : 'false');
1228
- items[i].setAttribute('part', `suggestion-item${isAct ? ' suggestion-item-active' : ''}`);
1784
+ items[i].classList.toggle('selected', isSel);
1785
+ items[i].setAttribute('aria-selected', isSel ? 'true' : 'false');
1786
+ const parts = ['suggestion-item'];
1787
+ if (isAct) parts.push('suggestion-item-active');
1788
+ if (isSel) parts.push('suggestion-item-selected');
1789
+ items[i].setAttribute('part', parts.join(' '));
1229
1790
  if (isAct) {
1230
1791
  this._input.setAttribute('aria-activedescendant', items[i].id);
1231
1792
  }
@@ -1253,7 +1814,9 @@ export class RichInput extends HTMLElement {
1253
1814
  this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1254
1815
  const selectDetail = {
1255
1816
  type: suggestion.type,
1256
- keyword: suggestion.keyword || suggestion.id,
1817
+ operator: this._context?.operator || null,
1818
+ combinator: suggestion.combinator || (suggestion.type === 'combinator' ? suggestion.value : null),
1819
+ keyword: suggestion.keyword || (suggestion.type === 'keyword' ? suggestion.id : null),
1257
1820
  value: suggestion.value || null,
1258
1821
  label: suggestion.label || null,
1259
1822
  query: newValue,
@@ -1263,12 +1826,9 @@ export class RichInput extends HTMLElement {
1263
1826
  composed: true,
1264
1827
  detail: selectDetail,
1265
1828
  }));
1266
- // If a keyword was selected (e.g. `mix:`), immediately show value suggestions
1267
- if (suggestion.type === 'keyword') {
1268
- this.updateSuggestions('keyword-selected');
1269
- } else {
1270
- this.hideSuggestions();
1271
- }
1829
+ // Immediately show value suggestions when a keyword was selected (e.g. `mix:`),
1830
+ // or keyword suggestions when a value or combinator was selected (since cursor is now after a space at a new token)
1831
+ this.updateSuggestions(suggestion.type === 'keyword' ? 'keyword-selected' : 'value-selected');
1272
1832
  }
1273
1833
  }
1274
1834