@juspay/svelte-ui-components 2.134.1 → 2.136.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.
Files changed (38) hide show
  1. package/dist/Chat/Chat.svelte +38 -2
  2. package/dist/Chat/properties.d.ts +7 -0
  3. package/dist/ChatMessageList/ChatMessageList.svelte +54 -1
  4. package/dist/CheckListItem/CheckListItem.svelte +4 -1
  5. package/dist/ChipInput/ChipInput.svelte +150 -10
  6. package/dist/ChipInput/properties.d.ts +18 -1
  7. package/dist/CommandMenu/CommandMenu.svelte +7 -3
  8. package/dist/EmptyState/EmptyState.svelte +17 -3
  9. package/dist/Input/Input.svelte +1 -0
  10. package/dist/ListItem/ListItem.svelte +14 -12
  11. package/dist/ListItem/properties.d.ts +7 -0
  12. package/dist/Menu/Menu.svelte +2 -1
  13. package/dist/Menu/properties.d.ts +2 -0
  14. package/dist/Modal/Modal.svelte +3 -3
  15. package/dist/Pill/Pill.svelte +2 -0
  16. package/dist/Pill/properties.d.ts +1 -0
  17. package/dist/Sheet/Sheet.svelte +3 -23
  18. package/dist/StatCard/StatCard.svelte +64 -1
  19. package/dist/StatCard/properties.d.ts +44 -2
  20. package/dist/Status/Status.svelte +2 -1
  21. package/dist/Status/properties.d.ts +10 -0
  22. package/dist/Stepper/Step.svelte +95 -2
  23. package/dist/Stepper/Stepper.svelte +52 -7
  24. package/dist/Stepper/properties.d.ts +34 -1
  25. package/dist/Table/BuiltinCell.svelte +46 -23
  26. package/dist/Table/Table.svelte +63 -40
  27. package/dist/Table/cellData.js +3 -0
  28. package/dist/Table/properties.d.ts +70 -0
  29. package/dist/ThinkingIndicator/ThinkingIndicator.svelte +93 -5
  30. package/dist/ThinkingIndicator/properties.d.ts +7 -3
  31. package/dist/TypewriterText/TypewriterText.svelte +67 -2
  32. package/dist/TypewriterText/properties.d.ts +97 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +1 -1
  35. package/dist/utils.d.ts +2 -0
  36. package/dist/utils.js +30 -0
  37. package/dist-wc/index.js +2937 -2658
  38. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  import ChatMessageList from '../ChatMessageList/ChatMessageList.svelte';
4
4
  import ChatComposer from '../ChatComposer/ChatComposer.svelte';
5
5
  import ChatSuggestions from '../ChatSuggestions/ChatSuggestions.svelte';
6
- import ChatToolStatus from '../ChatToolStatus/ChatToolStatus.svelte';
6
+ import ThinkingIndicator from '../ThinkingIndicator/ThinkingIndicator.svelte';
7
7
  import type { ChatProperties } from './properties';
8
8
 
9
9
  let {
@@ -18,6 +18,11 @@
18
18
  streaming = false,
19
19
  recording = false,
20
20
  autoscroll = true,
21
+ scrollPolicy = 'near-bottom',
22
+ pinHold = false,
23
+ jump = true,
24
+ jumpLabel = 'Jump to latest',
25
+ jumpIcon,
21
26
  toolStatus = null,
22
27
  suggestions = [],
23
28
  attachments = $bindable([]),
@@ -46,6 +51,7 @@
46
51
  onattach,
47
52
  onretry,
48
53
  onfeedback,
54
+ onscrollstate,
49
55
  testId,
50
56
  classes
51
57
  }: ChatProperties = $props();
@@ -92,6 +98,11 @@
92
98
  <ChatMessageList
93
99
  {messages}
94
100
  {autoscroll}
101
+ {scrollPolicy}
102
+ {pinHold}
103
+ {jump}
104
+ {jumpLabel}
105
+ {jumpIcon}
95
106
  {message}
96
107
  {messageBody}
97
108
  {messageAttachments}
@@ -99,6 +110,7 @@
99
110
  {allowCopy}
100
111
  {onretry}
101
112
  {onfeedback}
113
+ {onscrollstate}
102
114
  />
103
115
 
104
116
  <div class="footer">
@@ -106,7 +118,7 @@
106
118
  <ChatSuggestions items={suggestions} {disabled} onselect={handleSuggestion} />
107
119
  {/if}
108
120
  {#if toolStatus !== null}
109
- <div class="tool-status"><ChatToolStatus label={toolStatus.label} /></div>
121
+ <div class="tool-status"><ThinkingIndicator label={toolStatus.label} variant="chip" /></div>
110
122
  {/if}
111
123
  <ChatComposer
112
124
  bind:value
@@ -156,5 +168,29 @@
156
168
  .tool-status {
157
169
  display: flex;
158
170
  justify-content: var(--chat-tool-status-justify, center);
171
+ /* This row is ThinkingIndicator's chip variant internally now, not the standalone
172
+ ChatToolStatus component -- but its public theming contract stays
173
+ --chat-tool-status-*, unchanged, so an existing consumer's overrides keep working
174
+ exactly as before. Defaults below match ChatToolStatus's own byte for byte. */
175
+ --thinking-indicator-chip-gap: var(--chat-tool-status-gap, 8px);
176
+ --thinking-indicator-chip-padding: var(--chat-tool-status-padding, 8px 14px);
177
+ --thinking-indicator-chip-background: var(--chat-tool-status-background, #ffffff);
178
+ --thinking-indicator-chip-border: var(--chat-tool-status-border, 1px solid #e4e4e7);
179
+ --thinking-indicator-chip-border-radius: var(--chat-tool-status-border-radius, 999px);
180
+ --thinking-indicator-chip-box-shadow: var(
181
+ --chat-tool-status-box-shadow,
182
+ 0 6px 20px rgba(0, 0, 0, 0.08)
183
+ );
184
+ --thinking-indicator-chip-max-width: var(--chat-tool-status-max-width, 100%);
185
+ --thinking-indicator-chip-color: var(--chat-tool-status-color, #52525b);
186
+ --thinking-indicator-chip-font-size: var(--chat-tool-status-font-size, 0.85rem);
187
+ --thinking-indicator-chip-font-weight: var(--chat-tool-status-font-weight, 500);
188
+ --thinking-indicator-chip-icon-color: var(--chat-tool-status-indicator-color, currentColor);
189
+ --thinking-indicator-chip-spinner-color: var(--chat-tool-status-spinner-color, currentColor);
190
+ --thinking-indicator-chip-spinner-color-end: var(
191
+ --chat-tool-status-spinner-color-end,
192
+ transparent
193
+ );
194
+ --thinking-indicator-chip-spinner-size: var(--chat-tool-status-spinner-size, 14px);
159
195
  }
160
196
  </style>
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { ChatMessageData, ChatToolStatus } from './types';
3
3
  import type { ChatMessageFeedback } from '../ChatMessage/properties';
4
+ import type { ChatMessageListProperties } from '../ChatMessageList/properties';
4
5
  import type { ChatSuggestion } from '../ChatSuggestions/properties';
5
6
  export type ChatProperties = OptionalChatProperties & ChatEventProperties & MandatoryChatProperties;
6
7
  export type MandatoryChatProperties = {
@@ -17,6 +18,11 @@ export type OptionalChatProperties = {
17
18
  streaming?: boolean;
18
19
  recording?: boolean;
19
20
  autoscroll?: boolean;
21
+ scrollPolicy?: ChatMessageListProperties['scrollPolicy'];
22
+ pinHold?: ChatMessageListProperties['pinHold'];
23
+ jump?: ChatMessageListProperties['jump'];
24
+ jumpLabel?: ChatMessageListProperties['jumpLabel'];
25
+ jumpIcon?: ChatMessageListProperties['jumpIcon'];
20
26
  toolStatus?: ChatToolStatus | null;
21
27
  suggestions?: ChatSuggestion[];
22
28
  attachments?: File[];
@@ -50,4 +56,5 @@ export type ChatEventProperties = {
50
56
  onattach?: (files: File[]) => void;
51
57
  onretry?: () => void;
52
58
  onfeedback?: (value: ChatMessageFeedback, message: ChatMessageData) => void;
59
+ onscrollstate?: ChatMessageListProperties['onscrollstate'];
53
60
  };
@@ -35,6 +35,11 @@
35
35
  let atBottom = $state(true);
36
36
  let scrollable = $state(false);
37
37
  let pinActive = false;
38
+ // Set when a turn without pinHold has asked for its reservation back, but the reply is not yet
39
+ // tall enough to hold the pinned position without it. Cleared by tryReleasePin.
40
+ let pinReleasePending = false;
41
+ // The scroll offset the pin put the sender message at, so a later release can restore it.
42
+ let pinnedScrollTop = 0;
38
43
 
39
44
  let scrollKey = $derived(`${messages.length}:${messages.at(-1)?.content.length ?? 0}`);
40
45
  let showJump = $derived(!atBottom && messages.length > 0);
@@ -133,7 +138,13 @@
133
138
  pinActive = true;
134
139
  const listRect = listEl.getBoundingClientRect();
135
140
  const paddingTop = Number.parseFloat(getComputedStyle(listEl).paddingTop) || 0;
136
- listEl.scrollTop += targetRect.top - listRect.top - paddingTop;
141
+ // This is a layout correction, not a reader-initiated scroll, so it must land instantly.
142
+ // .chat-message-list sets scroll-behavior: smooth, under which `scrollTop = x` starts an
143
+ // ANIMATION: scrollTop still reads its old value on the next line, and anything that shrinks
144
+ // the scrollable range before the animation finishes clamps it partway. Releasing the
145
+ // reservation does exactly that, which is how the pin was being lost.
146
+ pinnedScrollTop = listEl.scrollTop + (targetRect.top - listRect.top - paddingTop);
147
+ listEl.scrollTo({ top: pinnedScrollTop, behavior: 'instant' });
137
148
  reportScrollState(listEl);
138
149
  }
139
150
 
@@ -142,11 +153,45 @@
142
153
  innerEl.style.minHeight = '';
143
154
  }
144
155
  pinActive = false;
156
+ pinReleasePending = false;
145
157
  if (listEl !== null) {
146
158
  reportScrollState(listEl);
147
159
  }
148
160
  }
149
161
 
162
+ /**
163
+ * Give the reserved headroom back only once the turn's own content can hold the pinned position.
164
+ *
165
+ * The reservation is what makes the sender message able to sit at the top at all. Clearing it
166
+ * while the reply is still shorter than the frame shrinks the scrollable range below the pinned
167
+ * offset, the browser clamps, and the question slides back down -- on short answers, which is
168
+ * where pinning matters most. So measure rather than assume: lift the reservation, check whether
169
+ * the natural content still reaches the pinned offset, and put it straight back if it does not.
170
+ * Reply growth re-runs this through the resize observer, so the reservation disappears by itself
171
+ * the moment it stops being load-bearing.
172
+ */
173
+ function tryReleasePin(): void {
174
+ if (!pinReleasePending || listEl === null || innerEl === null) {
175
+ return;
176
+ }
177
+ const reserved = innerEl.style.minHeight;
178
+ // Lifting the reservation to measure also shrinks the scrollable range, and the browser
179
+ // clamps scrollTop to the smaller maximum the moment it does. Putting the reservation back
180
+ // does NOT undo that clamp, so the probe would silently destroy the very position it is
181
+ // checking. Restore the offset explicitly on both paths.
182
+ innerEl.style.minHeight = '';
183
+ const naturalMaxScroll = listEl.scrollHeight - listEl.clientHeight;
184
+ if (naturalMaxScroll >= pinnedScrollTop) {
185
+ listEl.scrollTo({ top: pinnedScrollTop, behavior: 'instant' });
186
+ pinActive = false;
187
+ pinReleasePending = false;
188
+ reportScrollState(listEl);
189
+ return;
190
+ }
191
+ innerEl.style.minHeight = reserved;
192
+ listEl.scrollTo({ top: pinnedScrollTop, behavior: 'instant' });
193
+ }
194
+
150
195
  const pinToBottom: Action<HTMLElement, string> = (node) => {
151
196
  let previousCount = messages.length;
152
197
  let previousSenderId = lastSenderId();
@@ -162,6 +207,13 @@
162
207
  if (newMessage && senderId !== null && senderId !== previousSenderId) {
163
208
  queueMicrotask(() => {
164
209
  pinLatestSenderMessage();
210
+ // A host opts into reserved reply headroom with pinHold. Without it the reservation
211
+ // is still doing work until the reply itself can hold the pin, so mark it for release
212
+ // and let tryReleasePin decide when that is actually true.
213
+ if (!pinHold) {
214
+ pinReleasePending = true;
215
+ tryReleasePin();
216
+ }
165
217
  });
166
218
  }
167
219
  previousSenderId = senderId;
@@ -194,6 +246,7 @@
194
246
  if (scrollPolicy === 'near-bottom' && autoscroll && atBottom) {
195
247
  listEl.scrollTop = listEl.scrollHeight;
196
248
  }
249
+ tryReleasePin();
197
250
  reportScrollState(listEl);
198
251
  });
199
252
  observer.observe(node);
@@ -45,7 +45,6 @@
45
45
  }
46
46
 
47
47
  .container.disabled {
48
- opacity: var(--check-list-item-disabled-opacity, 0.4);
49
48
  cursor: not-allowed;
50
49
  }
51
50
 
@@ -53,6 +52,10 @@
53
52
  flex-shrink: 0;
54
53
  }
55
54
 
55
+ .container.disabled .checkbox-wrapper {
56
+ --checkbox-disabled-opacity: var(--check-list-item-disabled-opacity, 0.4);
57
+ }
58
+
56
59
  .text {
57
60
  font-size: var(--check-list-item-text-size, 14px);
58
61
  color: var(--check-list-item-text-color);
@@ -8,15 +8,26 @@
8
8
  ariaLabel,
9
9
  placeholder = 'Add value…',
10
10
  disabled = false,
11
+ editable = false,
11
12
  testId,
12
13
  classes,
13
14
  onadd,
14
15
  ondismiss,
16
+ onedit,
15
17
  onchange
16
18
  }: ChipInputProperties = $props();
17
19
 
18
20
  let draft = $state('');
19
21
 
22
+ // The chip currently swapped for an inline edit field, held by VALUE rather than by slot
23
+ // index. `values` is dedup-only by construction, so the text is a stable identity, and it is
24
+ // already what the {#each} is keyed by. An index is only a position: if the parent reorders or
25
+ // removes entries while an edit is open, the same index points at a different chip and the
26
+ // commit lands on the wrong one. Only one chip can be mid-edit at a time.
27
+ let editingChip = $state<string | null>(null);
28
+ let editDraft = $state('');
29
+ let editInputRef = $state<{ focus: () => void } | null>(null);
30
+
20
31
  function addChip(): void {
21
32
  const trimmed = draft.trim();
22
33
  draft = '';
@@ -38,6 +49,11 @@
38
49
  return;
39
50
  }
40
51
  values = [...values.slice(0, chipIndex), ...values.slice(chipIndex + 1)];
52
+ // Identity survives a delete elsewhere in the list, so only the edited chip's own removal
53
+ // has to close the field. No index fix-up is needed.
54
+ if (editingChip === chip) {
55
+ cancelEdit();
56
+ }
41
57
  ondismiss?.(chip);
42
58
  onchange?.([...values]);
43
59
  }
@@ -48,18 +64,106 @@
48
64
  addChip();
49
65
  }
50
66
  }
67
+
68
+ function startEdit(chip: string): void {
69
+ if (disabled) {
70
+ return;
71
+ }
72
+ editingChip = chip;
73
+ editDraft = chip;
74
+ queueMicrotask(() => editInputRef?.focus());
75
+ }
76
+
77
+ function cancelEdit(): void {
78
+ editingChip = null;
79
+ editDraft = '';
80
+ }
81
+
82
+ // A commit is only ever valid while the component is enabled and the chip it started on is
83
+ // still present. `disabled` can flip mid-edit, and a pending blur can arrive after it does.
84
+ //
85
+ // Together with the `!disabled` guard on the edit field's {#if}, this is what makes disabling
86
+ // mid-edit safe without an $effect: the field stops rendering, and any Enter or blur already in
87
+ // flight is refused here rather than writing to `values` behind a disabled control.
88
+ function commitEdit(chip: string): void {
89
+ if (disabled) {
90
+ cancelEdit();
91
+ return;
92
+ }
93
+ const index = values.indexOf(chip);
94
+ if (index === -1) {
95
+ cancelEdit();
96
+ return;
97
+ }
98
+ const original = chip;
99
+ const trimmed = editDraft.trim();
100
+ const isUnchanged = trimmed === original;
101
+ // Mirrors addChip's dedup-only contract: a blank edit or one that collides with a
102
+ // DIFFERENT existing chip is silently discarded rather than surfaced as an error.
103
+ const isBlankOrDuplicate = trimmed.length === 0 || (!isUnchanged && values.includes(trimmed));
104
+ if (isUnchanged || isBlankOrDuplicate) {
105
+ cancelEdit();
106
+ return;
107
+ }
108
+ values = values.map((value, valueIndex) => (valueIndex === index ? trimmed : value));
109
+ onedit?.(trimmed, original);
110
+ onchange?.([...values]);
111
+ cancelEdit();
112
+ }
113
+
114
+ function handleEditKeyDown(event: KeyboardEvent, chip: string): void {
115
+ if (event.key === 'Enter') {
116
+ event.preventDefault();
117
+ commitEdit(chip);
118
+ } else if (event.key === 'Escape') {
119
+ event.preventDefault();
120
+ cancelEdit();
121
+ }
122
+ }
123
+
124
+ function handleEditBlur(chip: string): void {
125
+ if (editingChip !== chip) {
126
+ // Already resolved (Escape, or the chip removed) before this trailing blur arrived --
127
+ // committing again would act on an edit that is no longer open.
128
+ return;
129
+ }
130
+ commitEdit(chip);
131
+ }
51
132
  </script>
52
133
 
53
134
  <div class="chip-input {classes ?? ''}" data-pw={testId} testID={testId}>
54
- {#each values as chip (chip)}
55
- <Pill
56
- text={chip}
57
- classes="chip-input-pill"
58
- dismissible={!disabled}
59
- {disabled}
60
- ondismiss={() => removeChip(chip)}
61
- {...typeof testId === 'string' ? { testId: `${testId}-chip` } : {}}
62
- />
135
+ {#each values as chip, index (chip)}
136
+ {#if editable && editingChip === chip && !disabled}
137
+ <div class="chip-input-edit-wrap">
138
+ <Input
139
+ value={editDraft}
140
+ dataType="text"
141
+ name=""
142
+ autoComplete="off"
143
+ actionInput={false}
144
+ classes="chip-input-edit"
145
+ bind:this={editInputRef}
146
+ onInput={(nextValue) => {
147
+ editDraft = nextValue;
148
+ }}
149
+ onKeyDown={(event) => handleEditKeyDown(event, chip)}
150
+ onBlur={() => handleEditBlur(chip)}
151
+ {...typeof testId === 'string' ? { testId: `${testId}-item-${index}-edit` } : {}}
152
+ />
153
+ </div>
154
+ {:else}
155
+ <Pill
156
+ text={chip}
157
+ classes="chip-input-pill"
158
+ dismissible={!disabled}
159
+ {disabled}
160
+ ondismiss={() => removeChip(chip)}
161
+ {...editable && !disabled
162
+ ? { title: `Edit "${chip}"`, onclick: () => startEdit(chip) }
163
+ : {}}
164
+ {...typeof testId === 'string' ? { testId: `${testId}-item-${index}` } : {}}
165
+ />
166
+ {/if}
63
167
  {/each}
64
168
  <div class="chip-input-draft-wrap">
65
169
  <Input
@@ -77,7 +181,7 @@
77
181
  }}
78
182
  onKeyDown={handleKeyDown}
79
183
  onBlur={addChip}
80
- {...typeof testId === 'string' ? { testId: `${testId}-input` } : {}}
184
+ {...typeof testId === 'string' ? { testId: `${testId}-add` } : {}}
81
185
  />
82
186
  </div>
83
187
  </div>
@@ -126,6 +230,10 @@
126
230
  flex: var(--chip-input-draft-flex, 0 1 auto);
127
231
  }
128
232
 
233
+ .chip-input-edit-wrap {
234
+ flex: var(--chip-input-edit-flex, 0 1 auto);
235
+ }
236
+
129
237
  .chip-input :global(.chip-input-pill) {
130
238
  --pill-gap: var(--chip-input-pill-gap, var(--chip-input-pill-gap-default));
131
239
  --pill-background: var(--chip-input-pill-background, var(--chip-input-pill-background-default));
@@ -170,4 +278,36 @@
170
278
  --input-margin: 0;
171
279
  --input-box-shadow: none;
172
280
  }
281
+
282
+ /* The in-place edit field replaces a Pill for the duration of one edit, so it reuses the same
283
+ captured --input-* cascade (--chip-input-draft-*-default, above) the draft field maps from --
284
+ an app that themes Input app-wide gets a matching edit field for free. Only sizing/padding
285
+ get their own --chip-input-edit-* tokens: the field holds an EXISTING (often longer) value
286
+ rather than a short in-progress draft, so it defaults wider. */
287
+ .chip-input-edit-wrap :global(.chip-input-edit) {
288
+ --input-width: var(--chip-input-edit-width, 110px);
289
+ --input-height: var(--chip-input-edit-height, 28px);
290
+ /* Fall through the DRAFT token before the captured default: the edit field is the same
291
+ control as the draft field, wearing the same clothes, so a consumer who themed the draft
292
+ with --chip-input-draft-* must get a matching edit field without naming it twice. */
293
+ --input-border: var(
294
+ --chip-input-edit-border,
295
+ var(--chip-input-draft-border, var(--chip-input-draft-border-default))
296
+ );
297
+ --input-radius: var(
298
+ --chip-input-edit-radius,
299
+ var(--chip-input-draft-radius, var(--chip-input-draft-radius-default))
300
+ );
301
+ --input-focus-border: var(
302
+ --chip-input-edit-focus-border,
303
+ var(--chip-input-draft-focus-border, var(--chip-input-draft-focus-border-default))
304
+ );
305
+ --input-font-size: var(
306
+ --chip-input-edit-font-size,
307
+ var(--chip-input-draft-font-size, var(--chip-input-draft-font-size-default))
308
+ );
309
+ --input-padding: var(--chip-input-edit-padding, 0 8px);
310
+ --input-margin: 0;
311
+ --input-box-shadow: none;
312
+ }
173
313
  </style>
@@ -17,12 +17,29 @@ export type OptionalChipInputProperties = {
17
17
  ariaLabel?: string;
18
18
  placeholder?: string;
19
19
  disabled?: boolean;
20
+ /**
21
+ * Reach for this when a consumer's chips are prone to being ALMOST right -- a misspelled tag,
22
+ * a near-correct email -- and forcing a delete-and-retype round trip for every small correction
23
+ * would be annoying. When `true`, activating a committed chip (click, or Enter/Space once
24
+ * tabbed to it) swaps it for an inline text field pre-filled with its current value: Enter or
25
+ * blurring the field commits the edit back into `values`, Escape restores the original text and
26
+ * leaves `values` untouched. An edit that comes back blank or duplicates another chip is
27
+ * silently discarded, same as a blank/duplicate draft on add. Defaults to `false` -- chips stay
28
+ * display-only and the only way to change one is still to delete it and retype it, unchanged
29
+ * from before this prop existed.
30
+ */
31
+ editable?: boolean;
20
32
  testId?: string;
21
33
  classes?: string;
22
34
  };
23
35
  export type ChipInputEventProperties = {
24
36
  onadd?: (value: string) => void;
25
37
  ondismiss?: (value: string) => void;
26
- /** Fires alongside `onadd`/`ondismiss`, after either has already updated `values`. */
38
+ /**
39
+ * Fires after an in-place edit (see `editable`) commits a value that actually changed. Not
40
+ * fired when the edit is cancelled (Escape) or committed with the text unchanged.
41
+ */
42
+ onedit?: (value: string, previousValue: string) => void;
43
+ /** Fires alongside `onadd`/`ondismiss`/`onedit`, after any of them has already updated `values`. */
27
44
  onchange?: (values: string[]) => void;
28
45
  };
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { CommandMenuProperties, CommandItem } from './properties';
3
3
  import { tick, onMount, onDestroy } from 'svelte';
4
+ import { lockBodyScroll, unlockBodyScroll } from '../utils';
4
5
  import { SvelteMap } from 'svelte/reactivity';
5
6
  import Img from '../Img/Img.svelte';
6
7
  import searchSvg from '../assets/search.svg?raw';
@@ -161,8 +162,12 @@
161
162
  }
162
163
  }
163
164
 
165
+ // Lock and unlock strictly in this action so the pairing is 1:1. Svelte destroys
166
+ // the action when the node goes away, including on component teardown, so the
167
+ // onDestroy below must NOT unlock as well: with a shared reference count a second
168
+ // decrement would release a lock another open surface still needs.
164
169
  function scrollLockAction(_node: HTMLElement) {
165
- document.body.style.overflow = 'hidden';
170
+ lockBodyScroll();
166
171
  tick().then(() => {
167
172
  if (inputElement !== null) {
168
173
  inputElement.focus();
@@ -170,7 +175,7 @@
170
175
  });
171
176
  return {
172
177
  destroy() {
173
- document.body.style.overflow = '';
178
+ unlockBodyScroll();
174
179
  }
175
180
  };
176
181
  }
@@ -181,7 +186,6 @@
181
186
 
182
187
  onDestroy(() => {
183
188
  if (typeof window !== 'undefined') {
184
- document.body.style.overflow = '';
185
189
  window.removeEventListener('keydown', handleGlobalKeyDown);
186
190
  }
187
191
  });
@@ -23,7 +23,11 @@
23
23
  {@render icon()}
24
24
  </div>
25
25
  {/if}
26
- <div class="empty-state-title">
26
+ <div
27
+ class="empty-state-title"
28
+ data-pw={typeof testId === 'string' ? `${testId}-title` : null}
29
+ testID={typeof testId === 'string' ? `${testId}-title` : null}
30
+ >
27
31
  {#if typeof titleSnippet === 'function'}
28
32
  {@render titleSnippet()}
29
33
  {:else}
@@ -31,11 +35,21 @@
31
35
  {/if}
32
36
  </div>
33
37
  {#if typeof descriptionSnippet === 'function'}
34
- <div class="empty-state-description">
38
+ <div
39
+ class="empty-state-description"
40
+ data-pw={typeof testId === 'string' ? `${testId}-description` : null}
41
+ testID={typeof testId === 'string' ? `${testId}-description` : null}
42
+ >
35
43
  {@render descriptionSnippet()}
36
44
  </div>
37
45
  {:else if typeof description === 'string' && description.length > 0}
38
- <div class="empty-state-description">{description}</div>
46
+ <div
47
+ class="empty-state-description"
48
+ data-pw={typeof testId === 'string' ? `${testId}-description` : null}
49
+ testID={typeof testId === 'string' ? `${testId}-description` : null}
50
+ >
51
+ {description}
52
+ </div>
39
53
  {/if}
40
54
  {#if typeof children === 'function'}
41
55
  <div class="empty-state-actions">
@@ -571,6 +571,7 @@
571
571
 
572
572
  .input-icon-left {
573
573
  left: var(--input-icon-gap, 12px);
574
+ color: var(--input-left-icon-color, var(--input-icon-color, inherit));
574
575
  }
575
576
 
576
577
  .input-icon-right {
@@ -20,6 +20,7 @@
20
20
  showRightContentLoader = false,
21
21
  expand = $bindable(false),
22
22
  preventFocus = false,
23
+ suppressRoleAndTabindex = false,
23
24
  leftContent,
24
25
  centerContent,
25
26
  rightContent,
@@ -31,6 +32,7 @@
31
32
  ontopSectionClick,
32
33
  onkeydown,
33
34
  classes,
35
+ transformSvg,
34
36
  role: itemRole,
35
37
  ariaSelected,
36
38
  id
@@ -68,8 +70,8 @@
68
70
  class:prevent-focus={preventFocus}
69
71
  onclick={handleItemClick}
70
72
  {onkeydown}
71
- role={itemRole ?? 'button'}
72
- tabindex={itemRole === 'option' ? -1 : 0}
73
+ role={suppressRoleAndTabindex ? null : (itemRole ?? 'button')}
74
+ tabindex={suppressRoleAndTabindex ? null : itemRole === 'option' ? -1 : 0}
73
75
  aria-selected={ariaSelected}
74
76
  {id}
75
77
  data-pw={testId}
@@ -80,8 +82,8 @@
80
82
  class:prevent-focus={preventFocus}
81
83
  onclick={handleTopSectionClick}
82
84
  {onkeydown}
83
- role="button"
84
- tabindex="0"
85
+ role={suppressRoleAndTabindex ? null : 'button'}
86
+ tabindex={suppressRoleAndTabindex ? null : 0}
85
87
  data-pw={topSectionTestId}
86
88
  testID={topSectionTestId}
87
89
  >
@@ -91,12 +93,12 @@
91
93
  class:prevent-focus={preventFocus}
92
94
  onclick={handleLeftImageClick}
93
95
  {onkeydown}
94
- role="button"
95
- tabindex="0"
96
+ role={suppressRoleAndTabindex ? null : 'button'}
97
+ tabindex={suppressRoleAndTabindex ? null : 0}
96
98
  data-pw={leftImageTestId}
97
99
  testID={leftImageTestId}
98
100
  >
99
- <Img src={leftImageUrl} alt="" fallback={leftImageFallbackUrl} />
101
+ <Img src={leftImageUrl} alt="" fallback={leftImageFallbackUrl} {transformSvg} />
100
102
  </div>
101
103
  {/if}
102
104
  {#if typeof leftContent === 'function'}
@@ -110,8 +112,8 @@
110
112
  class:prevent-focus={preventFocus}
111
113
  onclick={handleCenterTextClick}
112
114
  {onkeydown}
113
- role="button"
114
- tabindex="0"
115
+ role={suppressRoleAndTabindex ? null : 'button'}
116
+ tabindex={suppressRoleAndTabindex ? null : 0}
115
117
  data-pw={centerTextTestId}
116
118
  testID={centerTextTestId}
117
119
  >
@@ -132,13 +134,13 @@
132
134
  class:prevent-focus={preventFocus}
133
135
  onclick={handleRightImageClick}
134
136
  {onkeydown}
135
- role="button"
136
- tabindex="0"
137
+ role={suppressRoleAndTabindex ? null : 'button'}
138
+ tabindex={suppressRoleAndTabindex ? null : 0}
137
139
  data-pw={rightImageTestId}
138
140
  testID={rightImageTestId}
139
141
  >
140
142
  <div class="right-img-wrapper">
141
- <Img src={rightImageUrl} alt="" />
143
+ <Img src={rightImageUrl} alt="" {transformSvg} />
142
144
  </div>
143
145
  </div>
144
146
  {/if}
@@ -3,6 +3,8 @@ export type ListItemProperties = ListItemEventProperties & {
3
3
  leftImageUrl?: string | null;
4
4
  leftImageFallbackUrl?: string | null;
5
5
  rightImageUrl?: string | null;
6
+ /** Rewrites SVG markup before either image is inlined. Providing it enables SVG inlining. */
7
+ transformSvg?: (svg: string) => string;
6
8
  label?: string | null;
7
9
  useAccordion?: boolean;
8
10
  rightContentText?: string | null;
@@ -15,6 +17,11 @@ export type ListItemProperties = ListItemEventProperties & {
15
17
  showRightContentLoader?: boolean;
16
18
  expand?: boolean;
17
19
  preventFocus?: boolean;
20
+ /**
21
+ * Removes ListItem's synthetic roles and tab stops while retaining its mouse handlers.
22
+ * Opt in when an ancestor or consumer supplies the semantic interactive control.
23
+ */
24
+ suppressRoleAndTabindex?: boolean;
18
25
  leftContent?: Snippet;
19
26
  centerContent?: Snippet;
20
27
  rightContent?: Snippet;
@@ -15,6 +15,7 @@
15
15
  onopen,
16
16
  onclose,
17
17
  classes,
18
+ transformSvg,
18
19
  selectedValue = null,
19
20
  role: menuRole = 'menu',
20
21
  ariaLabel: menuAriaLabel,
@@ -474,7 +475,7 @@
474
475
  <!-- Inline the SVG so currentColor strokes/fills inherit the item's
475
476
  text colour (danger items tint their icon red, dark theme works);
476
477
  non-SVG URLs fall back to the plain <img> render automatically. -->
477
- <Img inlineSvg src={item.icon} alt="" fallback="" />
478
+ <Img inlineSvg src={item.icon} alt="" fallback="" {transformSvg} />
478
479
  </span>
479
480
  {/if}
480
481
  <span class="menu-item-label">{item.label}</span>
@@ -52,6 +52,8 @@ export type OptionalMenuProperties = {
52
52
  */
53
53
  interactiveTrigger?: boolean;
54
54
  classes?: string;
55
+ /** Rewrites each item icon's SVG markup before it is inlined. */
56
+ transformSvg?: (svg: string) => string;
55
57
  /** Value of the currently selected item. When set, opening the menu focuses the
56
58
  * selected option instead of the first item, the matching item gets the
57
59
  * `menu-item-selected` class (stylable via --menu-item-selected-*), and