@gusnips/react 0.9.12 → 0.9.13

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
@@ -417,6 +417,27 @@ In jsdom tests, mock `window.matchMedia`, which jsdom does not have, and run
417
417
  `delete window.__frontkitTheme` between tests. There is one controller per page, and a jsdom
418
418
  window lasts the whole file.
419
419
 
420
+ ## A copy button that says when it failed
421
+
422
+ ```tsx
423
+ import { useCopy } from "@gusnips/react";
424
+
425
+ const { state, copy } = useCopy();
426
+
427
+ <button onClick={() => void copy(apiKey)} aria-live="polite">
428
+ {state === "copied" ? "Copied" : state === "failed" ? "Select it and copy it yourself" : "Copy"}
429
+ </button>;
430
+ ```
431
+
432
+ Of eight copy buttons we read, five failed without a word, so the reader pasted whatever was
433
+ already on the clipboard. Two more threw on a page served without https, where there is no
434
+ clipboard API. `useCopy` shows `"copied"` for 2 seconds or `"failed"` for 6, then goes back to
435
+ `"idle"`, and a second copy restarts the clock. `copy` resolves `false` instead of throwing, so a
436
+ toast needs no `catch`. Outside a component, `copyText(text)` does the write on its own.
437
+
438
+ The words are yours, and so is the live region: a label that changes needs `aria-live` to be
439
+ heard.
440
+
420
441
  ## Also here
421
442
 
422
443
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
@@ -459,6 +480,8 @@ writes you no longer own.
459
480
  The main entry works in React Native. It imports `react` and nothing else you would have to go
460
481
  find — no `react-dom`, no router, no store — so the fetch client, the retry rule and the SSE
461
482
  parser all come along, and the four things that need a browser stay behind the subpaths above.
483
+ `useCopy` imports fine there but has no `navigator.clipboard` to write to, so every copy answers
484
+ `"failed"`: use the platform's clipboard on a phone.
462
485
 
463
486
  ## The Base UI wrappers
464
487
 
package/dist/copy.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /** Write `text` to the clipboard. `false` when the browser refused or has no clipboard API. */
2
+ export declare function copyText(text: string): Promise<boolean>;
3
+ export type CopyState = "idle" | "copied" | "failed";
4
+ export interface CopyOptions {
5
+ /** How long `"copied"` shows. Default 2000 ms. */
6
+ copiedMs?: number;
7
+ /** How long `"failed"` shows. Default 6000 ms: a failure is a sentence the reader has to act on. */
8
+ failedMs?: number;
9
+ }
10
+ /**
11
+ * A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
12
+ * and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
13
+ * needs `aria-live="polite"` to be heard.
14
+ *
15
+ * The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
16
+ * look like it did not take.
17
+ */
18
+ export declare function useCopy({ copiedMs, failedMs }?: CopyOptions): {
19
+ state: CopyState;
20
+ /** Resolves `false` instead of throwing, so a caller can toast without a `catch`. */
21
+ copy: (text: string) => Promise<boolean>;
22
+ };
23
+ //# sourceMappingURL=copy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copy.d.ts","sourceRoot":"","sources":["../src/copy.ts"],"names":[],"mappings":"AAWA,+FAA+F;AAC/F,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO7D;AAED,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAErD,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oGAAoG;IACpG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,EAAE,QAAe,EAAE,QAAe,EAAE,GAAE,WAAgB,GAAG;IAC/E,KAAK,EAAE,SAAS,CAAC;IACjB,qFAAqF;IACrF,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1C,CAmBA"}
package/dist/copy.js ADDED
@@ -0,0 +1,46 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ // Eight products wrote a copy button, and five of them failed silently, one under a comment
3
+ // saying it must not. Silent is worse than it looks: the reader pastes whatever was already on
4
+ // the clipboard, and for a key shown once that is pasting something else over the only copy.
5
+ // Two more called `writeText` outside a `try`, and on a page not served over https there is no
6
+ // `navigator.clipboard` at all, so the click threw a TypeError instead of rejecting.
7
+ //
8
+ // No `execCommand` fallback, and none of the eight had one: it is deprecated, the async API is
9
+ // there in every browser over https, and a copy that says it failed beats a shim that pretends.
10
+ /** Write `text` to the clipboard. `false` when the browser refused or has no clipboard API. */
11
+ export async function copyText(text) {
12
+ try {
13
+ await navigator.clipboard.writeText(text);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
22
+ * and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
23
+ * needs `aria-live="polite"` to be heard.
24
+ *
25
+ * The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
26
+ * look like it did not take.
27
+ */
28
+ export function useCopy({ copiedMs = 2000, failedMs = 6000 } = {}) {
29
+ // A new object on every copy, so copying again restarts the clock even when the state is the
30
+ // same: two copies a second apart read "Copied" for two seconds after the second one.
31
+ const [outcome, setOutcome] = useState({ state: "idle" });
32
+ useEffect(() => {
33
+ if (outcome.state === "idle")
34
+ return;
35
+ const ms = outcome.state === "copied" ? copiedMs : failedMs;
36
+ const timer = setTimeout(() => setOutcome({ state: "idle" }), ms);
37
+ return () => clearTimeout(timer);
38
+ }, [outcome, copiedMs, failedMs]);
39
+ const copy = useCallback(async (text) => {
40
+ const copied = await copyText(text);
41
+ setOutcome({ state: copied ? "copied" : "failed" });
42
+ return copied;
43
+ }, []);
44
+ return { state: outcome.state, copy };
45
+ }
46
+ //# sourceMappingURL=copy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"copy.js","sourceRoot":"","sources":["../src/copy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEzD,4FAA4F;AAC5F,+FAA+F;AAC/F,6FAA6F;AAC7F,+FAA+F;AAC/F,qFAAqF;AACrF,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAEhG,+FAA+F;AAC/F,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY;IACzC,IAAI,CAAC;QACH,MAAM,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAC1C,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAWD;;;;;;;GAOG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,QAAQ,GAAG,IAAI,EAAE,QAAQ,GAAG,IAAI,KAAkB,EAAE;IAK5E,6FAA6F;IAC7F,sFAAsF;IACtF,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAuB,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;IAEhF,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO,CAAC,KAAK,KAAK,MAAM;YAAE,OAAO;QACrC,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;QAC5D,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QAClE,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,CAAC,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;IAElC,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE;QAC9C,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;QACpC,UAAU,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;QACpD,OAAO,MAAM,CAAC;IAChB,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC;AACxC,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { cn } from "./cn.ts";
2
+ export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
2
3
  export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
3
4
  export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
4
5
  export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshResult, type RequestOptions, type SessionAdapter, } from "./api-client.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@
30
30
  // Check that claim against `dist/`, never against this file: a type-only re-export looks
31
31
  // identical in source.
32
32
  export { cn } from "./cn.js";
33
+ export { copyText, useCopy } from "./copy.js";
33
34
  export { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
34
35
  export { ApiError, isAbortError, retryAfterSecs } from "./api-error.js";
35
36
  export { createApiClient, } from "./api-client.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
4
4
  "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,7 +80,7 @@
80
80
  "release:major": "bun pm version major && bun publish --access public"
81
81
  },
82
82
  "dependencies": {
83
- "@gusnips/http": "0.1.0",
83
+ "@gusnips/http": "0.1.1",
84
84
  "clsx": "^2.1.1",
85
85
  "tailwind-merge": "^3.6.0"
86
86
  },
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { copyText } from "./copy.ts";
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ });
7
+
8
+ describe("copyText", () => {
9
+ it("answers true once the clipboard has the text", async () => {
10
+ const writeText = vi.fn(() => Promise.resolve());
11
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
12
+ await expect(copyText("sk_live_1")).resolves.toBe(true);
13
+ expect(writeText).toHaveBeenCalledWith("sk_live_1");
14
+ });
15
+
16
+ it("answers false when the browser refuses the write", async () => {
17
+ vi.stubGlobal("navigator", {
18
+ clipboard: { writeText: () => Promise.reject(new DOMException("denied", "NotAllowedError")) },
19
+ });
20
+ await expect(copyText("sk_live_1")).resolves.toBe(false);
21
+ });
22
+
23
+ // Over plain http there is no `navigator.clipboard`, so `writeText` is read off `undefined` and
24
+ // throws before any promise exists. Two copies in the fleet let that escape the click handler.
25
+ it("answers false, and does not throw, where there is no clipboard API", async () => {
26
+ vi.stubGlobal("navigator", {});
27
+ await expect(copyText("sk_live_1")).resolves.toBe(false);
28
+ });
29
+ });
package/src/copy.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ // Eight products wrote a copy button, and five of them failed silently, one under a comment
4
+ // saying it must not. Silent is worse than it looks: the reader pastes whatever was already on
5
+ // the clipboard, and for a key shown once that is pasting something else over the only copy.
6
+ // Two more called `writeText` outside a `try`, and on a page not served over https there is no
7
+ // `navigator.clipboard` at all, so the click threw a TypeError instead of rejecting.
8
+ //
9
+ // No `execCommand` fallback, and none of the eight had one: it is deprecated, the async API is
10
+ // there in every browser over https, and a copy that says it failed beats a shim that pretends.
11
+
12
+ /** Write `text` to the clipboard. `false` when the browser refused or has no clipboard API. */
13
+ export async function copyText(text: string): Promise<boolean> {
14
+ try {
15
+ await navigator.clipboard.writeText(text);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export type CopyState = "idle" | "copied" | "failed";
23
+
24
+ export interface CopyOptions {
25
+ /** How long `"copied"` shows. Default 2000 ms. */
26
+ copiedMs?: number;
27
+ /** How long `"failed"` shows. Default 6000 ms: a failure is a sentence the reader has to act on. */
28
+ failedMs?: number;
29
+ }
30
+
31
+ /**
32
+ * A copy with an outcome the reader can see. What it says stays yours — "Copied", or "Select it
33
+ * and copy it yourself", in a label or a toast — and so does announcing it: a label that changes
34
+ * needs `aria-live="polite"` to be heard.
35
+ *
36
+ * The outcome goes back to `"idle"` on its own. A "Copied" that never clears makes the second copy
37
+ * look like it did not take.
38
+ */
39
+ export function useCopy({ copiedMs = 2000, failedMs = 6000 }: CopyOptions = {}): {
40
+ state: CopyState;
41
+ /** Resolves `false` instead of throwing, so a caller can toast without a `catch`. */
42
+ copy: (text: string) => Promise<boolean>;
43
+ } {
44
+ // A new object on every copy, so copying again restarts the clock even when the state is the
45
+ // same: two copies a second apart read "Copied" for two seconds after the second one.
46
+ const [outcome, setOutcome] = useState<{ state: CopyState }>({ state: "idle" });
47
+
48
+ useEffect(() => {
49
+ if (outcome.state === "idle") return;
50
+ const ms = outcome.state === "copied" ? copiedMs : failedMs;
51
+ const timer = setTimeout(() => setOutcome({ state: "idle" }), ms);
52
+ return () => clearTimeout(timer);
53
+ }, [outcome, copiedMs, failedMs]);
54
+
55
+ const copy = useCallback(async (text: string) => {
56
+ const copied = await copyText(text);
57
+ setOutcome({ state: copied ? "copied" : "failed" });
58
+ return copied;
59
+ }, []);
60
+
61
+ return { state: outcome.state, copy };
62
+ }
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  // identical in source.
32
32
 
33
33
  export { cn } from "./cn.ts";
34
+ export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
34
35
  export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
35
36
 
36
37
  export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";