rich-input 1.2.0 → 1.2.2

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
@@ -86,6 +86,7 @@ Nest `<datalist>` elements inside `<rich-input>` to configure keywords and autoc
86
86
  <option value="Kranky"></option>
87
87
  <option value="Madhouse Records"></option>
88
88
  <option value="Ninja Tune"></option>
89
+ <option value="Warp Records"></option>
89
90
  <option value="We Play House Recordings"></option>
90
91
  <option value="XL Recordings"></option>
91
92
  </datalist>
@@ -149,6 +150,10 @@ Datalists can be added, updated, or removed dynamically at runtime; `<rich-input
149
150
  <img src="assets/ninja-tune.jpg" height="50" width="50" alt="Ninja Tune Logo">
150
151
  Ninja Tune
151
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>
152
157
  <option value="We Play House Recordings">
153
158
  <img src="assets/we-play-house-recordings.jpg" height="50" width="50" alt="We Play House Recordings Logo">
154
159
  We Play House Recordings
@@ -245,7 +250,7 @@ In browsers without `OpaqueRange` that support the CSS Custom Highlight API (suc
245
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:
246
251
 
247
252
  ```css
248
- /* Style the value set in label:"XL Recordings" */
253
+ /* Style the value set in label:"Warp Records" */
249
254
  ::highlight(label) {
250
255
  background-color: oklch(0.92 0.08 240);
251
256
  color: oklch(0.28 0.14 240);
@@ -282,7 +287,7 @@ In browsers using the `[contenteditable]` fallback inside Shadow DOM (such as Sa
282
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:
283
288
 
284
289
  ```html
285
- <rich-input value='artist:"Aphex Twin" label:"XL Recordings"'>
290
+ <rich-input value='artist:"Aphex Twin" label:"Warp Records"'>
286
291
  <style>
287
292
  ::highlight(label) {
288
293
  background-color: #dbeafe;
@@ -283,6 +283,7 @@ TEMPLATE.innerHTML = `
283
283
  aria-autocomplete="list"
284
284
  aria-expanded="false"
285
285
  aria-haspopup="listbox"
286
+ aria-controls="ri-listbox"
286
287
  />
287
288
  <button part="clear-button" type="button" class="clear-button" aria-label="Clear search query" hidden>
288
289
  <svg viewBox="0 0 20 20" fill="currentColor">
@@ -296,13 +297,17 @@ TEMPLATE.innerHTML = `
296
297
  part="popover suggestions"
297
298
  class="popover"
298
299
  popover="manual"
299
- role="listbox"
300
- aria-label="Search suggestions"
301
300
  >
302
301
  <div part="suggestions-header" class="popover-header">
303
- <span part="suggestions-title" class="popover-title">Suggestions</span>
302
+ <span id="ri-listbox-label" part="suggestions-title" class="popover-title">Suggestions</span>
304
303
  </div>
305
- <ul part="suggestions-list" class="suggestions-list" role="presentation"></ul>
304
+ <ul
305
+ id="ri-listbox"
306
+ part="suggestions-list"
307
+ class="suggestions-list"
308
+ role="listbox"
309
+ aria-labelledby="ri-listbox-label"
310
+ ></ul>
306
311
  </div>
307
312
 
308
313
  <slot style="display: none;"></slot>
@@ -361,6 +366,8 @@ export class RichInput extends HTMLElement {
361
366
  'autofocus',
362
367
  'required',
363
368
  'highlight-quotes',
369
+ 'aria-label',
370
+ 'aria-labelledby',
364
371
  ];
365
372
  }
366
373
 
@@ -383,6 +390,7 @@ export class RichInput extends HTMLElement {
383
390
  editableDiv.setAttribute('aria-autocomplete', 'list');
384
391
  editableDiv.setAttribute('aria-expanded', 'false');
385
392
  editableDiv.setAttribute('aria-haspopup', 'listbox');
393
+ editableDiv.setAttribute('aria-controls', 'ri-listbox');
386
394
  editableDiv.setAttribute('spellcheck', 'false');
387
395
  editableDiv.setAttribute('tabindex', '0');
388
396
  existingInput.replaceWith(editableDiv);
@@ -416,6 +424,7 @@ export class RichInput extends HTMLElement {
416
424
  this._onClick = this._onClick.bind(this);
417
425
  this._onSelectionChange = this._onSelectionChange.bind(this);
418
426
  this._onClearClick = this._onClearClick.bind(this);
427
+ this._onClearMouseDown = (e) => e.preventDefault();
419
428
  this._onSlotChange = this._onSlotChange.bind(this);
420
429
  this._onGlobalClick = this._onGlobalClick.bind(this);
421
430
  this._onGlobalResizeOrScroll = this._onGlobalResizeOrScroll.bind(this);
@@ -453,6 +462,7 @@ export class RichInput extends HTMLElement {
453
462
  this._input.addEventListener('focus', this._onFocus);
454
463
  this._input.addEventListener('blur', this._onBlur);
455
464
  this._input.addEventListener('click', this._onClick);
465
+ this._clearBtn.addEventListener('mousedown', this._onClearMouseDown);
456
466
  this._clearBtn.addEventListener('click', this._onClearClick);
457
467
 
458
468
  // Global events
@@ -475,6 +485,7 @@ export class RichInput extends HTMLElement {
475
485
  this._internals.setFormValue(this._input.value);
476
486
  }
477
487
 
488
+ this._syncAccessibleName();
478
489
  this._updateClearButton();
479
490
  this.updateHighlights();
480
491
  }
@@ -494,6 +505,7 @@ export class RichInput extends HTMLElement {
494
505
  this._input.removeEventListener('focus', this._onFocus);
495
506
  this._input.removeEventListener('blur', this._onBlur);
496
507
  this._input.removeEventListener('click', this._onClick);
508
+ this._clearBtn.removeEventListener('mousedown', this._onClearMouseDown);
497
509
  this._clearBtn.removeEventListener('click', this._onClearClick);
498
510
 
499
511
  document.removeEventListener('click', this._onGlobalClick);
@@ -518,6 +530,9 @@ export class RichInput extends HTMLElement {
518
530
  }
519
531
  } else if (name === 'placeholder') {
520
532
  this._input.placeholder = newValue || '';
533
+ this._syncAccessibleName();
534
+ } else if (name === 'aria-label' || name === 'aria-labelledby') {
535
+ this._syncAccessibleName();
521
536
  } else if (name === 'disabled') {
522
537
  this._input.disabled = newValue !== null;
523
538
  } else if (name === 'readonly') {
@@ -535,6 +550,25 @@ export class RichInput extends HTMLElement {
535
550
  }
536
551
  }
537
552
 
553
+ _syncAccessibleName() {
554
+ if (!this._input) return;
555
+ if (this.hasAttribute('aria-label')) {
556
+ this._input.setAttribute('aria-label', this.getAttribute('aria-label'));
557
+ } else if (this._internals?.labels?.length > 0) {
558
+ const labelText = Array.from(this._internals.labels)
559
+ .map((l) => l.textContent.trim())
560
+ .filter(Boolean)
561
+ .join(' ');
562
+ if (labelText) {
563
+ this._input.setAttribute('aria-label', labelText);
564
+ }
565
+ } else if (this.hasAttribute('placeholder')) {
566
+ this._input.setAttribute('aria-label', this.getAttribute('placeholder'));
567
+ } else {
568
+ this._input.setAttribute('aria-label', 'Search');
569
+ }
570
+ }
571
+
538
572
  // --- Form Associated Lifecycle ---
539
573
  formResetCallback() {
540
574
  this.value = this.getAttribute('value') || '';
@@ -949,6 +983,7 @@ export class RichInput extends HTMLElement {
949
983
  this.updateHighlights();
950
984
  // Delay closing so click events on popover items can fire
951
985
  setTimeout(() => {
986
+ if (this._isFocused) return;
952
987
  this.hideSuggestions();
953
988
  this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
954
989
  }, 150);
@@ -1079,9 +1114,9 @@ export class RichInput extends HTMLElement {
1079
1114
  if (!this._isPopoverOpen()) {
1080
1115
  try {
1081
1116
  this._popover.showPopover();
1082
- this._input.setAttribute('aria-expanded', 'true');
1083
1117
  } catch (e) {}
1084
1118
  }
1119
+ this._input.setAttribute('aria-expanded', 'true');
1085
1120
 
1086
1121
  // Position popover at the start of the current OpaqueRange (or via mirror-div fallback when OpaqueRange is unsupported)
1087
1122
  const currentRange = this._getCurrentOpaqueRange();
@@ -1190,10 +1225,14 @@ export class RichInput extends HTMLElement {
1190
1225
  const isAct = i === this._selectedIndex;
1191
1226
  items[i].classList.toggle('active', isAct);
1192
1227
  items[i].setAttribute('aria-selected', isAct ? 'true' : 'false');
1228
+ items[i].setAttribute('part', `suggestion-item${isAct ? ' suggestion-item-active' : ''}`);
1193
1229
  if (isAct) {
1194
1230
  this._input.setAttribute('aria-activedescendant', items[i].id);
1195
1231
  }
1196
1232
  }
1233
+ if (this._selectedIndex === -1) {
1234
+ this._input.removeAttribute('aria-activedescendant');
1235
+ }
1197
1236
  }
1198
1237
 
1199
1238
  _applySelectedSuggestion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rich-input",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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",
@@ -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,