rich-input 1.0.0 → 1.1.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
@@ -15,14 +15,14 @@ The `<rich-input>` component is a rich input field that acts like a standard `<i
15
15
  - **Dual Contextual Autocomplete**:
16
16
  - **Keywords**: Typing at the start of a token (e.g. typing `a`) suggests configured keywords like `artist:` and `style:`.
17
17
  - **Values**: Typing within a keyword value (e.g. `label:"K` or `label:K`) suggests matching options like `"Keinemusik"` and `"Kranky"`.
18
- - **Range-Based Positioning via OpaqueRange**: Positions autocomplete dropdown popovers anchored to the start of the active `OpaqueRange` (e.g. at the opening quotation mark of a value) using `range.getBoundingClientRect()`, rather than shifting with the cursor.
18
+ - **Range-Based Positioning via OpaqueRange**: Positions autocomplete dropdown popovers anchored to the start of the active `OpaqueRange` (e.g. at the opening quotation mark of a value) using `range.getBoundingClientRect()`, rather than shifting with the cursor (with a hidden mirror-div fallback in unsupported browsers).
19
19
  - **Native In-Input Highlighting via CSS Custom Highlight API**: Highlights keyword values inside the `<input>` control using standard CSS rules like `::highlight(label)` or `::highlight(year)` without brittle mirror-div overlays.
20
20
  - **Declarative Configuration via `<datalist>`**: Configure keywords and options purely in HTML by nesting standard `<datalist>` elements with `<option>` tags inside `<rich-input>`.
21
21
  - **Rich Option Markup**: Embed custom HTML markup (such as logos, images, icons, and avatars) directly inside `<option>` elements for rich, visual suggestion popovers.
22
22
  - **Form Associated**: Implements `static formAssociated = true` and `ElementInternals` to participate seamlessly in `<form>` submission, `FormData`, and form reset lifecycles.
23
23
  - **Shadow Parts Theming (`::part`)**: Full CSS customizability using `::part(input)`, `::part(control)`, `::part(popover)`, `::part(suggestion-item)`, etc.
24
24
  - **Accessible (W3C Combobox Pattern)**: ARIA 1.2 compliant combobox with keyboard navigation (`ArrowUp`, `ArrowDown`, `Enter`, `Tab`, `Escape`), `aria-expanded`, and `aria-activedescendant`.
25
- - **Graceful Fallback**: Automatically feature-detects `createValueRange` and gracefully falls back to control-aligned popovers in environments without OpaqueRange.
25
+ - **Graceful Fallback**: Automatically feature-detects browser capabilities. In browsers with no `OpaqueRange` support but with CSS Custom Highlight API support (e.g. Safari 17.2+, Firefox 141+), it falls back to an adapted `[contenteditable]` element to provide in-input syntax highlighting via standard DOM `Range`s. In environments without `OpaqueRange`, dropdown popovers are positioned using a hidden mirror-div measurement fallback.
26
26
 
27
27
  ---
28
28
 
@@ -40,7 +40,7 @@ The visual below illustrates the internal Shadow DOM elements, exposed CSS Shado
40
40
  - `::part(input)`: The native `<input type="text">` where users type.
41
41
  - `::highlight(<keyword>)`: Target pseudo-element for styling keyword values via the CSS Custom Highlight API (e.g. `::highlight(label)`, `::highlight(year)`).
42
42
  - `::part(clear-button)`: The clear button (visible when text is present).
43
- - `::part(popover)`: The autocomplete dropdown popover container anchored to the start of the active range via `OpaqueRange`.
43
+ - `::part(popover)`: The autocomplete dropdown popover container anchored to the start of the active range via `OpaqueRange` (or mirror-div fallback).
44
44
  - `::part(suggestions-header)`: The header bar at the top of the suggestions popover.
45
45
  - `::part(suggestions-list)`: The `<ul>` container holding autocomplete suggestion items.
46
46
  - `::part(suggestion-item)`: Each suggestion `<li>` row.
@@ -233,16 +233,18 @@ const highlight = new Highlight(valueRange);
233
233
  CSS.highlights.set('label', highlight);
234
234
  ```
235
235
 
236
- `<rich-input>` automatically checks `typeof HTMLInputElement.prototype.createValueRange === 'function'`. On supported browsers, caret tracking and `::highlight()` are applied natively. On browsers without `createValueRange`, `<rich-input>` falls back to input-relative popover positioning.
236
+ `<rich-input>` automatically checks `typeof HTMLInputElement.prototype.createValueRange === 'function'`. On supported browsers (Chromium 152+), caret tracking and `::highlight()` are applied natively via `OpaqueRange`.
237
+
238
+ In browsers without `OpaqueRange` that support the CSS Custom Highlight API (such as Safari 17.2+ and Firefox 141+), `<rich-input>` automatically falls back to an adapted single-line `[contenteditable]` element inside its shadow DOM. This exposes standard DOM `Text` nodes so standard `Range` objects can be created and passed to `CSS.highlights`, delivering on-the-fly `::highlight()` syntax highlighting across browsers. In environments lacking range positioning, popover dropdowns are positioned using a hidden mirror `<div>` text measurement fallback.
237
239
 
238
240
  ---
239
241
 
240
242
  ## Styling Highlights with the CSS Custom Highlight API
241
243
 
242
- Values corresponding to configured keywords are registered into the global `CSS.highlights` registry. Style them directly using `::highlight(keyword)`:
244
+ 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:
243
245
 
244
246
  ```css
245
- /* Style the value set in label:"We Play House Recordings" */
247
+ /* Style the value set in label:"XL Recordings" */
246
248
  ::highlight(label) {
247
249
  background-color: oklch(0.92 0.08 240);
248
250
  color: oklch(0.28 0.14 240);
@@ -261,18 +263,6 @@ Values corresponding to configured keywords are registered into the global `CSS.
261
263
  color: oklch(0.32 0.14 320);
262
264
  }
263
265
 
264
- /* Style musical style filters */
265
- ::highlight(style) {
266
- background-color: oklch(0.93 0.08 190);
267
- color: oklch(0.3 0.12 190);
268
- }
269
-
270
- /* Style genre filters */
271
- ::highlight(genre) {
272
- background-color: oklch(0.93 0.1 145);
273
- color: oklch(0.3 0.14 145);
274
- }
275
-
276
266
  /* Generic prefix highlight for keyword labels (e.g. "label:", "year:") */
277
267
  ::highlight(rich-input-keyword) {
278
268
  color: #64748b;
@@ -280,6 +270,22 @@ Values corresponding to configured keywords are registered into the global `CSS.
280
270
  }
281
271
  ```
282
272
 
273
+ 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.
274
+
275
+ 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
+
277
+ ```html
278
+ <rich-input value='artist:"Aphex Twin" label:"XL Recordings"'>
279
+ <style>
280
+ ::highlight(label) {
281
+ background-color: #dbeafe;
282
+ color: #1e40af;
283
+ }
284
+ </style>
285
+ <datalist id="label" label="Record Label">...</datalist>
286
+ </rich-input>
287
+ ```
288
+
283
289
  > **Note:** Supported CSS properties on `::highlight()` include `color`, `background-color`, `text-decoration`, `text-shadow`, `-webkit-text-stroke-color`, `-webkit-text-stroke-width`, and `-webkit-text-fill-color`.
284
290
 
285
291
  ---
@@ -1,7 +1,7 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1340 480" width="100%" height="100%">
2
- <defs>
3
- <style>
4
- :root {
2
+ <style>
3
+ @scope {
4
+ :scope {
5
5
  --canvas-bg: #f8fafc;
6
6
  --card-bg: #ffffff;
7
7
  --border-color: #cbd5e1;
@@ -31,7 +31,7 @@
31
31
  }
32
32
 
33
33
  @media (prefers-color-scheme: dark) {
34
- :root {
34
+ :scope {
35
35
  --canvas-bg: #0f172a;
36
36
  --card-bg: #1e293b;
37
37
  --border-color: #334155;
@@ -148,116 +148,117 @@
148
148
  }
149
149
 
150
150
  /* When hovering the SVG, lines and labels go down to opacity 0.5 */
151
- svg:hover .code-label,
152
- svg:hover .guide-line,
153
- svg:hover .guide-box,
154
- svg:hover .guide-dot {
151
+ :scope:hover .code-label,
152
+ :scope:hover .guide-line,
153
+ :scope:hover .guide-box,
154
+ :scope:hover .guide-dot {
155
155
  opacity: 0.4;
156
156
  }
157
157
 
158
158
  /* When a specific code-label is hovered, dim all other labels, lines, boxes, and comp-parts */
159
- svg:has(.code-label:hover) .code-label:not(:hover) {
159
+ :scope:has(.code-label:hover) .code-label:not(:hover) {
160
160
  opacity: 0.18;
161
161
  }
162
162
 
163
- svg:has(.code-label:hover) .guide-line,
164
- svg:has(.code-label:hover) .guide-box,
165
- svg:has(.code-label:hover) .guide-dot {
163
+ :scope:has(.code-label:hover) .guide-line,
164
+ :scope:has(.code-label:hover) .guide-box,
165
+ :scope:has(.code-label:hover) .guide-dot {
166
166
  opacity: 0.05;
167
167
  }
168
168
 
169
- svg:has(.code-label:hover) .comp-part {
169
+ :scope:has(.code-label:hover) .comp-part {
170
170
  opacity: 0.25;
171
171
  }
172
172
 
173
173
  /* Specific label hover: keep matching guide line, box, dot, and element at 100% */
174
- svg:has(.lbl-host:hover) .target-host,
175
- svg:has(.lbl-control:hover) .target-control,
176
- svg:has(.lbl-icon:hover) .target-icon,
177
- svg:has(.lbl-input:hover) .target-input,
178
- svg:has(.lbl-hl-val:hover) .target-hl-val,
179
- svg:has(.lbl-clear:hover) .target-clear,
180
- svg:has(.lbl-popover:hover) .target-popover,
181
- svg:has(.lbl-header:hover) .target-header,
182
- svg:has(.lbl-list:hover) .target-list,
183
- svg:has(.lbl-item:hover) .target-item,
184
- svg:has(.lbl-active:hover) .target-active,
185
- svg:has(.lbl-image:hover) .target-image,
186
- svg:has(.lbl-val:hover) .target-val {
174
+ :scope:has(.lbl-host:hover) .target-host,
175
+ :scope:has(.lbl-control:hover) .target-control,
176
+ :scope:has(.lbl-icon:hover) .target-icon,
177
+ :scope:has(.lbl-input:hover) .target-input,
178
+ :scope:has(.lbl-hl-val:hover) .target-hl-val,
179
+ :scope:has(.lbl-clear:hover) .target-clear,
180
+ :scope:has(.lbl-popover:hover) .target-popover,
181
+ :scope:has(.lbl-header:hover) .target-header,
182
+ :scope:has(.lbl-list:hover) .target-list,
183
+ :scope:has(.lbl-item:hover) .target-item,
184
+ :scope:has(.lbl-active:hover) .target-active,
185
+ :scope:has(.lbl-image:hover) .target-image,
186
+ :scope:has(.lbl-val:hover) .target-val {
187
187
  opacity: 1 !important;
188
188
  }
189
189
 
190
190
  /* When host label is hovered, keep entire component at 100% */
191
- svg:has(.lbl-host:hover) .comp-part {
191
+ :scope:has(.lbl-host:hover) .comp-part {
192
192
  opacity: 1 !important;
193
193
  }
194
194
 
195
195
  /* Contained parts: when hovering a container, contained parts in the preview also remain at opacity 1 */
196
196
  /* Control contains icon, input (including highlight hou), and clear button */
197
- svg:has(.lbl-control:hover) #component-preview .target-icon,
198
- svg:has(.lbl-control:hover) #component-preview .target-input,
199
- svg:has(.lbl-control:hover) #component-preview .target-hl-val,
200
- svg:has(.lbl-control:hover) #component-preview .target-clear {
197
+ :scope:has(.lbl-control:hover) #component-preview .target-icon,
198
+ :scope:has(.lbl-control:hover) #component-preview .target-input,
199
+ :scope:has(.lbl-control:hover) #component-preview .target-hl-val,
200
+ :scope:has(.lbl-control:hover) #component-preview .target-clear {
201
201
  opacity: 1 !important;
202
202
  }
203
203
 
204
204
  /* Input contains highlight label (hou text and background pill) */
205
- svg:has(.lbl-input:hover) #component-preview .target-hl-val {
205
+ :scope:has(.lbl-input:hover) #component-preview .target-hl-val {
206
206
  opacity: 1 !important;
207
207
  }
208
208
 
209
209
  /* Popover contains header, list, and all suggestion items */
210
- svg:has(.lbl-popover:hover) #component-preview .target-header,
211
- svg:has(.lbl-popover:hover) #component-preview .target-item,
212
- svg:has(.lbl-popover:hover) #component-preview .target-active {
210
+ :scope:has(.lbl-popover:hover) #component-preview .target-header,
211
+ :scope:has(.lbl-popover:hover) #component-preview .target-item,
212
+ :scope:has(.lbl-popover:hover) #component-preview .target-active {
213
213
  opacity: 1 !important;
214
214
  }
215
215
 
216
216
  /* Suggestions list contains all suggestion items */
217
- svg:has(.lbl-list:hover) #component-preview .target-item,
218
- svg:has(.lbl-list:hover) #component-preview .target-active {
217
+ :scope:has(.lbl-list:hover) #component-preview .target-item,
218
+ :scope:has(.lbl-list:hover) #component-preview .target-active {
219
219
  opacity: 1 !important;
220
220
  }
221
221
 
222
222
  /* Thicken active guide line when hovered */
223
- svg:has(.lbl-host:hover) .target-host.guide-line,
224
- svg:has(.lbl-control:hover) .target-control.guide-line,
225
- svg:has(.lbl-icon:hover) .target-icon.guide-line,
226
- svg:has(.lbl-input:hover) .target-input.guide-line,
227
- svg:has(.lbl-hl-val:hover) .target-hl-val.guide-line,
228
- svg:has(.lbl-clear:hover) .target-clear.guide-line,
229
- svg:has(.lbl-popover:hover) .target-popover.guide-line,
230
- svg:has(.lbl-header:hover) .target-header.guide-line,
231
- svg:has(.lbl-list:hover) .target-list.guide-line,
232
- svg:has(.lbl-item:hover) .target-item.guide-line,
233
- svg:has(.lbl-active:hover) .target-active.guide-line,
234
- svg:has(.lbl-image:hover) .target-image.guide-line,
235
- svg:has(.lbl-val:hover) .target-val.guide-line {
223
+ :scope:has(.lbl-host:hover) .target-host.guide-line,
224
+ :scope:has(.lbl-control:hover) .target-control.guide-line,
225
+ :scope:has(.lbl-icon:hover) .target-icon.guide-line,
226
+ :scope:has(.lbl-input:hover) .target-input.guide-line,
227
+ :scope:has(.lbl-hl-val:hover) .target-hl-val.guide-line,
228
+ :scope:has(.lbl-clear:hover) .target-clear.guide-line,
229
+ :scope:has(.lbl-popover:hover) .target-popover.guide-line,
230
+ :scope:has(.lbl-header:hover) .target-header.guide-line,
231
+ :scope:has(.lbl-list:hover) .target-list.guide-line,
232
+ :scope:has(.lbl-item:hover) .target-item.guide-line,
233
+ :scope:has(.lbl-active:hover) .target-active.guide-line,
234
+ :scope:has(.lbl-image:hover) .target-image.guide-line,
235
+ :scope:has(.lbl-val:hover) .target-val.guide-line {
236
236
  stroke-width: 2px;
237
237
  }
238
238
 
239
239
  /* Thicken active guide box when hovered */
240
- svg:has(.lbl-host:hover) .target-host.guide-box,
241
- svg:has(.lbl-control:hover) .target-control.guide-box,
242
- svg:has(.lbl-icon:hover) .target-icon.guide-box,
243
- svg:has(.lbl-input:hover) .target-input.guide-box,
244
- svg:has(.lbl-hl-val:hover) .target-hl-val.guide-box,
245
- svg:has(.lbl-clear:hover) .target-clear.guide-box,
246
- svg:has(.lbl-popover:hover) .target-popover.guide-box,
247
- svg:has(.lbl-header:hover) .target-header.guide-box,
248
- svg:has(.lbl-list:hover) .target-list.guide-box,
249
- svg:has(.lbl-item:hover) .target-item.guide-box,
250
- svg:has(.lbl-active:hover) .target-active.guide-box,
251
- svg:has(.lbl-image:hover) .target-image.guide-box,
252
- svg:has(.lbl-val:hover) .target-val.guide-box {
240
+ :scope:has(.lbl-host:hover) .target-host.guide-box,
241
+ :scope:has(.lbl-control:hover) .target-control.guide-box,
242
+ :scope:has(.lbl-icon:hover) .target-icon.guide-box,
243
+ :scope:has(.lbl-input:hover) .target-input.guide-box,
244
+ :scope:has(.lbl-hl-val:hover) .target-hl-val.guide-box,
245
+ :scope:has(.lbl-clear:hover) .target-clear.guide-box,
246
+ :scope:has(.lbl-popover:hover) .target-popover.guide-box,
247
+ :scope:has(.lbl-header:hover) .target-header.guide-box,
248
+ :scope:has(.lbl-list:hover) .target-list.guide-box,
249
+ :scope:has(.lbl-item:hover) .target-item.guide-box,
250
+ :scope:has(.lbl-active:hover) .target-active.guide-box,
251
+ :scope:has(.lbl-image:hover) .target-image.guide-box,
252
+ :scope:has(.lbl-val:hover) .target-val.guide-box {
253
253
  stroke-width: 2px;
254
254
  }
255
255
 
256
256
  .code-label:hover {
257
257
  opacity: 1 !important;
258
258
  }
259
- </style>
260
-
259
+ }
260
+ </style>
261
+ <defs>
261
262
  <filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
262
263
  <feDropShadow dx="0" dy="8" stdDeviation="12" flood-opacity="0.12"/>
263
264
  </filter>
@@ -6,6 +6,7 @@
6
6
  import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } 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
+ import { setupContentEditableAdapter, isContentEditableFallbackActive } from '../utils/contenteditable-adapter.js';
9
10
 
10
11
  const TEMPLATE = document.createElement('template');
11
12
  TEMPLATE.innerHTML = `
@@ -89,12 +90,41 @@ TEMPLATE.innerHTML = `
89
90
  line-height: 1.5;
90
91
  padding: 0.625rem 0;
91
92
  color: var(--ri-input-color, var(--rs-input-color, #111827));
93
+ white-space: pre;
94
+ overflow-x: auto;
95
+ overflow-y: hidden;
96
+ scrollbar-width: none;
97
+ box-sizing: border-box;
98
+ }
99
+
100
+ .search-input::-webkit-scrollbar {
101
+ display: none;
92
102
  }
93
103
 
94
104
  .search-input::placeholder {
95
105
  color: var(--ri-placeholder-color, var(--rs-placeholder-color, #9ca3af));
96
106
  }
97
107
 
108
+ /* Contenteditable empty placeholder fallback */
109
+ .search-input:empty::before {
110
+ content: attr(data-placeholder);
111
+ color: var(--ri-placeholder-color, var(--rs-placeholder-color, #9ca3af));
112
+ pointer-events: none;
113
+ display: inline-block;
114
+ }
115
+
116
+ /* Generic prefix highlight for keywords */
117
+ ::highlight(rich-input-keyword) {
118
+ color: var(--ri-keyword-color, #64748b);
119
+ text-shadow: 0 0 1px rgba(0, 0, 0, 0.15);
120
+ }
121
+
122
+ /* Visually hide datalists and custom style tags in slot */
123
+ ::slotted(datalist),
124
+ ::slotted(style) {
125
+ display: none !important;
126
+ }
127
+
98
128
  .clear-button {
99
129
  flex-shrink: 0;
100
130
  display: inline-flex;
@@ -266,6 +296,45 @@ TEMPLATE.innerHTML = `
266
296
  </div>
267
297
  `;
268
298
 
299
+ function extractHighlightRules(rules) {
300
+ let css = '';
301
+ for (const rule of rules) {
302
+ try {
303
+ if (rule.cssRules && rule.cssRules.length > 0) {
304
+ css += extractHighlightRules(rule.cssRules);
305
+ } else if (rule.cssText && rule.cssText.includes('::highlight')) {
306
+ css += rule.cssText + '\n';
307
+ }
308
+ } catch (e) {}
309
+ }
310
+ return css;
311
+ }
312
+
313
+ function syncDocumentHighlightStyles(shadowRoot) {
314
+ if (typeof document === 'undefined' || !shadowRoot || !isContentEditableFallbackActive) return;
315
+ try {
316
+ let highlightCss = '';
317
+ for (const sheet of document.styleSheets) {
318
+ try {
319
+ if (sheet.cssRules) {
320
+ highlightCss += extractHighlightRules(sheet.cssRules);
321
+ }
322
+ } catch (e) {}
323
+ }
324
+ if (highlightCss) {
325
+ let styleEl = shadowRoot.getElementById('ri-synced-highlight-styles');
326
+ if (!styleEl) {
327
+ styleEl = document.createElement('style');
328
+ styleEl.id = 'ri-synced-highlight-styles';
329
+ shadowRoot.appendChild(styleEl);
330
+ }
331
+ if (styleEl.textContent !== highlightCss) {
332
+ styleEl.textContent = highlightCss;
333
+ }
334
+ }
335
+ } catch (e) {}
336
+ }
337
+
269
338
  export class RichInput extends HTMLElement {
270
339
  static formAssociated = true;
271
340
 
@@ -289,7 +358,26 @@ export class RichInput extends HTMLElement {
289
358
  this.attachShadow({ mode: 'open' });
290
359
  this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true));
291
360
 
292
- this._input = this.shadowRoot.querySelector('.search-input');
361
+ // In browsers lacking OpaqueRange but supporting CSS Custom Highlights (Safari 17.2+, Firefox 141+),
362
+ // swap in a [contenteditable] element to expose standard DOM Text nodes for Custom Highlights.
363
+ const useContentEditable = isContentEditableFallbackActive;
364
+ if (useContentEditable) {
365
+ const existingInput = this.shadowRoot.querySelector('.search-input');
366
+ const editableDiv = document.createElement('div');
367
+ editableDiv.className = 'search-input';
368
+ editableDiv.setAttribute('part', 'input');
369
+ editableDiv.setAttribute('role', 'combobox');
370
+ editableDiv.setAttribute('aria-autocomplete', 'list');
371
+ editableDiv.setAttribute('aria-expanded', 'false');
372
+ editableDiv.setAttribute('aria-haspopup', 'listbox');
373
+ editableDiv.setAttribute('spellcheck', 'false');
374
+ editableDiv.setAttribute('tabindex', '0');
375
+ existingInput.replaceWith(editableDiv);
376
+ this._input = setupContentEditableAdapter(editableDiv, this);
377
+ } else {
378
+ this._input = this.shadowRoot.querySelector('.search-input');
379
+ }
380
+
293
381
  this._popover = this.shadowRoot.querySelector('.popover');
294
382
  this._popoverTitle = this.shadowRoot.querySelector('.popover-title');
295
383
  this._suggestionsList = this.shadowRoot.querySelector('.suggestions-list');
@@ -317,15 +405,18 @@ export class RichInput extends HTMLElement {
317
405
  }
318
406
 
319
407
  connectedCallback() {
408
+ this._syncInjectedStyles();
409
+ syncDocumentHighlightStyles(this.shadowRoot);
320
410
  highlightManager.register(this);
321
411
 
322
412
  // Parse initial datalists
323
413
  this._loadDatalists();
324
414
 
325
- // Listen to changes on light DOM datalists
415
+ // Listen to changes on light DOM datalists and style tags
326
416
  this._slot.addEventListener('slotchange', this._onSlotChange);
327
417
  this._mutationObserver = new MutationObserver(() => {
328
418
  this._loadDatalists();
419
+ this._syncInjectedStyles();
329
420
  this.updateHighlights();
330
421
  });
331
422
  this._mutationObserver.observe(this, { childList: true, subtree: true, attributes: true, characterData: true });
@@ -587,11 +678,42 @@ export class RichInput extends HTMLElement {
587
678
  this._configuredKeywords = newMap;
588
679
  }
589
680
 
681
+ // --- Injected <style> Synchronization ---
682
+ _syncInjectedStyles() {
683
+ const styleEls = this.querySelectorAll('style');
684
+ let customCss = '';
685
+ for (const styleEl of styleEls) {
686
+ if (styleEl.textContent) {
687
+ customCss += styleEl.textContent + '\n';
688
+ }
689
+ }
690
+
691
+ let targetStyle = this.shadowRoot.getElementById('ri-injected-styles');
692
+ if (customCss.trim()) {
693
+ if (!targetStyle) {
694
+ targetStyle = document.createElement('style');
695
+ targetStyle.id = 'ri-injected-styles';
696
+ this.shadowRoot.appendChild(targetStyle);
697
+ }
698
+ if (targetStyle.textContent !== customCss) {
699
+ targetStyle.textContent = customCss;
700
+ }
701
+ } else if (targetStyle) {
702
+ targetStyle.remove();
703
+ }
704
+ }
705
+
590
706
  _onSlotChange() {
591
707
  this._loadDatalists();
708
+ this._syncInjectedStyles();
592
709
  this.updateHighlights();
593
710
  }
594
711
 
712
+ // --- Public Properties ---
713
+ get inputElement() {
714
+ return this._input;
715
+ }
716
+
595
717
  // --- Highlighting with OpaqueRange & Custom Highlight API ---
596
718
  _disconnectOwnedRanges() {
597
719
  for (const r of this._ownedRanges) {
@@ -606,10 +728,16 @@ export class RichInput extends HTMLElement {
606
728
  updateHighlights() {
607
729
  this._disconnectOwnedRanges();
608
730
 
609
- if (!isOpaqueRangeSupported || !isHighlightSupported) {
731
+ if (!isHighlightSupported) {
732
+ return;
733
+ }
734
+ if (!isOpaqueRangeSupported && typeof this._input?.createValueRange !== 'function') {
610
735
  return;
611
736
  }
612
737
 
738
+ this._syncInjectedStyles();
739
+ syncDocumentHighlightStyles(this.shadowRoot);
740
+
613
741
  const text = this._input.value;
614
742
  if (!text) {
615
743
  highlightManager.syncAll();
@@ -739,7 +867,10 @@ export class RichInput extends HTMLElement {
739
867
  }, 150);
740
868
  }
741
869
 
742
- _onClick() {
870
+ _onClick(e) {
871
+ if (e && typeof e.clientX === 'number' && typeof this._input?.updateCaretFromPoint === 'function') {
872
+ this._input.updateCaretFromPoint(e.clientX, e.clientY);
873
+ }
743
874
  this.updateSuggestions('click');
744
875
  }
745
876
 
@@ -758,7 +889,8 @@ export class RichInput extends HTMLElement {
758
889
  }
759
890
 
760
891
  _getCurrentOpaqueRange() {
761
- if (!this._context || !isOpaqueRangeSupported) return null;
892
+ if (!this._context) return null;
893
+ if (!isOpaqueRangeSupported && typeof this._input?.createValueRange !== 'function') return null;
762
894
 
763
895
  const { mode, token, caretPos } = this._context;
764
896
 
@@ -851,7 +983,7 @@ export class RichInput extends HTMLElement {
851
983
  } catch (e) {}
852
984
  }
853
985
 
854
- // Position popover at the start of the current OpaqueRange
986
+ // Position popover at the start of the current OpaqueRange (or via mirror-div fallback when OpaqueRange is unsupported)
855
987
  const currentRange = this._getCurrentOpaqueRange();
856
988
  const anchor = currentRange || (context.replaceStart !== undefined ? context.replaceStart : context.caretPos);
857
989
  const anchorCoords = getCaretCoordinates(this._input, anchor);
package/index.js CHANGED
@@ -6,7 +6,8 @@
6
6
  import { RichInput } from './components/rich-input.js';
7
7
  import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } from './utils/query-parser.js';
8
8
  import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from './utils/highlights.js';
9
- import { getCaretCoordinates, getRangeCoordinates, positionPopover } from './utils/positioning.js';
9
+ import { getCaretCoordinates, getRangeCoordinates, positionPopover, getCaretLeftWithMirrorDiv } from './utils/positioning.js';
10
+ import { setupContentEditableAdapter, isContentEditableFallbackActive } from './utils/contenteditable-adapter.js';
10
11
 
11
12
  if (typeof customElements !== 'undefined') {
12
13
  if (!customElements.get('rich-input')) {
@@ -24,9 +25,12 @@ export {
24
25
  highlightManager,
25
26
  isOpaqueRangeSupported,
26
27
  isHighlightSupported,
28
+ isContentEditableFallbackActive,
29
+ setupContentEditableAdapter,
27
30
  getCaretCoordinates,
28
31
  getRangeCoordinates,
29
32
  positionPopover,
33
+ getCaretLeftWithMirrorDiv,
30
34
  };
31
35
 
32
36
  export default RichInput;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rich-input",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -0,0 +1,421 @@
1
+ /**
2
+ * ContentEditable adapter for <rich-input>
3
+ *
4
+ * In browsers that do not support the OpaqueRange API (HTMLInputElement.createValueRange)
5
+ * but DO support the CSS Custom Highlight API (Safari 17.2+, Firefox 141+), this adapter
6
+ * enables using a single-line [contenteditable] element inside <rich-input>.
7
+ *
8
+ * This exposes standard DOM Text nodes so standard DOM Range objects can be created
9
+ * and registered with CSS.highlights, delivering on-the-fly syntax highlighting.
10
+ */
11
+
12
+ import { isOpaqueRangeSupported, isHighlightSupported } from './highlights.js';
13
+
14
+ export const isContentEditableFallbackActive = !isOpaqueRangeSupported && isHighlightSupported;
15
+
16
+ /**
17
+ * Calculates character offset of a container + offset within an element.
18
+ *
19
+ * @param {HTMLElement} el
20
+ * @param {Node} container
21
+ * @param {number} offset
22
+ * @returns {number|null}
23
+ */
24
+ export function getOffsetInElement(el, container, offset) {
25
+ if (!el || !container) return null;
26
+ if (!el.contains(container) && el !== container) return null;
27
+
28
+ try {
29
+ const range = document.createRange();
30
+ range.selectNodeContents(el);
31
+ range.setEnd(container, offset);
32
+ return range.toString().length;
33
+ } catch (e) {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Derives caret character offset directly from viewport client coordinates.
40
+ * Uses WebKit document.caretRangeFromPoint and W3C document.caretPositionFromPoint.
41
+ *
42
+ * @param {HTMLElement} el
43
+ * @param {number} clientX
44
+ * @param {number} clientY
45
+ * @returns {number|null}
46
+ */
47
+ export function getCaretOffsetFromPoint(el, clientX, clientY) {
48
+ if (typeof document === 'undefined') return null;
49
+
50
+ // 1. WebKit/Blink standard: document.caretRangeFromPoint (supported in Safari)
51
+ if (typeof document.caretRangeFromPoint === 'function') {
52
+ try {
53
+ const range = document.caretRangeFromPoint(clientX, clientY);
54
+ if (range && range.startContainer) {
55
+ const offset = getOffsetInElement(el, range.startContainer, range.startOffset);
56
+ if (offset !== null) return offset;
57
+ }
58
+ } catch (e) {}
59
+ }
60
+
61
+ // 2. W3C standard: document.caretPositionFromPoint (supported in Firefox & modern WebKit)
62
+ if (typeof document.caretPositionFromPoint === 'function') {
63
+ try {
64
+ const pos = document.caretPositionFromPoint(clientX, clientY);
65
+ if (pos && pos.offsetNode) {
66
+ const offset = getOffsetInElement(el, pos.offsetNode, pos.offset);
67
+ if (offset !== null) return offset;
68
+ }
69
+ } catch (e) {}
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Gets the current character caret offset inside a contenteditable element.
77
+ * Pierces Shadow DOM boundaries via shadowRoot.getSelection() and Selection.getComposedRanges().
78
+ *
79
+ * @param {HTMLElement} el
80
+ * @param {boolean} isStart Whether to get selection start (true) or end (false)
81
+ * @returns {number}
82
+ */
83
+ export function getCaretCharacterOffset(el, isStart = true) {
84
+ if (typeof window === 'undefined') return 0;
85
+
86
+ const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null;
87
+ let range = null;
88
+
89
+ // 1. Try ShadowRoot.getSelection() if supported
90
+ if (root && typeof root.getSelection === 'function') {
91
+ try {
92
+ const shadowSel = root.getSelection();
93
+ if (shadowSel && shadowSel.rangeCount > 0) {
94
+ range = shadowSel.getRangeAt(0);
95
+ }
96
+ } catch (e) {}
97
+ }
98
+
99
+ // 2. Try window.getSelection().getComposedRanges() (W3C standard in Safari 17+, Chrome)
100
+ const sel = window.getSelection ? window.getSelection() : null;
101
+ if (!range && sel && typeof sel.getComposedRanges === 'function' && root) {
102
+ try {
103
+ const composed = sel.getComposedRanges({ shadowRoots: [root] });
104
+ if (composed && composed.length > 0) {
105
+ range = composed[0];
106
+ }
107
+ } catch (e) {}
108
+ }
109
+
110
+ // 3. Fallback to standard window.getSelection()
111
+ if (!range && sel && sel.rangeCount > 0) {
112
+ try {
113
+ range = sel.getRangeAt(0);
114
+ } catch (e) {}
115
+ }
116
+
117
+ if (range) {
118
+ const container = isStart ? range.startContainer : range.endContainer;
119
+ const offset = isStart ? range.startOffset : range.endOffset;
120
+ const pos = getOffsetInElement(el, container, offset);
121
+ if (pos !== null) {
122
+ el._lastCaretOffset = pos;
123
+ return pos;
124
+ }
125
+ }
126
+
127
+ return typeof el._lastCaretOffset === 'number' ? el._lastCaretOffset : 0;
128
+ }
129
+
130
+ /**
131
+ * Sets the caret or selection character range inside a contenteditable element.
132
+ *
133
+ * @param {HTMLElement} el
134
+ * @param {number} start
135
+ * @param {number} [end]
136
+ */
137
+ export function setCaretCharacterOffset(el, start, end = start) {
138
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
139
+
140
+ const textNode = getSingleTextNode(el);
141
+ const len = (textNode.textContent || '').length;
142
+ const s = Math.max(0, Math.min(start ?? 0, len));
143
+ const e = Math.max(0, Math.min(end ?? s, len));
144
+
145
+ el._lastCaretOffset = s;
146
+
147
+ const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null;
148
+ const sel = (root && typeof root.getSelection === 'function') ? root.getSelection() : window.getSelection();
149
+ if (!sel) return;
150
+
151
+ // setBaseAndExtent works natively across WebKit, Gecko, and Blink
152
+ if (typeof sel.setBaseAndExtent === 'function') {
153
+ try {
154
+ sel.setBaseAndExtent(textNode, s, textNode, e);
155
+ return;
156
+ } catch (err) {}
157
+ }
158
+
159
+ try {
160
+ const range = document.createRange();
161
+ range.setStart(textNode, s);
162
+ range.setEnd(textNode, e);
163
+ sel.removeAllRanges();
164
+ sel.addRange(range);
165
+ } catch (err) {}
166
+ }
167
+
168
+ /**
169
+ * Ensures the element has exactly one child Text node and returns it.
170
+ *
171
+ * @param {HTMLElement} el
172
+ * @returns {Text}
173
+ */
174
+ export function getSingleTextNode(el) {
175
+ if (!el.firstChild) {
176
+ const tn = document.createTextNode('');
177
+ el.appendChild(tn);
178
+ return tn;
179
+ }
180
+
181
+ if (el.childNodes.length === 1 && el.firstChild.nodeType === 3) {
182
+ return el.firstChild;
183
+ }
184
+
185
+ el.normalize();
186
+ if (el.childNodes.length === 1 && el.firstChild.nodeType === 3) {
187
+ return el.firstChild;
188
+ }
189
+
190
+ // Strip elements (<br>, <div>) inserted by contenteditable
191
+ const text = el.textContent || '';
192
+ el.textContent = text;
193
+ return el.firstChild || el.appendChild(document.createTextNode(''));
194
+ }
195
+
196
+ /**
197
+ * Configures an HTMLElement as a contenteditable input adapter.
198
+ *
199
+ * @param {HTMLElement} el
200
+ * @param {HTMLElement} host
201
+ * @returns {HTMLElement}
202
+ */
203
+ export function setupContentEditableAdapter(el, host) {
204
+ el._lastCaretOffset = 0;
205
+
206
+ // 1. Enforce plaintext-only editing (Firefox falls back to true)
207
+ el.setAttribute('contenteditable', 'plaintext-only');
208
+ if (el.contentEditable !== 'plaintext-only') {
209
+ el.setAttribute('contenteditable', 'true');
210
+ }
211
+
212
+ // 2. Track caret offset on mouse click, pointer, keyboard, and selection interactions
213
+ const updateCaret = (e) => {
214
+ if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
215
+ const offset = getCaretOffsetFromPoint(el, e.clientX, e.clientY);
216
+ if (offset !== null) {
217
+ el._lastCaretOffset = offset;
218
+ return;
219
+ }
220
+ }
221
+ const fromSel = getCaretCharacterOffset(el, true);
222
+ if (typeof fromSel === 'number') {
223
+ el._lastCaretOffset = fromSel;
224
+ }
225
+ };
226
+
227
+ el.addEventListener('pointerdown', updateCaret);
228
+ el.addEventListener('mousedown', updateCaret);
229
+ el.addEventListener('pointerup', updateCaret);
230
+ el.addEventListener('mouseup', updateCaret);
231
+ el.addEventListener('click', updateCaret);
232
+ el.addEventListener('keyup', updateCaret);
233
+
234
+ if (typeof document !== 'undefined') {
235
+ const onSelectionChange = () => {
236
+ const root = typeof el.getRootNode === 'function' ? el.getRootNode() : null;
237
+ const activeEl = root && root.activeElement ? root.activeElement : document.activeElement;
238
+ if (activeEl === el || el.contains(activeEl)) {
239
+ const offset = getCaretCharacterOffset(el, true);
240
+ if (typeof offset === 'number') {
241
+ el._lastCaretOffset = offset;
242
+ }
243
+ }
244
+ };
245
+ document.addEventListener('selectionchange', onSelectionChange);
246
+ }
247
+
248
+ // 3. Prevent linebreaks on Enter (single-line input behavior)
249
+ el.addEventListener('keydown', (e) => {
250
+ if (e.key === 'Enter') {
251
+ e.preventDefault();
252
+ }
253
+ });
254
+
255
+ // 5. Strip linebreaks from pasted content
256
+ el.addEventListener('paste', (e) => {
257
+ e.preventDefault();
258
+ const clipboard = e.clipboardData || window.clipboardData;
259
+ const text = (clipboard?.getData('text/plain') || '').replace(/\r?\n/g, ' ');
260
+ if (text) {
261
+ if (typeof document.execCommand === 'function') {
262
+ document.execCommand('insertText', false, text);
263
+ } else {
264
+ const start = el.selectionStart;
265
+ const current = el.textContent || '';
266
+ el.textContent = current.slice(0, start) + text + current.slice(el.selectionEnd);
267
+ setCaretCharacterOffset(el, start + text.length);
268
+ el.dispatchEvent(new Event('input', { bubbles: true }));
269
+ }
270
+ }
271
+ });
272
+
273
+ // 6. Clean empty element so :empty matches data-placeholder
274
+ el.addEventListener('input', () => {
275
+ if (!el.textContent) {
276
+ el.replaceChildren();
277
+ el._lastCaretOffset = 0;
278
+ } else {
279
+ el._lastCaretOffset = getCaretCharacterOffset(el, true);
280
+ }
281
+ });
282
+
283
+ // 7. Define HTMLInputElement-compatible properties and methods
284
+ Object.defineProperty(el, 'value', {
285
+ get() {
286
+ return el.textContent || '';
287
+ },
288
+ set(val) {
289
+ const next = val || '';
290
+ if (el.textContent !== next) {
291
+ el.textContent = next;
292
+ el._lastCaretOffset = Math.min(el._lastCaretOffset || 0, next.length);
293
+ }
294
+ },
295
+ configurable: true,
296
+ });
297
+
298
+ Object.defineProperty(el, 'placeholder', {
299
+ get() {
300
+ return el.getAttribute('data-placeholder') || '';
301
+ },
302
+ set(val) {
303
+ if (val) {
304
+ el.setAttribute('data-placeholder', val);
305
+ } else {
306
+ el.removeAttribute('data-placeholder');
307
+ }
308
+ },
309
+ configurable: true,
310
+ });
311
+
312
+ Object.defineProperty(el, 'disabled', {
313
+ get() {
314
+ return el.hasAttribute('disabled') || el.contentEditable === 'false';
315
+ },
316
+ set(val) {
317
+ if (val) {
318
+ el.setAttribute('disabled', '');
319
+ el.setAttribute('contenteditable', 'false');
320
+ } else {
321
+ el.removeAttribute('disabled');
322
+ el.setAttribute('contenteditable', 'plaintext-only');
323
+ if (el.contentEditable !== 'plaintext-only') {
324
+ el.setAttribute('contenteditable', 'true');
325
+ }
326
+ }
327
+ },
328
+ configurable: true,
329
+ });
330
+
331
+ Object.defineProperty(el, 'readOnly', {
332
+ get() {
333
+ return el.contentEditable === 'false';
334
+ },
335
+ set(val) {
336
+ if (val) {
337
+ el.setAttribute('contenteditable', 'false');
338
+ } else {
339
+ el.setAttribute('contenteditable', 'plaintext-only');
340
+ if (el.contentEditable !== 'plaintext-only') {
341
+ el.setAttribute('contenteditable', 'true');
342
+ }
343
+ }
344
+ },
345
+ configurable: true,
346
+ });
347
+
348
+ Object.defineProperty(el, 'selectionStart', {
349
+ get() {
350
+ return getCaretCharacterOffset(el, true);
351
+ },
352
+ set(val) {
353
+ const currentEnd = getCaretCharacterOffset(el, false);
354
+ const nextEnd = Math.max(val, currentEnd);
355
+ setCaretCharacterOffset(el, val, nextEnd);
356
+ },
357
+ configurable: true,
358
+ });
359
+
360
+ Object.defineProperty(el, 'selectionEnd', {
361
+ get() {
362
+ return getCaretCharacterOffset(el, false);
363
+ },
364
+ set(val) {
365
+ const currentStart = getCaretCharacterOffset(el, true);
366
+ const nextStart = Math.min(val, currentStart);
367
+ setCaretCharacterOffset(el, nextStart, val);
368
+ },
369
+ configurable: true,
370
+ });
371
+
372
+ Object.defineProperty(el, 'selectionDirection', {
373
+ value: 'forward',
374
+ writable: true,
375
+ configurable: true,
376
+ });
377
+
378
+ el.setSelectionRange = function (start, end) {
379
+ setCaretCharacterOffset(el, start, end);
380
+ };
381
+
382
+ el.select = function () {
383
+ setCaretCharacterOffset(el, 0, (el.textContent || '').length);
384
+ };
385
+
386
+ el.updateCaretFromPoint = function (clientX, clientY) {
387
+ if (typeof clientX === 'number' && typeof clientY === 'number') {
388
+ const offset = getCaretOffsetFromPoint(el, clientX, clientY);
389
+ if (offset !== null) {
390
+ el._lastCaretOffset = offset;
391
+ return el._lastCaretOffset;
392
+ }
393
+ }
394
+ const offset = getCaretCharacterOffset(el, true);
395
+ if (typeof offset === 'number') {
396
+ el._lastCaretOffset = offset;
397
+ }
398
+ return el._lastCaretOffset;
399
+ };
400
+
401
+ // 8. createValueRange implementation returning a standard DOM Range over the text node
402
+ el.createValueRange = function (start, end) {
403
+ const textNode = getSingleTextNode(el);
404
+ const len = (textNode.textContent || '').length;
405
+ const s = Math.max(0, Math.min(start ?? 0, len));
406
+ const e = Math.max(0, Math.min(end ?? s, len));
407
+
408
+ const range = new Range();
409
+ range.setStart(textNode, s);
410
+ range.setEnd(textNode, e);
411
+ range.disconnect = () => {};
412
+ return range;
413
+ };
414
+
415
+ el.checkValidity = () => true;
416
+ el.reportValidity = () => true;
417
+ el.validity = { valid: true };
418
+ el.validationMessage = '';
419
+
420
+ return el;
421
+ }
@@ -35,7 +35,7 @@ class HighlightRegistryManager {
35
35
  }
36
36
 
37
37
  syncAll() {
38
- if (!isHighlightSupported || !isOpaqueRangeSupported) return;
38
+ if (!isHighlightSupported) return;
39
39
 
40
40
  // Aggregate ranges across all active instances
41
41
  const rangesByKeyword = new Map();
@@ -65,35 +65,55 @@ class HighlightRegistryManager {
65
65
 
66
66
  // 1. Set/update individual keyword highlights (e.g. ::highlight(label))
67
67
  for (const kw of this.registeredKeywords) {
68
- const ranges = rangesByKeyword.get(kw);
69
- if (ranges && ranges.length > 0) {
68
+ const ranges = rangesByKeyword.get(kw) || [];
69
+ let hl = CSS.highlights.get(kw);
70
+ if (!hl) {
70
71
  try {
71
- CSS.highlights.set(kw, new Highlight(...ranges));
72
- } catch (e) {
73
- console.warn(`[rich-input] Failed to register highlight for "${kw}":`, e);
72
+ hl = new Highlight();
73
+ CSS.highlights.set(kw, hl);
74
+ } catch (e) {}
75
+ }
76
+ if (hl) {
77
+ hl.clear();
78
+ for (const r of ranges) {
79
+ try {
80
+ hl.add(r);
81
+ } catch (e) {}
74
82
  }
75
- } else {
76
- CSS.highlights.delete(kw);
77
83
  }
78
84
  }
79
85
 
80
86
  // 2. Set generic highlights
81
- if (allKeywordRanges.length > 0) {
87
+ let kwHl = CSS.highlights.get('rich-input-keyword');
88
+ if (!kwHl) {
82
89
  try {
83
- const kwHl = new Highlight(...allKeywordRanges);
90
+ kwHl = new Highlight();
84
91
  CSS.highlights.set('rich-input-keyword', kwHl);
85
92
  } catch (e) {}
86
- } else {
87
- CSS.highlights.delete('rich-input-keyword');
93
+ }
94
+ if (kwHl) {
95
+ kwHl.clear();
96
+ for (const r of allKeywordRanges) {
97
+ try {
98
+ kwHl.add(r);
99
+ } catch (e) {}
100
+ }
88
101
  }
89
102
 
90
- if (allValueRanges.length > 0) {
103
+ let valHl = CSS.highlights.get('rich-input-value');
104
+ if (!valHl) {
91
105
  try {
92
- const valHl = new Highlight(...allValueRanges);
106
+ valHl = new Highlight();
93
107
  CSS.highlights.set('rich-input-value', valHl);
94
108
  } catch (e) {}
95
- } else {
96
- CSS.highlights.delete('rich-input-value');
109
+ }
110
+ if (valHl) {
111
+ valHl.clear();
112
+ for (const r of allValueRanges) {
113
+ try {
114
+ valHl.add(r);
115
+ } catch (e) {}
116
+ }
97
117
  }
98
118
  }
99
119
  }
@@ -1,8 +1,108 @@
1
1
  /**
2
2
  * Caret measurement and popover positioning for <rich-input>
3
- * Uses OpaqueRange.getBoundingClientRect() with input fallback.
3
+ * Uses OpaqueRange.getBoundingClientRect() with hidden mirror div fallback.
4
4
  */
5
5
 
6
+ import { isOpaqueRangeSupported } from './highlights.js';
7
+
8
+ let mirrorDiv = null;
9
+
10
+ /**
11
+ * Gets or creates the reusable hidden mirror div for measuring text width in the input.
12
+ * @returns {HTMLDivElement|null}
13
+ */
14
+ function getMirrorDiv() {
15
+ if (typeof document === 'undefined') return null;
16
+
17
+ if (!mirrorDiv || !mirrorDiv.isConnected) {
18
+ mirrorDiv = document.createElement('div');
19
+ mirrorDiv.id = 'rich-input-mirror';
20
+ mirrorDiv.setAttribute('aria-hidden', 'true');
21
+ mirrorDiv.style.position = 'absolute';
22
+ mirrorDiv.style.top = '-9999px';
23
+ mirrorDiv.style.left = '-9999px';
24
+ mirrorDiv.style.visibility = 'hidden';
25
+ mirrorDiv.style.pointerEvents = 'none';
26
+ mirrorDiv.style.whiteSpace = 'pre';
27
+ mirrorDiv.style.display = 'inline-block';
28
+ mirrorDiv.style.width = 'auto';
29
+ mirrorDiv.style.height = 'auto';
30
+ mirrorDiv.style.margin = '0';
31
+ mirrorDiv.style.padding = '0';
32
+ mirrorDiv.style.border = '0';
33
+ (document.body || document.documentElement).appendChild(mirrorDiv);
34
+ }
35
+
36
+ return mirrorDiv;
37
+ }
38
+
39
+ /**
40
+ * Fallback measurement using a hidden mirror div for browsers that don't support OpaqueRange.
41
+ * Sets the div's text content to the input's partial text (or full text) up to the target
42
+ * character offset to figure out the left position to use for the popover.
43
+ *
44
+ * @param {HTMLInputElement} input
45
+ * @param {number|OpaqueRange} [target] Character offset or range
46
+ * @returns {number} Viewport-relative left position in pixels
47
+ */
48
+ export function getCaretLeftWithMirrorDiv(input, target) {
49
+ const inputRect = input.getBoundingClientRect();
50
+ const value = input.value || '';
51
+
52
+ // Determine character offset
53
+ let offset = value.length;
54
+ if (typeof target === 'number') {
55
+ offset = target;
56
+ } else if (target && typeof target.startOffset === 'number') {
57
+ offset = target.startOffset;
58
+ } else if (typeof input.selectionStart === 'number') {
59
+ offset = input.selectionStart;
60
+ }
61
+ offset = Math.max(0, Math.min(offset, value.length));
62
+
63
+ // Input's text or partial text up to target offset
64
+ const textContent = value.slice(0, offset);
65
+
66
+ const mirror = getMirrorDiv();
67
+ if (!mirror) {
68
+ return inputRect.left;
69
+ }
70
+
71
+ // Copy computed font and text layout styles from the input
72
+ const computed = window.getComputedStyle(input);
73
+ mirror.style.fontFamily = computed.fontFamily;
74
+ mirror.style.fontSize = computed.fontSize;
75
+ mirror.style.fontWeight = computed.fontWeight;
76
+ mirror.style.fontStyle = computed.fontStyle;
77
+ mirror.style.fontVariant = computed.fontVariant;
78
+ mirror.style.fontStretch = computed.fontStretch;
79
+ mirror.style.letterSpacing = computed.letterSpacing;
80
+ mirror.style.wordSpacing = computed.wordSpacing;
81
+ mirror.style.textTransform = computed.textTransform;
82
+ mirror.style.textIndent = computed.textIndent;
83
+ mirror.style.direction = computed.direction;
84
+
85
+ // Set the text content of the hidden div
86
+ mirror.textContent = textContent;
87
+
88
+ const textWidth = mirror.getBoundingClientRect().width;
89
+ const isRtl = computed.direction === 'rtl';
90
+
91
+ let left;
92
+ if (isRtl) {
93
+ const borderRight = parseFloat(computed.borderRightWidth) || 0;
94
+ const paddingRight = parseFloat(computed.paddingRight) || 0;
95
+ left = inputRect.right - borderRight - paddingRight - textWidth + (input.scrollLeft || 0);
96
+ } else {
97
+ const borderLeft = parseFloat(computed.borderLeftWidth) || 0;
98
+ const paddingLeft = parseFloat(computed.paddingLeft) || 0;
99
+ left = inputRect.left + borderLeft + paddingLeft + textWidth - (input.scrollLeft || 0);
100
+ }
101
+
102
+ // Clamp within input bounds
103
+ return Math.max(inputRect.left, Math.min(left, inputRect.right));
104
+ }
105
+
6
106
  /**
7
107
  * Gets caret or range coordinates relative to viewport.
8
108
  * Accepts either an OpaqueRange instance or a character offset number.
@@ -12,6 +112,22 @@
12
112
  */
13
113
  export function getCaretCoordinates(input, target) {
14
114
  const inputRect = input.getBoundingClientRect();
115
+
116
+ // For browsers that don't support OpaqueRange to position the popover,
117
+ // do the trick with a hidden div and setting its text contents to the input's
118
+ // text (or partial text) to figure out the left position to use for the popover.
119
+ if (!isOpaqueRangeSupported) {
120
+ const left = getCaretLeftWithMirrorDiv(input, target);
121
+ return {
122
+ left,
123
+ top: inputRect.top,
124
+ bottom: inputRect.bottom,
125
+ height: inputRect.height,
126
+ isCaret: false,
127
+ };
128
+ }
129
+
130
+ // When OpaqueRange is supported, use native OpaqueRange measurement
15
131
  let rect = null;
16
132
  let tempRange = null;
17
133
 
@@ -77,6 +193,11 @@ export const getRangeCoordinates = getCaretCoordinates;
77
193
  export function positionPopover(popover, caretCoords, inputElement) {
78
194
  if (!popover) return;
79
195
 
196
+ if (!caretCoords && inputElement) {
197
+ caretCoords = getCaretCoordinates(inputElement);
198
+ }
199
+ if (!caretCoords) return;
200
+
80
201
  const popoverWidth = popover.offsetWidth || 280;
81
202
  const popoverHeight = popover.offsetHeight || 220;
82
203