@juspay/svelte-ui-components 4.54.2 → 4.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,7 @@
65
65
  statusText,
66
66
  statusTestId,
67
67
  slashCommands = null,
68
+ inlineTriggers = null,
68
69
  slashMenuAriaLabel = 'slash commands',
69
70
  slashMenuHint = '↑↓ move · tab complete · ↵ select · esc dismiss',
70
71
  slashMenuClasses,
@@ -96,6 +97,7 @@
96
97
 
97
98
  let fileInput: HTMLInputElement | null = $state(null);
98
99
  let inputElement: HTMLTextAreaElement | null = $state(null);
100
+ let menuRaised = $state(false);
99
101
 
100
102
  export const focus = (options?: FocusOptions): void => {
101
103
  inputElement?.focus(options);
@@ -133,6 +135,10 @@
133
135
  let slashDismissed = $state(false);
134
136
  let slashSelected = $state(0);
135
137
  let slashList: HTMLUListElement | null = $state(null);
138
+ let highlightLayer: HTMLDivElement | null = $state(null);
139
+
140
+ // Where the caret sits, so an inline trigger can open the menu on the word it is in.
141
+ let caret = $state(0);
136
142
 
137
143
  // A command opens on the first character of its name: "/model" on "/",
138
144
  // "@support" on "@". Letters, digits and whitespace never trigger, so a
@@ -143,12 +149,31 @@
143
149
  return /^[^\p{L}\p{N}\s]$/u.test(first) ? first : null;
144
150
  }
145
151
 
152
+ // The word ending at the caret when it starts with an inline trigger: "@ge" in
153
+ // "ask @ge|". Null unless the caret sits at the end of that word.
154
+ const inlineWord = $derived.by((): { start: number; text: string } | null => {
155
+ if (inlineTriggers === null || slashCommands === null) {
156
+ return null;
157
+ }
158
+ const text = /(?:^|\s)(\S*)$/.exec(value.slice(0, caret))?.[1] ?? '';
159
+ const trigger = slashTrigger(text);
160
+ if (trigger === null || !inlineTriggers.includes(trigger) || /^\S/.test(value.slice(caret))) {
161
+ return null;
162
+ }
163
+ return { start: caret - text.length, text };
164
+ });
165
+
146
166
  const slashMatches = $derived.by((): ChatComposerSlashCommand[] => {
147
- const trigger = slashTrigger(value);
148
- if (slashCommands === null || slashDismissed || trigger === null || /\s/.test(value)) {
167
+ const typed = inlineWord?.text ?? value;
168
+ const trigger = slashTrigger(typed);
169
+ if (slashCommands === null || slashDismissed || trigger === null || /\s/.test(typed)) {
149
170
  return [];
150
171
  }
151
- const query = value.toLowerCase();
172
+ // An inline trigger opens only on the word at the caret.
173
+ if (inlineWord === null && inlineTriggers?.includes(trigger)) {
174
+ return [];
175
+ }
176
+ const query = typed.toLowerCase();
152
177
  return slashCommands.filter(
153
178
  (command) =>
154
179
  slashTrigger(command.name) === trigger &&
@@ -163,7 +188,8 @@
163
188
  return null;
164
189
  }
165
190
  const parsed = /^(\S+)\s(\S*)$/.exec(value);
166
- if (parsed === null || slashTrigger(parsed[1] ?? '') === null) {
191
+ const trigger = parsed === null ? null : slashTrigger(parsed[1] ?? '');
192
+ if (parsed === null || trigger === null || inlineTriggers?.includes(trigger)) {
167
193
  return null;
168
194
  }
169
195
  const name = (parsed[1] ?? '').toLowerCase();
@@ -229,6 +255,15 @@
229
255
  if (typeof command !== 'object') {
230
256
  return;
231
257
  }
258
+ // Inline: replace only the word at the caret and keep the rest of the text.
259
+ const word = inlineWord;
260
+ if (word !== null) {
261
+ const rest = value.slice(caret).replace(/^\s/, '');
262
+ value = `${value.slice(0, word.start)}${command.name} ${rest}`;
263
+ placeCaret(word.start + command.name.length + 1);
264
+ slashSelected = 0;
265
+ return;
266
+ }
232
267
  // A command that takes arguments gets a trailing space and waits for
233
268
  // them; with enumerable choices the menu stays open for stage two.
234
269
  value = `${command.name} `;
@@ -236,6 +271,111 @@
236
271
  slashSelected = 0;
237
272
  }
238
273
 
274
+ function placeCaret(position: number): void {
275
+ caret = position;
276
+ void tick().then(() => {
277
+ inputElement?.focus();
278
+ inputElement?.setSelectionRange(position, position);
279
+ });
280
+ }
281
+
282
+ function trackCaret(event: Event & { currentTarget: HTMLTextAreaElement }): void {
283
+ caret = event.currentTarget.selectionStart ?? value.length;
284
+ }
285
+
286
+ // Command names with an inline trigger, drawn in the mention colour wherever they sit.
287
+ const mentionNames = $derived.by((): Set<string> => {
288
+ if (inlineTriggers === null || slashCommands === null) {
289
+ return new Set();
290
+ }
291
+ const triggers = inlineTriggers;
292
+ return new Set(
293
+ slashCommands
294
+ .map((command) => command.name.toLowerCase())
295
+ .filter((name) => {
296
+ const trigger = slashTrigger(name);
297
+ return trigger !== null && triggers.includes(trigger);
298
+ })
299
+ );
300
+ });
301
+
302
+ function syncHighlightScroll(): void {
303
+ if (highlightLayer !== null && inputElement !== null) {
304
+ highlightLayer.scrollTop = inputElement.scrollTop;
305
+ }
306
+ }
307
+
308
+ // Painted from script, not the template: the layer must hold the text exactly as typed,
309
+ // and template whitespace would be collapsed. The trailing space keeps a final newline
310
+ // its own line, as the textarea shows it.
311
+ const paintMentions: Action<HTMLDivElement, { text: string; names: Set<string> }> = (
312
+ node,
313
+ initial
314
+ ) => {
315
+ function paint({ text, names }: { text: string; names: Set<string> }): void {
316
+ node.replaceChildren(
317
+ ...text.split(/(\s+)/).map((part) => {
318
+ if (!names.has(part.toLowerCase())) {
319
+ return document.createTextNode(part);
320
+ }
321
+ const mention = document.createElement('span');
322
+ mention.className = 'mention';
323
+ mention.textContent = part;
324
+ return mention;
325
+ }),
326
+ document.createTextNode(' ')
327
+ );
328
+ syncHighlightScroll();
329
+ }
330
+ paint(initial);
331
+ return { update: paint };
332
+ };
333
+
334
+ // The menu opens in the browser's top layer where it can, so no ancestor's overflow or
335
+ // mask clips it, and is then placed against the composer exactly where it sat before.
336
+ // Without popover support it stays absolutely positioned inside the composer.
337
+ const raiseMenu: Action<HTMLDivElement> = (node) => {
338
+ // The menu is the composer's own child, so its parent is the box it sits above.
339
+ const anchor = node.parentElement;
340
+ if (anchor === null || typeof node.showPopover !== 'function') {
341
+ return {};
342
+ }
343
+ node.setAttribute('popover', 'manual');
344
+ try {
345
+ node.showPopover();
346
+ } catch {
347
+ node.removeAttribute('popover');
348
+ return {};
349
+ }
350
+ menuRaised = true;
351
+ function place(): void {
352
+ if (anchor === null) {
353
+ return;
354
+ }
355
+ const rect = anchor.getBoundingClientRect();
356
+ const outset =
357
+ parseFloat(
358
+ getComputedStyle(anchor).getPropertyValue('--chat-composer-slash-menu-outset')
359
+ ) || 0;
360
+ node.style.left = `${rect.left + anchor.clientLeft - outset}px`;
361
+ node.style.width = `${anchor.clientWidth + outset * 2}px`;
362
+ node.style.bottom = `${document.documentElement.clientHeight - (rect.top + anchor.clientTop) + outset}px`;
363
+ }
364
+ place();
365
+ const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(place) : null;
366
+ resizeObserver?.observe(anchor);
367
+ window.addEventListener('resize', place);
368
+ window.addEventListener('scroll', place, true);
369
+ return {
370
+ destroy() {
371
+ resizeObserver?.disconnect();
372
+ window.removeEventListener('resize', place);
373
+ window.removeEventListener('scroll', place, true);
374
+ menuRaised = false;
375
+ }
376
+ };
377
+ };
378
+
239
379
  // Keeps the highlighted row in view while the list scrolls.
240
380
  function revealSlash(): void {
241
381
  void tick().then(() => {
@@ -265,8 +405,8 @@
265
405
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
266
406
  event.preventDefault();
267
407
  // Enter on a complete match submits it; otherwise it completes and
268
- // the next Enter submits.
269
- if (value.trim() === slashCompletion(slashActive)) {
408
+ // the next Enter submits. An inline word always completes.
409
+ if (inlineWord === null && value.trim() === slashCompletion(slashActive)) {
270
410
  submit();
271
411
  } else {
272
412
  pickSlash(slashActive);
@@ -342,6 +482,7 @@
342
482
  // A fresh edit un-dismisses the slash menu and resets its highlight.
343
483
  slashDismissed = false;
344
484
  slashSelected = 0;
485
+ trackCaret(event);
345
486
  oninput?.(event.currentTarget.value, event);
346
487
  }
347
488
 
@@ -409,8 +550,25 @@
409
550
  {/snippet}
410
551
 
411
552
  {#snippet textInput()}
553
+ {#if inlineTriggers !== null}
554
+ <div class="input-field">
555
+ <div
556
+ class="input-highlights"
557
+ aria-hidden="true"
558
+ bind:this={highlightLayer}
559
+ use:paintMentions={{ text: value, names: mentionNames }}
560
+ ></div>
561
+ {@render textArea()}
562
+ </div>
563
+ {:else}
564
+ {@render textArea()}
565
+ {/if}
566
+ {/snippet}
567
+
568
+ {#snippet textArea()}
412
569
  <textarea
413
570
  class="input"
571
+ class:inline-mentions={inlineTriggers !== null}
414
572
  bind:this={inputElement}
415
573
  data-pw={inputTestId ?? null}
416
574
  bind:value
@@ -427,6 +585,9 @@
427
585
  oninput={handleInput}
428
586
  onfocus={(event) => oninputfocus?.(event)}
429
587
  onkeydown={handleKeydown}
588
+ onkeyup={trackCaret}
589
+ onclick={trackCaret}
590
+ onscroll={syncHighlightScroll}
430
591
  onpaste={(event) => onpaste?.(event)}
431
592
  use:autoGrow={value}
432
593
  ></textarea>
@@ -518,10 +679,12 @@
518
679
  {#if slashOpen}
519
680
  <div
520
681
  class="slash-menu {slashMenuClasses ?? ''}"
682
+ class:top-layer={menuRaised}
521
683
  role="listbox"
522
684
  id={slashListboxId}
523
685
  aria-label={slashMenuAriaLabel}
524
686
  use:slashDismissal
687
+ use:raiseMenu
525
688
  >
526
689
  <!-- role="presentation": a listbox's accessible children must be its own role=option
527
690
  rows. Left as a plain <ul>, this list's implicit role=list would sit between the
@@ -541,6 +704,7 @@
541
704
  >
542
705
  <Button
543
706
  type="button"
707
+ variant="ghost"
544
708
  classes={[slashItemClasses, index === slashActive ? slashSelectedItemClasses : '']
545
709
  .filter((value) => typeof value === 'string' && value.length > 0)
546
710
  .join(' ')}
@@ -716,6 +880,58 @@
716
880
  overflow-y: auto;
717
881
  }
718
882
 
883
+ /* Inline mentions: an identical text layer sits behind a transparent textarea and
884
+ colours the mentions, so the caret, selection and editing stay the textarea's own. */
885
+ .input-field {
886
+ position: relative;
887
+ flex: 1;
888
+ min-width: 0;
889
+ display: flex;
890
+ }
891
+
892
+ .input-field .input {
893
+ position: relative;
894
+ width: 100%;
895
+ scrollbar-gutter: stable;
896
+ }
897
+
898
+ .input.inline-mentions {
899
+ color: transparent;
900
+ caret-color: var(--chat-composer-color, #18181b);
901
+ }
902
+
903
+ .input.inline-mentions::selection {
904
+ color: var(--chat-composer-color, #18181b);
905
+ }
906
+
907
+ .input-highlights {
908
+ position: absolute;
909
+ inset: 0;
910
+ box-sizing: border-box;
911
+ overflow: hidden;
912
+ scrollbar-gutter: stable;
913
+ pointer-events: none;
914
+ white-space: pre-wrap;
915
+ overflow-wrap: break-word;
916
+ letter-spacing: normal;
917
+ word-spacing: normal;
918
+ font-family: var(--chat-composer-font-family, inherit);
919
+ font-size: var(--chat-composer-font-size, 0.9375rem);
920
+ font-weight: var(--chat-composer-font-weight, 400);
921
+ line-height: var(--chat-composer-line-height, 1.5);
922
+ color: var(--chat-composer-color, #18181b);
923
+ padding: var(--chat-composer-input-padding, 6px 4px);
924
+ }
925
+
926
+ /* The mention must lay out exactly like the textarea's text, so it inherits every font
927
+ property: a consumer's global span styles would otherwise shift it off the caret. */
928
+ .input-highlights :global(.mention) {
929
+ font: inherit;
930
+ letter-spacing: inherit;
931
+ word-spacing: inherit;
932
+ color: var(--chat-composer-mention-color, var(--chat-composer-slash-name-color, #2563eb));
933
+ }
934
+
719
935
  .input::placeholder {
720
936
  color: var(--chat-composer-placeholder-color, #52525b);
721
937
  }
@@ -792,10 +1008,24 @@
792
1008
  margin-bottom: var(--chat-composer-slash-menu-gap, 8px);
793
1009
  overflow: hidden;
794
1010
  z-index: var(--chat-composer-slash-menu-z-index, 30);
795
- background: var(--chat-composer-slash-menu-background, #ffffff);
796
- border: var(--chat-composer-slash-menu-border, 1px solid #e4e4e7);
1011
+ background: var(--chat-composer-slash-menu-background, var(--menu-background-color, #ffffff));
1012
+ border: var(--chat-composer-slash-menu-border, var(--menu-border, 1px solid #e0e0e0));
797
1013
  border-radius: var(--chat-composer-slash-menu-border-radius, 12px);
798
- box-shadow: var(--chat-composer-slash-menu-box-shadow, 0 8px 24px rgb(0 0 0 / 12%));
1014
+ box-shadow: var(
1015
+ --chat-composer-slash-menu-box-shadow,
1016
+ var(--menu-box-shadow, 0px 4px 16px rgba(0, 0, 0, 0.12))
1017
+ );
1018
+ }
1019
+
1020
+ /* In the top layer the menu is placed by script against the composer, so the popover's
1021
+ own defaults (inset, auto margin, padding, overflow) are cleared. */
1022
+ .chat-composer > .slash-menu.top-layer {
1023
+ position: fixed;
1024
+ inset: auto;
1025
+ box-sizing: border-box;
1026
+ margin: 0 0 var(--chat-composer-slash-menu-gap, 8px);
1027
+ padding: 0;
1028
+ color: inherit;
799
1029
  }
800
1030
 
801
1031
  .slash-menu > .slash-list {
@@ -808,17 +1038,40 @@
808
1038
  gap: var(--chat-composer-slash-list-gap, 2px);
809
1039
  }
810
1040
 
1041
+ /* Rows are menu items, not buttons: the row sets its Button's tokens itself so a
1042
+ consumer's app-wide button fill never reaches it, and the highlighted row is
1043
+ tinted by the <li> beneath a transparent Button. */
811
1044
  .slash-menu .slash-list li {
812
1045
  min-width: 0;
1046
+ border-radius: var(--chat-composer-slash-item-border-radius, 8px);
1047
+ --button-color: var(--chat-composer-slash-item-background, transparent);
1048
+ --button-background: none;
1049
+ --button-hover-color: var(--chat-composer-slash-item-hover-background, transparent);
1050
+ --button-text-color: var(--chat-composer-slash-item-color, var(--menu-item-color, #333333));
1051
+ --button-hover-text-color: var(
1052
+ --chat-composer-slash-item-color,
1053
+ var(--menu-item-color, #333333)
1054
+ );
1055
+ --button-border: none;
1056
+ --button-box-shadow: none;
1057
+ --button-border-radius: var(--chat-composer-slash-item-border-radius, 8px);
1058
+ --button-width: 100%;
1059
+ --button-height: auto;
1060
+ --button-justify-content: flex-start;
1061
+ --button-padding: var(--chat-composer-slash-item-padding, 8px 12px);
1062
+ --button-content-gap: var(--chat-composer-slash-item-gap, 8px);
813
1063
  }
814
1064
 
815
1065
  .slash-menu > .slash-foot {
816
1066
  padding: var(--chat-composer-slash-foot-padding, 4px 12px);
817
- border-top: var(--chat-composer-slash-foot-border, 1px solid #e4e4e7);
1067
+ border-top: var(
1068
+ --chat-composer-slash-foot-border,
1069
+ 1px solid var(--menu-separator-color, #e0e0e0)
1070
+ );
818
1071
  font-family: var(--chat-composer-slash-foot-font-family, inherit);
819
1072
  font-variant-ligatures: var(--chat-composer-slash-foot-font-variant-ligatures, inherit);
820
1073
  font-size: var(--chat-composer-slash-foot-font-size, 12px);
821
- color: var(--chat-composer-slash-foot-color, #71717a);
1074
+ color: var(--chat-composer-slash-foot-color, var(--menu-item-color, #333333));
822
1075
  }
823
1076
 
824
1077
  /* The default row content (a consumer's slashItem snippet replaces it). */
@@ -837,6 +1090,7 @@
837
1090
  .slash-menu .slash-desc {
838
1091
  flex: 1;
839
1092
  min-width: 0;
1093
+ text-align: start;
840
1094
  overflow: hidden;
841
1095
  text-overflow: ellipsis;
842
1096
  white-space: nowrap;
@@ -849,7 +1103,10 @@
849
1103
  Scoped so a consumer's own class stays authoritative the moment it's
850
1104
  supplied: this rule simply never applies then. */
851
1105
  .slash-menu .slash-item-selected-default {
852
- background: var(--chat-composer-slash-item-selected-background, #f4f4f5);
1106
+ background: var(
1107
+ --chat-composer-slash-item-selected-background,
1108
+ var(--menu-item-hover-background-color, #f5f5f5)
1109
+ );
853
1110
  }
854
1111
 
855
1112
  .slash-menu .slash-badge {
@@ -111,6 +111,14 @@ export type OptionalChatComposerProperties = {
111
111
  * stage listing them. `null` (the default) turns the whole feature off.
112
112
  */
113
113
  slashCommands?: ChatComposerSlashCommand[] | null;
114
+ /**
115
+ * Triggers that open the command menu on the word at the caret, anywhere in the text,
116
+ * instead of only when the whole input is one command: `['@']` makes "@geo" mid-sentence
117
+ * list the `@` commands, and picking one replaces just that word. Each command name with
118
+ * one of these triggers is drawn in `--chat-composer-mention-color` wherever it appears in
119
+ * the input. `null` (the default) keeps every trigger start-of-input only.
120
+ */
121
+ inlineTriggers?: string[] | null;
114
122
  /** Accessible name of the slash-command listbox. Defaults to 'slash commands'. */
115
123
  slashMenuAriaLabel?: string;
116
124
  /** Key-hint line under the slash-command list. */
@@ -43,7 +43,8 @@ export declare function getFocusTrapBoundary(container: ParentNode | null): Focu
43
43
  * element focus should move to, or null when the default browser behavior
44
44
  * (move to the next/previous focusable element) should be left alone.
45
45
  *
46
- * Wrapping only happens exactly at an edge: focus sits on the last focusable
46
+ * An empty panel keeps focus on itself when it is already the active element.
47
+ * Otherwise wrapping only happens exactly at an edge: focus sits on the last focusable
47
48
  * element and Tab (not Shift+Tab) was pressed, or focus sits on the first and
48
49
  * Shift+Tab was pressed. Anywhere else in the middle of the tab order, the
49
50
  * browser's own handling is correct and this returns null.
@@ -95,7 +95,8 @@ export function getFocusTrapBoundary(container) {
95
95
  * element focus should move to, or null when the default browser behavior
96
96
  * (move to the next/previous focusable element) should be left alone.
97
97
  *
98
- * Wrapping only happens exactly at an edge: focus sits on the last focusable
98
+ * An empty panel keeps focus on itself when it is already the active element.
99
+ * Otherwise wrapping only happens exactly at an edge: focus sits on the last focusable
99
100
  * element and Tab (not Shift+Tab) was pressed, or focus sits on the first and
100
101
  * Shift+Tab was pressed. Anywhere else in the middle of the tab order, the
101
102
  * browser's own handling is correct and this returns null.
@@ -103,7 +104,14 @@ export function getFocusTrapBoundary(container) {
103
104
  export function focusTrapTabTarget(params) {
104
105
  const { first, last } = getFocusTrapBoundary(params.container);
105
106
  if (first === null || last === null) {
106
- return null;
107
+ // focusEntryPoint focuses the panel itself when it has no controls. Keep
108
+ // that panel, but never take focus from another (possibly nested) layer.
109
+ const active = params.activeElement;
110
+ return active === params.container &&
111
+ typeof HTMLElement === 'function' &&
112
+ active instanceof HTMLElement
113
+ ? active
114
+ : null;
107
115
  }
108
116
  const edge = params.shiftKey ? first : last;
109
117
  if (params.activeElement !== edge) {