rich-input 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -37,8 +37,9 @@ The visual below illustrates the internal Shadow DOM elements, exposed CSS Shado
37
37
  - `<rich-input>`: The host custom element wrapping the control, datalists, and suggestions popover.
38
38
  - `::part(control)`: The outer input container enclosing the icon, input, and clear button.
39
39
  - `::part(icon)`: The default leading search magnifying glass SVG icon (fallback in `slot="leading"`).
40
- - `::part(input)`: The native `<input type="text">` where users type.
41
40
  - `::highlight(<keyword>)`: Target pseudo-element for styling keyword values via the CSS Custom Highlight API (e.g. `::highlight(label)`, `::highlight(year)`).
41
+ - `::highlight(rich-input-keyword)`: Target pseudo-element for styling keyword prefixes (e.g. `label:`, `year:`).
42
+ - `::highlight(rich-input-invalid)`: Target pseudo-element for marking unrecognized keywords or invalid keyword values (not in datalist) with a squiggly underline.
42
43
  - `::part(clear-button)`: The clear button (visible when text is present).
43
44
  - `::part(popover)`: The autocomplete dropdown popover container anchored to the start of the active range via `OpaqueRange` (or mirror-div fallback).
44
45
  - `::part(suggestions-header)`: The header bar at the top of the suggestions popover.
@@ -268,6 +269,12 @@ Values corresponding to configured keywords are registered into the global `CSS.
268
269
  color: #64748b;
269
270
  text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
270
271
  }
272
+
273
+ /* Invalid highlight (squiggly underline for unrecognized keywords or values not present in datalist) */
274
+ ::highlight(rich-input-invalid) {
275
+ text-decoration: underline wavy #ef4444;
276
+ text-decoration-skip-ink: none;
277
+ }
271
278
  ```
272
279
 
273
280
  In browsers using the `[contenteditable]` fallback inside Shadow DOM (such as Safari and Firefox), `<rich-input>` automatically syncs document-level `::highlight()` rules into its shadow stylesheet so that highlighting works across shadow boundaries without extra markup.
@@ -3,7 +3,7 @@
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 } from '../utils/query-parser.js';
6
+ import { 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';
@@ -119,6 +119,19 @@ TEMPLATE.innerHTML = `
119
119
  text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
120
120
  }
121
121
 
122
+ /* Squiggly line underneath invalid keyword values */
123
+ ::highlight(rich-input-invalid) {
124
+ text-decoration: underline wavy var(--ri-invalid-color, var(--rs-invalid-color, #ef4444));
125
+ -webkit-text-decoration: underline wavy var(--ri-invalid-color, var(--rs-invalid-color, #ef4444));
126
+ text-decoration-line: underline;
127
+ -webkit-text-decoration-line: underline;
128
+ text-decoration-style: wavy;
129
+ -webkit-text-decoration-style: wavy;
130
+ text-decoration-color: var(--ri-invalid-color, var(--rs-invalid-color, #ef4444));
131
+ -webkit-text-decoration-color: var(--ri-invalid-color, var(--rs-invalid-color, #ef4444));
132
+ text-decoration-skip-ink: none;
133
+ }
134
+
122
135
  /* Visually hide datalists and custom style tags in slot */
123
136
  ::slotted(datalist),
124
137
  ::slotted(style) {
@@ -389,7 +402,10 @@ export class RichInput extends HTMLElement {
389
402
  this._selectedIndex = -1;
390
403
  this._context = null;
391
404
  this._ownedRanges = [];
405
+ this._invalidRanges = [];
392
406
  this._activeKeywordHighlightMap = new Map();
407
+ this._isFocused = false;
408
+ this._lastCaretPosition = -1;
393
409
 
394
410
  // Bound listeners for easy cleanup
395
411
  this._onInput = this._onInput.bind(this);
@@ -398,6 +414,7 @@ export class RichInput extends HTMLElement {
398
414
  this._onFocus = this._onFocus.bind(this);
399
415
  this._onBlur = this._onBlur.bind(this);
400
416
  this._onClick = this._onClick.bind(this);
417
+ this._onSelectionChange = this._onSelectionChange.bind(this);
401
418
  this._onClearClick = this._onClearClick.bind(this);
402
419
  this._onSlotChange = this._onSlotChange.bind(this);
403
420
  this._onGlobalClick = this._onGlobalClick.bind(this);
@@ -409,6 +426,14 @@ export class RichInput extends HTMLElement {
409
426
  syncDocumentHighlightStyles(this.shadowRoot);
410
427
  highlightManager.register(this);
411
428
 
429
+ // Track initial focus state
430
+ this._isFocused = Boolean(
431
+ this.shadowRoot?.activeElement === this._input ||
432
+ document.activeElement === this ||
433
+ document.activeElement === this._input ||
434
+ this.matches?.(':focus-within')
435
+ );
436
+
412
437
  // Parse initial datalists
413
438
  this._loadDatalists();
414
439
 
@@ -432,6 +457,7 @@ export class RichInput extends HTMLElement {
432
457
 
433
458
  // Global events
434
459
  document.addEventListener('click', this._onGlobalClick);
460
+ document.addEventListener('selectionchange', this._onSelectionChange);
435
461
  window.addEventListener('resize', this._onGlobalResizeOrScroll);
436
462
  window.addEventListener('scroll', this._onGlobalResizeOrScroll, { passive: true });
437
463
 
@@ -471,6 +497,7 @@ export class RichInput extends HTMLElement {
471
497
  this._clearBtn.removeEventListener('click', this._onClearClick);
472
498
 
473
499
  document.removeEventListener('click', this._onGlobalClick);
500
+ document.removeEventListener('selectionchange', this._onSelectionChange);
474
501
  window.removeEventListener('resize', this._onGlobalResizeOrScroll);
475
502
  window.removeEventListener('scroll', this._onGlobalResizeOrScroll);
476
503
 
@@ -603,10 +630,12 @@ export class RichInput extends HTMLElement {
603
630
 
604
631
  // --- Public Methods ---
605
632
  focus(options) {
633
+ this._isFocused = true;
606
634
  this._input.focus(options);
607
635
  }
608
636
 
609
637
  blur() {
638
+ this._isFocused = false;
610
639
  this._input.blur();
611
640
  }
612
641
 
@@ -722,6 +751,7 @@ export class RichInput extends HTMLElement {
722
751
  } catch (e) {}
723
752
  }
724
753
  this._ownedRanges = [];
754
+ this._invalidRanges = [];
725
755
  this._activeKeywordHighlightMap.clear();
726
756
  }
727
757
 
@@ -747,9 +777,17 @@ export class RichInput extends HTMLElement {
747
777
  const tokens = parseSearchTokens(text);
748
778
  const highlightQuotes = this.getAttribute('highlight-quotes') !== 'exclude';
749
779
 
780
+ const isFocused = this._isFocused;
781
+
782
+ const caretStart = typeof this._input.selectionStart === 'number' ? this._input.selectionStart : null;
783
+ const caretEnd = typeof this._input.selectionEnd === 'number' ? this._input.selectionEnd : null;
784
+ const selMin = caretStart !== null ? Math.min(caretStart, caretEnd ?? caretStart) : -1;
785
+ const selMax = caretEnd !== null ? Math.max(caretStart ?? caretEnd, caretEnd) : -1;
786
+
750
787
  for (const token of tokens) {
751
788
  if (token.type === 'keyword' && this._configuredKeywords.has(token.keywordLower)) {
752
789
  const kw = token.keywordLower;
790
+ const kwConfig = this._configuredKeywords.get(kw);
753
791
 
754
792
  if (!this._activeKeywordHighlightMap.has(kw)) {
755
793
  this._activeKeywordHighlightMap.set(kw, {
@@ -764,7 +802,7 @@ export class RichInput extends HTMLElement {
764
802
  const start = highlightQuotes ? token.valueStart : token.innerStart;
765
803
  const end = highlightQuotes ? token.valueEnd : token.innerEnd;
766
804
 
767
- if (end >= start && end <= text.length) {
805
+ if (end > start && end <= text.length) {
768
806
  try {
769
807
  const valRange = this._input.createValueRange(start, end);
770
808
  this._ownedRanges.push(valRange);
@@ -782,6 +820,42 @@ export class RichInput extends HTMLElement {
782
820
  bucket.keywordRanges.push(kwRange);
783
821
  } catch (e) {}
784
822
  }
823
+
824
+ // 3. Validation: Check if value is part of the datalist
825
+ // Don't mark as invalid if:
826
+ // - Value is empty (user hasn't entered a value yet)
827
+ // - Input is focused and user is currently editing this token (caret is on/within this token)
828
+ const isEditingToken = isFocused && selMin !== -1 && selMax >= token.start && selMin <= token.end;
829
+ const hasValue = Boolean(token.innerValue && token.innerValue.trim().length > 0);
830
+
831
+ if (hasValue && !isEditingToken && !isDatalistValue(kwConfig, token.innerValue)) {
832
+ if (end > start && end <= text.length) {
833
+ try {
834
+ const invRange = this._input.createValueRange(start, end);
835
+ this._ownedRanges.push(invRange);
836
+ this._invalidRanges.push(invRange);
837
+ } catch (e) {
838
+ console.warn('[rich-input] Invalid range creation error:', e);
839
+ }
840
+ }
841
+ }
842
+ } else if (token.type === 'keyword') {
843
+ // Unrecognized key:value pair (keyword is not in configured datalists)
844
+ const isEditingToken = isFocused && selMin !== -1 && selMax >= token.start && selMin <= token.end;
845
+
846
+ if (!isEditingToken) {
847
+ const start = token.start;
848
+ const end = token.end;
849
+ if (end > start && end <= text.length) {
850
+ try {
851
+ const invRange = this._input.createValueRange(start, end);
852
+ this._ownedRanges.push(invRange);
853
+ this._invalidRanges.push(invRange);
854
+ } catch (e) {
855
+ console.warn('[rich-input] Invalid range creation error:', e);
856
+ }
857
+ }
858
+ }
785
859
  }
786
860
  }
787
861
 
@@ -792,8 +866,13 @@ export class RichInput extends HTMLElement {
792
866
  return this._activeKeywordHighlightMap;
793
867
  }
794
868
 
869
+ getActiveInvalidRanges() {
870
+ return this._invalidRanges;
871
+ }
872
+
795
873
  // --- Suggestions Popover Handling ---
796
874
  _onInput(e) {
875
+ this._lastCaretPosition = this._input.selectionStart;
797
876
  this._updateClearButton();
798
877
  this.updateHighlights();
799
878
  this.updateSuggestions('input');
@@ -847,12 +926,17 @@ export class RichInput extends HTMLElement {
847
926
  }
848
927
 
849
928
  _onKeyUp(e) {
929
+ this._lastCaretPosition = this._input.selectionStart;
850
930
  if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
931
+ this.updateHighlights();
851
932
  this.updateSuggestions('caret');
852
933
  }
853
934
  }
854
935
 
855
936
  _onFocus() {
937
+ this._isFocused = true;
938
+ this._lastCaretPosition = this._input.selectionStart;
939
+ this.updateHighlights();
856
940
  // Optionally open suggestions if context matches
857
941
  if (this._input.value.length > 0) {
858
942
  this.updateSuggestions('focus');
@@ -860,6 +944,9 @@ export class RichInput extends HTMLElement {
860
944
  }
861
945
 
862
946
  _onBlur() {
947
+ this._isFocused = false;
948
+ this._lastCaretPosition = -1;
949
+ this.updateHighlights();
863
950
  // Delay closing so click events on popover items can fire
864
951
  setTimeout(() => {
865
952
  this.hideSuggestions();
@@ -868,12 +955,25 @@ export class RichInput extends HTMLElement {
868
955
  }
869
956
 
870
957
  _onClick(e) {
958
+ this._isFocused = true;
871
959
  if (e && typeof e.clientX === 'number' && typeof this._input?.updateCaretFromPoint === 'function') {
872
960
  this._input.updateCaretFromPoint(e.clientX, e.clientY);
873
961
  }
962
+ this._lastCaretPosition = this._input.selectionStart;
963
+ this.updateHighlights();
874
964
  this.updateSuggestions('click');
875
965
  }
876
966
 
967
+ _onSelectionChange() {
968
+ if (!this._isFocused) return;
969
+
970
+ const caret = this._input.selectionStart;
971
+ if (this._lastCaretPosition !== caret) {
972
+ this._lastCaretPosition = caret;
973
+ this.updateHighlights();
974
+ }
975
+ }
976
+
877
977
  _onClearClick() {
878
978
  this.value = '';
879
979
  this._input.focus();
package/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { RichInput } from './components/rich-input.js';
7
- import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } from './utils/query-parser.js';
7
+ import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion, isDatalistValue } from './utils/query-parser.js';
8
8
  import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from './utils/highlights.js';
9
9
  import { getCaretCoordinates, getRangeCoordinates, positionPopover, getCaretLeftWithMirrorDiv } from './utils/positioning.js';
10
10
  import { setupContentEditableAdapter, isContentEditableFallbackActive } from './utils/contenteditable-adapter.js';
@@ -22,6 +22,7 @@ export {
22
22
  getCaretContext,
23
23
  getSuggestions,
24
24
  applySuggestion,
25
+ isDatalistValue,
25
26
  highlightManager,
26
27
  isOpaqueRangeSupported,
27
28
  isHighlightSupported,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rich-input",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A rich input field with keyword-based autocomplete and in-input highlighting powered by <datalist>, OpaqueRange, and the Custom Highlight API",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -13,6 +13,17 @@ export const isHighlightSupported =
13
13
  typeof CSS !== 'undefined' &&
14
14
  'highlights' in CSS;
15
15
 
16
+ function isRangeCollapsed(range) {
17
+ if (!range) return true;
18
+ if (range.collapsed === true) return true;
19
+ if (typeof range.startOffset === 'number' && typeof range.endOffset === 'number') {
20
+ if (range.startOffset === range.endOffset && range.startContainer === range.endContainer) {
21
+ return true;
22
+ }
23
+ }
24
+ return false;
25
+ }
26
+
16
27
  class HighlightRegistryManager {
17
28
  constructor() {
18
29
  this.instances = new Set();
@@ -41,6 +52,7 @@ class HighlightRegistryManager {
41
52
  const rangesByKeyword = new Map();
42
53
  const allKeywordRanges = [];
43
54
  const allValueRanges = [];
55
+ const allInvalidRanges = [];
44
56
 
45
57
  for (const inst of this.instances) {
46
58
  const instanceRanges = inst.getActiveHighlightRanges();
@@ -61,6 +73,13 @@ class HighlightRegistryManager {
61
73
  allKeywordRanges.push(...data.keywordRanges);
62
74
  }
63
75
  }
76
+
77
+ if (typeof inst.getActiveInvalidRanges === 'function') {
78
+ const invRanges = inst.getActiveInvalidRanges();
79
+ if (invRanges && invRanges.length > 0) {
80
+ allInvalidRanges.push(...invRanges);
81
+ }
82
+ }
64
83
  }
65
84
 
66
85
  // 1. Set/update individual keyword highlights (e.g. ::highlight(label))
@@ -77,6 +96,7 @@ class HighlightRegistryManager {
77
96
  hl.clear();
78
97
  for (const r of ranges) {
79
98
  try {
99
+ if (isRangeCollapsed(r)) continue;
80
100
  hl.add(r);
81
101
  } catch (e) {}
82
102
  }
@@ -95,6 +115,7 @@ class HighlightRegistryManager {
95
115
  kwHl.clear();
96
116
  for (const r of allKeywordRanges) {
97
117
  try {
118
+ if (isRangeCollapsed(r)) continue;
98
119
  kwHl.add(r);
99
120
  } catch (e) {}
100
121
  }
@@ -111,10 +132,32 @@ class HighlightRegistryManager {
111
132
  valHl.clear();
112
133
  for (const r of allValueRanges) {
113
134
  try {
135
+ if (isRangeCollapsed(r)) continue;
114
136
  valHl.add(r);
115
137
  } catch (e) {}
116
138
  }
117
139
  }
140
+
141
+ // 3. Set invalid highlights (rich-input-invalid)
142
+ let invHl = CSS.highlights.get('rich-input-invalid');
143
+ if (!invHl) {
144
+ try {
145
+ invHl = new Highlight();
146
+ CSS.highlights.set('rich-input-invalid', invHl);
147
+ } catch (e) {}
148
+ }
149
+ if (invHl) {
150
+ try {
151
+ invHl.priority = 10;
152
+ } catch (e) {}
153
+ invHl.clear();
154
+ for (const r of allInvalidRanges) {
155
+ try {
156
+ if (isRangeCollapsed(r)) continue;
157
+ invHl.add(r);
158
+ } catch (e) {}
159
+ }
160
+ }
118
161
  }
119
162
  }
120
163
 
@@ -342,3 +342,25 @@ export function applySuggestion(inputStr, suggestion, context) {
342
342
  const newCaret = before.length + insertText.length;
343
343
  return { newValue, newCaret };
344
344
  }
345
+
346
+ /**
347
+ * Checks whether a given value matches any option in the keyword configuration.
348
+ * @param {Object} kwConfig
349
+ * @param {string} value
350
+ * @returns {boolean}
351
+ */
352
+ export function isDatalistValue(kwConfig, value) {
353
+ if (!kwConfig || !Array.isArray(kwConfig.options) || kwConfig.options.length === 0) {
354
+ return true;
355
+ }
356
+ const val = (value ?? '').trim().toLowerCase();
357
+ if (!val) {
358
+ return true;
359
+ }
360
+ return kwConfig.options.some((opt) => {
361
+ const optVal = (opt.value ?? '').trim().toLowerCase();
362
+ const optLabel = (opt.label ?? '').trim().toLowerCase();
363
+ const optText = (opt.text ?? '').trim().toLowerCase();
364
+ return optVal === val || optLabel === val || optText === val;
365
+ });
366
+ }