@juspay/svelte-ui-components 4.20.0 → 4.21.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.
@@ -570,6 +570,11 @@
570
570
  opacity: var(--chat-message-blockquote-opacity, 0.85);
571
571
  }
572
572
 
573
+ /* Fallback for a bare <table> in caller-supplied `html`, which has no scroll
574
+ container of its own: it keeps the block treatment so a wide table scrolls
575
+ rather than overflowing the message. A table that came from `markdown` (or
576
+ from `renderMarkdown`) arrives inside `.markdown-table-wrapper`, and the
577
+ rules below take it back out of block layout. */
573
578
  .body :global(table) {
574
579
  display: block;
575
580
  max-width: 100%;
@@ -578,6 +583,40 @@
578
583
  margin: 0.5em 0;
579
584
  }
580
585
 
586
+ /* The wrapper scrolls, not the table. `display: block` on a <table> is what
587
+ makes `overflow-x` work on the element itself, but it strips the table's
588
+ semantics for assistive technology -- a screen reader loses row and column
589
+ navigation. MarkdownText separates the scroll container from the table for
590
+ exactly that reason; this component rendered the same wrapper markup and
591
+ styled the table instead, so a chat table was scrollable and no longer a
592
+ table. Chat is where markdown tables overwhelmingly land. */
593
+ .body :global(.markdown-table-wrapper) {
594
+ display: block;
595
+ max-width: 100%;
596
+ overflow-x: auto;
597
+ margin: 0.5em 0;
598
+ }
599
+
600
+ /* The wrapper is a tab stop (the renderer emits tabindex="0" so the scroll is
601
+ reachable by keyboard). The browser's default ring already applies; this
602
+ replaces it with the same 2px treatment the rest of the library uses, since
603
+ the 1px UA default is easy to miss against a message bubble. */
604
+ .body :global(.markdown-table-wrapper:focus-visible) {
605
+ outline: 2px solid var(--chat-message-table-focus-outline-color, #3b82f6);
606
+ outline-offset: 2px;
607
+ }
608
+
609
+ /* Without an intrinsic width the table is exactly its container's width and a
610
+ wide table compresses instead of scrolling. `max-content` supplies it;
611
+ `min-width` keeps a narrow table filling the bubble. */
612
+ .body :global(.markdown-table-wrapper table) {
613
+ display: table;
614
+ width: max-content;
615
+ min-width: 100%;
616
+ overflow-x: visible;
617
+ margin: 0;
618
+ }
619
+
581
620
  .body :global(th),
582
621
  .body :global(td) {
583
622
  padding: 5px 10px;
@@ -8,10 +8,13 @@
8
8
  testId,
9
9
  classes,
10
10
  tableLabel,
11
+ tableWrapperClass,
11
12
  sanitize
12
13
  }: MarkdownTextProperties = $props();
13
14
 
14
- let html = $derived(renderMarkdown(markdown, { breaks, tableLabel, sanitize }));
15
+ let html = $derived(
16
+ renderMarkdown(markdown, { breaks, tableLabel, tableWrapperClass, sanitize })
17
+ );
15
18
  </script>
16
19
 
17
20
  <div
@@ -245,14 +245,32 @@ function annotateExternalLinks(html) {
245
245
  scrollable region with a tabindex. */
246
246
  const TABLE_OPEN = /<table>/g;
247
247
  const TABLE_CLOSE = /<\/table>/g;
248
- function wrapTables(html, label) {
248
+ const DEFAULT_TABLE_WRAPPER_CLASS = 'markdown-table-wrapper';
249
+ function wrapTables(html, label, wrapperClass) {
249
250
  /* `role="region"` without an accessible name announces a landmark the user
250
251
  cannot identify, which is worse than no landmark, so the role appears only
251
252
  when the caller supplies a name. The tabindex does not depend on it:
252
253
  keyboard scrolling should work either way. */
254
+ /* A caller's class is ADDED, never substituted. The wrapper's own styling --
255
+ `overflow-x: auto`, the `:focus-visible` ring, and the table's max-content
256
+ width -- is scoped to `.markdown-table-wrapper` in MarkdownText.svelte, and
257
+ a Svelte scoped selector cannot be templated to a name supplied at runtime.
258
+ Substituting the class would leave a `tabindex="0"` element that cannot
259
+ scroll and shows no focus ring, undoing the fix that made a wide table
260
+ keyboard-scrollable in the first place. Adding costs a consumer nothing:
261
+ their own selector still matches, and they can still override by
262
+ specificity. */
263
+ const custom = typeof wrapperClass === 'string' ? wrapperClass.trim() : '';
264
+ // The caller's value lands inside a double-quoted HTML attribute, same as
265
+ // `label` below -- escaping it the same way is what stops a value like
266
+ // `" onmouseover="alert(1)` from closing the attribute early and injecting a
267
+ // new one. The built-in name is a literal and needs no escaping.
268
+ const classAttr = custom.length > 0
269
+ ? `${DEFAULT_TABLE_WRAPPER_CLASS} ${escapeHtml(custom)}`
270
+ : DEFAULT_TABLE_WRAPPER_CLASS;
253
271
  const open = typeof label === 'string' && label.length > 0
254
- ? `<div class="markdown-table-wrapper" tabindex="0" role="region" aria-label="${escapeHtml(label)}">`
255
- : '<div class="markdown-table-wrapper" tabindex="0">';
272
+ ? `<div class="${classAttr}" tabindex="0" role="region" aria-label="${escapeHtml(label)}">`
273
+ : `<div class="${classAttr}" tabindex="0">`;
256
274
  return html.replace(TABLE_OPEN, `${open}<table>`).replace(TABLE_CLOSE, '</table></div>');
257
275
  }
258
276
  function instanceFor(options) {
@@ -292,5 +310,7 @@ export function renderMarkdown(markdown, options = {}) {
292
310
  }
293
311
  /* Inline parsing produces no block elements, so there is no table to wrap. */
294
312
  const linked = annotateExternalLinks(output);
295
- return options.inline === true ? linked : wrapTables(linked, options.tableLabel);
313
+ return options.inline === true
314
+ ? linked
315
+ : wrapTables(linked, options.tableLabel, options.tableWrapperClass);
296
316
  }
@@ -20,6 +20,15 @@ export type OptionalMarkdownTextProperties = {
20
20
  * worse than none.
21
21
  */
22
22
  tableLabel?: string;
23
+ /**
24
+ * Extra class name added to the scrollable region wrapping each table,
25
+ * alongside the built-in `markdown-table-wrapper` rather than replacing it
26
+ * (the wrapper's scroll/focus behaviour is scoped to that class and cannot
27
+ * be retargeted). Lets a consumer whose own stylesheet targets a different
28
+ * class keep that styling too. Omitting it, or passing an empty or
29
+ * whitespace-only string, keeps only `markdown-table-wrapper`.
30
+ */
31
+ tableWrapperClass?: string;
23
32
  /**
24
33
  * Narrow which URL protocols survive on rendered links/images. See
25
34
  * `MarkdownSanitizeOptions`.
@@ -104,6 +113,8 @@ export type RenderMarkdownOptions = {
104
113
  inline?: boolean;
105
114
  /** Accessible name for the scroll region wrapping each table. See `MarkdownTextProperties.tableLabel`. */
106
115
  tableLabel?: string;
116
+ /** Class name for the scroll region wrapping each table. See `MarkdownTextProperties.tableWrapperClass`. */
117
+ tableWrapperClass?: string;
107
118
  /** Narrow the protocol allow-list. See `MarkdownSanitizeOptions`. */
108
119
  sanitize?: MarkdownSanitizeOptions;
109
120
  };
@@ -51,7 +51,8 @@
51
51
  summaryRowIndex = null,
52
52
  headerTooltipIcon,
53
53
  headerTooltipPosition,
54
- usePortal = false
54
+ usePortal = false,
55
+ labels
55
56
  }: TableProperties = $props();
56
57
 
57
58
  // ─── Keyed column model → positional projection ─────────────────────────────
@@ -646,7 +647,7 @@
646
647
  class="table-search-clear"
647
648
  type="button"
648
649
  onclick={clearSearch}
649
- aria-label="Clear search"
650
+ aria-label={labels?.clearSearch ?? 'Clear search'}
650
651
  >
651
652
  <!-- eslint-disable svelte/no-at-html-tags -->
652
653
  {@html closeSvg}
@@ -688,7 +689,7 @@
688
689
  so the tri-state is computed here and never flipped locally. -->
689
690
  <Checkbox
690
691
  text=""
691
- ariaLabel="Select all rows"
692
+ ariaLabel={labels?.selectAllRows ?? 'Select all rows'}
692
693
  controlled
693
694
  checked={headerCheckboxState === 'all'}
694
695
  indeterminate={headerCheckboxState === 'some'}
@@ -773,7 +774,7 @@
773
774
  >
774
775
  <Button
775
776
  {...triggerProps}
776
- ariaLabel="Filter by {header}"
777
+ ariaLabel={labels?.filterBy?.(header) ?? `Filter by ${header}`}
777
778
  testId={headerColumn.testId &&
778
779
  `${headerColumn.testId}-filter-trigger`}
779
780
  >
@@ -791,7 +792,10 @@
791
792
  {/if}
792
793
  {#if isColumnSortable(colIndex)}
793
794
  <div class="sort-button">
794
- <Button onclick={() => handleSort(colIndex)} ariaLabel="Sort by {header}">
795
+ <Button
796
+ onclick={() => handleSort(colIndex)}
797
+ ariaLabel={labels?.sortBy?.(header) ?? `Sort by ${header}`}
798
+ >
795
799
  {#if sortColumn === colIndex && sortDirection === 'asc'}
796
800
  {#if typeof sortAscIcon === 'function'}
797
801
  {@render sortAscIcon()}
@@ -840,7 +844,7 @@
840
844
  <Button
841
845
  variant="ghost"
842
846
  iconOnly={true}
843
- ariaLabel="Close search"
847
+ ariaLabel={labels?.closeSearch ?? 'Close search'}
844
848
  onclick={clearSearch}
845
849
  >
846
850
  {#snippet icon()}
@@ -921,7 +925,8 @@
921
925
  >
922
926
  <Checkbox
923
927
  text=""
924
- ariaLabel={`Select row ${rowId || 'non-selectable'}`}
928
+ ariaLabel={labels?.selectRow?.(rowId) ??
929
+ `Select row ${rowId || 'non-selectable'}`}
925
930
  controlled
926
931
  checked={rowSelected}
927
932
  disabled={rowDisabled}
@@ -434,7 +434,39 @@ export type TableSearchConfig = {
434
434
  displayMode?: 'toolbar' | 'inline';
435
435
  };
436
436
  export type TableProperties = OptionalTableProperties & TableEventProperties;
437
+ /**
438
+ * Accessible names Table generates for its own controls, for callers who do
439
+ * not ship an English UI.
440
+ *
441
+ * Every member is optional and falls back to the English string Table has
442
+ * always used, so adding this prop changes nothing for existing consumers.
443
+ *
444
+ * The two that depend on a column, and the one that depends on a row, are
445
+ * functions rather than templates with a placeholder: a translator needs to
446
+ * put the header where their grammar wants it, not where an English sentence
447
+ * happens to leave a slot.
448
+ *
449
+ * `Search` is deliberately absent -- `searchConfig.placeholder` already names
450
+ * the search input and has since search shipped.
451
+ */
452
+ export type TableLabels = {
453
+ /** Sort button on a column header. Default: `Sort by ${header}`. */
454
+ sortBy?: (header: string) => string;
455
+ /** Filter trigger on a column header. Default: `Filter by ${header}`. */
456
+ filterBy?: (header: string) => string;
457
+ /** Per-row selection checkbox. Receives the row id, or `''` when the row
458
+ * cannot be selected -- the default spells that case out rather than
459
+ * naming an empty row. Default: `Select row ${rowId || 'non-selectable'}`. */
460
+ selectRow?: (rowId: string) => string;
461
+ /** Header checkbox that selects every row on the page. Default: `Select all rows`. */
462
+ selectAllRows?: string;
463
+ /** Button that empties the search box. Default: `Clear search`. */
464
+ clearSearch?: string;
465
+ /** Button that dismisses the search box. Default: `Close search`. */
466
+ closeSearch?: string;
467
+ };
437
468
  export type OptionalTableProperties = {
469
+ labels?: TableLabels;
438
470
  tableTitle?: string | null;
439
471
  tableHeaders?: string[];
440
472
  tableData?: Array<JSONValue[]>;
@@ -0,0 +1,83 @@
1
+ import type { ChartHighlightAPI } from './highlight';
2
+ /**
3
+ * Drives a chart's highlight from a growing narration transcript.
4
+ *
5
+ * `LineChart`, `BarChart` and `PieChart` hand back a `ChartHighlightAPI` from
6
+ * `onChartReady`, described there as being "for imperative point highlighting
7
+ * from external orchestrators (e.g. voice narration sync, step-through
8
+ * animations)". The socket shipped; the plug did not, so the obvious consumer —
9
+ * an assistant speaking over a chart — hand-rolled it, together with a registry
10
+ * of charts and a store of pending highlights.
11
+ *
12
+ * None of that is needed here. `getCategories()` is already the label list a
13
+ * registry would have duplicated, and `highlight(null)` is already the clear
14
+ * path, so this holds one `ChartHighlightAPI` and nothing else. Passing the API
15
+ * straight in also removes a race the registry version had to work around: a
16
+ * registry keyed by chart identity has to tolerate `onChartReady` firing before
17
+ * the chart is registered, which is a window that does not exist when the
18
+ * caller simply hands over the object it was given.
19
+ *
20
+ * ```ts
21
+ * let highlighter: NarrationHighlighter | null = null;
22
+ *
23
+ * <LineChart {data} onChartReady={(chart) => {
24
+ * highlighter = createNarrationHighlighter({ chart });
25
+ * }} />
26
+ *
27
+ * // as each transcript chunk arrives
28
+ * highlighter?.processText(transcriptSoFar);
29
+ * // when the assistant starts a new answer
30
+ * highlighter?.reset();
31
+ * ```
32
+ */
33
+ export type NarrationHighlighterOptions = {
34
+ /** The API handed to `onChartReady` by the chart being narrated. */
35
+ chart: ChartHighlightAPI;
36
+ /** Milliseconds a highlight stays up before clearing. Defaults to `2000`. */
37
+ autoClearMs?: number;
38
+ /** Match category labels case-sensitively. Defaults to `false`. */
39
+ caseSensitive?: boolean;
40
+ /**
41
+ * Replaces the built-in matching rule. Receives the narration and one
42
+ * category label, already lower-cased unless `caseSensitive` is set.
43
+ *
44
+ * The default matches a label only as a whole word. Substring matching is
45
+ * available through this hook — `(narration, category) =>
46
+ * narration.includes(category)` — but is a poor default: category labels are
47
+ * frequently ordinary words like `Other`, `All` or `None`, especially when
48
+ * they come from a model's tool output rather than a curated list, and a
49
+ * substring rule fires `Other` on `otherwise` and `others`.
50
+ *
51
+ * Whole-word matching narrows that surface; it does not close it. In "on the
52
+ * other hand", `other` is genuinely a standalone word, so the label still
53
+ * matches and no lexical rule could decide otherwise without reading the
54
+ * sentence. A caller who cannot curate labels should require something
55
+ * stronger here, and should `reset()` between turns so a mis-fire cannot
56
+ * outlive one answer.
57
+ */
58
+ match?: (narration: string, category: string) => boolean;
59
+ };
60
+ export type NarrationHighlighter = {
61
+ /**
62
+ * Reads the transcript so far and highlights the first not-yet-seen category
63
+ * it names. Returns that category's index, or `null` when the narration named
64
+ * nothing new.
65
+ *
66
+ * Safe to call on every chunk: the transcript is expected to grow, and a
67
+ * category already highlighted this turn is not highlighted again.
68
+ */
69
+ processText: (narration: string) => number | null;
70
+ /**
71
+ * Starts a new turn: clears the highlight and forgets which categories have
72
+ * fired, so they can fire again for the next answer.
73
+ */
74
+ reset: () => void;
75
+ /**
76
+ * Cancels a pending auto-clear. Call from the owner's teardown.
77
+ *
78
+ * Deliberately does not call into the chart: teardown ordering is the
79
+ * caller's, and the chart may already be gone.
80
+ */
81
+ destroy: () => void;
82
+ };
83
+ export declare const createNarrationHighlighter: (options: NarrationHighlighterOptions) => NarrationHighlighter;
@@ -0,0 +1,113 @@
1
+ const DEFAULT_AUTO_CLEAR_MS = 2000;
2
+ /** Letters, digits and underscore, so a label is matched only as a whole word. */
3
+ const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
4
+ /**
5
+ * `charAt` rather than indexing: out of range it returns `''`, which is not a
6
+ * word character, so the string's edges need no special case.
7
+ */
8
+ const isWordCharacter = (character) => WORD_CHARACTER.test(character);
9
+ /**
10
+ * True when `needle` appears in `haystack` bounded by non-word characters.
11
+ *
12
+ * Written as a scan rather than a `RegExp` for two reasons: a category label is
13
+ * caller data and would have to be escaped, and `\b` is defined over ASCII word
14
+ * characters, so it mismatches labels like `Café` or `北区`. Lookbehind would
15
+ * solve the second but is not available everywhere this library runs.
16
+ */
17
+ const occursAsWord = (haystack, needle) => {
18
+ for (let from = 0; from <= haystack.length - needle.length;) {
19
+ const at = haystack.indexOf(needle, from);
20
+ if (at === -1) {
21
+ return false;
22
+ }
23
+ const before = at === 0 ? '' : haystack.charAt(at - 1);
24
+ const after = haystack.charAt(at + needle.length);
25
+ if (!isWordCharacter(before) && !isWordCharacter(after)) {
26
+ return true;
27
+ }
28
+ from = at + 1;
29
+ }
30
+ return false;
31
+ };
32
+ /**
33
+ * The type says `number`, but a plain-JS or web-component caller can pass
34
+ * anything. `NaN` reaches `setTimeout` as an immediate fire, clearing the
35
+ * highlight before it has been seen, so an unusable delay degrades to the
36
+ * default rather than breaking the affordance it controls.
37
+ */
38
+ const resolveAutoClearMs = (value) => {
39
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
40
+ return DEFAULT_AUTO_CLEAR_MS;
41
+ }
42
+ return value;
43
+ };
44
+ /** `getCategories` is the chart's, and a torn-down chart can throw or return junk. */
45
+ const readCategories = (chart) => {
46
+ try {
47
+ const categories = chart.getCategories();
48
+ if (!Array.isArray(categories)) {
49
+ return [];
50
+ }
51
+ return categories.flatMap((category, sourceIndex) => typeof category === 'string' ? [{ label: category, sourceIndex }] : []);
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ };
57
+ export const createNarrationHighlighter = (options) => {
58
+ const { chart, caseSensitive = false, match } = options;
59
+ const autoClearMs = resolveAutoClearMs(options.autoClearMs ?? null);
60
+ const fired = new Set();
61
+ let clearTimer = null;
62
+ const cancelPendingClear = () => {
63
+ if (clearTimer !== null) {
64
+ clearTimeout(clearTimer);
65
+ clearTimer = null;
66
+ }
67
+ };
68
+ const matches = (narration, category) => match ? match(narration, category) : occursAsWord(narration, category);
69
+ return {
70
+ processText: (narration) => {
71
+ if (typeof narration !== 'string' || narration.length === 0) {
72
+ return null;
73
+ }
74
+ const haystack = caseSensitive ? narration : narration.toLowerCase();
75
+ const categories = readCategories(chart);
76
+ /*
77
+ * Longest label wins. `Q1` is a whole-word match inside `Q1 Europe`, so
78
+ * taking the first hit would highlight the broader slice while the
79
+ * narration is naming the narrower one. Ties keep the earlier index,
80
+ * which makes the choice stable across calls.
81
+ */
82
+ let bestIndex = null;
83
+ let bestLength = 0;
84
+ categories.forEach(({ label, sourceIndex }) => {
85
+ if (label.length === 0 || fired.has(sourceIndex) || label.length <= bestLength) {
86
+ return;
87
+ }
88
+ const needle = caseSensitive ? label : label.toLowerCase();
89
+ if (matches(haystack, needle)) {
90
+ bestIndex = sourceIndex;
91
+ bestLength = label.length;
92
+ }
93
+ });
94
+ if (bestIndex === null) {
95
+ return null;
96
+ }
97
+ fired.add(bestIndex);
98
+ cancelPendingClear();
99
+ chart.highlight(bestIndex);
100
+ clearTimer = setTimeout(() => {
101
+ clearTimer = null;
102
+ chart.highlight(null);
103
+ }, autoClearMs);
104
+ return bestIndex;
105
+ },
106
+ reset: () => {
107
+ cancelPendingClear();
108
+ fired.clear();
109
+ chart.highlight(null);
110
+ },
111
+ destroy: cancelPendingClear
112
+ };
113
+ };
package/dist/index.d.ts CHANGED
@@ -185,6 +185,7 @@ export type * from './ProportionBar/properties';
185
185
  export type * from './ChipInput/properties';
186
186
  export type * from './FileDropzoneTrigger/properties';
187
187
  export type * from './_chart/highlight';
188
+ export type * from './_chart/narrationHighlighter';
188
189
  export type * from './Chat/properties';
189
190
  export type * from './Chat/types';
190
191
  export type * from './SpeechToText/types';
@@ -205,3 +206,4 @@ export type * from './Gallery/properties';
205
206
  export { createSoundKit } from './SoundKit/SoundKit';
206
207
  export { validateInput, lockBodyScroll, unlockBodyScroll } from './utils';
207
208
  export { formatNumberIndian } from './_chart/format';
209
+ export { createNarrationHighlighter } from './_chart/narrationHighlighter';
package/dist/index.js CHANGED
@@ -106,3 +106,4 @@ export { createCopyState } from './Snippet/copyState.svelte';
106
106
  export { createSoundKit } from './SoundKit/SoundKit';
107
107
  export { validateInput, lockBodyScroll, unlockBodyScroll } from './utils';
108
108
  export { formatNumberIndian } from './_chart/format';
109
+ export { createNarrationHighlighter } from './_chart/narrationHighlighter';