rich-input 1.3.0 → 1.4.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 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, isDatalistValue } from './utils/query-parser.js';
7
+ import { DEFAULT_OPERATORS, normalizeOperators, DEFAULT_COMBINATORS, normalizeCombinators, DEFAULT_DELIMITERS, normalizeDelimiters, 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';
@@ -17,6 +17,12 @@ if (typeof customElements !== 'undefined') {
17
17
 
18
18
  export {
19
19
  RichInput,
20
+ DEFAULT_OPERATORS,
21
+ normalizeOperators,
22
+ DEFAULT_COMBINATORS,
23
+ normalizeCombinators,
24
+ DEFAULT_DELIMITERS,
25
+ normalizeDelimiters,
20
26
  parseSearchTokens,
21
27
  parseSearchQuery,
22
28
  getCaretContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rich-input",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",
@@ -52,6 +52,9 @@ class HighlightRegistryManager {
52
52
  const rangesByKeyword = new Map();
53
53
  const allKeywordRanges = [];
54
54
  const allValueRanges = [];
55
+ const allOperatorRanges = [];
56
+ const allCombinatorRanges = [];
57
+ const allDelimiterRanges = [];
55
58
  const allInvalidRanges = [];
56
59
 
57
60
  for (const inst of this.instances) {
@@ -74,6 +77,27 @@ class HighlightRegistryManager {
74
77
  }
75
78
  }
76
79
 
80
+ if (typeof inst.getActiveOperatorRanges === 'function') {
81
+ const opRanges = inst.getActiveOperatorRanges();
82
+ if (opRanges && opRanges.length > 0) {
83
+ allOperatorRanges.push(...opRanges);
84
+ }
85
+ }
86
+
87
+ if (typeof inst.getActiveCombinatorRanges === 'function') {
88
+ const combRanges = inst.getActiveCombinatorRanges();
89
+ if (combRanges && combRanges.length > 0) {
90
+ allCombinatorRanges.push(...combRanges);
91
+ }
92
+ }
93
+
94
+ if (typeof inst.getActiveDelimiterRanges === 'function') {
95
+ const delimRanges = inst.getActiveDelimiterRanges();
96
+ if (delimRanges && delimRanges.length > 0) {
97
+ allDelimiterRanges.push(...delimRanges);
98
+ }
99
+ }
100
+
77
101
  if (typeof inst.getActiveInvalidRanges === 'function') {
78
102
  const invRanges = inst.getActiveInvalidRanges();
79
103
  if (invRanges && invRanges.length > 0) {
@@ -104,6 +128,57 @@ class HighlightRegistryManager {
104
128
  }
105
129
 
106
130
  // 2. Set generic highlights
131
+ let opHl = CSS.highlights.get('rich-input-operator');
132
+ if (!opHl) {
133
+ try {
134
+ opHl = new Highlight();
135
+ CSS.highlights.set('rich-input-operator', opHl);
136
+ } catch (e) {}
137
+ }
138
+ if (opHl) {
139
+ opHl.clear();
140
+ for (const r of allOperatorRanges) {
141
+ try {
142
+ if (isRangeCollapsed(r)) continue;
143
+ opHl.add(r);
144
+ } catch (e) {}
145
+ }
146
+ }
147
+
148
+ let combHl = CSS.highlights.get('rich-input-combinator');
149
+ if (!combHl) {
150
+ try {
151
+ combHl = new Highlight();
152
+ CSS.highlights.set('rich-input-combinator', combHl);
153
+ } catch (e) {}
154
+ }
155
+ if (combHl) {
156
+ combHl.clear();
157
+ for (const r of allCombinatorRanges) {
158
+ try {
159
+ if (isRangeCollapsed(r)) continue;
160
+ combHl.add(r);
161
+ } catch (e) {}
162
+ }
163
+ }
164
+
165
+ let delimHl = CSS.highlights.get('rich-input-delimiter');
166
+ if (!delimHl) {
167
+ try {
168
+ delimHl = new Highlight();
169
+ CSS.highlights.set('rich-input-delimiter', delimHl);
170
+ } catch (e) {}
171
+ }
172
+ if (delimHl) {
173
+ delimHl.clear();
174
+ for (const r of allDelimiterRanges) {
175
+ try {
176
+ if (isRangeCollapsed(r)) continue;
177
+ delimHl.add(r);
178
+ } catch (e) {}
179
+ }
180
+ }
181
+
107
182
  let kwHl = CSS.highlights.get('rich-input-keyword');
108
183
  if (!kwHl) {
109
184
  try {
@@ -3,14 +3,187 @@
3
3
  * Handles keyword:value tokenization, caret context inspection, and suggestion application.
4
4
  */
5
5
 
6
+ export const DEFAULT_OPERATORS = ['-'];
7
+ export const DEFAULT_COMBINATORS = [];
8
+
9
+ /**
10
+ * Normalizes an operators configuration (string or array) into an array of non-empty operator strings.
11
+ * @param {string|string[]|null|undefined} operators
12
+ * @returns {string[]}
13
+ */
14
+ export function normalizeOperators(operators) {
15
+ if (operators === undefined) {
16
+ return [...DEFAULT_OPERATORS];
17
+ }
18
+ if (operators === null || operators === '') {
19
+ return [];
20
+ }
21
+ const rawList = Array.isArray(operators)
22
+ ? operators
23
+ : typeof operators === 'string'
24
+ ? operators.split(/\s+/)
25
+ : [];
26
+
27
+ const result = [];
28
+ const seen = new Set();
29
+ for (const item of rawList) {
30
+ if (typeof item !== 'string') continue;
31
+ const parts = item.trim().split(/\s+/).filter(Boolean);
32
+ for (let part of parts) {
33
+ part = part.replace(/^(.)\1+$/, '$1');
34
+ if (!seen.has(part)) {
35
+ seen.add(part);
36
+ result.push(part);
37
+ }
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * Normalizes a combinators configuration (string or array) into an array of non-empty combinator strings.
45
+ * @param {string|string[]|null|undefined} combinators
46
+ * @returns {string[]}
47
+ */
48
+ export function normalizeCombinators(combinators) {
49
+ if (combinators === undefined) {
50
+ return [...DEFAULT_COMBINATORS];
51
+ }
52
+ if (combinators === null || combinators === '') {
53
+ return [];
54
+ }
55
+ const rawList = Array.isArray(combinators)
56
+ ? combinators
57
+ : typeof combinators === 'string'
58
+ ? combinators.split(/\s+/)
59
+ : [];
60
+
61
+ const result = [];
62
+ const seen = new Set();
63
+ for (const item of rawList) {
64
+ if (typeof item !== 'string') continue;
65
+ const parts = item.trim().split(/\s+/).filter(Boolean);
66
+ for (const part of parts) {
67
+ if (!seen.has(part)) {
68
+ seen.add(part);
69
+ result.push(part);
70
+ }
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+
76
+ /**
77
+ * Matches if a string starts with any of the configured operators (longest match first).
78
+ * @param {string} str
79
+ * @param {string[]} normalizedOperators
80
+ * @returns {string|null}
81
+ */
82
+ function matchOperator(str, normalizedOperators) {
83
+ if (!str || !normalizedOperators || normalizedOperators.length === 0) {
84
+ return null;
85
+ }
86
+ const sorted = [...normalizedOperators].sort((a, b) => b.length - a.length);
87
+ for (const op of sorted) {
88
+ if (str.startsWith(op)) {
89
+ return op;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ export const DEFAULT_DELIMITERS = ['()'];
96
+
97
+ /**
98
+ * Normalizes delimiters input into a deduplicated array of delimiter pair strings (e.g. ['()', '{}', '[]']).
99
+ * @param {string|string[]|null|undefined} delimiters
100
+ * @returns {string[]}
101
+ */
102
+ export function normalizeDelimiters(delimiters) {
103
+ if (delimiters === undefined) {
104
+ return [...DEFAULT_DELIMITERS];
105
+ }
106
+ if (delimiters === null || delimiters === '') {
107
+ return [];
108
+ }
109
+ const rawList = Array.isArray(delimiters)
110
+ ? delimiters
111
+ : typeof delimiters === 'string'
112
+ ? delimiters.split(/\s+/)
113
+ : [];
114
+
115
+ const flatTokens = [];
116
+ for (const item of rawList) {
117
+ if (typeof item !== 'string') continue;
118
+ const parts = item.trim().split(/\s+/).filter(Boolean);
119
+ flatTokens.push(...parts);
120
+ }
121
+
122
+ const result = [];
123
+ const seen = new Set();
124
+
125
+ for (let i = 0; i < flatTokens.length; i++) {
126
+ let token = flatTokens[i];
127
+ if (token.length === 1 && i + 1 < flatTokens.length && flatTokens[i + 1].length === 1) {
128
+ token = token + flatTokens[i + 1];
129
+ i++;
130
+ } else if (token.length > 2) {
131
+ token = token.replace(/(.)\1+/g, '$1');
132
+ }
133
+ if (token.length >= 2 && !seen.has(token)) {
134
+ seen.add(token);
135
+ result.push(token);
136
+ }
137
+ }
138
+
139
+ return result;
140
+ }
141
+
142
+ function buildDelimiterEntries(normalizedDelims) {
143
+ const entries = [];
144
+ if (!normalizedDelims || normalizedDelims.length === 0) return entries;
145
+ for (const pair of normalizedDelims) {
146
+ if (typeof pair !== 'string' || pair.length < 2) continue;
147
+ const open = pair[0];
148
+ const close = pair[pair.length - 1];
149
+ entries.push({ delimiter: open, role: 'open', pair });
150
+ if (close !== open) {
151
+ entries.push({ delimiter: close, role: 'close', pair });
152
+ }
153
+ }
154
+ return entries;
155
+ }
156
+
157
+ function matchDelimiter(str, delimEntries) {
158
+ if (!str || !delimEntries || delimEntries.length === 0) return null;
159
+ for (const entry of delimEntries) {
160
+ if (str.startsWith(entry.delimiter)) {
161
+ return entry;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
6
167
  /**
7
168
  * Parses raw search input into token objects.
8
169
  * @param {string} inputStr
170
+ * @param {string|string[]} [operators=DEFAULT_OPERATORS]
171
+ * @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
172
+ * @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
9
173
  * @returns {Array<Object>}
10
174
  */
11
- export function parseSearchTokens(inputStr) {
175
+ export function parseSearchTokens(
176
+ inputStr,
177
+ operators = DEFAULT_OPERATORS,
178
+ combinators = DEFAULT_COMBINATORS,
179
+ delimiters = DEFAULT_DELIMITERS
180
+ ) {
12
181
  if (typeof inputStr !== 'string') return [];
13
182
 
183
+ const normalizedOps = normalizeOperators(operators);
184
+ const normalizedCombs = normalizeCombinators(combinators);
185
+ const normalizedDelims = normalizeDelimiters(delimiters);
186
+ const delimEntries = buildDelimiterEntries(normalizedDelims);
14
187
  const tokens = [];
15
188
  let i = 0;
16
189
  const len = inputStr.length;
@@ -31,17 +204,49 @@ export function parseSearchTokens(inputStr) {
31
204
  continue;
32
205
  }
33
206
 
207
+ // 1b. Delimiter
208
+ const matchedDelim = matchDelimiter(inputStr.slice(i), delimEntries);
209
+ if (matchedDelim) {
210
+ tokens.push({
211
+ type: 'delimiter',
212
+ delimiter: matchedDelim.delimiter,
213
+ role: matchedDelim.role,
214
+ pair: matchedDelim.pair,
215
+ raw: matchedDelim.delimiter,
216
+ start: i,
217
+ end: i + matchedDelim.delimiter.length,
218
+ });
219
+ i += matchedDelim.delimiter.length;
220
+ continue;
221
+ }
222
+
34
223
  const tokenStart = i;
35
224
 
36
- // 2. Check for keyword pattern: [a-zA-Z0-9_-]+:
225
+ // 2. Check for optional operator followed by keyword pattern: [a-zA-Z0-9][a-zA-Z0-9_-]*:
37
226
  const remaining = inputStr.slice(i);
38
- const colonMatch = remaining.match(/^([a-zA-Z0-9_-]+):/);
227
+ const matchedOp = matchOperator(remaining, normalizedOps);
228
+ let colonMatch = null;
229
+ let opLen = 0;
230
+
231
+ if (matchedOp) {
232
+ const afterOp = remaining.slice(matchedOp.length);
233
+ const afterOpMatch = afterOp.match(/^([a-zA-Z0-9][a-zA-Z0-9_-]*):/);
234
+ if (afterOpMatch) {
235
+ colonMatch = afterOpMatch;
236
+ opLen = matchedOp.length;
237
+ }
238
+ } else {
239
+ colonMatch = remaining.match(/^([a-zA-Z0-9][a-zA-Z0-9_-]*):/);
240
+ }
39
241
 
40
242
  if (colonMatch) {
41
243
  const keyword = colonMatch[1];
42
- const keywordStart = i;
43
- const colonIndex = i + keyword.length;
44
- i += colonMatch[0].length; // Move index past ':'
244
+ const operator = matchedOp || null;
245
+ const operatorStart = operator ? tokenStart : null;
246
+ const operatorEnd = operator ? tokenStart + opLen : null;
247
+ const keywordStart = tokenStart + opLen;
248
+ const colonIndex = keywordStart + keyword.length;
249
+ i = colonIndex + 1; // Move index past ':'
45
250
 
46
251
  const valueStart = i;
47
252
  let quoted = false;
@@ -67,9 +272,13 @@ export function parseSearchTokens(inputStr) {
67
272
  i++; // skip closing quote
68
273
  }
69
274
  } else {
70
- // Unquoted value: until next whitespace or end
275
+ // Unquoted value: until next whitespace, delimiter, or end
71
276
  innerStart = i;
72
- while (i < len && !/\s/.test(inputStr[i])) {
277
+ while (
278
+ i < len &&
279
+ !/\s/.test(inputStr[i]) &&
280
+ !matchDelimiter(inputStr.slice(i), delimEntries)
281
+ ) {
73
282
  i++;
74
283
  }
75
284
  innerEnd = i;
@@ -84,6 +293,9 @@ export function parseSearchTokens(inputStr) {
84
293
  raw: inputStr.slice(tokenStart, i),
85
294
  start: tokenStart,
86
295
  end: i,
296
+ operator,
297
+ operatorStart,
298
+ operatorEnd,
87
299
  keyword,
88
300
  keywordLower: keyword.toLowerCase(),
89
301
  keywordStart,
@@ -100,16 +312,38 @@ export function parseSearchTokens(inputStr) {
100
312
  isClosed,
101
313
  });
102
314
  } else {
103
- // 3. Plain text token (single word or unfinished keyword)
104
- while (i < len && !/\s/.test(inputStr[i])) {
315
+ // 3. Standalone word token (combinator or plain text)
316
+ while (
317
+ i < len &&
318
+ !/\s/.test(inputStr[i]) &&
319
+ !matchDelimiter(inputStr.slice(i), delimEntries)
320
+ ) {
105
321
  i++;
106
322
  }
107
- tokens.push({
108
- type: 'text',
109
- raw: inputStr.slice(tokenStart, i),
110
- start: tokenStart,
111
- end: i,
112
- });
323
+ const raw = inputStr.slice(tokenStart, i);
324
+ if (normalizedCombs.includes(raw)) {
325
+ tokens.push({
326
+ type: 'combinator',
327
+ combinator: raw,
328
+ raw,
329
+ start: tokenStart,
330
+ end: i,
331
+ });
332
+ } else {
333
+ const textOp = matchOperator(raw, normalizedOps);
334
+ const textToken = {
335
+ type: 'text',
336
+ raw,
337
+ start: tokenStart,
338
+ end: i,
339
+ };
340
+ if (textOp) {
341
+ textToken.operator = textOp;
342
+ textToken.operatorStart = tokenStart;
343
+ textToken.operatorEnd = tokenStart + textOp.length;
344
+ }
345
+ tokens.push(textToken);
346
+ }
113
347
  }
114
348
  }
115
349
 
@@ -117,31 +351,23 @@ export function parseSearchTokens(inputStr) {
117
351
  }
118
352
 
119
353
  /**
120
- * Parses search query into a structured object with keywords and free text.
354
+ * Parses search query into a structured object with the raw string and lexical tokens.
121
355
  * @param {string} inputStr
122
- * @returns {{ raw: string, text: string, keywords: Record<string, string[]>, tokens: Array<Object> }}
356
+ * @param {string|string[]} [operators=DEFAULT_OPERATORS]
357
+ * @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
358
+ * @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
359
+ * @returns {{ raw: string, tokens: Array<Object> }}
123
360
  */
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
- }
361
+ export function parseSearchQuery(
362
+ inputStr,
363
+ operators = DEFAULT_OPERATORS,
364
+ combinators = DEFAULT_COMBINATORS,
365
+ delimiters = DEFAULT_DELIMITERS
366
+ ) {
367
+ const tokens = parseSearchTokens(inputStr, operators, combinators, delimiters);
140
368
 
141
369
  return {
142
370
  raw: inputStr,
143
- text: textWords.join(' '),
144
- keywords,
145
371
  tokens,
146
372
  };
147
373
  }
@@ -151,25 +377,36 @@ export function parseSearchQuery(inputStr) {
151
377
  * @param {string} inputStr
152
378
  * @param {number} caretPos
153
379
  * @param {Map<string, Object>|Object} configuredKeywords
380
+ * @param {string|string[]} [operators=DEFAULT_OPERATORS]
381
+ * @param {string|string[]} [combinators=DEFAULT_COMBINATORS]
382
+ * @param {string|string[]} [delimiters=DEFAULT_DELIMITERS]
154
383
  * @returns {Object}
155
384
  */
156
- export function getCaretContext(inputStr, caretPos, configuredKeywords) {
385
+ export function getCaretContext(
386
+ inputStr,
387
+ caretPos,
388
+ configuredKeywords,
389
+ operators = DEFAULT_OPERATORS,
390
+ combinators = DEFAULT_COMBINATORS,
391
+ delimiters = DEFAULT_DELIMITERS
392
+ ) {
157
393
  if (typeof inputStr !== 'string') inputStr = '';
158
394
  caretPos = Math.max(0, Math.min(caretPos || 0, inputStr.length));
159
395
 
160
- const tokens = parseSearchTokens(inputStr);
396
+ const tokens = parseSearchTokens(inputStr, operators, combinators, delimiters);
161
397
 
162
- // Find token at caret
398
+ // Find token at caret (skip whitespace and delimiter boundary tokens)
163
399
  let activeToken = null;
164
400
  for (const token of tokens) {
401
+ if (token.type === 'whitespace' || token.type === 'delimiter') continue;
165
402
  if (caretPos >= token.start && caretPos <= token.end) {
166
403
  activeToken = token;
167
404
  break;
168
405
  }
169
406
  }
170
407
 
171
- // If caret is in whitespace or empty input (at the start of a new token)
172
- if (!activeToken || activeToken.type === 'whitespace') {
408
+ // If caret is in whitespace, delimiter, or empty input (at the start of a new token)
409
+ if (!activeToken || activeToken.type === 'whitespace' || activeToken.type === 'delimiter') {
173
410
  return {
174
411
  mode: 'keyword',
175
412
  query: '',
@@ -185,21 +422,25 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
185
422
  if (caretPos <= activeToken.colonIndex) {
186
423
  // User is editing the keyword name (filter based on full keyword name, not caret position)
187
424
  const query = activeToken.keyword;
188
- return {
425
+ const context = {
189
426
  mode: 'keyword',
190
427
  query,
191
- replaceStart: activeToken.start,
428
+ replaceStart: activeToken.keywordStart,
192
429
  replaceEnd: activeToken.colonIndex + 1,
193
430
  caretPos,
194
431
  token: activeToken,
195
432
  tokens,
196
433
  };
434
+ if (activeToken.operator) {
435
+ context.operator = activeToken.operator;
436
+ }
437
+ return context;
197
438
  } else {
198
439
  // User is editing the keyword value (filter based on full value string, not caret position)
199
440
  const isQuoted = activeToken.quoted;
200
441
  const value = activeToken.innerValue;
201
442
 
202
- return {
443
+ const context = {
203
444
  mode: 'value',
204
445
  keyword: activeToken.keyword,
205
446
  keywordLower: activeToken.keywordLower,
@@ -214,11 +455,41 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
214
455
  token: activeToken,
215
456
  tokens,
216
457
  };
458
+ if (activeToken.operator) {
459
+ context.operator = activeToken.operator;
460
+ }
461
+ return context;
217
462
  }
218
463
  }
219
464
 
220
- // 2. Caret is within a plain text token (filter based on full word, not caret position)
465
+ // 2. Caret is within a combinator token
466
+ if (activeToken.type === 'combinator') {
467
+ return {
468
+ mode: 'keyword',
469
+ query: activeToken.raw,
470
+ replaceStart: activeToken.start,
471
+ replaceEnd: activeToken.end,
472
+ caretPos,
473
+ token: activeToken,
474
+ tokens,
475
+ };
476
+ }
477
+
478
+ // 3. Caret is within a plain text token (filter based on full word, not caret position)
221
479
  if (activeToken.type === 'text') {
480
+ if (activeToken.operator) {
481
+ const query = activeToken.raw.slice(activeToken.operator.length);
482
+ return {
483
+ mode: 'keyword',
484
+ operator: activeToken.operator,
485
+ query,
486
+ replaceStart: activeToken.operatorEnd,
487
+ replaceEnd: activeToken.end,
488
+ caretPos,
489
+ token: activeToken,
490
+ tokens,
491
+ };
492
+ }
222
493
  const query = activeToken.raw;
223
494
  return {
224
495
  mode: 'keyword',
@@ -242,9 +513,10 @@ export function getCaretContext(inputStr, caretPos, configuredKeywords) {
242
513
  * Generates suggestions for the given caret context.
243
514
  * @param {Object} context
244
515
  * @param {Map<string, Object>} configuredKeywords
516
+ * @param {string|string[]} [configuredCombinators=DEFAULT_COMBINATORS]
245
517
  * @returns {Array<Object>}
246
518
  */
247
- export function getSuggestions(context, configuredKeywords) {
519
+ export function getSuggestions(context, configuredKeywords, configuredCombinators = DEFAULT_COMBINATORS) {
248
520
  if (!context || context.mode === 'none') return [];
249
521
 
250
522
  const suggestions = [];
@@ -268,6 +540,44 @@ export function getSuggestions(context, configuredKeywords) {
268
540
  });
269
541
  }
270
542
  }
543
+
544
+ if (!context.operator) {
545
+ const normalizedCombs = normalizeCombinators(configuredCombinators);
546
+ if (normalizedCombs.length > 0) {
547
+ let shouldIncludeCombinators = true;
548
+ if (!q && Array.isArray(context.tokens)) {
549
+ const precedingTokens = context.tokens.filter(
550
+ (t) => t.type !== 'whitespace' && t.end <= context.replaceStart
551
+ );
552
+ const lastPreceding = precedingTokens[precedingTokens.length - 1];
553
+ // Don't suggest combinators at the very start of an empty query (unless typing a query prefix),
554
+ // immediately after another combinator, or immediately after an opening delimiter
555
+ if (
556
+ precedingTokens.length === 0 ||
557
+ lastPreceding?.type === 'combinator' ||
558
+ (lastPreceding?.type === 'delimiter' && lastPreceding?.role === 'open')
559
+ ) {
560
+ shouldIncludeCombinators = false;
561
+ }
562
+ }
563
+ if (shouldIncludeCombinators) {
564
+ for (const comb of normalizedCombs) {
565
+ const combLower = comb.toLowerCase();
566
+ if (!q || combLower.startsWith(q)) {
567
+ suggestions.push({
568
+ type: 'combinator',
569
+ id: comb,
570
+ combinator: comb,
571
+ label: comb,
572
+ display: comb,
573
+ insertText: comb,
574
+ description: 'Combinator',
575
+ });
576
+ }
577
+ }
578
+ }
579
+ }
580
+ }
271
581
  } else if (context.mode === 'value') {
272
582
  const kwConfig = configuredKeywords.get(context.keywordLower);
273
583
  if (!kwConfig) return [];
@@ -322,12 +632,23 @@ export function applySuggestion(inputStr, suggestion, context) {
322
632
  let after = inputStr.slice(replaceEnd);
323
633
  let insertText = suggestion.insertText;
324
634
 
635
+ const followingToken = Array.isArray(context.tokens)
636
+ ? context.tokens.find((t) => t.start === replaceEnd)
637
+ : null;
638
+ const isFollowedByCloseDelimiter =
639
+ followingToken?.type === 'delimiter' && followingToken?.role === 'close';
640
+
325
641
  if (suggestion.type === 'keyword') {
326
642
  // When inserting a keyword like `mix:`, do not add trailing space so user can immediately type value
327
643
  // If after text starts with colon, remove it to avoid `mix::`
328
644
  if (after.startsWith(':')) {
329
645
  after = after.slice(1);
330
- } else if (context.token?.type !== 'keyword' && after.length > 0 && !/^\s/.test(after)) {
646
+ } else if (
647
+ context.token?.type !== 'keyword' &&
648
+ after.length > 0 &&
649
+ !/^\s/.test(after) &&
650
+ !isFollowedByCloseDelimiter
651
+ ) {
331
652
  // Ensure space before following token so it doesn't become the value of this keyword
332
653
  after = ' ' + after;
333
654
  }
@@ -337,9 +658,9 @@ export function applySuggestion(inputStr, suggestion, context) {
337
658
  }
338
659
 
339
660
  // When inserting a value (e.g. `"We Play House Recordings"`)
340
- // Add a trailing space if after does not already start with whitespace
661
+ // Add a trailing space if after does not already start with whitespace or a closing delimiter
341
662
  const hasLeadingSpaceAfter = /^\s/.test(after);
342
- if (after.length === 0 || !hasLeadingSpaceAfter) {
663
+ if ((after.length === 0 || !hasLeadingSpaceAfter) && !isFollowedByCloseDelimiter) {
343
664
  insertText += ' ';
344
665
  }
345
666