@juspay/svelte-ui-components 4.7.0 → 4.9.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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Focus-trap utility for Modal (#529). Pure DOM-reading functions, no Svelte
3
+ * runtime dependency, so they can be unit tested without mounting the
4
+ * component -- mirrors the split Tooltip already uses for its action logic
5
+ * (see tooltip-action.test.ts).
6
+ *
7
+ * Modal.svelte owns the event wiring (which key was pressed, calling
8
+ * .focus()); this module owns the two decisions that wiring needs answered:
9
+ * "what are the edges of the tab order in here" and "given where focus is and
10
+ * which way Tab was pressed, should it wrap, and to where".
11
+ */
12
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
13
+ /**
14
+ * The first and last focusable descendants of `container`, in DOM order.
15
+ * Both are null when the container is null or holds nothing focusable.
16
+ */
17
+ export function getFocusTrapBoundary(container) {
18
+ if (container === null) {
19
+ return { first: null, last: null };
20
+ }
21
+ const focusable = container.querySelectorAll(FOCUSABLE_SELECTOR);
22
+ return { first: focusable.item(0), last: focusable.item(focusable.length - 1) };
23
+ }
24
+ /**
25
+ * Given a Tab/Shift+Tab keydown inside a focus-trapped container, returns the
26
+ * element focus should move to, or null when the default browser behavior
27
+ * (move to the next/previous focusable element) should be left alone.
28
+ *
29
+ * Wrapping only happens exactly at an edge: focus sits on the last focusable
30
+ * element and Tab (not Shift+Tab) was pressed, or focus sits on the first and
31
+ * Shift+Tab was pressed. Anywhere else in the middle of the tab order, the
32
+ * browser's own handling is correct and this returns null.
33
+ */
34
+ export function focusTrapTabTarget(params) {
35
+ const { first, last } = getFocusTrapBoundary(params.container);
36
+ if (first === null || last === null) {
37
+ return null;
38
+ }
39
+ const edge = params.shiftKey ? first : last;
40
+ if (params.activeElement !== edge) {
41
+ return null;
42
+ }
43
+ return params.shiftKey ? last : first;
44
+ }
45
+ /**
46
+ * Moves focus into `container` -- its first focusable descendant, or the
47
+ * container itself as a fallback for a modal with no focusable content (it
48
+ * must carry tabindex="-1" for that fallback to actually take focus). A no-op
49
+ * when the container is null.
50
+ */
51
+ export function focusEntryPoint(container) {
52
+ if (container === null) {
53
+ return;
54
+ }
55
+ const { first } = getFocusTrapBoundary(container);
56
+ (first ?? container).focus();
57
+ }
@@ -11,6 +11,12 @@ export type ModalAlign = 'top' | 'center' | 'bottom';
11
11
  * Leave unset to keep the existing per-align default.
12
12
  */
13
13
  export type ModalEntryAnimation = 'fade' | 'slide-up' | 'slide-down';
14
+ /**
15
+ * 'alertdialog' is for a dialog that interrupts to demand an immediate
16
+ * response (e.g. a destructive confirmation) -- semantically distinct from an
17
+ * ordinary 'dialog' in how assistive tech announces it. See the `role` prop.
18
+ */
19
+ export type ModalRole = 'dialog' | 'alertdialog';
14
20
  export type ModalProperties = OptionalModalProperties & ModalEventProperties;
15
21
  export type OptionalModalProperties = {
16
22
  size?: ModalSize;
@@ -38,6 +44,15 @@ export type OptionalModalProperties = {
38
44
  leftImageTestId?: string;
39
45
  /** Accessible name for the left header image's role="button" wrapper (e.g. a back control). Rendered as aria-label. */
40
46
  leftImageAriaLabel?: string;
47
+ /**
48
+ * Accessible name for the overlay's `role="button"` wrapper, rendered as
49
+ * `aria-label` -- same pattern as `leftImageAriaLabel` and
50
+ * `header.buttonAriaLabel`. Only meaningful when the overlay is dismissible
51
+ * (an `onoverlayclick` or `ondismiss` handler is supplied), which is the
52
+ * only state where the overlay is announced as a focusable "button" at all.
53
+ * Default: unset (no accessible name).
54
+ */
55
+ overlayAriaLabel?: string;
41
56
  testId?: string;
42
57
  content?: Snippet;
43
58
  footerSnippet?: Snippet;
@@ -52,6 +67,24 @@ export type OptionalModalProperties = {
52
67
  lockScroll?: boolean;
53
68
  /** Milliseconds after mount to automatically fire onclose, e.g. for a transient success/confirmation modal. null (default) disables auto-dismiss. */
54
69
  autoDismissAfter?: number | null;
70
+ /**
71
+ * Accessible name for the modal, rendered as `aria-label` on the modal
72
+ * content panel (the element carrying `role`/`aria-modal` when `role` is
73
+ * set). Without it a screen reader announces an unnamed dialog. Default:
74
+ * unset (no aria-label) -- matches today's behavior, where the panel has no
75
+ * accessible name at all.
76
+ */
77
+ ariaLabel?: string;
78
+ /**
79
+ * ARIA role for the modal content panel. Default: unset -- the panel
80
+ * renders no `role` and no `aria-modal` at all, byte-for-byte matching
81
+ * behavior prior to this prop's introduction, so an existing consumer who
82
+ * never touches `role` sees no DOM/ARIA change. Set it explicitly (e.g.
83
+ * `'dialog'` or `'alertdialog'`) to opt in: the panel then also carries
84
+ * `aria-modal="true"`, since a role without aria-modal is not a complete
85
+ * dialog announcement.
86
+ */
87
+ role?: ModalRole;
55
88
  };
56
89
  export type ModalEventProperties = {
57
90
  onclose?: () => void;
@@ -61,4 +94,15 @@ export type ModalEventProperties = {
61
94
  onsecondarybuttonclick?: (event: MouseEvent) => void;
62
95
  onoverlayclick?: () => void;
63
96
  onkeydown?: (event: KeyboardEvent) => void;
97
+ /**
98
+ * Fires on every path that dismisses the modal from the OUTSIDE: an overlay
99
+ * click, the Escape key, and a hardware back-press. A superset of
100
+ * onoverlayclick (backdrop click + Escape only) and onclose (hardware
101
+ * back-press + autoDismissAfter only) -- wire this single callback instead
102
+ * of both when all a consumer needs is "the modal wants to close". Both
103
+ * existing events keep firing unchanged alongside it; autoDismissAfter's
104
+ * timer still fires only onclose, not ondismiss, since that path is the
105
+ * modal closing itself rather than something dismissing it.
106
+ */
107
+ ondismiss?: () => void;
64
108
  };
@@ -1,4 +1,4 @@
1
1
  import type { SelectProperties } from './properties';
2
- declare const Select: import("svelte").Component<SelectProperties, {}, "value" | "open">;
2
+ declare const Select: import("svelte").Component<SelectProperties, {}, "open" | "value">;
3
3
  type Select = ReturnType<typeof Select>;
4
4
  export default Select;
@@ -12,6 +12,7 @@
12
12
  showOverlay = true,
13
13
  dismissOnOutsideClick = showOverlay,
14
14
  showCloseButton = true,
15
+ headingLevel,
15
16
  testId,
16
17
  content,
17
18
  footer,
@@ -34,6 +35,10 @@
34
35
  return { x: 0, y: -400, duration: 300 };
35
36
  case 'bottom':
36
37
  return { x: 0, y: 400, duration: 300 };
38
+ // No edge to fly in from — a centered dialog only fades, so x/y stay
39
+ // at 0 and `fly` degrades to exactly the overlay's own fade transition.
40
+ case 'center':
41
+ return { x: 0, y: 0, duration: 300 };
37
42
  }
38
43
  });
39
44
 
@@ -101,6 +106,20 @@
101
106
  onafterclose();
102
107
  }
103
108
  }
109
+
110
+ // headingLevel's `1 | 2 | 3 | 4 | 5 | 6` union only constrains Svelte-import
111
+ // consumers. Through the web component, attributes are untyped strings
112
+ // coerced to Number — heading-level="7"/"0"/"-1"/"foo" would otherwise reach
113
+ // `h${headingLevel}` unchecked and render an invalid tag (<h7>, <h0>, <h-1>,
114
+ // <hNaN>). Validate at runtime and fall back to the existing <span>.
115
+ const headingTag = $derived(
116
+ typeof headingLevel === 'number' &&
117
+ Number.isInteger(headingLevel) &&
118
+ headingLevel >= 1 &&
119
+ headingLevel <= 6
120
+ ? `h${headingLevel}`
121
+ : null
122
+ );
104
123
  </script>
105
124
 
106
125
  {#if open}
@@ -135,7 +154,11 @@
135
154
  {#if typeof title === 'string' || showCloseButton}
136
155
  <div class="sheet-header">
137
156
  {#if typeof title === 'string'}
138
- <span class="sheet-title">{title}</span>
157
+ {#if headingTag !== null}
158
+ <svelte:element this={headingTag} class="sheet-title">{title}</svelte:element>
159
+ {:else}
160
+ <span class="sheet-title">{title}</span>
161
+ {/if}
139
162
  {/if}
140
163
  {#if showCloseButton}
141
164
  <div class="sheet-close-button">
@@ -241,6 +264,20 @@
241
264
  border-top: var(--sheet-border, none);
242
265
  }
243
266
 
267
+ /* `side="center"` is a fixed-size floating dialog, not an edge-anchored
268
+ panel: no top/bottom/left/right offset, sized by its own tokens rather
269
+ than --sheet-width/--sheet-height (so an existing left/right/top/bottom
270
+ theme is untouched by adding this variant), and centered with a
271
+ transform instead of stretching to an edge. */
272
+ .sheet-panel.center {
273
+ top: 50%;
274
+ left: 50%;
275
+ transform: translate(-50%, -50%);
276
+ width: var(--sheet-center-width, 480px);
277
+ max-width: var(--sheet-center-max-width, calc(100vw - 32px));
278
+ max-height: var(--sheet-center-max-height, 90vh);
279
+ }
280
+
244
281
  .sheet-header {
245
282
  display: flex;
246
283
  align-items: center;
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- export type SheetSide = 'left' | 'right' | 'top' | 'bottom';
2
+ export type SheetSide = 'left' | 'right' | 'top' | 'bottom' | 'center';
3
3
  export type SheetProperties = MandatorySheetProperties & OptionalSheetProperties & SheetEventProperties;
4
4
  export type MandatorySheetProperties = {
5
5
  content: Snippet;
@@ -18,6 +18,14 @@ export type OptionalSheetProperties = {
18
18
  */
19
19
  dismissOnOutsideClick?: boolean;
20
20
  showCloseButton?: boolean;
21
+ /**
22
+ * Renders `title` through a real `<h1>`-`<h6>` heading element instead of
23
+ * the default `<span>`. Omit to keep the existing `<span>` markup
24
+ * unchanged — the default when this prop is not set. Set it to the level
25
+ * that is correct in the surrounding document outline (a sheet opened from
26
+ * a page whose top-level heading is an `<h1>` typically wants `2`).
27
+ */
28
+ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
21
29
  testId?: string;
22
30
  footer?: Snippet;
23
31
  classes?: string;
@@ -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 = {
@@ -6,6 +6,7 @@
6
6
  text,
7
7
  backIcon,
8
8
  backLabel = 'Back',
9
+ backHref,
9
10
  leftContent,
10
11
  centerContent,
11
12
  rightContent,
@@ -23,6 +24,22 @@
23
24
  // The icon is decorative, so the label is the button's only accessible name — an empty or
24
25
  // whitespace-only value must not strip it.
25
26
  const backAccessibleName = $derived(backLabel.trim().length > 0 ? backLabel : 'Back');
27
+ // A real link only when a non-empty href is actually given — same emptiness check the
28
+ // rest of the component already applies to backIcon, so `backHref=""` behaves like "unset"
29
+ // rather than rendering a same-page anchor.
30
+ const hasBackHref = $derived(typeof backHref === 'string' && backHref.length > 0);
31
+
32
+ // A native <a> does not fire `click` on Space — only Enter — so the keyboard activation the
33
+ // old <button> gave for free would silently regress when backHref swaps the tag. Enter already
34
+ // reaches `onbackclick` through the anchor's native click, so only Space needs help here; the
35
+ // consumer's own `onkeydown` still runs unconditionally, same as it always has.
36
+ const handleBackKeydown = (event: KeyboardEvent): void => {
37
+ onkeydown?.(event);
38
+ if (hasBackHref && event.key === ' ') {
39
+ event.preventDefault();
40
+ onbackclick?.();
41
+ }
42
+ };
26
43
  </script>
27
44
 
28
45
  <div class="toolbar {classes ?? ''}" data-pw={testId} testID={testId}>
@@ -34,12 +51,15 @@
34
51
  {#if typeof leftContent === 'function'}
35
52
  {@render leftContent()}
36
53
  {:else if showBackControl}
37
- <button
38
- type="button"
54
+ <svelte:element
55
+ this={hasBackHref ? 'a' : 'button'}
56
+ type={hasBackHref ? null : 'button'}
57
+ href={hasBackHref ? backHref : null}
58
+ role={null}
39
59
  class="back"
40
60
  aria-label={backAccessibleName}
41
61
  onclick={onbackclick}
42
- {onkeydown}
62
+ onkeydown={handleBackKeydown}
43
63
  >
44
64
  {#if typeof backIcon === 'string'}
45
65
  <img src={backIcon} alt="" />
@@ -56,7 +76,7 @@
56
76
  />
57
77
  </svg>
58
78
  {/if}
59
- </button>
79
+ </svelte:element>
60
80
  {/if}
61
81
  {#if typeof centerContent === 'function'}
62
82
  <div class="center-content">
@@ -4,6 +4,17 @@ export type ToolbarProperties = ToolbarEventProperties & {
4
4
  text?: string | null;
5
5
  backIcon?: string | null;
6
6
  backLabel?: string;
7
+ /**
8
+ * Renders the built-in back control as a real `<a href={backHref}>` instead of a
9
+ * `<button>` — for back navigation that is a real link (browser back-forward, open-in-new-tab,
10
+ * a working href with JS disabled) rather than only reachable through `onbackclick`.
11
+ * Omit it (the default) for the existing callback-only `<button>`. `onbackclick`
12
+ * still fires as the anchor's `click` handler when both are supplied, so navigation and any
13
+ * side effect (e.g. analytics) coexist exactly as they do on the library's own `Button`
14
+ * `href` + `onclick`. Ignored when `leftContent` is provided or the back control is hidden
15
+ * (`showBackButton={false}` or `backIcon={null}`).
16
+ */
17
+ backHref?: string;
7
18
  leftContent?: Snippet;
8
19
  centerContent?: Snippet;
9
20
  rightContent?: Snippet;