@gusnips/react 0.9.26 → 0.9.27
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 +42 -1
- package/dist/busy-ring.d.ts +13 -0
- package/dist/busy-ring.d.ts.map +1 -0
- package/dist/busy-ring.js +16 -0
- package/dist/busy-ring.js.map +1 -0
- package/dist/held-pending.d.ts +14 -0
- package/dist/held-pending.d.ts.map +1 -0
- package/dist/held-pending.js +36 -0
- package/dist/held-pending.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/busy-ring.tsx +14 -0
- package/src/held-pending.test.ts +88 -0
- package/src/held-pending.ts +39 -0
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -529,6 +529,47 @@ toast needs no `catch`. Outside a component, `copyText(text)` does the write on
|
|
|
529
529
|
The words are yours, and so is the live region: a label that changes needs `aria-live` to be
|
|
530
530
|
heard.
|
|
531
531
|
|
|
532
|
+
## A busy button a person can see
|
|
533
|
+
|
|
534
|
+
```tsx
|
|
535
|
+
import { BusyRing, useHeldPending } from "@gusnips/react";
|
|
536
|
+
|
|
537
|
+
function SaveButton({ saving, ...props }: { saving: boolean } & ButtonProps) {
|
|
538
|
+
const busy = useHeldPending(saving);
|
|
539
|
+
return (
|
|
540
|
+
<button {...props} disabled={busy} aria-busy={busy} className="relative rounded-md ...">
|
|
541
|
+
Save
|
|
542
|
+
{busy && <BusyRing />}
|
|
543
|
+
</button>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
```css
|
|
549
|
+
@import "@gusnips/tokens/busy-ring.css";
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
A refresh that answers in 80 ms turns its pending flag on and off inside a frame. Nothing was
|
|
553
|
+
painted, so the reader pressed "Reload", saw nothing change, and decided the press was ignored.
|
|
554
|
+
A louder spinner cannot fix that, because the spinner never shows. `useHeldPending(pending)`
|
|
555
|
+
keeps the flag on for at least 400 ms once it turns on. It is `true` on the very render
|
|
556
|
+
`pending` turns true, so the busy look is there on the press and not a frame later, and a slow
|
|
557
|
+
request is not made slower. Pass a second argument to change the 400.
|
|
558
|
+
|
|
559
|
+
`<BusyRing />` is a ring that runs round the button's edge. It paints in `currentColor`, so it
|
|
560
|
+
contrasts with every variant in both themes and adds nothing to your palette. The button needs
|
|
561
|
+
`position: relative` and a disabled style that does not dim it (`disabled:opacity-100`), or the
|
|
562
|
+
ring shows at half strength. It copies the button's `border-radius`.
|
|
563
|
+
|
|
564
|
+
Tune it in your own CSS: `--busy-ring-width` (2px), `--busy-ring-duration` (900ms) and
|
|
565
|
+
`--busy-ring-duration-reduced` (2400ms). Under reduced motion the ring turns slower and does not
|
|
566
|
+
stop, because a frozen ring reads as a hang. The `@gusnips/tokens` base rules clamp every
|
|
567
|
+
animation to one pass under reduced motion, and the ring wins that fight on purpose.
|
|
568
|
+
|
|
569
|
+
There is no shared `Button`, so the two lines above live in your button once. That is also the
|
|
570
|
+
place to change the look later: swap the ring for an icon or a label there, or edit the CSS for a
|
|
571
|
+
different ring everywhere.
|
|
572
|
+
|
|
532
573
|
## Slash commands in a text field
|
|
533
574
|
|
|
534
575
|
A slash command is text like `/help` that picks an action instead of sending a message.
|
|
@@ -636,7 +677,7 @@ or scheduler: offer a repeat command only when your app has real scheduling behi
|
|
|
636
677
|
## Also here
|
|
637
678
|
|
|
638
679
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
|
639
|
-
`i18nInitOptions`, and `cn`.
|
|
680
|
+
`i18nInitOptions`, `useHeldPending` and `BusyRing` (above), and `cn`.
|
|
640
681
|
|
|
641
682
|
Pass `storageWriter: "app"` to `i18nInitOptions` when your own language control writes the stored
|
|
642
683
|
locale. Leave it out and the detector writes it, which is right until you offer "follow the
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ring that runs round a busy control's edge. Put it inside the control while it is busy.
|
|
3
|
+
*
|
|
4
|
+
* It draws nothing by itself: the look lives in `@gusnips/tokens/busy-ring.css`, which you import
|
|
5
|
+
* once. The control needs `position: relative` and the radius it already has (the ring copies it),
|
|
6
|
+
* and its disabled style must not dim it, or the ring is drawn at half strength and nobody sees it.
|
|
7
|
+
*
|
|
8
|
+
* Why a component for one `span`: the class name and the markup are one contract. Changing how
|
|
9
|
+
* busy looks, a second element or another shape, is then one edit here and one release, not one
|
|
10
|
+
* edit in every product's button.
|
|
11
|
+
*/
|
|
12
|
+
export declare function BusyRing(): import("react").JSX.Element;
|
|
13
|
+
//# sourceMappingURL=busy-ring.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"busy-ring.d.ts","sourceRoot":"","sources":["../src/busy-ring.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,gCAEvB"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The ring that runs round a busy control's edge. Put it inside the control while it is busy.
|
|
4
|
+
*
|
|
5
|
+
* It draws nothing by itself: the look lives in `@gusnips/tokens/busy-ring.css`, which you import
|
|
6
|
+
* once. The control needs `position: relative` and the radius it already has (the ring copies it),
|
|
7
|
+
* and its disabled style must not dim it, or the ring is drawn at half strength and nobody sees it.
|
|
8
|
+
*
|
|
9
|
+
* Why a component for one `span`: the class name and the markup are one contract. Changing how
|
|
10
|
+
* busy looks, a second element or another shape, is then one edit here and one release, not one
|
|
11
|
+
* edit in every product's button.
|
|
12
|
+
*/
|
|
13
|
+
export function BusyRing() {
|
|
14
|
+
return _jsx("span", { "aria-hidden": "true", className: "busy-ring" });
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=busy-ring.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"busy-ring.js","sourceRoot":"","sources":["../src/busy-ring.tsx"],"names":[],"mappings":";AAAA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,QAAQ;IACtB,OAAO,8BAAkB,MAAM,EAAC,SAAS,EAAC,WAAW,GAAG,CAAC;AAC3D,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pending`, kept on for at least `minMs` once it turns on.
|
|
3
|
+
*
|
|
4
|
+
* It answers `true` on the very render `pending` turns true, with no wait for an effect, so the
|
|
5
|
+
* busy state shows on the press and not a frame later. A slow request is not made slower: the
|
|
6
|
+
* hold only fills the gap when the request ended early, and ends within one timer tick of it
|
|
7
|
+
* otherwise.
|
|
8
|
+
*
|
|
9
|
+
* 400 ms is the floor for a busy state a person can read as "it ran". Use it for a button's busy
|
|
10
|
+
* look, a refresh icon, a save that returns at once. Do not use it to gate real work: it changes
|
|
11
|
+
* what is shown, never what the request does.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useHeldPending(pending: boolean, minMs?: number): boolean;
|
|
14
|
+
//# sourceMappingURL=held-pending.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"held-pending.d.ts","sourceRoot":"","sources":["../src/held-pending.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,SAAM,GAAG,OAAO,CAmBrE"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
// A refresh that answers in 80 ms flips its `pending` flag on and off inside a frame or two. The
|
|
3
|
+
// reader pressed the button, nothing on screen changed, and the page looks like it ignored them.
|
|
4
|
+
// A louder spinner does not fix that, because the spinner is never painted. The flag itself has
|
|
5
|
+
// to stay on long enough to be seen.
|
|
6
|
+
/**
|
|
7
|
+
* `pending`, kept on for at least `minMs` once it turns on.
|
|
8
|
+
*
|
|
9
|
+
* It answers `true` on the very render `pending` turns true, with no wait for an effect, so the
|
|
10
|
+
* busy state shows on the press and not a frame later. A slow request is not made slower: the
|
|
11
|
+
* hold only fills the gap when the request ended early, and ends within one timer tick of it
|
|
12
|
+
* otherwise.
|
|
13
|
+
*
|
|
14
|
+
* 400 ms is the floor for a busy state a person can read as "it ran". Use it for a button's busy
|
|
15
|
+
* look, a refresh icon, a save that returns at once. Do not use it to gate real work: it changes
|
|
16
|
+
* what is shown, never what the request does.
|
|
17
|
+
*/
|
|
18
|
+
export function useHeldPending(pending, minMs = 400) {
|
|
19
|
+
const [held, setHeld] = useState(false);
|
|
20
|
+
const startedAt = useRef(0);
|
|
21
|
+
// Raised while rendering, not in an effect, so there is no frame where `pending` is true and the
|
|
22
|
+
// hold is not. It only ever turns on here. The timer below is the one place it turns off.
|
|
23
|
+
if (pending && !held)
|
|
24
|
+
setHeld(true);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (pending) {
|
|
27
|
+
startedAt.current = Date.now();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const remaining = Math.max(0, startedAt.current + minMs - Date.now());
|
|
31
|
+
const timer = setTimeout(() => setHeld(false), remaining);
|
|
32
|
+
return () => clearTimeout(timer);
|
|
33
|
+
}, [pending, minMs]);
|
|
34
|
+
return pending || held;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=held-pending.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"held-pending.js","sourceRoot":"","sources":["../src/held-pending.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEpD,iGAAiG;AACjG,iGAAiG;AACjG,gGAAgG;AAChG,qCAAqC;AAErC;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,cAAc,CAAC,OAAgB,EAAE,KAAK,GAAG,GAAG;IAC1D,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IACxC,MAAM,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IAE5B,iGAAiG;IACjG,0FAA0F;IAC1F,IAAI,OAAO,IAAI,CAAC,IAAI;QAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpC,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO,EAAE,CAAC;YACZ,SAAS,CAAC,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAC/B,OAAO;QACT,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QACtE,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC,CAAC;QAC1D,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;IAErB,OAAO,OAAO,IAAI,IAAI,CAAC;AACzB,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { cn } from "./cn.ts";
|
|
2
2
|
export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
|
|
3
|
+
export { BusyRing } from "./busy-ring.tsx";
|
|
4
|
+
export { useHeldPending } from "./held-pending.ts";
|
|
3
5
|
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
4
6
|
export { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.ts";
|
|
5
7
|
export { ApiError, isAbortError } from "./api-error.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,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,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,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,2BAA2B,EAChC,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,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,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,2BAA2B,EAChC,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
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
// identical in source.
|
|
32
32
|
export { cn } from "./cn.js";
|
|
33
33
|
export { copyText, useCopy } from "./copy.js";
|
|
34
|
+
export { BusyRing } from "./busy-ring.js";
|
|
35
|
+
export { useHeldPending } from "./held-pending.js";
|
|
34
36
|
export { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
|
|
35
37
|
export { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.js";
|
|
36
38
|
export { ApiError, isAbortError } from "./api-error.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,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,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,GASb,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,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,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,GASb,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.27",
|
|
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",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ring that runs round a busy control's edge. Put it inside the control while it is busy.
|
|
3
|
+
*
|
|
4
|
+
* It draws nothing by itself: the look lives in `@gusnips/tokens/busy-ring.css`, which you import
|
|
5
|
+
* once. The control needs `position: relative` and the radius it already has (the ring copies it),
|
|
6
|
+
* and its disabled style must not dim it, or the ring is drawn at half strength and nobody sees it.
|
|
7
|
+
*
|
|
8
|
+
* Why a component for one `span`: the class name and the markup are one contract. Changing how
|
|
9
|
+
* busy looks, a second element or another shape, is then one edit here and one release, not one
|
|
10
|
+
* edit in every product's button.
|
|
11
|
+
*/
|
|
12
|
+
export function BusyRing() {
|
|
13
|
+
return <span aria-hidden="true" className="busy-ring" />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useHeldPending } from "./held-pending.ts";
|
|
6
|
+
|
|
7
|
+
// A refresh that answers in 80 ms flipped `pending` on and off inside a frame, so the button never
|
|
8
|
+
// visibly changed and the reader thought the press was ignored. The flag has to stay on for the
|
|
9
|
+
// minimum once it turns on, and a slow request must not be made to wait any longer.
|
|
10
|
+
|
|
11
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
12
|
+
|
|
13
|
+
const roots = new Set<Root>();
|
|
14
|
+
|
|
15
|
+
beforeEach(() => vi.useFakeTimers());
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const root of roots) act(() => root.unmount());
|
|
19
|
+
roots.clear();
|
|
20
|
+
document.body.replaceChildren();
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function mount(initial: boolean, minMs?: number) {
|
|
25
|
+
const element = document.createElement("div");
|
|
26
|
+
document.body.append(element);
|
|
27
|
+
const root = createRoot(element);
|
|
28
|
+
roots.add(root);
|
|
29
|
+
const seen: boolean[] = [];
|
|
30
|
+
|
|
31
|
+
function Probe({ pending }: { pending: boolean }) {
|
|
32
|
+
const held = useHeldPending(pending, minMs);
|
|
33
|
+
seen.push(held);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const render = (pending: boolean) => act(() => root.render(createElement(Probe, { pending })));
|
|
38
|
+
render(initial);
|
|
39
|
+
return { render, current: () => seen.at(-1) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("useHeldPending", () => {
|
|
43
|
+
it("is false while nothing is pending", () => {
|
|
44
|
+
expect(mount(false).current()).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("is true on the very render pending starts true, with no wait for an effect", () => {
|
|
48
|
+
const { render, current } = mount(false);
|
|
49
|
+
render(true);
|
|
50
|
+
expect(current()).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("holds a request that ended early until the minimum has passed", () => {
|
|
54
|
+
const { render, current } = mount(false, 400);
|
|
55
|
+
render(true);
|
|
56
|
+
act(() => void vi.advanceTimersByTime(80));
|
|
57
|
+
render(false);
|
|
58
|
+
expect(current()).toBe(true);
|
|
59
|
+
act(() => void vi.advanceTimersByTime(319));
|
|
60
|
+
expect(current()).toBe(true);
|
|
61
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
62
|
+
expect(current()).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not make a slow request wait longer than one timer tick", () => {
|
|
66
|
+
const { render, current } = mount(false, 400);
|
|
67
|
+
render(true);
|
|
68
|
+
act(() => void vi.advanceTimersByTime(900));
|
|
69
|
+
render(false);
|
|
70
|
+
act(() => void vi.advanceTimersByTime(0));
|
|
71
|
+
expect(current()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("starts the clock again when it turns on a second time", () => {
|
|
75
|
+
const { render, current } = mount(false, 400);
|
|
76
|
+
render(true);
|
|
77
|
+
act(() => void vi.advanceTimersByTime(100));
|
|
78
|
+
render(false);
|
|
79
|
+
act(() => void vi.advanceTimersByTime(300));
|
|
80
|
+
expect(current()).toBe(false);
|
|
81
|
+
render(true);
|
|
82
|
+
act(() => void vi.advanceTimersByTime(50));
|
|
83
|
+
render(false);
|
|
84
|
+
expect(current()).toBe(true);
|
|
85
|
+
act(() => void vi.advanceTimersByTime(350));
|
|
86
|
+
expect(current()).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// A refresh that answers in 80 ms flips its `pending` flag on and off inside a frame or two. The
|
|
4
|
+
// reader pressed the button, nothing on screen changed, and the page looks like it ignored them.
|
|
5
|
+
// A louder spinner does not fix that, because the spinner is never painted. The flag itself has
|
|
6
|
+
// to stay on long enough to be seen.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `pending`, kept on for at least `minMs` once it turns on.
|
|
10
|
+
*
|
|
11
|
+
* It answers `true` on the very render `pending` turns true, with no wait for an effect, so the
|
|
12
|
+
* busy state shows on the press and not a frame later. A slow request is not made slower: the
|
|
13
|
+
* hold only fills the gap when the request ended early, and ends within one timer tick of it
|
|
14
|
+
* otherwise.
|
|
15
|
+
*
|
|
16
|
+
* 400 ms is the floor for a busy state a person can read as "it ran". Use it for a button's busy
|
|
17
|
+
* look, a refresh icon, a save that returns at once. Do not use it to gate real work: it changes
|
|
18
|
+
* what is shown, never what the request does.
|
|
19
|
+
*/
|
|
20
|
+
export function useHeldPending(pending: boolean, minMs = 400): boolean {
|
|
21
|
+
const [held, setHeld] = useState(false);
|
|
22
|
+
const startedAt = useRef(0);
|
|
23
|
+
|
|
24
|
+
// Raised while rendering, not in an effect, so there is no frame where `pending` is true and the
|
|
25
|
+
// hold is not. It only ever turns on here. The timer below is the one place it turns off.
|
|
26
|
+
if (pending && !held) setHeld(true);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (pending) {
|
|
30
|
+
startedAt.current = Date.now();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const remaining = Math.max(0, startedAt.current + minMs - Date.now());
|
|
34
|
+
const timer = setTimeout(() => setHeld(false), remaining);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, [pending, minMs]);
|
|
37
|
+
|
|
38
|
+
return pending || held;
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
|
|
33
33
|
export { cn } from "./cn.ts";
|
|
34
34
|
export { copyText, useCopy, type CopyOptions, type CopyState } from "./copy.ts";
|
|
35
|
+
export { BusyRing } from "./busy-ring.tsx";
|
|
36
|
+
export { useHeldPending } from "./held-pending.ts";
|
|
35
37
|
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
36
38
|
|
|
37
39
|
export { forgetAuthEmail, recallAuthEmail, rememberAuthEmail } from "./auth-email.ts";
|