rich-input 1.1.1 → 1.2.1

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.
@@ -85,6 +86,7 @@ Nest `<datalist>` elements inside `<rich-input>` to configure keywords and autoc
85
86
  <option value="Kranky"></option>
86
87
  <option value="Madhouse Records"></option>
87
88
  <option value="Ninja Tune"></option>
89
+ <option value="Warp Records"></option>
88
90
  <option value="We Play House Recordings"></option>
89
91
  <option value="XL Recordings"></option>
90
92
  </datalist>
@@ -148,6 +150,10 @@ Datalists can be added, updated, or removed dynamically at runtime; `<rich-input
148
150
  <img src="assets/ninja-tune.jpg" height="50" width="50" alt="Ninja Tune Logo">
149
151
  Ninja Tune
150
152
  </option>
153
+ <option value="Warp Records">
154
+ <img src="assets/warp-records.png" height="50" width="50" alt="Warp Records Logo">
155
+ Warp Records
156
+ </option>
151
157
  <option value="We Play House Recordings">
152
158
  <img src="assets/we-play-house-recordings.jpg" height="50" width="50" alt="We Play House Recordings Logo">
153
159
  We Play House Recordings
@@ -244,7 +250,7 @@ In browsers without `OpaqueRange` that support the CSS Custom Highlight API (suc
244
250
  Values corresponding to configured keywords are registered into the global `CSS.highlights` registry and styled using standard CSS `::highlight(keyword)` pseudo-elements in your stylesheet:
245
251
 
246
252
  ```css
247
- /* Style the value set in label:"XL Recordings" */
253
+ /* Style the value set in label:"Warp Records" */
248
254
  ::highlight(label) {
249
255
  background-color: oklch(0.92 0.08 240);
250
256
  color: oklch(0.28 0.14 240);
@@ -268,6 +274,12 @@ Values corresponding to configured keywords are registered into the global `CSS.
268
274
  color: #64748b;
269
275
  text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
270
276
  }
277
+
278
+ /* Invalid highlight (squiggly underline for unrecognized keywords or values not present in datalist) */
279
+ ::highlight(rich-input-invalid) {
280
+ text-decoration: underline wavy #ef4444;
281
+ text-decoration-skip-ink: none;
282
+ }
271
283
  ```
272
284
 
273
285
  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.
@@ -275,7 +287,7 @@ In browsers using the `[contenteditable]` fallback inside Shadow DOM (such as Sa
275
287
  For self-contained widgets or instance-specific style overrides, `<rich-input>` also supports an optional embedded `<style>` block as a direct child, which is automatically injected into the shadow root:
276
288
 
277
289
  ```html
278
- <rich-input value='artist:"Aphex Twin" label:"XL Recordings"'>
290
+ <rich-input value='artist:"Aphex Twin" label:"Warp Records"'>
279
291
  <style>
280
292
  ::highlight(label) {
281
293
  background-color: #dbeafe;
@@ -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, {
@@ -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.1",
3
+ "version": "1.2.1",
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",
@@ -52,6 +52,7 @@ class HighlightRegistryManager {
52
52
  const rangesByKeyword = new Map();
53
53
  const allKeywordRanges = [];
54
54
  const allValueRanges = [];
55
+ const allInvalidRanges = [];
55
56
 
56
57
  for (const inst of this.instances) {
57
58
  const instanceRanges = inst.getActiveHighlightRanges();
@@ -72,6 +73,13 @@ class HighlightRegistryManager {
72
73
  allKeywordRanges.push(...data.keywordRanges);
73
74
  }
74
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
+ }
75
83
  }
76
84
 
77
85
  // 1. Set/update individual keyword highlights (e.g. ::highlight(label))
@@ -129,6 +137,27 @@ class HighlightRegistryManager {
129
137
  } catch (e) {}
130
138
  }
131
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
+ }
132
161
  }
133
162
  }
134
163
 
@@ -181,8 +181,8 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
181
181
  // 1. Caret is within a keyword token
182
182
  if (activeToken.type === 'keyword') {
183
183
  if (caretPos <= activeToken.colonIndex) {
184
- // User is editing the keyword name
185
- const query = inputStr.slice(activeToken.start, caretPos);
184
+ // User is editing the keyword name (filter based on full keyword name, not caret position)
185
+ const query = activeToken.keyword;
186
186
  return {
187
187
  mode: 'keyword',
188
188
  query,
@@ -193,17 +193,16 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
193
193
  tokens,
194
194
  };
195
195
  } else {
196
- // User is editing the keyword value
196
+ // User is editing the keyword value (filter based on full value string, not caret position)
197
197
  const isQuoted = activeToken.quoted;
198
- const innerStart = activeToken.innerStart;
199
- const innerEnd = activeToken.innerEnd;
200
- const valuePrefix = inputStr.slice(innerStart, Math.min(caretPos, innerEnd));
198
+ const value = activeToken.innerValue;
201
199
 
202
200
  return {
203
201
  mode: 'value',
204
202
  keyword: activeToken.keyword,
205
203
  keywordLower: activeToken.keywordLower,
206
- valuePrefix,
204
+ valuePrefix: value,
205
+ query: value,
207
206
  quoted: isQuoted,
208
207
  quoteChar: activeToken.quoteChar || '"',
209
208
  isClosed: activeToken.isClosed,
@@ -216,9 +215,9 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
216
215
  }
217
216
  }
218
217
 
219
- // 2. Caret is within a plain text token
218
+ // 2. Caret is within a plain text token (filter based on full word, not caret position)
220
219
  if (activeToken.type === 'text') {
221
- const query = inputStr.slice(activeToken.start, caretPos);
220
+ const query = activeToken.raw;
222
221
  return {
223
222
  mode: 'keyword',
224
223
  query,
@@ -342,3 +341,25 @@ export function applySuggestion(inputStr, suggestion, context) {
342
341
  const newCaret = before.length + insertText.length;
343
342
  return { newValue, newCaret };
344
343
  }
344
+
345
+ /**
346
+ * Checks whether a given value matches any option in the keyword configuration.
347
+ * @param {Object} kwConfig
348
+ * @param {string} value
349
+ * @returns {boolean}
350
+ */
351
+ export function isDatalistValue(kwConfig, value) {
352
+ if (!kwConfig || !Array.isArray(kwConfig.options) || kwConfig.options.length === 0) {
353
+ return true;
354
+ }
355
+ const val = (value ?? '').trim().toLowerCase();
356
+ if (!val) {
357
+ return true;
358
+ }
359
+ return kwConfig.options.some((opt) => {
360
+ const optVal = (opt.value ?? '').trim().toLowerCase();
361
+ const optLabel = (opt.label ?? '').trim().toLowerCase();
362
+ const optText = (opt.text ?? '').trim().toLowerCase();
363
+ return optVal === val || optLabel === val || optText === val;
364
+ });
365
+ }