@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 +23 -0
- package/dist/copy.d.ts +23 -0
- package/dist/copy.d.ts.map +1 -0
- package/dist/copy.js +46 -0
- package/dist/copy.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/copy.test.ts +29 -0
- package/src/copy.ts +62 -0
- package/src/index.ts +1 -0
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
|
package/dist/copy.js.map
ADDED
|
@@ -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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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.
|
|
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.
|
|
83
|
+
"@gusnips/http": "0.1.1",
|
|
84
84
|
"clsx": "^2.1.1",
|
|
85
85
|
"tailwind-merge": "^3.6.0"
|
|
86
86
|
},
|
package/src/copy.test.ts
ADDED
|
@@ -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";
|