@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.
- package/dist/ChatMessage/ChatMessage.svelte +39 -0
- package/dist/MarkdownText/MarkdownText.svelte +4 -1
- package/dist/MarkdownText/markdown.js +24 -4
- package/dist/MarkdownText/properties.d.ts +11 -0
- package/dist/Table/Table.svelte +12 -7
- package/dist/Table/properties.d.ts +32 -0
- package/dist/_chart/narrationHighlighter.d.ts +83 -0
- package/dist/_chart/narrationHighlighter.js +113 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist-wc/index.js +257 -215
- package/package.json +1 -1
|
@@ -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(
|
|
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
|
-
|
|
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="
|
|
255
|
-
:
|
|
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
|
|
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
|
};
|
package/dist/Table/Table.svelte
CHANGED
|
@@ -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=
|
|
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=
|
|
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=
|
|
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
|
|
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=
|
|
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={
|
|
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';
|