@juspay/svelte-ui-components 2.135.0 → 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 (36) hide show
  1. package/dist/Chat/Chat.svelte +12 -0
  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/TypewriterText/TypewriterText.svelte +67 -2
  30. package/dist/TypewriterText/properties.d.ts +97 -1
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.js +1 -1
  33. package/dist/utils.d.ts +2 -0
  34. package/dist/utils.js +30 -0
  35. package/dist-wc/index.js +1186 -926
  36. package/package.json +1 -1
@@ -58,6 +58,32 @@
58
58
  : ''
59
59
  );
60
60
 
61
+ const defaultTestIdSuffixes = {
62
+ icon: 'icon',
63
+ thumbnail: 'thumb',
64
+ thumbnailPlaceholder: 'thumb-placeholder',
65
+ tag: 'tag',
66
+ trendUp: 'trend-up',
67
+ trendDown: 'trend-down',
68
+ menu: 'menu',
69
+ menuTrigger: 'menu-trigger',
70
+ popup: 'popup',
71
+ popupTrigger: 'popup-trigger',
72
+ link: 'link',
73
+ copy: 'copy',
74
+ linkCopied: 'link-copied'
75
+ } as const;
76
+
77
+ const generatedTestId = (suffixName: keyof typeof defaultTestIdSuffixes, index?: number) => {
78
+ if (!column.testId) {
79
+ return;
80
+ }
81
+ const suffix = column.testIdSuffixes?.[suffixName] ?? defaultTestIdSuffixes[suffixName];
82
+ return typeof index === 'number'
83
+ ? `${column.testId}-${suffix}-${index}`
84
+ : `${column.testId}-${suffix}`;
85
+ };
86
+
61
87
  let copied = $state(false);
62
88
  let copyResetTimer: ReturnType<typeof setTimeout> | null = null;
63
89
 
@@ -143,8 +169,8 @@
143
169
  {#each icons as iconSrc, iconIndex (`${iconIndex}-${iconSrc}`)}
144
170
  <span
145
171
  class="builtin-icon-label-icon"
146
- data-pw={column.testId ? `${column.testId}-icon-${iconIndex}` : null}
147
- testID={column.testId ? `${column.testId}-icon-${iconIndex}` : null}
172
+ data-pw={generatedTestId('icon', iconIndex)}
173
+ testID={generatedTestId('icon', iconIndex)}
148
174
  >
149
175
  <Img inlineSvg src={String(iconSrc)} alt="" fallback="" />
150
176
  </span>
@@ -157,8 +183,8 @@
157
183
  {#if typeof data.imageUrl === 'string' && data.imageUrl}
158
184
  <span
159
185
  class="builtin-thumb"
160
- data-pw={column.testId ? `${column.testId}-thumb` : null}
161
- testID={column.testId ? `${column.testId}-thumb` : null}
186
+ data-pw={generatedTestId('thumbnail')}
187
+ testID={generatedTestId('thumbnail')}
162
188
  >
163
189
  <Img
164
190
  src={data.imageUrl}
@@ -169,8 +195,8 @@
169
195
  {:else}
170
196
  <span
171
197
  class="builtin-thumb builtin-thumb-placeholder"
172
- data-pw={column.testId ? `${column.testId}-thumb-placeholder` : null}
173
- testID={column.testId ? `${column.testId}-thumb-placeholder` : null}
198
+ data-pw={generatedTestId('thumbnailPlaceholder')}
199
+ testID={generatedTestId('thumbnailPlaceholder')}
174
200
  >{typeof data.text1 === 'string' && data.text1
175
201
  ? data.text1.charAt(0).toUpperCase()
176
202
  : ''}</span
@@ -190,7 +216,7 @@
190
216
  <Pill
191
217
  text={tag.text}
192
218
  classes={tag.classes ?? ''}
193
- testId={tag.testId ?? (column.testId && `${column.testId}-tag-${tagIndex}`)}
219
+ testId={tag.testId ?? generatedTestId('tag', tagIndex)}
194
220
  />
195
221
  {/each}
196
222
  </div>
@@ -227,8 +253,8 @@
227
253
  {#if compare.trendPercent > 0}
228
254
  <span
229
255
  class="builtin-trend builtin-trend-up"
230
- data-pw={column.testId ? `${column.testId}-trend-up` : null}
231
- testID={column.testId ? `${column.testId}-trend-up` : null}
256
+ data-pw={generatedTestId('trendUp')}
257
+ testID={generatedTestId('trendUp')}
232
258
  >
233
259
  <!-- eslint-disable svelte/no-at-html-tags -->
234
260
  <span class="builtin-trend-icon">{@html trendUpSvg}</span>
@@ -237,8 +263,8 @@
237
263
  {:else if compare.trendPercent < 0}
238
264
  <span
239
265
  class="builtin-trend builtin-trend-down"
240
- data-pw={column.testId ? `${column.testId}-trend-down` : null}
241
- testID={column.testId ? `${column.testId}-trend-down` : null}
266
+ data-pw={generatedTestId('trendDown')}
267
+ testID={generatedTestId('trendDown')}
242
268
  >
243
269
  <!-- eslint-disable svelte/no-at-html-tags -->
244
270
  <span class="builtin-trend-icon">{@html trendDownSvg}</span>
@@ -419,16 +445,13 @@
419
445
  danger: item.danger,
420
446
  separator: item.separator
421
447
  }))}
422
- testId={column.testId && `${column.testId}-menu-${rowIndex}`}
448
+ testId={generatedTestId('menu', rowIndex)}
423
449
  {usePortal}
424
450
  onselect={(menuItem) => column.onMenuAction?.(rowIndex, menuItem.value, originalIndex)}
425
451
  >
426
452
  {#snippet trigger()}
427
453
  <span class="builtin-icon-button">
428
- <Button
429
- ariaLabel="More actions"
430
- testId={column.testId && `${column.testId}-menu-trigger-${rowIndex}`}
431
- >
454
+ <Button ariaLabel="More actions" testId={generatedTestId('menuTrigger', rowIndex)}>
432
455
  {#snippet icon()}
433
456
  <!-- eslint-disable svelte/no-at-html-tags -->
434
457
  <span class="builtin-menu-dots">{@html dotsSvg}</span>
@@ -458,7 +481,7 @@
458
481
  danger: item.danger,
459
482
  separator: item.separator
460
483
  }))}
461
- testId={column.testId && `${column.testId}-popup-${rowIndex}`}
484
+ testId={generatedTestId('popup', rowIndex)}
462
485
  {usePortal}
463
486
  onselect={(menuItem) => column.onMenuAction?.(rowIndex, menuItem.value, originalIndex)}
464
487
  >
@@ -466,7 +489,7 @@
466
489
  <span class="builtin-icon-button">
467
490
  <Button
468
491
  ariaLabel={popupData.ariaLabel ?? 'More actions'}
469
- testId={column.testId && `${column.testId}-popup-trigger-${rowIndex}`}
492
+ testId={generatedTestId('popupTrigger', rowIndex)}
470
493
  >
471
494
  {#snippet icon()}
472
495
  <!-- eslint-disable svelte/no-at-html-tags -->
@@ -490,8 +513,8 @@
490
513
  target="_blank"
491
514
  rel="noopener noreferrer"
492
515
  class="builtin-link-anchor"
493
- data-pw={column.testId ? `${column.testId}-link-${rowIndex}` : null}
494
- testID={column.testId ? `${column.testId}-link-${rowIndex}` : null}
516
+ data-pw={generatedTestId('link', rowIndex)}
517
+ testID={generatedTestId('link', rowIndex)}
495
518
  >
496
519
  {link.label ?? link.url}
497
520
  </a>
@@ -499,7 +522,7 @@
499
522
  <span class="builtin-link-copy">
500
523
  <Button
501
524
  ariaLabel={copied ? 'Link copied' : 'Copy link'}
502
- testId={column.testId && `${column.testId}-copy-${rowIndex}`}
525
+ testId={generatedTestId('copy', rowIndex)}
503
526
  onclick={() => handleCopy(link.url)}
504
527
  >
505
528
  {#snippet icon()}
@@ -511,8 +534,8 @@
511
534
  {#if copied}
512
535
  <span
513
536
  class="builtin-link-copied"
514
- data-pw={column.testId ? `${column.testId}-link-copied` : null}
515
- testID={column.testId ? `${column.testId}-link-copied` : null}>Copied</span
537
+ data-pw={generatedTestId('linkCopied')}
538
+ testID={generatedTestId('linkCopied')}>Copied</span
516
539
  >
517
540
  {/if}
518
541
  {/if}
@@ -375,6 +375,14 @@
375
375
  return total > 0 ? `${from}-${to} of ${total}` : '';
376
376
  });
377
377
  let pageSizeOptions = $derived(pagination?.pageSizeOptions ?? [10, 25, 50, 100]);
378
+ // `hideControls` remains a shorthand for hiding both at once — existing
379
+ // consumers of it are unaffected. `hidePageSizeSelector`/`hideSteppers` let
380
+ // a consumer suppress either independently, e.g. a working external
381
+ // paginator that only wants Table's range text plus its own steppers.
382
+ let hidePageSizeSelector = $derived(
383
+ (pagination?.hideControls || pagination?.hidePageSizeSelector) ?? false
384
+ );
385
+ let hideSteppers = $derived((pagination?.hideControls || pagination?.hideSteppers) ?? false);
378
386
 
379
387
  const handlePageChange = (page: number): void => {
380
388
  pageOverride = page;
@@ -722,6 +730,7 @@
722
730
  data-pw={headerColumn?.testId ?? null}
723
731
  testID={headerColumn?.testId ?? null}
724
732
  style:text-align={headerColumn?.align ?? null}
733
+ style:width={headerColumn?.width ?? null}
725
734
  style:max-width={headerColumn?.maxWidth ?? null}
726
735
  >
727
736
  <span
@@ -965,6 +974,7 @@
965
974
  ? getCellTestId(row, cellValue, rowIndex)
966
975
  : null}
967
976
  style:text-align={keyedColumn?.align ?? null}
977
+ style:width={keyedColumn?.width ?? null}
968
978
  style:max-width={keyedColumn?.maxWidth ?? null}
969
979
  title={keyedColumn?.maxWidth && isScalarCell ? String(cellValue) : null}
970
980
  >
@@ -1007,10 +1017,15 @@
1007
1017
  <div class="table-footer">
1008
1018
  {@render paginatorSlot()}
1009
1019
  </div>
1010
- {:else if pagination && (paginationTotalPages > 1 || pagination.hasMore)}
1020
+ {:else if pagination && (paginationTotalPages > 1 || pagination.hasMore || pagination.showFooterOnSinglePage || (hidePageSizeSelector && hideSteppers))}
1011
1021
  <!-- DataGrid parity: pagination chrome only renders when the data spans
1012
1022
  more than one page (or the server reports more chunks); a
1013
- single-page table shows no footer. -->
1023
+ single-page table shows no footer. `showFooterOnSinglePage` opts
1024
+ a consumer out of that default, e.g. to keep the page-size
1025
+ selector reachable even for a one-page result. Suppressing BOTH
1026
+ controls (via `hideControls`, or `hidePageSizeSelector` +
1027
+ `hideSteppers` together) implies the same — a count-only footer
1028
+ has nothing to hide behind "already on the only page". -->
1014
1029
  <div
1015
1030
  class="table-footer table-paginator"
1016
1031
  data-pw={pagination.testId ?? null}
@@ -1018,47 +1033,55 @@
1018
1033
  >
1019
1034
  <span
1020
1035
  class="table-paginator-range"
1021
- data-pw={typeof testId === 'string'
1022
- ? `${testId}-paginator-range`
1023
- : pagination?.testId
1024
- ? `${pagination.testId}-range`
1025
- : null}
1026
- testID={typeof testId === 'string'
1027
- ? `${testId}-paginator-range`
1028
- : pagination?.testId
1029
- ? `${pagination.testId}-range`
1030
- : null}>{paginationRangeText}</span
1036
+ data-pw={pagination.rangeTestId ??
1037
+ (typeof testId === 'string'
1038
+ ? `${testId}-paginator-range`
1039
+ : pagination?.testId
1040
+ ? `${pagination.testId}-range`
1041
+ : null)}
1042
+ testID={pagination.rangeTestId ??
1043
+ (typeof testId === 'string'
1044
+ ? `${testId}-paginator-range`
1045
+ : pagination?.testId
1046
+ ? `${pagination.testId}-range`
1047
+ : null)}>{paginationRangeText}</span
1031
1048
  >
1032
- <span class="table-paginator-controls">
1033
- {#if pageSizeOptions.length > 0}
1034
- <span class="table-paginator-size">
1035
- <Select
1036
- items={pageSizeOptions.map((sizeOption) => ({
1037
- id: String(sizeOption),
1038
- label: String(sizeOption)
1039
- }))}
1040
- value={[String(effectivePageSize)]}
1049
+ {#if !hidePageSizeSelector || !hideSteppers}
1050
+ <span class="table-paginator-controls">
1051
+ {#if !hidePageSizeSelector && pageSizeOptions.length > 0}
1052
+ <span class="table-paginator-size">
1053
+ <Select
1054
+ items={pageSizeOptions.map((sizeOption) => ({
1055
+ id: String(sizeOption),
1056
+ label: String(sizeOption)
1057
+ }))}
1058
+ value={[String(effectivePageSize)]}
1059
+ disabled={pagination.isLoading ?? false}
1060
+ usePortal
1061
+ testId={pagination.testId && `${pagination.testId}-page-size`}
1062
+ onchange={(selectedSizes) => {
1063
+ if (selectedSizes.length > 0) {
1064
+ handlePageSizeChange(Number(selectedSizes[0]));
1065
+ }
1066
+ }}
1067
+ />
1068
+ </span>
1069
+ {/if}
1070
+ {#if !hideSteppers}
1071
+ <Pagination
1072
+ totalPages={paginationTotalPages}
1073
+ currentPage={effectivePage}
1074
+ hasMore={pagination.hasMore ?? false}
1041
1075
  disabled={pagination.isLoading ?? false}
1042
- usePortal
1043
- testId={pagination.testId && `${pagination.testId}-page-size`}
1044
- onchange={(selectedSizes) => {
1045
- if (selectedSizes.length > 0) {
1046
- handlePageSizeChange(Number(selectedSizes[0]));
1047
- }
1048
- }}
1076
+ testId={pagination.testId && `${pagination.testId}-pages`}
1077
+ prevButtonTestId={pagination.prevButtonTestId}
1078
+ nextButtonTestId={pagination.nextButtonTestId}
1079
+ onchange={handlePageChange}
1080
+ onLoadMore={pagination.onLoadMore}
1049
1081
  />
1050
- </span>
1051
- {/if}
1052
- <Pagination
1053
- totalPages={paginationTotalPages}
1054
- currentPage={effectivePage}
1055
- hasMore={pagination.hasMore ?? false}
1056
- disabled={pagination.isLoading ?? false}
1057
- testId={pagination.testId && `${pagination.testId}-pages`}
1058
- onchange={handlePageChange}
1059
- onLoadMore={pagination.onLoadMore}
1060
- />
1061
- </span>
1082
+ {/if}
1083
+ </span>
1084
+ {/if}
1062
1085
  </div>
1063
1086
  {/if}
1064
1087
  </div>
@@ -68,6 +68,9 @@ export const asTagArrayItems = (value) => {
68
68
  if (typeof record.classes === 'string') {
69
69
  item.classes = record.classes;
70
70
  }
71
+ if (typeof record.testId === 'string') {
72
+ item.testId = record.testId;
73
+ }
71
74
  items.push(item);
72
75
  }
73
76
  return items;
@@ -201,6 +201,27 @@ export type TablePopupMenuCellData = {
201
201
  * the options, selection state, and filtering itself belong to the consumer.
202
202
  * Selecting the already-selected option clears the filter (emits `null`).
203
203
  */
204
+ /**
205
+ * Customizes the generated `data-pw` suffixes of built-in cells for one
206
+ * column. Values replace only the named suffix; row and item indices remain
207
+ * appended where the default includes them. Cell-data `testId` values still
208
+ * take precedence for renderers that accept one.
209
+ */
210
+ export type TableBuiltinCellTestIdSuffixes = {
211
+ icon?: string;
212
+ thumbnail?: string;
213
+ thumbnailPlaceholder?: string;
214
+ tag?: string;
215
+ trendUp?: string;
216
+ trendDown?: string;
217
+ menu?: string;
218
+ menuTrigger?: string;
219
+ popup?: string;
220
+ popupTrigger?: string;
221
+ link?: string;
222
+ copy?: string;
223
+ linkCopied?: string;
224
+ };
204
225
  export type TableColumnFilterConfig = {
205
226
  options: Array<{
206
227
  label: string;
@@ -246,11 +267,18 @@ export type TableColumn = {
246
267
  * cells follow the table-wide `--table-text-align` (left by default).
247
268
  */
248
269
  align?: 'left' | 'center' | 'right';
270
+ /** Fixed/preferred column width (any CSS length), applied inline to its header and cells. */
271
+ width?: string;
249
272
  /**
250
273
  * Caps the column width (any CSS length). Overflowing scalar cell text
251
274
  * ellipsizes with the full value available on the native title tooltip.
252
275
  */
253
276
  maxWidth?: string;
277
+ /**
278
+ * Per-built-in generated `data-pw` suffix overrides for this column. Omitted
279
+ * entries preserve the existing suffixes exactly; row/item indices remain.
280
+ */
281
+ testIdSuffixes?: TableBuiltinCellTestIdSuffixes;
254
282
  /**
255
283
  * Paints this column's header and body cells with the highlight wash —
256
284
  * `--table-col-highlight-background` (body) and
@@ -342,12 +370,54 @@ export type TablePaginationConfig = {
342
370
  hasMore?: boolean;
343
371
  /** Disables the paginator and page-size selector during a fetch. */
344
372
  isLoading?: boolean;
373
+ /**
374
+ * Keeps the paginator footer (range text, page-size selector, steppers)
375
+ * visible even when the data fits on a single page. Default `false`
376
+ * matches DataGrid parity — a single page renders no footer at all.
377
+ */
378
+ showFooterOnSinglePage?: boolean;
379
+ /**
380
+ * Renders only the range summary text ("{from}-{to} of {total}") and
381
+ * suppresses the page-size selector and page steppers — for a bare
382
+ * "Showing X-Y of Z" affordance with no navigation controls. Implies
383
+ * `showFooterOnSinglePage`: a count-only footer has nothing to hide behind
384
+ * "already on the only page". Shorthand for `hidePageSizeSelector: true`
385
+ * plus `hideSteppers: true`; reach for those two directly when you only
386
+ * want to suppress one of the pair. Default `false`.
387
+ */
388
+ hideControls?: boolean;
389
+ /**
390
+ * Suppresses just the page-size Select, independently of the steppers —
391
+ * e.g. a fixed page size with no reason to expose the selector, while
392
+ * still keeping working page navigation. Default `false`.
393
+ */
394
+ hidePageSizeSelector?: boolean;
395
+ /**
396
+ * Suppresses just the Pagination steppers, independently of the page-size
397
+ * Select — for a call site with its own working paginator elsewhere that
398
+ * only wants Table's range text (and, optionally, its page-size selector).
399
+ * Default `false`.
400
+ */
401
+ hideSteppers?: boolean;
345
402
  /** Range text override; default "{from}-{to} of {total}". */
346
403
  rangeLabel?: (from: number, to: number, total: number) => string;
404
+ /**
405
+ * Explicit `data-pw`/`testID` for the range summary span. Wins over the
406
+ * derived id — the default derives from the table's own `testId`
407
+ * (`${testId}-paginator-range`), falling back to `${pagination.testId}-range`
408
+ * only when the table has none, so a call site whose table `testId` is
409
+ * already load-bearing (built-in cell ids derive from it) has no way to
410
+ * give the range span an independent locator without this.
411
+ */
412
+ rangeTestId?: string;
347
413
  onPageChange?: (page: number) => void;
348
414
  onPageSizeChange?: (pageSize: number) => void;
349
415
  onLoadMore?: () => void;
350
416
  testId?: string;
417
+ /** Forwarded to Pagination's previous-page button without modification. */
418
+ prevButtonTestId?: string;
419
+ /** Forwarded to Pagination's next-page (or load-more) button without modification. */
420
+ nextButtonTestId?: string;
351
421
  };
352
422
  /**
353
423
  * Configuration for the built-in search bar (C2-3).
@@ -1,12 +1,16 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy, untrack } from 'svelte';
3
- import type { TypewriterTextProperties } from './properties';
3
+ import type { TypewriterTextProperties, TypewriterCharacterDelayRange } from './properties';
4
4
 
5
5
  let {
6
6
  text,
7
7
  speed = 15,
8
8
  isStreaming = false,
9
9
  renderText,
10
+ variableDelay,
11
+ resolveDelay,
12
+ onProgress,
13
+ renderCharacter,
10
14
  testId,
11
15
  classes
12
16
  }: TypewriterTextProperties = $props();
@@ -18,11 +22,63 @@
18
22
  // Where typing left off, so newly streamed-in text continues rather than restarts.
19
23
  let previousTextLength = $state(0);
20
24
 
25
+ // Whitespace characters revealed so far — the state `resolveDelay` reads via
26
+ // `TypewriterDelayContext.wordCount`. Reset alongside the other typing state
27
+ // whenever `text` is replaced rather than continued (see the mount $effect below).
28
+ let revealedWordCount = $state(0);
29
+
30
+ const WHITESPACE_CHARACTERS = new Set([' ', '\n']);
31
+ const PUNCTUATION_CHARACTERS = new Set([',', '.', '?', '!']);
32
+ const DIGIT_PATTERN = /\d/;
33
+
34
+ const randomDelayInRange = (range: TypewriterCharacterDelayRange): number => {
35
+ return range.min + Math.random() * (range.max - range.min);
36
+ };
37
+
38
+ // Falls straight through to the flat `speed` — byte-identical to the pre-`variableDelay`
39
+ // behaviour — for any consumer that never sets `variableDelay`/`resolveDelay`, or leaves
40
+ // a class out of `variableDelay`. `resolveDelay`, when set, takes over entirely: it is
41
+ // the more general mechanism (see its doc comment in properties.ts) and is expected to
42
+ // own the full pacing decision rather than compose with `variableDelay`/`speed`.
43
+ const resolveTypingDelay = (character: string, characterIndex: number): number => {
44
+ if (typeof resolveDelay === 'function') {
45
+ return resolveDelay({ character, index: characterIndex, wordCount: revealedWordCount });
46
+ }
47
+ if (!variableDelay) {
48
+ return speed;
49
+ }
50
+ if (DIGIT_PATTERN.test(character) && variableDelay.digit) {
51
+ return randomDelayInRange(variableDelay.digit);
52
+ }
53
+ if (WHITESPACE_CHARACTERS.has(character) && variableDelay.whitespace) {
54
+ return randomDelayInRange(variableDelay.whitespace);
55
+ }
56
+ if (PUNCTUATION_CHARACTERS.has(character) && variableDelay.punctuation) {
57
+ return randomDelayInRange(variableDelay.punctuation);
58
+ }
59
+ if (variableDelay.default) {
60
+ return randomDelayInRange(variableDelay.default);
61
+ }
62
+ return speed;
63
+ };
64
+
21
65
  const typeNextCharacter = (): void => {
22
66
  if (currentIndex < text.length) {
67
+ const revealedCharacter = text[currentIndex];
68
+ const revealedCharacterIndex = currentIndex;
69
+ // Word count is updated for THIS character before it is used to resolve THIS
70
+ // character's own delay — the ordering `TypewriterDelayContext.wordCount` documents
71
+ // and that a cyclical/positional `resolveDelay` depends on.
72
+ if (WHITESPACE_CHARACTERS.has(revealedCharacter)) {
73
+ revealedWordCount++;
74
+ }
23
75
  displayedText = text.substring(0, currentIndex + 1);
24
76
  currentIndex++;
25
- timeoutId = setTimeout(typeNextCharacter, speed);
77
+ onProgress?.({ index: currentIndex, total: text.length, displayedText });
78
+ timeoutId = setTimeout(
79
+ typeNextCharacter,
80
+ resolveTypingDelay(revealedCharacter, revealedCharacterIndex)
81
+ );
26
82
  }
27
83
  };
28
84
 
@@ -47,6 +103,7 @@
47
103
  displayedText = '';
48
104
  currentIndex = 0;
49
105
  previousTextLength = 0;
106
+ revealedWordCount = 0;
50
107
  }
51
108
  if (nextText.length > 0 && nextText.length > previousTextLength) {
52
109
  if (currentIndex >= previousTextLength) {
@@ -66,8 +123,12 @@
66
123
  if (timeoutId !== null) {
67
124
  clearTimeout(timeoutId);
68
125
  }
126
+ const hadRemainingText = currentIndex < text.length;
69
127
  displayedText = text;
70
128
  currentIndex = text.length;
129
+ if (hadRemainingText) {
130
+ onProgress?.({ index: currentIndex, total: text.length, displayedText });
131
+ }
71
132
  }
72
133
  });
73
134
 
@@ -94,6 +155,10 @@
94
155
  {#if typeof renderText === 'function'}
95
156
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
96
157
  {@html renderText(displayedText)}
158
+ {:else if renderCharacter}
159
+ {#each displayedText.split('') as character, index (index)}
160
+ {@render renderCharacter({ character, index })}
161
+ {/each}
97
162
  {:else}
98
163
  {displayedText}
99
164
  {/if}
@@ -1,9 +1,72 @@
1
+ import type { Snippet } from 'svelte';
1
2
  export type TypewriterTextProperties = OptionalTypewriterTextProperties & MandatoryTypewriterTextProperties;
2
3
  export type MandatoryTypewriterTextProperties = {
3
4
  text: string;
4
5
  };
6
+ /** A random delay in `[min, max]` milliseconds is picked per character in that class. Pass equal `min`/`max` for a fixed delay. */
7
+ export type TypewriterCharacterDelayRange = {
8
+ min: number;
9
+ max: number;
10
+ };
11
+ /**
12
+ * Per-character-class pacing, in place of the flat `speed`. Reach for this when a
13
+ * conversation needs to slow down for numbers (prices, OTPs, phone numbers) or add a
14
+ * beat at punctuation, the way a person reading it aloud would. Any class you omit —
15
+ * including `default` — falls back to `speed` for that character.
16
+ */
17
+ export type TypewriterVariableDelay = {
18
+ /** Digits (`0`–`9`). */
19
+ digit?: TypewriterCharacterDelayRange;
20
+ /** A space or newline — the natural word-boundary pause. */
21
+ whitespace?: TypewriterCharacterDelayRange;
22
+ /** Sentence punctuation: `,` `.` `?` `!`. */
23
+ punctuation?: TypewriterCharacterDelayRange;
24
+ /** Every other character (letters and anything not covered above). */
25
+ default?: TypewriterCharacterDelayRange;
26
+ };
27
+ export type TypewriterProgress = {
28
+ /** Characters revealed so far. */
29
+ index: number;
30
+ /** Length of `text` at the time of this update — compare against `index` for a percentage. */
31
+ total: number;
32
+ /** The text revealed so far — the same value driving what's on screen. */
33
+ displayedText: string;
34
+ };
35
+ export type TypewriterCharacterContext = {
36
+ /** The character being rendered. */
37
+ character: string;
38
+ /** Its position within the full `text` string. */
39
+ index: number;
40
+ };
41
+ /**
42
+ * Everything `resolveDelay` gets to decide the next character's pacing from — a
43
+ * superset of `TypewriterCharacterContext` because pacing can depend on more than the
44
+ * character alone (see `wordCount`).
45
+ */
46
+ export type TypewriterDelayContext = TypewriterCharacterContext & {
47
+ /**
48
+ * Whitespace characters (space or newline) revealed so far — INCLUDING this one, if
49
+ * `character` itself is whitespace. The component always updates this count before
50
+ * calling `resolveDelay`, so a cadence that keys off word position (a cyclical
51
+ * acceleration window, slowing down only for the first word of a sentence, etc.) can
52
+ * rely on that ordering instead of inferring it through a side channel like
53
+ * `onProgress`. Counts only whitespace, matching the `whitespace` class in
54
+ * `TypewriterVariableDelay` — it is not a word index in any richer sense.
55
+ */
56
+ wordCount: number;
57
+ };
58
+ /**
59
+ * Computes the delay before the NEXT character types, given the one that was just
60
+ * revealed and enough state (`index`, `wordCount`) to vary that delay with position —
61
+ * not just character class. Reach for this instead of `variableDelay` when the cadence
62
+ * needs to depend on where typing currently is, not only on what the character is: e.g.
63
+ * an accelerating window every N words, or a pause that only applies to the first
64
+ * occurrence of a character class. `variableDelay` stays the right tool for a pure
65
+ * per-class rule with no positional state.
66
+ */
67
+ export type TypewriterDelayResolver = (context: TypewriterDelayContext) => number;
5
68
  export type OptionalTypewriterTextProperties = {
6
- /** Milliseconds between characters. */
69
+ /** Milliseconds between characters. Ignored per character class covered by `variableDelay`. */
7
70
  speed?: number;
8
71
  /**
9
72
  * While `true`, text revealed so far stays and new text keeps typing as `text` grows.
@@ -16,6 +79,39 @@ export type OptionalTypewriterTextProperties = {
16
79
  * text can contain untrusted input. When omitted, the text renders as plain text.
17
80
  */
18
81
  renderText?: (text: string) => string;
82
+ /**
83
+ * Opt into per-character-class pacing (digits, whitespace, punctuation, everything
84
+ * else) instead of the flat `speed`. Omit to keep the flat `speed` for every character.
85
+ */
86
+ variableDelay?: TypewriterVariableDelay;
87
+ /**
88
+ * Opt into a fully custom pacing function, called once per character in place of
89
+ * `variableDelay`/`speed`. Needed when the cadence isn't a pure function of the
90
+ * character's own class — e.g. an accelerating window that recurs every N words and,
91
+ * while active, collapses whitespace/punctuation/default pacing to a single flat
92
+ * range regardless of class, with only digits kept slow throughout. `variableDelay`
93
+ * cannot express that fourth, position-driven case because it re-evaluates from
94
+ * scratch per character with no notion of where typing currently is; `resolveDelay`
95
+ * closes that gap by handing the resolver `index` and `wordCount` alongside the
96
+ * character (see `TypewriterDelayContext` for the ordering guarantee on `wordCount`),
97
+ * so state like a running cycle position can live in the resolver's own closure
98
+ * instead of a side channel like swapping `variableDelay` from inside `onProgress`.
99
+ * Takes priority over `variableDelay` when both are set. Omit to keep
100
+ * `variableDelay`/`speed` resolution exactly as today.
101
+ */
102
+ resolveDelay?: TypewriterDelayResolver;
103
+ /**
104
+ * Called every time a character is revealed (and once more if `isStreaming` turns
105
+ * `false` while text remains, since the rest appears at once) — scroll a container to
106
+ * follow the reveal, or show how far along it is. Not called when omitted.
107
+ */
108
+ onProgress?: (progress: TypewriterProgress) => void;
109
+ /**
110
+ * Render each revealed character yourself — highlight a token, wrap a number — instead
111
+ * of the plain text node. Ignored when `renderText` is set, since that renderer already
112
+ * owns the full markup. Falls back to plain text per character when omitted.
113
+ */
114
+ renderCharacter?: Snippet<[TypewriterCharacterContext]>;
19
115
  testId?: string;
20
116
  classes?: string;
21
117
  };
package/dist/index.d.ts CHANGED
@@ -120,6 +120,7 @@ export type * from './Table/properties';
120
120
  export type { NormalizedColumns } from './Table/normalizeColumns';
121
121
  export type { SortTableRowsOptions, TableSortType } from './Table/sortEngine';
122
122
  export type * from './Stepper/properties';
123
+ export type { Step as StepperStep } from './Stepper/properties';
123
124
  export type * from './Toast/properties';
124
125
  export type * from './IconStack/properties';
125
126
  export type * from './Img/properties';
@@ -197,5 +198,5 @@ export type * from './MediaPlayer/properties';
197
198
  export type * from './MediaUpload/properties';
198
199
  export type * from './Gallery/properties';
199
200
  export { createSoundKit } from './soundKit/soundKit';
200
- export { validateInput } from './utils';
201
+ export { validateInput, lockBodyScroll, unlockBodyScroll } from './utils';
201
202
  export { formatNumberIndian } from './_chart/format';
package/dist/index.js CHANGED
@@ -101,5 +101,5 @@ export { ChatController } from './Chat/controller.svelte';
101
101
  export { partyOf } from './Chat/roles';
102
102
  export { SpeechToTextController } from './SpeechToText/controller.svelte';
103
103
  export { createSoundKit } from './soundKit/soundKit';
104
- export { validateInput } from './utils';
104
+ export { validateInput, lockBodyScroll, unlockBodyScroll } from './utils';
105
105
  export { formatNumberIndian } from './_chart/format';