@juspay/svelte-ui-components 4.6.0 → 4.8.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.
@@ -5,7 +5,13 @@
5
5
  import Card from '../Card/Card.svelte';
6
6
  import Progress from '../Progress/Progress.svelte';
7
7
  import { pauseAllConfirmationTimers } from './timers';
8
- import type { HITLAction, HITLProperties, HITLResponse, HITLSection } from './properties';
8
+ import type {
9
+ HITLAction,
10
+ HITLExtraAction,
11
+ HITLProperties,
12
+ HITLResponse,
13
+ HITLSection
14
+ } from './properties';
9
15
 
10
16
  /**
11
17
  * Human-in-the-loop approval card: the assistant wants to run an action and the
@@ -43,6 +49,8 @@
43
49
  cancelTestId,
44
50
  completionTestId,
45
51
  completionTextTestId,
52
+ actions,
53
+ children,
46
54
  classes
47
55
  }: HITLProperties = $props();
48
56
 
@@ -180,6 +188,20 @@
180
188
  await decide(action);
181
189
  };
182
190
 
191
+ // An extra action (e.g. "Deny with instructions") is a side path, not a
192
+ // decision — it never settles the card, so `onconfirm` never fires for it.
193
+ // It still counts as interacting with the card: pause this card's own
194
+ // countdown and every sibling's, exactly like clicking confirm or cancel.
195
+ const selectExtraAction = (action: HITLExtraAction): void => {
196
+ if (decisionPending || isProcessing || isCompleted) {
197
+ return;
198
+ }
199
+ pauseAllConfirmationTimers.set(true);
200
+ stopCountdown();
201
+ clearAutoCancel();
202
+ action.onSelect();
203
+ };
204
+
183
205
  onMount(() => {
184
206
  if (isHistoryMode) {
185
207
  return;
@@ -375,6 +397,11 @@
375
397
  </span>
376
398
  </div>
377
399
  {:else}
400
+ {#if children}
401
+ <div class="extra-content">
402
+ {@render children()}
403
+ </div>
404
+ {/if}
378
405
  <div class="action-buttons">
379
406
  <div class="cancel-button">
380
407
  <Button
@@ -385,6 +412,19 @@
385
412
  onclick={() => interact('rejected')}
386
413
  />
387
414
  </div>
415
+ {#each actions ?? [] as action, index (action.testId ?? `${index}-${action.label}`)}
416
+ <div class="extra-action-button">
417
+ <Button
418
+ variant="secondary"
419
+ text={action.label}
420
+ enable={!isProcessing}
421
+ testId={action.testId ?? (testId && `${testId}-action-${index}`)}
422
+ classes={action.classes}
423
+ ariaLabel={action.ariaLabel}
424
+ onclick={() => selectExtraAction(action)}
425
+ />
426
+ </div>
427
+ {/each}
388
428
  <div class="confirm-button">
389
429
  {#if countdownActive}
390
430
  <div class="progress-anchor">
@@ -480,6 +520,13 @@
480
520
  text-transform: capitalize;
481
521
  }
482
522
 
523
+ /* Rendered only when `children` is supplied; the flex gap on
524
+ .confirmation-body already spaces it from the params above and the
525
+ action-buttons row below, so no default-behavior change when it's absent. */
526
+ .extra-content {
527
+ width: 100%;
528
+ }
529
+
483
530
  .action-buttons {
484
531
  display: flex;
485
532
  gap: var(--hitl-buttons-gap, 0.75rem);
@@ -487,7 +534,8 @@
487
534
  }
488
535
 
489
536
  .cancel-button,
490
- .confirm-button {
537
+ .confirm-button,
538
+ .extra-action-button {
491
539
  flex: 1;
492
540
  position: relative;
493
541
  --button-width: 100%;
@@ -15,6 +15,23 @@ export type HITLInitialState = {
15
15
  /** `'EXPIRED'` renders the timed-out completion state. */
16
16
  status?: string;
17
17
  };
18
+ /**
19
+ * A side action rendered beside confirm/cancel — e.g. "Deny with instructions"
20
+ * opening a compose panel, or a third disposition a plain approve/reject can't
21
+ * express. Selecting one does NOT settle the card: no completion state, no
22
+ * `onconfirm`. It only pauses the countdown (own and siblings', like any other
23
+ * interaction) and calls `onSelect` — settling, if any, is the caller's job.
24
+ */
25
+ export type HITLExtraAction = {
26
+ label: string;
27
+ onSelect: () => void;
28
+ /** `data-pw` for this action's button (default: `<testId>-action-<index>`). */
29
+ testId?: string;
30
+ /** Class string on this action's Button, e.g. to pick a variant. */
31
+ classes?: string;
32
+ /** Accessible name for this action's Button, when `label` alone isn't descriptive enough. */
33
+ ariaLabel?: string;
34
+ };
18
35
  export type HITLProperties = OptionalHITLProperties & MandatoryHITLProperties;
19
36
  export type MandatoryHITLProperties = {
20
37
  confirmationId: string;
@@ -67,5 +84,16 @@ export type OptionalHITLProperties = {
67
84
  completionTestId?: string;
68
85
  /** Override the completion text's test id (default: `<testId>-completion-text`). */
69
86
  completionTextTestId?: string;
87
+ /**
88
+ * Extra side actions rendered between cancel and confirm — e.g. "Deny with
89
+ * instructions". None of these settle the card; see `HITLExtraAction`.
90
+ */
91
+ actions?: HITLExtraAction[];
92
+ /**
93
+ * Arbitrary extra controls — free-text input, multi-select chips — rendered
94
+ * in the body above the action row while the card is pending. Never shown
95
+ * once the card is completed or in history mode.
96
+ */
97
+ children?: Snippet;
70
98
  classes?: string;
71
99
  };
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
+ import { onDestroy } from 'svelte';
2
3
  import type { SnippetProperties } from './properties';
3
4
  import Button from '../Button/Button.svelte';
4
5
  import copySvg from '../assets/copy.svg?raw';
6
+ import { createCopyResetTimer } from './copyResetTimer';
5
7
 
6
8
  let {
7
9
  text,
@@ -9,20 +11,31 @@
9
11
  showCopyButton = true,
10
12
  testId,
11
13
  copyIcon,
14
+ copiedLabel = 'Copied!',
15
+ copyResetMs = 2000,
12
16
  oncopy,
13
17
  classes
14
18
  }: SnippetProperties = $props();
15
19
 
16
20
  let copied = $state(false);
17
21
 
22
+ // The reset timer used to be a bare setTimeout with nothing clearing it: a
23
+ // second copy before the first one's timer fired left two timers racing to
24
+ // flip `copied`, and navigating away mid-flash left the timer armed against
25
+ // a destroyed component. `createCopyResetTimer` (unit-tested on its own in
26
+ // copyResetTimer.test.ts) guarantees only one timer is ever pending, and
27
+ // onDestroy guarantees it never outlives the component it targets.
28
+ const resetTimer = createCopyResetTimer();
29
+ onDestroy(resetTimer.cancel);
30
+
18
31
  async function handleCopy() {
19
32
  try {
20
33
  await navigator.clipboard.writeText(text);
21
34
  copied = true;
22
35
  oncopy?.();
23
- setTimeout(() => {
36
+ resetTimer.arm(() => {
24
37
  copied = false;
25
- }, 2000);
38
+ }, copyResetMs);
26
39
  } catch {
27
40
  // Clipboard API unavailable (non-secure context, iframe restrictions)
28
41
  }
@@ -42,7 +55,7 @@
42
55
  <div class="snippet-copy">
43
56
  <Button onclick={handleCopy} ariaLabel="Copy to clipboard">
44
57
  {#if copied}
45
- <span class="snippet-copied">Copied!</span>
58
+ <span class="snippet-copied" aria-live="polite">{copiedLabel}</span>
46
59
  {:else if typeof copyIcon === 'function'}
47
60
  {@render copyIcon()}
48
61
  {:else}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Manages the single "revert the copied-feedback flag" timer behind Snippet's
3
+ * copy button (#530). Extracted as a plain, Svelte-free module so the timer
4
+ * safety this fixes -- never leaving two timers racing after a fast double
5
+ * copy, never firing against a component that has already unmounted -- is
6
+ * unit-testable on its own, the same way `Tooltip/tooltip-action.ts` keeps
7
+ * its DOM-timing logic outside the component.
8
+ */
9
+ export type CopyResetTimer = {
10
+ /**
11
+ * (Re)arms the timer: clears any timer already pending, then schedules
12
+ * `onReset` to run after `delayMs`.
13
+ */
14
+ arm: (onReset: () => void, delayMs: number) => void;
15
+ /**
16
+ * Cancels a pending timer, if any. Safe to call when nothing is pending --
17
+ * this is what Snippet calls from `onDestroy`.
18
+ */
19
+ cancel: () => void;
20
+ };
21
+ export declare function createCopyResetTimer(): CopyResetTimer;
@@ -0,0 +1,20 @@
1
+ export function createCopyResetTimer() {
2
+ let handle = null;
3
+ return {
4
+ arm(onReset, delayMs) {
5
+ if (handle !== null) {
6
+ clearTimeout(handle);
7
+ }
8
+ handle = setTimeout(() => {
9
+ handle = null;
10
+ onReset();
11
+ }, delayMs);
12
+ },
13
+ cancel() {
14
+ if (handle !== null) {
15
+ clearTimeout(handle);
16
+ handle = null;
17
+ }
18
+ }
19
+ };
20
+ }
@@ -8,6 +8,10 @@ export type OptionalSnippetProperties = {
8
8
  showCopyButton?: boolean;
9
9
  testId?: string;
10
10
  copyIcon?: Snippet;
11
+ /** Text shown in place of the copy icon after a successful copy. Defaults to `'Copied!'`. */
12
+ copiedLabel?: string;
13
+ /** Milliseconds before the copied feedback reverts to the copy icon. Defaults to `2000`. */
14
+ copyResetMs?: number;
11
15
  classes?: string;
12
16
  };
13
17
  export type SnippetEventProperties = {