@juspay/svelte-ui-components 4.12.0 → 4.13.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.
@@ -3,7 +3,7 @@
3
3
  import type { SnippetProperties } from './properties';
4
4
  import Button from '../Button/Button.svelte';
5
5
  import copySvg from '../assets/copy.svg?raw';
6
- import { createCopyResetTimer } from './copyResetTimer';
6
+ import { createCopyState } from './copyState.svelte';
7
7
 
8
8
  let {
9
9
  text,
@@ -17,29 +17,25 @@
17
17
  classes
18
18
  }: SnippetProperties = $props();
19
19
 
20
- let copied = $state(false);
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
-
31
- async function handleCopy() {
32
- try {
33
- await navigator.clipboard.writeText(text);
34
- copied = true;
35
- oncopy?.();
36
- resetTimer.arm(() => {
37
- copied = false;
38
- }, copyResetMs);
39
- } catch {
40
- // Clipboard API unavailable (non-secure context, iframe restrictions)
20
+ // The whole copy affordance -- clipboard write, `copied` flag, the reset
21
+ // timer's single-pending guarantee from #530, and teardown -- lives in
22
+ // `createCopyState` so a consumer who wants the behaviour without this
23
+ // component's `<code>` box can have it (#574). Getters rather than plain
24
+ // values, because the factory reads its options at copy time and these are
25
+ // props: passing them by value would freeze whatever they were at mount.
26
+ const copyState = createCopyState({
27
+ get copyResetMs() {
28
+ return copyResetMs;
29
+ },
30
+ get oncopy() {
31
+ return oncopy;
41
32
  }
42
- }
33
+ });
34
+ onDestroy(copyState.destroy);
35
+
36
+ const handleCopy = (): void => {
37
+ void copyState.copy(text);
38
+ };
43
39
  </script>
44
40
 
45
41
  <div
@@ -53,9 +49,13 @@
53
49
  </code>
54
50
  {#if showCopyButton}
55
51
  <div class="snippet-copy">
56
- <Button onclick={handleCopy} ariaLabel="Copy to clipboard">
57
- {#if copied}
58
- <span class="snippet-copied" aria-live="polite">{copiedLabel}</span>
52
+ <!-- The label is only supplied while the button shows an icon and has no
53
+ visible text of its own. Keeping it once the label swaps to
54
+ "Copied!" would leave a screen reader announcing "Copy to clipboard"
55
+ over the visible feedback (WCAG 2.5.3, Label in Name). -->
56
+ <Button onclick={handleCopy} {...copyState.copied ? {} : { ariaLabel: 'Copy to clipboard' }}>
57
+ {#if copyState.copied}
58
+ <span class="snippet-copied">{copiedLabel}</span>
59
59
  {:else if typeof copyIcon === 'function'}
60
60
  {@render copyIcon()}
61
61
  {:else}
@@ -64,6 +64,13 @@
64
64
  {/if}
65
65
  </Button>
66
66
  </div>
67
+ <!-- A live region has to be in the DOM *before* its text changes, or many
68
+ screen-reader/browser pairs never announce it. The visible label is
69
+ swapped inside the button, so the announcement lives here instead:
70
+ always rendered, outside the button, with only its text changing. -->
71
+ <span class="snippet-status" role="status" aria-live="polite"
72
+ >{copyState.copied ? copiedLabel : ''}</span
73
+ >
67
74
  {/if}
68
75
  </div>
69
76
 
@@ -132,6 +139,20 @@
132
139
  height: 100%;
133
140
  }
134
141
 
142
+ /* Announced, never shown: the visible "Copied!" is the label inside the
143
+ button, and repeating it on screen would read as a duplicate. */
144
+ .snippet-status {
145
+ position: absolute;
146
+ width: 1px;
147
+ height: 1px;
148
+ margin: -1px;
149
+ padding: 0;
150
+ overflow: hidden;
151
+ clip-path: inset(50%);
152
+ white-space: nowrap;
153
+ border: 0;
154
+ }
155
+
135
156
  .snippet-copied {
136
157
  color: var(--snippet-copied-color, #4caf50);
137
158
  font-size: var(--snippet-copied-font-size, 12px);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The copy-and-flash affordance behind `Snippet`, without its presentation.
3
+ *
4
+ * `Snippet` couples two separable things: this state machine — clipboard
5
+ * write, `copied` flag, reset timer, teardown — and a `<code>` box with a
6
+ * `$`-style prompt. A consumer who wants "copy this value" beside arbitrary
7
+ * content (a hostname in a settings row, a token, a URL) could take only the
8
+ * pair, so the affordance got hand-rolled outside the library instead. That
9
+ * is how the timer safety added in #530 failed to reach the surfaces most
10
+ * likely to need it: a duplicate has its own bare `setTimeout` and its own
11
+ * bugs.
12
+ *
13
+ * Extracted as a headless factory rather than a Svelte action because the
14
+ * caller has to *render* from `copied` — swapping a label for an icon — and
15
+ * an action can attach behaviour to an element but cannot hand state back to
16
+ * the template. The split follows `Tooltip/tooltip-action.ts`, which likewise
17
+ * keeps timing logic outside the component that uses it.
18
+ *
19
+ * Options are read at copy time, not captured at creation, so a caller with
20
+ * reactive values can pass getters:
21
+ *
22
+ * ```ts
23
+ * const state = createCopyState({
24
+ * get copyResetMs() { return copyResetMs; },
25
+ * get oncopy() { return oncopy; }
26
+ * });
27
+ * ```
28
+ */
29
+ export type CopyStateOptions = {
30
+ /** Milliseconds before `copied` reverts. Defaults to `2000`. */
31
+ copyResetMs?: number;
32
+ /** Called once per successful copy. Never called when the write fails. */
33
+ oncopy?: () => void;
34
+ };
35
+ export type CopyState = {
36
+ /** True from a successful copy until the reset delay elapses. */
37
+ readonly copied: boolean;
38
+ /**
39
+ * Writes `text` to the clipboard. Resolves `true` on success, `false` if
40
+ * the clipboard was unavailable or refused — it never rejects, so a caller
41
+ * needs no try/catch of its own.
42
+ */
43
+ copy: (text: string) => Promise<boolean>;
44
+ /** Cancels any pending reset. Call from the owner's teardown. */
45
+ destroy: () => void;
46
+ };
47
+ export declare function createCopyState(options?: CopyStateOptions): CopyState;
@@ -0,0 +1,69 @@
1
+ import { createCopyResetTimer } from './copyResetTimer';
2
+ const DEFAULT_COPY_RESET_MS = 2000;
3
+ /**
4
+ * The type says `number`, but a plain-JS or web-component caller can pass
5
+ * anything, and a getter can throw. Either would reach `setTimeout` as `NaN`
6
+ * — which fires immediately, so the feedback never appears — or leave the
7
+ * flag stuck on with no timer at all. An unusable delay must degrade to the
8
+ * default rather than break the affordance it controls.
9
+ */
10
+ function resolveResetDelay(options) {
11
+ let requested;
12
+ try {
13
+ requested = options.copyResetMs;
14
+ }
15
+ catch {
16
+ return DEFAULT_COPY_RESET_MS;
17
+ }
18
+ if (typeof requested !== 'number' || !Number.isFinite(requested) || requested < 0) {
19
+ return DEFAULT_COPY_RESET_MS;
20
+ }
21
+ return requested;
22
+ }
23
+ export function createCopyState(options = {}) {
24
+ let copied = $state(false);
25
+ let destroyed = false;
26
+ const resetTimer = createCopyResetTimer();
27
+ return {
28
+ get copied() {
29
+ return copied;
30
+ },
31
+ async copy(text) {
32
+ if (destroyed) {
33
+ return false;
34
+ }
35
+ try {
36
+ await navigator.clipboard.writeText(text);
37
+ }
38
+ catch {
39
+ /* Clipboard unavailable: non-secure context, sandboxed iframe, or a
40
+ permission refusal. Reporting success here — and flashing "Copied!"
41
+ over a clipboard that never received the text — is the one outcome
42
+ worse than showing no feedback at all. */
43
+ return false;
44
+ }
45
+ // The clipboard promise can settle after its owner has unmounted.
46
+ if (destroyed) {
47
+ return false;
48
+ }
49
+ // Resolved before the flag is set, so a throwing getter cannot leave
50
+ // `copied` true with nothing armed to clear it.
51
+ const delay = resolveResetDelay(options);
52
+ copied = true;
53
+ resetTimer.arm(() => {
54
+ copied = false;
55
+ }, delay);
56
+ try {
57
+ options.oncopy?.();
58
+ }
59
+ catch {
60
+ // A notification callback cannot undo the successful clipboard write.
61
+ }
62
+ return true;
63
+ },
64
+ destroy() {
65
+ destroyed = true;
66
+ resetTimer.cancel();
67
+ }
68
+ };
69
+ }
package/dist/index.d.ts CHANGED
@@ -102,6 +102,7 @@ export { default as Gallery } from './Gallery/Gallery.svelte';
102
102
  export { ChatController } from './Chat/controller.svelte';
103
103
  export { partyOf } from './Chat/roles';
104
104
  export { SpeechToTextController } from './SpeechToText/controller.svelte';
105
+ export { createCopyState } from './Snippet/copyState.svelte';
105
106
  export type * from './Button/properties';
106
107
  export type * from './Modal/properties';
107
108
  export type * from './Input/properties';
@@ -139,6 +140,7 @@ export type * from './Progress/properties';
139
140
  export type * from './Pill/properties';
140
141
  export type * from './Pagination/properties';
141
142
  export type * from './Snippet/properties';
143
+ export type * from './Snippet/copyState.svelte';
142
144
  export type * from './Gauge/properties';
143
145
  export type * from './SplitButton/properties';
144
146
  export type * from './KeyboardInput/properties';
package/dist/index.js CHANGED
@@ -102,6 +102,7 @@ export { default as Gallery } from './Gallery/Gallery.svelte';
102
102
  export { ChatController } from './Chat/controller.svelte';
103
103
  export { partyOf } from './Chat/roles';
104
104
  export { SpeechToTextController } from './SpeechToText/controller.svelte';
105
+ export { createCopyState } from './Snippet/copyState.svelte';
105
106
  export { createSoundKit } from './SoundKit/SoundKit';
106
107
  export { validateInput, lockBodyScroll, unlockBodyScroll } from './utils';
107
108
  export { formatNumberIndian } from './_chart/format';
package/dist-wc/index.js CHANGED
@@ -18013,23 +18013,66 @@ function createCopyResetTimer() {
18013
18013
  };
18014
18014
  }
18015
18015
  //#endregion
18016
+ //#region src/lib/Snippet/copyState.svelte.ts
18017
+ var DEFAULT_COPY_RESET_MS = 2e3;
18018
+ function resolveResetDelay(e) {
18019
+ let t;
18020
+ try {
18021
+ t = e.copyResetMs;
18022
+ } catch {
18023
+ return DEFAULT_COPY_RESET_MS;
18024
+ }
18025
+ return typeof t != "number" || !Number.isFinite(t) || t < 0 ? DEFAULT_COPY_RESET_MS : t;
18026
+ }
18027
+ function createCopyState(e = {}) {
18028
+ let t = /* @__PURE__ */ state(!1), n = !1, i = createCopyResetTimer();
18029
+ return {
18030
+ get copied() {
18031
+ return get(t);
18032
+ },
18033
+ async copy(a) {
18034
+ if (n) return !1;
18035
+ try {
18036
+ await navigator.clipboard.writeText(a);
18037
+ } catch {
18038
+ return !1;
18039
+ }
18040
+ if (n) return !1;
18041
+ let o = resolveResetDelay(e);
18042
+ set(t, !0), i.arm(() => {
18043
+ set(t, !1);
18044
+ }, o);
18045
+ try {
18046
+ e.oncopy?.();
18047
+ } catch {}
18048
+ return !0;
18049
+ },
18050
+ destroy() {
18051
+ n = !0, i.cancel();
18052
+ }
18053
+ };
18054
+ }
18055
+ //#endregion
18016
18056
  //#region src/lib/Snippet/Snippet.svelte
18017
- var root$30 = /* @__PURE__ */ from_html("<span class=\"snippet-copied svelte-ql5iye\" aria-live=\"polite\"> </span>"), root_1$27 = /* @__PURE__ */ from_html("<span class=\"snippet-copy-icon svelte-ql5iye\"></span>"), root_2$21 = /* @__PURE__ */ from_html("<div class=\"snippet-copy svelte-ql5iye\"><!></div>"), root_3$19 = /* @__PURE__ */ from_html("<div><code class=\"snippet-code svelte-ql5iye\"><span class=\"snippet-prompt svelte-ql5iye\"> </span> <span class=\"snippet-text svelte-ql5iye\"> </span></code> <!></div>"), $$css$32 = {
18057
+ var root$30 = /* @__PURE__ */ from_html("<span class=\"snippet-copied svelte-ql5iye\"> </span>"), root_1$27 = /* @__PURE__ */ from_html("<span class=\"snippet-copy-icon svelte-ql5iye\"></span>"), root_2$21 = /* @__PURE__ */ from_html("<div class=\"snippet-copy svelte-ql5iye\"><!></div> <span class=\"snippet-status svelte-ql5iye\" role=\"status\" aria-live=\"polite\"> </span>", 1), root_3$19 = /* @__PURE__ */ from_html("<div><code class=\"snippet-code svelte-ql5iye\"><span class=\"snippet-prompt svelte-ql5iye\"> </span> <span class=\"snippet-text svelte-ql5iye\"> </span></code> <!></div>"), $$css$32 = {
18018
18058
  hash: "svelte-ql5iye",
18019
- code: ".snippet.svelte-ql5iye {display:flex;align-items:center;justify-content:space-between;gap:var(--snippet-gap, 8px);background:var(--snippet-background, #1e1e1e);border:var(--snippet-border, 1px solid #333);border-radius:var(--snippet-border-radius, var(--radius, 4px));padding:var(--snippet-padding, 12px 16px);font-family:var(--snippet-font-family, monospace);font-size:var(--snippet-font-size, 14px);color:var(--snippet-color, #e0e0e0);margin:var(--snippet-margin, 0);}.snippet-code.svelte-ql5iye {display:flex;align-items:center;overflow:hidden;flex:1;min-width:0;}.snippet-prompt.svelte-ql5iye {color:var(--snippet-prompt-color, #888);margin-right:var(--snippet-prompt-margin-right, 8px);flex-shrink:0;}.snippet-text.svelte-ql5iye {color:var(--snippet-text-color, #e0e0e0);font-family:var(--snippet-text-font-family, inherit);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.snippet-copy.svelte-ql5iye {--button-color: var(--snippet-copy-background, transparent);--button-text-color: var(--snippet-copy-color, #888);--button-border: var(--snippet-copy-border, none);--button-padding: var(--snippet-copy-padding, 4px);--button-border-radius: var(--snippet-copy-border-radius, var(--radius, 4px));--cursor: var(--snippet-copy-cursor, pointer);--button-hover-color: var(--snippet-copy-hover-background, #333);display:flex;align-items:center;justify-content:center;flex-shrink:0;}.snippet-copy-icon.svelte-ql5iye {display:inline-flex;align-items:center;justify-content:center;width:var(--snippet-copy-size, 16px);height:var(--snippet-copy-size, 16px);}.snippet-copy-icon.svelte-ql5iye svg {width:100%;height:100%;}.snippet-copied.svelte-ql5iye {color:var(--snippet-copied-color, #4caf50);font-size:var(--snippet-copied-font-size, 12px);font-family:var(--snippet-font-family, monospace);}"
18059
+ code: ".snippet.svelte-ql5iye {display:flex;align-items:center;justify-content:space-between;gap:var(--snippet-gap, 8px);background:var(--snippet-background, #1e1e1e);border:var(--snippet-border, 1px solid #333);border-radius:var(--snippet-border-radius, var(--radius, 4px));padding:var(--snippet-padding, 12px 16px);font-family:var(--snippet-font-family, monospace);font-size:var(--snippet-font-size, 14px);color:var(--snippet-color, #e0e0e0);margin:var(--snippet-margin, 0);}.snippet-code.svelte-ql5iye {display:flex;align-items:center;overflow:hidden;flex:1;min-width:0;}.snippet-prompt.svelte-ql5iye {color:var(--snippet-prompt-color, #888);margin-right:var(--snippet-prompt-margin-right, 8px);flex-shrink:0;}.snippet-text.svelte-ql5iye {color:var(--snippet-text-color, #e0e0e0);font-family:var(--snippet-text-font-family, inherit);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.snippet-copy.svelte-ql5iye {--button-color: var(--snippet-copy-background, transparent);--button-text-color: var(--snippet-copy-color, #888);--button-border: var(--snippet-copy-border, none);--button-padding: var(--snippet-copy-padding, 4px);--button-border-radius: var(--snippet-copy-border-radius, var(--radius, 4px));--cursor: var(--snippet-copy-cursor, pointer);--button-hover-color: var(--snippet-copy-hover-background, #333);display:flex;align-items:center;justify-content:center;flex-shrink:0;}.snippet-copy-icon.svelte-ql5iye {display:inline-flex;align-items:center;justify-content:center;width:var(--snippet-copy-size, 16px);height:var(--snippet-copy-size, 16px);}.snippet-copy-icon.svelte-ql5iye svg {width:100%;height:100%;}\n\n /* Announced, never shown: the visible \"Copied!\" is the label inside the\n button, and repeating it on screen would read as a duplicate. */.snippet-status.svelte-ql5iye {position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;}.snippet-copied.svelte-ql5iye {color:var(--snippet-copied-color, #4caf50);font-size:var(--snippet-copied-font-size, 12px);font-family:var(--snippet-font-family, monospace);}"
18020
18060
  };
18021
18061
  function Snippet(e, t) {
18022
18062
  push(t, !0), append_styles$1(e, $$css$32);
18023
- let n = prop(t, "text", 7), i = prop(t, "prompt", 7, "$"), a = prop(t, "showCopyButton", 7, !0), o = prop(t, "testId", 7), s = prop(t, "copyIcon", 7), c = prop(t, "copiedLabel", 7, "Copied!"), l = prop(t, "copyResetMs", 7, 2e3), u = prop(t, "oncopy", 7), f = prop(t, "classes", 7), p = /* @__PURE__ */ state(!1), h = createCopyResetTimer();
18024
- onDestroy(h.cancel);
18025
- async function S() {
18026
- try {
18027
- await navigator.clipboard.writeText(n()), set(p, !0), u()?.(), h.arm(() => {
18028
- set(p, !1);
18029
- }, l());
18030
- } catch {}
18031
- }
18032
- var C = {
18063
+ let n = prop(t, "text", 7), i = prop(t, "prompt", 7, "$"), a = prop(t, "showCopyButton", 7, !0), o = prop(t, "testId", 7), s = prop(t, "copyIcon", 7), c = prop(t, "copiedLabel", 7, "Copied!"), l = prop(t, "copyResetMs", 7, 2e3), u = prop(t, "oncopy", 7), f = prop(t, "classes", 7), p = createCopyState({
18064
+ get copyResetMs() {
18065
+ return l();
18066
+ },
18067
+ get oncopy() {
18068
+ return u();
18069
+ }
18070
+ });
18071
+ onDestroy(p.destroy);
18072
+ let h = () => {
18073
+ p.copy(n());
18074
+ };
18075
+ var S = {
18033
18076
  get text() {
18034
18077
  return n();
18035
18078
  },
@@ -18084,15 +18127,13 @@ function Snippet(e, t) {
18084
18127
  set classes(e) {
18085
18128
  f(e), flushSync();
18086
18129
  }
18087
- }, k = root_3$19(), R = child(k), G = child(R), te = child(G, !0);
18088
- reset(G);
18089
- var ne = sibling(G, 2), re = child(ne, !0);
18090
- reset(ne), reset(R);
18091
- var be = sibling(R, 2), Me = (e) => {
18092
- var t = root_2$21();
18093
- Button(child(t), {
18094
- onclick: S,
18095
- ariaLabel: "Copy to clipboard",
18130
+ }, C = root_3$19(), k = child(C), R = child(k), G = child(R, !0);
18131
+ reset(R);
18132
+ var te = sibling(R, 2), ne = child(te, !0);
18133
+ reset(te), reset(k);
18134
+ var re = sibling(k, 2), be = (e) => {
18135
+ var t = root_2$21(), n = first_child(t);
18136
+ Button(child(n), spread_props({ onclick: h }, () => p.copied ? {} : { ariaLabel: "Copy to clipboard" }, {
18096
18137
  children: (e, t) => {
18097
18138
  var n = comment(), i = first_child(n), a = (e) => {
18098
18139
  var t = root$30(), n = child(t, !0);
@@ -18105,17 +18146,19 @@ function Snippet(e, t) {
18105
18146
  html(t, () => copy_default, !0), reset(t), append(e, t);
18106
18147
  };
18107
18148
  if_block(i, (e) => {
18108
- get(p) ? e(a) : typeof s() == "function" ? e(o, 1) : e(l, -1);
18149
+ p.copied ? e(a) : typeof s() == "function" ? e(o, 1) : e(l, -1);
18109
18150
  }), append(e, n);
18110
18151
  },
18111
18152
  $$slots: { default: !0 }
18112
- }), reset(t), append(e, t);
18153
+ })), reset(n);
18154
+ var i = sibling(n, 2), a = child(i, !0);
18155
+ reset(i), template_effect(() => set_text(a, p.copied ? c() : "")), append(e, t);
18113
18156
  };
18114
- return if_block(be, (e) => {
18115
- a() && e(Me);
18116
- }), reset(k), template_effect(() => {
18117
- set_class(k, 1, `snippet ${f() ?? "" ?? ""}`, "svelte-ql5iye"), set_attribute(k, "data-pw", typeof o() == "string" ? o() : null), set_attribute(k, "testid", typeof o() == "string" ? o() : null), set_text(te, i()), set_text(re, n());
18118
- }), append(e, k), pop(C);
18157
+ return if_block(re, (e) => {
18158
+ a() && e(be);
18159
+ }), reset(C), template_effect(() => {
18160
+ set_class(C, 1, `snippet ${f() ?? "" ?? ""}`, "svelte-ql5iye"), set_attribute(C, "data-pw", typeof o() == "string" ? o() : null), set_attribute(C, "testid", typeof o() == "string" ? o() : null), set_text(G, i()), set_text(ne, n());
18161
+ }), append(e, C), pop(S);
18119
18162
  }
18120
18163
  create_custom_element(Snippet, {
18121
18164
  text: {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "4.12.0",
3
+ "version": "4.13.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",