@juspay/svelte-ui-components 4.53.0 → 4.54.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/README.md CHANGED
@@ -154,6 +154,20 @@ fix to something that did not work.
154
154
  | **RelativeTime** | Auto-updating relative time display ("5 minutes ago") with locale support and optional tooltip. | [docs](docs/RelativeTime.md) |
155
155
  | **AspectRatio** | Constrains content to a fixed width-to-height ratio using CSS `aspect-ratio`, reserving layout space before an image, video or iframe loads. | [docs](docs/AspectRatio.md) |
156
156
 
157
+ ### Charts
158
+
159
+ Pure-SVG charts with no charting-library dependency; each takes plain data arrays.
160
+
161
+ | Component | Description | Docs |
162
+ | --- | --- | --- |
163
+ | **AreaChart** | Responsive area chart for volume under a trend: multiple series, regular or 100% stacking, gradient fill, data labels and a hover overlay. | [docs](docs/AreaChart.md) |
164
+ | **BarChart** | Responsive bar chart for comparing categories: vertical or horizontal, single or multi-series (grouped or stacked), value labels, per-point colours and hover tooltips. | [docs](docs/BarChart.md) |
165
+ | **DualAxisBarChart** | Two independent Y-axes over one categorical X-axis; each series declares its own axis and render type. | [docs](docs/DualAxisBarChart.md) |
166
+ | **FunnelChart** | Horizontal funnel: one bar per stage, proportional to its value, with connectors between stages. | [docs](docs/FunnelChart.md) |
167
+ | **LineChart** | Responsive line chart for trends: multiple series, five curve interpolations, gradient fill and an optional area mode. | [docs](docs/LineChart.md) |
168
+ | **PieChart** | Responsive pie or donut chart with slice hover highlighting, a delta badge, custom labels, a legend and donut centre content. | [docs](docs/PieChart.md) |
169
+ | **SankeyChart** | Sankey diagram for flows between nodes, positioned into columns automatically. | [docs](docs/SankeyChart.md) |
170
+
157
171
  ### Feedback & Loading
158
172
 
159
173
  | Component | Description | Docs |
@@ -134,15 +134,27 @@
134
134
  let slashSelected = $state(0);
135
135
  let slashList: HTMLUListElement | null = $state(null);
136
136
 
137
+ // A command opens on the first character of its name: "/model" on "/",
138
+ // "@support" on "@". Letters, digits and whitespace never trigger, so a
139
+ // plain word typed into the composer never opens the menu. Read as a whole
140
+ // code point, so an emoji trigger is never split into half a surrogate pair.
141
+ function slashTrigger(text: string): string | null {
142
+ const [first = ''] = text;
143
+ return /^[^\p{L}\p{N}\s]$/u.test(first) ? first : null;
144
+ }
145
+
137
146
  const slashMatches = $derived.by((): ChatComposerSlashCommand[] => {
138
- if (slashCommands === null || slashDismissed || !/^\/\S*$/.test(value)) {
147
+ const trigger = slashTrigger(value);
148
+ if (slashCommands === null || slashDismissed || trigger === null || /\s/.test(value)) {
139
149
  return [];
140
150
  }
141
151
  const query = value.toLowerCase();
142
152
  return slashCommands.filter(
143
153
  (command) =>
144
- command.name.toLowerCase().startsWith(query) ||
145
- (query.length > 1 && (command.description ?? '').toLowerCase().includes(query.slice(1)))
154
+ slashTrigger(command.name) === trigger &&
155
+ (command.name.toLowerCase().startsWith(query) ||
156
+ (query.length > trigger.length &&
157
+ (command.description ?? '').toLowerCase().includes(query.slice(trigger.length))))
146
158
  );
147
159
  });
148
160
  const slashArg = $derived.by(
@@ -150,8 +162,8 @@
150
162
  if (slashCommands === null || slashDismissed) {
151
163
  return null;
152
164
  }
153
- const parsed = /^(\/\S+)\s(\S*)$/.exec(value);
154
- if (parsed === null) {
165
+ const parsed = /^(\S+)\s(\S*)$/.exec(value);
166
+ if (parsed === null || slashTrigger(parsed[1] ?? '') === null) {
155
167
  return null;
156
168
  }
157
169
  const name = (parsed[1] ?? '').toLowerCase();
@@ -100,9 +100,11 @@ export type OptionalChatComposerProperties = {
100
100
  /** Test id on the status region (see `statusText`). */
101
101
  statusTestId?: string;
102
102
  /**
103
- * Opt-in slash-command menu. With commands passed, typing "/" opens a
104
- * listbox of the ones that match (name prefix, or the text after "/" found
105
- * in a description); arrows move, Tab completes, Enter picks (Enter on a
103
+ * Opt-in slash-command menu. With commands passed, typing a command's
104
+ * trigger -- the first character of its name, "/" for "/model" or "@" for
105
+ * "@support" -- opens a listbox of the commands sharing that trigger that
106
+ * match (name prefix, or the text after the trigger found in a
107
+ * description); arrows move, Tab completes, Enter picks (Enter on a
106
108
  * complete match submits), Escape dismisses -- through the shared
107
109
  * dismissible stack, so an overlay above the composer closes first. A
108
110
  * command with `choices` keeps the menu open past the space for a second
@@ -197,7 +199,11 @@ export type ChatComposerSlashCommandChoice = {
197
199
  };
198
200
  /** One entry of ChatComposer's slash-command menu. */
199
201
  export type ChatComposerSlashCommand = {
200
- /** Including the slash, e.g. "/model". */
202
+ /**
203
+ * Including its trigger character, e.g. "/model" or "@support". The first
204
+ * character opens the menu, so it must be punctuation or a symbol: a name
205
+ * starting with a letter, digit or space is never offered.
206
+ */
201
207
  name: string;
202
208
  description?: string;
203
209
  /** Shown after the name, e.g. "[model]". */
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type {
3
+ DateRangeCompareDraft,
3
4
  DateRangePickerProperties,
4
5
  DateRangePreset,
5
6
  TimeDisplayBoundary
@@ -95,8 +96,11 @@
95
96
  let draftStart: Date | null = $state(null);
96
97
  let draftEnd: Date | null = $state(null);
97
98
  let draftValue: Date | null = $state(null);
98
- let draftCompareStart: Date | null = $state(null);
99
- let draftCompareEnd: Date | null = $state(null);
99
+ // The compare range is a draft too. It is handed to `compareCalendar` so a consumer's calendar
100
+ // has somewhere to write that Apply commits and Cancel discards; `compareSnapshot` is what the
101
+ // committed range was when the session opened, which is what Cancel restores.
102
+ const compareDraft: DateRangeCompareDraft = $state({ start: null, end: null });
103
+ let compareSnapshot: DateRangeCompareDraft = $state({ start: null, end: null });
100
104
 
101
105
  // Tracks the label of the currently active preset, or null when the user
102
106
  // made a custom calendar selection (or before any selection is made).
@@ -339,6 +343,67 @@
339
343
  }
340
344
  }
341
345
 
346
+ const sameInstant = (a: Date | null, b: Date | null): boolean =>
347
+ (a === null ? null : a.getTime()) === (b === null ? null : b.getTime());
348
+
349
+ // The compare calendar lives in the main panel unless a standalone `compareTrigger` gives it
350
+ // its own panel and its own Apply/Cancel.
351
+ const compareInMain: boolean = $derived(
352
+ typeof compareCalendar === 'function' && typeof compareTrigger !== 'function'
353
+ );
354
+
355
+ function seedCompareSession(): void {
356
+ compareSnapshot = { start: compareStart, end: compareEnd };
357
+ compareDraft.start = compareStart;
358
+ compareDraft.end = compareEnd;
359
+ }
360
+
361
+ // What Apply commits. A calendar bound to `compareDraft` edits the draft. A calendar bound
362
+ // straight to `compareStart`/`compareEnd` (how this component's docs wired it before the draft
363
+ // was exposed) edits the committed values in place and leaves the draft at its seed; Apply used
364
+ // to write that stale seed back over the pick, and could not be pressed at all on first use.
365
+ const pendingCompare = $derived.by(() => {
366
+ const edited =
367
+ !sameInstant(compareDraft.start, compareSnapshot.start) ||
368
+ !sameInstant(compareDraft.end, compareSnapshot.end);
369
+ return edited
370
+ ? { start: compareDraft.start, end: compareDraft.end }
371
+ : { start: compareStart, end: compareEnd };
372
+ });
373
+ const canApplyCompare: boolean = $derived(
374
+ pendingCompare.start !== null && pendingCompare.end !== null
375
+ );
376
+
377
+ function commitCompare(): void {
378
+ const { start, end } = pendingCompare;
379
+ if (start === null || end === null) {
380
+ return;
381
+ }
382
+ compareStart = start;
383
+ compareEnd = end;
384
+ onapplycompare?.({ compareStart: start, compareEnd: end, presetLabel: selectedPresetLabel });
385
+ }
386
+
387
+ // Cancel, Escape and an outside press all land here: whatever the session did to the draft or
388
+ // to the committed range goes back to what it was when the session opened.
389
+ function discardCompare(): void {
390
+ if (!sameInstant(compareStart, compareSnapshot.start)) {
391
+ compareStart = compareSnapshot.start;
392
+ }
393
+ if (!sameInstant(compareEnd, compareSnapshot.end)) {
394
+ compareEnd = compareSnapshot.end;
395
+ }
396
+ compareDraft.start = compareSnapshot.start;
397
+ compareDraft.end = compareSnapshot.end;
398
+ }
399
+
400
+ function abandonPicker(): void {
401
+ if (compareInMain) {
402
+ discardCompare();
403
+ }
404
+ closePicker();
405
+ }
406
+
342
407
  function openPicker(): void {
343
408
  // Pointer activation need not focus a button in WebKit. Return to the
344
409
  // invoking control, not an unrelated previously focused field or body.
@@ -347,8 +412,9 @@
347
412
  draftStart = rangeStart;
348
413
  draftEnd = rangeEnd;
349
414
  draftValue = value;
350
- draftCompareStart = compareStart;
351
- draftCompareEnd = compareEnd;
415
+ if (compareInMain) {
416
+ seedCompareSession();
417
+ }
352
418
 
353
419
  // Re-seed the session from the committed preset so the sidebar re-highlights it
354
420
  // by label. A direct calendar click later clears this, reverting to date matching.
@@ -500,7 +566,7 @@
500
566
  // a second click feeling dead — the panel could only be closed by clicking away.
501
567
  function togglePicker(): void {
502
568
  if (isOpen) {
503
- closePicker();
569
+ abandonPicker();
504
570
  } else {
505
571
  openPicker();
506
572
  }
@@ -508,8 +574,7 @@
508
574
 
509
575
  function openComparePicker(): void {
510
576
  compareFocusReturnEl = compareTriggerRef ? findInteractive(compareTriggerRef) : null;
511
- draftCompareStart = compareStart ?? null;
512
- draftCompareEnd = compareEnd ?? null;
577
+ seedCompareSession();
513
578
  openCompare = true;
514
579
  tick().then(() => focusFirstElement(comparePanelRef));
515
580
  }
@@ -524,19 +589,12 @@
524
589
  }
525
590
 
526
591
  function handleApplyCompare(): void {
527
- if (draftCompareStart !== null && draftCompareEnd !== null) {
528
- compareStart = draftCompareStart;
529
- compareEnd = draftCompareEnd;
530
- onapplycompare?.({
531
- compareStart: draftCompareStart,
532
- compareEnd: draftCompareEnd,
533
- presetLabel: selectedPresetLabel
534
- });
535
- }
592
+ commitCompare();
536
593
  closeComparePicker();
537
594
  }
538
595
 
539
596
  function handleCancelCompare(): void {
597
+ discardCompare();
540
598
  closeComparePicker();
541
599
  }
542
600
 
@@ -568,18 +626,8 @@
568
626
  presetLabel: selectedPresetLabel
569
627
  });
570
628
  }
571
- if (
572
- typeof compareCalendar === 'function' &&
573
- draftCompareStart !== null &&
574
- draftCompareEnd !== null
575
- ) {
576
- compareStart = draftCompareStart;
577
- compareEnd = draftCompareEnd;
578
- onapplycompare?.({
579
- compareStart: draftCompareStart,
580
- compareEnd: draftCompareEnd,
581
- presetLabel: selectedPresetLabel
582
- });
629
+ if (typeof compareCalendar === 'function') {
630
+ commitCompare();
583
631
  }
584
632
  } else {
585
633
  if (draftValue !== null) {
@@ -601,7 +649,7 @@
601
649
 
602
650
  function handleCancel(): void {
603
651
  oncancel?.();
604
- closePicker();
652
+ abandonPicker();
605
653
  }
606
654
 
607
655
  function handlePreset(preset: DateRangePreset): void {
@@ -893,9 +941,10 @@
893
941
  outside both at once. */
894
942
  function handleDismissOutside(): void {
895
943
  if (isOpen) {
896
- closePicker();
944
+ abandonPicker();
897
945
  }
898
946
  if (openCompare) {
947
+ discardCompare();
899
948
  closeComparePicker();
900
949
  }
901
950
  }
@@ -1053,7 +1102,7 @@
1053
1102
  >
1054
1103
  {#if typeof compareCalendar === 'function'}
1055
1104
  <div class="drp-compare-panel-body">
1056
- {@render compareCalendar()}
1105
+ {@render compareCalendar(compareDraft)}
1057
1106
  </div>
1058
1107
  {/if}
1059
1108
  <div class="drp-footer">
@@ -1066,7 +1115,7 @@
1066
1115
  onclick={handleApplyCompare}
1067
1116
  ariaLabel="Apply compare selection"
1068
1117
  classes="drp-btn-apply"
1069
- disabled={draftCompareStart === null || draftCompareEnd === null}>Apply</Button
1118
+ disabled={!canApplyCompare}>Apply</Button
1070
1119
  >
1071
1120
  </div>
1072
1121
  </div>
@@ -1442,7 +1491,7 @@
1442
1491
  causes a Svelte 5 runtime error. -->
1443
1492
  {#if typeof compareCalendar === 'function' && typeof compareTrigger !== 'function'}
1444
1493
  <div class="drp-compare-section">
1445
- {@render compareCalendar()}
1494
+ {@render compareCalendar(compareDraft)}
1446
1495
  </div>
1447
1496
  {/if}
1448
1497
  </div>
@@ -1,4 +1,12 @@
1
1
  import type { Snippet } from 'svelte';
2
+ /**
3
+ * The compare range a `compareCalendar` snippet edits before Apply commits it. A plain
4
+ * reactive object, so a Calendar can bind to its fields.
5
+ */
6
+ export type DateRangeCompareDraft = {
7
+ start: Date | null;
8
+ end: Date | null;
9
+ };
2
10
  export type DateRangePreset = {
3
11
  label: string;
4
12
  getValue: () => {
@@ -102,8 +110,16 @@ export type OptionalDateRangePickerProperties = {
102
110
  compareStart?: Date | null;
103
111
  /** End of compare range (bindable). Used when compareCalendar snippet is provided. */
104
112
  compareEnd?: Date | null;
105
- /** Snippet rendered in the compare-calendar slot. Consumer controls all compare UI. */
106
- compareCalendar?: Snippet;
113
+ /**
114
+ * Snippet rendered in the compare-calendar slot. Consumer controls all compare UI.
115
+ *
116
+ * It receives the compare draft, a reactive `{ start, end }` that Apply commits and that
117
+ * Cancel, Escape and an outside press discard. Bind a Calendar to it:
118
+ * `bind:rangeStart={draft.start} bind:rangeEnd={draft.end}`. A snippet that takes no
119
+ * argument and edits `compareStart`/`compareEnd` directly still works: Apply keeps what
120
+ * it wrote, and Cancel restores the range the session opened with.
121
+ */
122
+ compareCalendar?: Snippet<[DateRangeCompareDraft]>;
107
123
  /** Which day of the week starts the calendar. 0=Sunday, 1=Monday. */
108
124
  weekStartsOn?: 0 | 1;
109
125
  /** BCP-47 locale string for date formatting. */
@@ -289,10 +289,10 @@
289
289
  gap: var(--tool-call-log-gap, 8px);
290
290
  }
291
291
 
292
- /* Bespoke <button> markup (Pill's root is a non-interactive <div> and can't carry
293
- aria-expanded/button semantics — see docs), but its sizing recipe is deliberately
294
- pulled from Pill's own tokens (line-height, cursor) so it reads as the same family
295
- of control as the diffstat Pill badges nested inside it. */
292
+ /* Bespoke <button> markup (the chip holds mixed content -- spinner, label, meta and nested
293
+ diffstat Pill badges -- where Pill renders one `text` string; see docs), but its sizing
294
+ recipe is deliberately pulled from Pill's own tokens (line-height, cursor) so it reads as
295
+ the same family of control as the diffstat Pill badges nested inside it. */
296
296
  .chip {
297
297
  display: inline-flex;
298
298
  align-items: center;