@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 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";
@@ -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.26",
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";