rich-input 1.0.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/index.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * <rich-input> entry point
3
+ * Defines <rich-input> custom element and exports RichInput class & parser utilities.
4
+ */
5
+
6
+ import { RichInput } from './components/rich-input.js';
7
+ import { parseSearchTokens, parseSearchQuery, getCaretContext, getSuggestions, applySuggestion } from './utils/query-parser.js';
8
+ import { highlightManager, isOpaqueRangeSupported, isHighlightSupported } from './utils/highlights.js';
9
+ import { getCaretCoordinates, getRangeCoordinates, positionPopover } from './utils/positioning.js';
10
+
11
+ if (typeof customElements !== 'undefined') {
12
+ if (!customElements.get('rich-input')) {
13
+ customElements.define('rich-input', RichInput);
14
+ }
15
+ }
16
+
17
+ export {
18
+ RichInput,
19
+ parseSearchTokens,
20
+ parseSearchQuery,
21
+ getCaretContext,
22
+ getSuggestions,
23
+ applySuggestion,
24
+ highlightManager,
25
+ isOpaqueRangeSupported,
26
+ isHighlightSupported,
27
+ getCaretCoordinates,
28
+ getRangeCoordinates,
29
+ positionPopover,
30
+ };
31
+
32
+ export default RichInput;
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "rich-input",
3
+ "version": "1.0.0",
4
+ "description": "A rich input field with keyword-based autocomplete and in-input highlighting powered by <datalist>, OpaqueRange, and the Custom Highlight API",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "module": "./index.js",
8
+ "exports": {
9
+ ".": "./index.js"
10
+ },
11
+ "keywords": [
12
+ "rich-input",
13
+ "custom-element",
14
+ "web-components",
15
+ "opaque-range",
16
+ "custom-highlight-api",
17
+ "autocomplete",
18
+ "keyword-search",
19
+ "search-input"
20
+ ],
21
+ "author": {
22
+ "name": "Bramus Van Damme",
23
+ "email": "bramus@bram.us",
24
+ "url": "https://www.bram.us/"
25
+ },
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/bramus/rich-input.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/bramus/rich-input/issues"
33
+ },
34
+ "homepage": "https://github.com/bramus/rich-input#readme"
35
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Highlight manager for <rich-input>
3
+ * Uses the OpaqueRange API and CSS Custom Highlight API to apply syntax highlighting.
4
+ */
5
+
6
+ export const isOpaqueRangeSupported =
7
+ typeof HTMLInputElement !== 'undefined' &&
8
+ typeof HTMLInputElement.prototype.createValueRange === 'function';
9
+
10
+ export const isHighlightSupported =
11
+ typeof window !== 'undefined' &&
12
+ typeof window.Highlight !== 'undefined' &&
13
+ typeof CSS !== 'undefined' &&
14
+ 'highlights' in CSS;
15
+
16
+ class HighlightRegistryManager {
17
+ constructor() {
18
+ this.instances = new Set();
19
+ this.registeredKeywords = new Set();
20
+ }
21
+
22
+ register(instance) {
23
+ this.instances.add(instance);
24
+ }
25
+
26
+ unregister(instance) {
27
+ this.instances.delete(instance);
28
+ this.syncAll();
29
+ }
30
+
31
+ recordKeyword(keyword) {
32
+ if (keyword) {
33
+ this.registeredKeywords.add(keyword.toLowerCase());
34
+ }
35
+ }
36
+
37
+ syncAll() {
38
+ if (!isHighlightSupported || !isOpaqueRangeSupported) return;
39
+
40
+ // Aggregate ranges across all active instances
41
+ const rangesByKeyword = new Map();
42
+ const allKeywordRanges = [];
43
+ const allValueRanges = [];
44
+
45
+ for (const inst of this.instances) {
46
+ const instanceRanges = inst.getActiveHighlightRanges();
47
+ for (const [kw, data] of instanceRanges.entries()) {
48
+ const kwLower = kw.toLowerCase();
49
+ this.registeredKeywords.add(kwLower);
50
+
51
+ if (!rangesByKeyword.has(kwLower)) {
52
+ rangesByKeyword.set(kwLower, []);
53
+ }
54
+
55
+ if (data.valueRanges && data.valueRanges.length > 0) {
56
+ rangesByKeyword.get(kwLower).push(...data.valueRanges);
57
+ allValueRanges.push(...data.valueRanges);
58
+ }
59
+
60
+ if (data.keywordRanges && data.keywordRanges.length > 0) {
61
+ allKeywordRanges.push(...data.keywordRanges);
62
+ }
63
+ }
64
+ }
65
+
66
+ // 1. Set/update individual keyword highlights (e.g. ::highlight(label))
67
+ for (const kw of this.registeredKeywords) {
68
+ const ranges = rangesByKeyword.get(kw);
69
+ if (ranges && ranges.length > 0) {
70
+ try {
71
+ CSS.highlights.set(kw, new Highlight(...ranges));
72
+ } catch (e) {
73
+ console.warn(`[rich-input] Failed to register highlight for "${kw}":`, e);
74
+ }
75
+ } else {
76
+ CSS.highlights.delete(kw);
77
+ }
78
+ }
79
+
80
+ // 2. Set generic highlights
81
+ if (allKeywordRanges.length > 0) {
82
+ try {
83
+ const kwHl = new Highlight(...allKeywordRanges);
84
+ CSS.highlights.set('rich-input-keyword', kwHl);
85
+ } catch (e) {}
86
+ } else {
87
+ CSS.highlights.delete('rich-input-keyword');
88
+ }
89
+
90
+ if (allValueRanges.length > 0) {
91
+ try {
92
+ const valHl = new Highlight(...allValueRanges);
93
+ CSS.highlights.set('rich-input-value', valHl);
94
+ } catch (e) {}
95
+ } else {
96
+ CSS.highlights.delete('rich-input-value');
97
+ }
98
+ }
99
+ }
100
+
101
+ export const highlightManager = new HighlightRegistryManager();
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Caret measurement and popover positioning for <rich-input>
3
+ * Uses OpaqueRange.getBoundingClientRect() with input fallback.
4
+ */
5
+
6
+ /**
7
+ * Gets caret or range coordinates relative to viewport.
8
+ * Accepts either an OpaqueRange instance or a character offset number.
9
+ * @param {HTMLInputElement} input
10
+ * @param {number|OpaqueRange} [target] Caret position number or OpaqueRange
11
+ * @returns {{ left: number, top: number, bottom: number, height: number, isCaret: boolean }}
12
+ */
13
+ export function getCaretCoordinates(input, target) {
14
+ const inputRect = input.getBoundingClientRect();
15
+ let rect = null;
16
+ let tempRange = null;
17
+
18
+ if (target && typeof target.getBoundingClientRect === 'function') {
19
+ try {
20
+ const r = target.getBoundingClientRect();
21
+ if (r.height > 0 || r.width > 0 || r.left > 0) {
22
+ rect = r;
23
+ }
24
+ } catch (e) {}
25
+ } else if (typeof input.createValueRange === 'function' && input.value.length > 0) {
26
+ try {
27
+ const pos = Math.max(0, Math.min(typeof target === 'number' ? target : 0, input.value.length));
28
+ tempRange = input.createValueRange(pos, pos);
29
+ const r = tempRange.getBoundingClientRect();
30
+ if (r.height > 0 || r.width > 0 || r.left > 0) {
31
+ rect = r;
32
+ }
33
+ } catch (e) {}
34
+ }
35
+
36
+ // Clean up temporary measurement range
37
+ if (tempRange) {
38
+ try {
39
+ tempRange.disconnect();
40
+ } catch (e) {}
41
+ }
42
+
43
+ if (rect) {
44
+ // Keep caret left within input bounds
45
+ const left = Math.max(inputRect.left, Math.min(rect.left, inputRect.right));
46
+ const bottom = rect.bottom > 0 ? rect.bottom : inputRect.bottom;
47
+ const top = rect.top > 0 ? rect.top : inputRect.top;
48
+ return {
49
+ left,
50
+ top,
51
+ bottom,
52
+ height: rect.height || inputRect.height,
53
+ isCaret: true,
54
+ };
55
+ }
56
+
57
+ return {
58
+ left: inputRect.left,
59
+ top: inputRect.top,
60
+ bottom: inputRect.bottom,
61
+ height: inputRect.height,
62
+ isCaret: false,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Alias for getCaretCoordinates that clearly conveys range support.
68
+ */
69
+ export const getRangeCoordinates = getCaretCoordinates;
70
+
71
+ /**
72
+ * Positions popover at caret or input element.
73
+ * @param {HTMLElement} popover
74
+ * @param {{ left: number, top: number, bottom: number, height: number }} caretCoords
75
+ * @param {HTMLInputElement} inputElement
76
+ */
77
+ export function positionPopover(popover, caretCoords, inputElement) {
78
+ if (!popover) return;
79
+
80
+ const popoverWidth = popover.offsetWidth || 280;
81
+ const popoverHeight = popover.offsetHeight || 220;
82
+
83
+ const viewportWidth = window.innerWidth;
84
+ const viewportHeight = window.innerHeight;
85
+
86
+ let left = caretCoords.left;
87
+
88
+ // Viewport right edge clamp
89
+ if (left + popoverWidth > viewportWidth - 12) {
90
+ left = viewportWidth - popoverWidth - 12;
91
+ }
92
+ // Viewport left edge clamp
93
+ if (left < 12) {
94
+ left = 12;
95
+ }
96
+
97
+ let top = caretCoords.bottom + 6;
98
+
99
+ // Viewport bottom edge flip
100
+ if (top + popoverHeight > viewportHeight - 12) {
101
+ const spaceAbove = caretCoords.top - 6;
102
+ if (spaceAbove >= popoverHeight) {
103
+ top = spaceAbove - popoverHeight;
104
+ }
105
+ }
106
+
107
+ popover.style.margin = '0';
108
+ popover.style.inset = 'auto';
109
+ popover.style.left = `${Math.round(left)}px`;
110
+ popover.style.top = `${Math.round(top)}px`;
111
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Query tokenizer and parser for <rich-input>
3
+ * Handles keyword:value tokenization, caret context inspection, and suggestion application.
4
+ */
5
+
6
+ /**
7
+ * Parses raw search input into token objects.
8
+ * @param {string} inputStr
9
+ * @returns {Array<Object>}
10
+ */
11
+ export function parseSearchTokens(inputStr) {
12
+ if (typeof inputStr !== 'string') return [];
13
+
14
+ const tokens = [];
15
+ let i = 0;
16
+ const len = inputStr.length;
17
+
18
+ while (i < len) {
19
+ // 1. Whitespace
20
+ if (/\s/.test(inputStr[i])) {
21
+ const wsStart = i;
22
+ while (i < len && /\s/.test(inputStr[i])) {
23
+ i++;
24
+ }
25
+ tokens.push({
26
+ type: 'whitespace',
27
+ raw: inputStr.slice(wsStart, i),
28
+ start: wsStart,
29
+ end: i,
30
+ });
31
+ continue;
32
+ }
33
+
34
+ const tokenStart = i;
35
+
36
+ // 2. Check for keyword pattern: [a-zA-Z0-9_-]+:
37
+ const remaining = inputStr.slice(i);
38
+ const colonMatch = remaining.match(/^([a-zA-Z0-9_-]+):/);
39
+
40
+ if (colonMatch) {
41
+ const keyword = colonMatch[1];
42
+ const keywordStart = i;
43
+ const colonIndex = i + keyword.length;
44
+ i += colonMatch[0].length; // Move index past ':'
45
+
46
+ const valueStart = i;
47
+ let quoted = false;
48
+ let quoteChar = '';
49
+ let isClosed = false;
50
+ let innerStart = i;
51
+ let innerEnd = i;
52
+
53
+ if (i < len && (inputStr[i] === '"' || inputStr[i] === "'")) {
54
+ // Quoted string value
55
+ quoted = true;
56
+ quoteChar = inputStr[i];
57
+ i++; // skip opening quote
58
+ innerStart = i;
59
+
60
+ while (i < len && inputStr[i] !== quoteChar) {
61
+ i++;
62
+ }
63
+ innerEnd = i;
64
+
65
+ if (i < len && inputStr[i] === quoteChar) {
66
+ isClosed = true;
67
+ i++; // skip closing quote
68
+ }
69
+ } else {
70
+ // Unquoted value: until next whitespace or end
71
+ innerStart = i;
72
+ while (i < len && !/\s/.test(inputStr[i])) {
73
+ i++;
74
+ }
75
+ innerEnd = i;
76
+ isClosed = true;
77
+ }
78
+
79
+ const rawValue = inputStr.slice(valueStart, i);
80
+ const innerValue = inputStr.slice(innerStart, innerEnd);
81
+
82
+ tokens.push({
83
+ type: 'keyword',
84
+ raw: inputStr.slice(tokenStart, i),
85
+ start: tokenStart,
86
+ end: i,
87
+ keyword,
88
+ keywordLower: keyword.toLowerCase(),
89
+ keywordStart,
90
+ keywordEnd: colonIndex + 1,
91
+ colonIndex,
92
+ valueStart,
93
+ valueEnd: i,
94
+ rawValue,
95
+ innerValue,
96
+ innerStart,
97
+ innerEnd,
98
+ quoted,
99
+ quoteChar,
100
+ isClosed,
101
+ });
102
+ } else {
103
+ // 3. Plain text token (single word or unfinished keyword)
104
+ while (i < len && !/\s/.test(inputStr[i])) {
105
+ i++;
106
+ }
107
+ tokens.push({
108
+ type: 'text',
109
+ raw: inputStr.slice(tokenStart, i),
110
+ start: tokenStart,
111
+ end: i,
112
+ });
113
+ }
114
+ }
115
+
116
+ return tokens;
117
+ }
118
+
119
+ /**
120
+ * Parses search query into a structured object with keywords and free text.
121
+ * @param {string} inputStr
122
+ * @returns {{ raw: string, text: string, keywords: Record<string, string[]>, tokens: Array<Object> }}
123
+ */
124
+ export function parseSearchQuery(inputStr) {
125
+ const tokens = parseSearchTokens(inputStr);
126
+ const keywords = {};
127
+ const textWords = [];
128
+
129
+ for (const token of tokens) {
130
+ if (token.type === 'keyword') {
131
+ const kw = token.keywordLower;
132
+ if (!keywords[kw]) {
133
+ keywords[kw] = [];
134
+ }
135
+ keywords[kw].push(token.innerValue);
136
+ } else if (token.type === 'text') {
137
+ textWords.push(token.raw);
138
+ }
139
+ }
140
+
141
+ return {
142
+ raw: inputStr,
143
+ text: textWords.join(' '),
144
+ keywords,
145
+ tokens,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Determines caret context and what autocomplete suggestions are appropriate.
151
+ * @param {string} inputStr
152
+ * @param {number} caretPos
153
+ * @param {Map<string, Object>|Object} configuredKeywords
154
+ * @returns {Object}
155
+ */
156
+ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
157
+ if (typeof inputStr !== 'string') inputStr = '';
158
+ caretPos = Math.max(0, Math.min(caretPos || 0, inputStr.length));
159
+
160
+ const tokens = parseSearchTokens(inputStr);
161
+
162
+ // Find token at caret
163
+ let activeToken = null;
164
+ for (const token of tokens) {
165
+ if (caretPos >= token.start && caretPos <= token.end) {
166
+ activeToken = token;
167
+ break;
168
+ }
169
+ }
170
+
171
+ // If caret is in whitespace or between tokens
172
+ if (!activeToken || activeToken.type === 'whitespace') {
173
+ // Check if the previous token was a colon without value or if we're in open space
174
+ return {
175
+ mode: 'none',
176
+ caretPos,
177
+ tokens,
178
+ };
179
+ }
180
+
181
+ // 1. Caret is within a keyword token
182
+ if (activeToken.type === 'keyword') {
183
+ if (caretPos <= activeToken.colonIndex) {
184
+ // User is editing the keyword name
185
+ const query = inputStr.slice(activeToken.start, caretPos);
186
+ return {
187
+ mode: 'keyword',
188
+ query,
189
+ replaceStart: activeToken.start,
190
+ replaceEnd: activeToken.colonIndex + 1,
191
+ caretPos,
192
+ token: activeToken,
193
+ tokens,
194
+ };
195
+ } else {
196
+ // User is editing the keyword value
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));
201
+
202
+ return {
203
+ mode: 'value',
204
+ keyword: activeToken.keyword,
205
+ keywordLower: activeToken.keywordLower,
206
+ valuePrefix,
207
+ quoted: isQuoted,
208
+ quoteChar: activeToken.quoteChar || '"',
209
+ isClosed: activeToken.isClosed,
210
+ replaceStart: activeToken.valueStart,
211
+ replaceEnd: activeToken.valueEnd,
212
+ caretPos,
213
+ token: activeToken,
214
+ tokens,
215
+ };
216
+ }
217
+ }
218
+
219
+ // 2. Caret is within a plain text token
220
+ if (activeToken.type === 'text') {
221
+ const query = inputStr.slice(activeToken.start, caretPos);
222
+ return {
223
+ mode: 'keyword',
224
+ query,
225
+ replaceStart: activeToken.start,
226
+ replaceEnd: activeToken.end,
227
+ caretPos,
228
+ token: activeToken,
229
+ tokens,
230
+ };
231
+ }
232
+
233
+ return {
234
+ mode: 'none',
235
+ caretPos,
236
+ tokens,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Generates suggestions for the given caret context.
242
+ * @param {Object} context
243
+ * @param {Map<string, Object>} configuredKeywords
244
+ * @returns {Array<Object>}
245
+ */
246
+ export function getSuggestions(context, configuredKeywords) {
247
+ if (!context || context.mode === 'none') return [];
248
+
249
+ const suggestions = [];
250
+
251
+ if (context.mode === 'keyword') {
252
+ const q = (context.query || '').toLowerCase().trim();
253
+ for (const [id, kw] of configuredKeywords.entries()) {
254
+ const idLower = id.toLowerCase();
255
+ const labelLower = (kw.label || '').toLowerCase();
256
+ const matches = !q || idLower.startsWith(q) || labelLower.startsWith(q) || idLower.includes(q);
257
+
258
+ if (matches) {
259
+ suggestions.push({
260
+ type: 'keyword',
261
+ id: kw.id,
262
+ label: kw.label || kw.id,
263
+ display: `${kw.id}:`,
264
+ insertText: `${kw.id}:`,
265
+ description: kw.label || kw.id,
266
+ dataType: kw.dataType || 'string',
267
+ });
268
+ }
269
+ }
270
+ } else if (context.mode === 'value') {
271
+ const kwConfig = configuredKeywords.get(context.keywordLower);
272
+ if (!kwConfig) return [];
273
+
274
+ const prefix = (context.valuePrefix || '').toLowerCase();
275
+
276
+ for (const opt of kwConfig.options) {
277
+ const valLower = (opt.value || '').toLowerCase();
278
+ const lblLower = (opt.label || '').toLowerCase();
279
+ const txtLower = (opt.text || '').toLowerCase();
280
+ const matches = !prefix || valLower.includes(prefix) || lblLower.includes(prefix) || txtLower.includes(prefix);
281
+
282
+ if (matches) {
283
+ // Needs quotes if option contains spaces, or if already quoted, or if contains colons
284
+ const needsQuotes = context.quoted || opt.value.includes(' ') || opt.value.includes(':');
285
+ const quoteChar = context.quoteChar || '"';
286
+ const formatted = needsQuotes ? `${quoteChar}${opt.value}${quoteChar}` : opt.value;
287
+ const displayText = opt.text || opt.value;
288
+
289
+ suggestions.push({
290
+ type: 'value',
291
+ keyword: kwConfig.id,
292
+ keywordLabel: kwConfig.label || kwConfig.id,
293
+ value: opt.value,
294
+ label: opt.label || opt.value,
295
+ display: displayText,
296
+ insertText: formatted,
297
+ description: (opt.label && opt.label !== opt.value && opt.label !== displayText) ? opt.label : '',
298
+ dataType: kwConfig.dataType || 'string',
299
+ element: opt.element,
300
+ image: opt.image,
301
+ });
302
+ }
303
+ }
304
+ }
305
+
306
+ return suggestions;
307
+ }
308
+
309
+ /**
310
+ * Applies a selected suggestion to the current input string.
311
+ * @param {string} inputStr
312
+ * @param {Object} suggestion
313
+ * @param {Object} context
314
+ * @returns {{ newValue: string, newCaret: number }}
315
+ */
316
+ export function applySuggestion(inputStr, suggestion, context) {
317
+ const replaceStart = context.replaceStart;
318
+ const replaceEnd = context.replaceEnd;
319
+
320
+ const before = inputStr.slice(0, replaceStart);
321
+ let after = inputStr.slice(replaceEnd);
322
+ let insertText = suggestion.insertText;
323
+
324
+ if (suggestion.type === 'keyword') {
325
+ // When inserting a keyword like `mix:`, do not add trailing space so user can immediately type value
326
+ // If after text starts with colon, remove it to avoid `mix::`
327
+ if (after.startsWith(':')) {
328
+ after = after.slice(1);
329
+ }
330
+ const newValue = before + insertText + after;
331
+ const newCaret = before.length + insertText.length;
332
+ return { newValue, newCaret };
333
+ }
334
+
335
+ // When inserting a value (e.g. `"We Play House Recordings"`)
336
+ // Add a trailing space if after does not already start with whitespace
337
+ if (after.length === 0 || !/^\s/.test(after)) {
338
+ insertText += ' ';
339
+ }
340
+
341
+ const newValue = before + insertText + after;
342
+ const newCaret = before.length + insertText.length;
343
+ return { newValue, newCaret };
344
+ }