@seekalfred/primitives 0.5.1 → 0.6.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.
- package/README.md +664 -9
- package/dist/accordion/accordion.js +10 -4
- package/dist/accordion/accordion.js.map +1 -1
- package/dist/action-button/action-button.d.ts +50 -0
- package/dist/action-button/action-button.js +159 -0
- package/dist/action-button/action-button.js.map +1 -0
- package/dist/action-button/index.d.ts +2 -0
- package/dist/action-button/index.js +2 -0
- package/dist/alert/alert.d.ts +15 -1
- package/dist/alert/alert.js +116 -20
- package/dist/alert/alert.js.map +1 -1
- package/dist/avatar/avatar.d.ts +6 -0
- package/dist/avatar/avatar.js +47 -16
- package/dist/avatar/avatar.js.map +1 -1
- package/dist/badge/badge.d.ts +11 -4
- package/dist/badge/badge.js +35 -5
- package/dist/badge/badge.js.map +1 -1
- package/dist/breadcrumb/breadcrumb.d.ts +0 -27
- package/dist/breadcrumb/breadcrumb.js +20 -6
- package/dist/breadcrumb/breadcrumb.js.map +1 -1
- package/dist/button/button.d.ts +35 -22
- package/dist/button/button.js +177 -34
- package/dist/button/button.js.map +1 -1
- package/dist/button-group/button-group.d.ts +76 -0
- package/dist/button-group/button-group.js +403 -0
- package/dist/button-group/button-group.js.map +1 -0
- package/dist/button-group/index.d.ts +2 -0
- package/dist/button-group/index.js +2 -0
- package/dist/calendar/calendar.d.ts +1 -1
- package/dist/calendar/calendar.js +257 -30
- package/dist/calendar/calendar.js.map +1 -1
- package/dist/card/card.d.ts +9 -2
- package/dist/card/card.js +15 -4
- package/dist/card/card.js.map +1 -1
- package/dist/checkbox/checkbox.d.ts +0 -7
- package/dist/checkbox/checkbox.js +24 -27
- package/dist/checkbox/checkbox.js.map +1 -1
- package/dist/confirm-morph/confirm-morph.d.ts +85 -0
- package/dist/confirm-morph/confirm-morph.js +652 -0
- package/dist/confirm-morph/confirm-morph.js.map +1 -0
- package/dist/confirm-morph/index.d.ts +2 -0
- package/dist/confirm-morph/index.js +2 -0
- package/dist/copy-button/copy-button.d.ts +32 -0
- package/dist/copy-button/copy-button.js +134 -0
- package/dist/copy-button/copy-button.js.map +1 -0
- package/dist/copy-button/index.d.ts +2 -0
- package/dist/copy-button/index.js +2 -0
- package/dist/date-picker/date-picker.d.ts +51 -0
- package/dist/date-picker/date-picker.js +241 -0
- package/dist/date-picker/date-picker.js.map +1 -0
- package/dist/date-picker/date-range-picker.d.ts +78 -0
- package/dist/date-picker/date-range-picker.js +1015 -0
- package/dist/date-picker/date-range-picker.js.map +1 -0
- package/dist/date-picker/index.d.ts +4 -0
- package/dist/date-picker/index.js +4 -0
- package/dist/date-picker/picker-parts.d.ts +46 -0
- package/dist/date-picker/picker-parts.js +135 -0
- package/dist/date-picker/picker-parts.js.map +1 -0
- package/dist/dialog/dialog.d.ts +10 -14
- package/dist/dialog/dialog.js +39 -11
- package/dist/dialog/dialog.js.map +1 -1
- package/dist/empty-state/empty-state.d.ts +44 -0
- package/dist/empty-state/empty-state.js +83 -0
- package/dist/empty-state/empty-state.js.map +1 -0
- package/dist/empty-state/index.d.ts +2 -0
- package/dist/empty-state/index.js +2 -0
- package/dist/expanding-button-group/expanding-button-group.d.ts +54 -0
- package/dist/expanding-button-group/expanding-button-group.js +350 -0
- package/dist/expanding-button-group/expanding-button-group.js.map +1 -0
- package/dist/expanding-button-group/index.d.ts +2 -0
- package/dist/expanding-button-group/index.js +2 -0
- package/dist/file-drop-zone/file-drop-zone.d.ts +71 -0
- package/dist/file-drop-zone/file-drop-zone.js +116 -0
- package/dist/file-drop-zone/file-drop-zone.js.map +1 -0
- package/dist/file-drop-zone/index.d.ts +2 -0
- package/dist/file-drop-zone/index.js +2 -0
- package/dist/filter-bar/filter-bar.d.ts +121 -0
- package/dist/filter-bar/filter-bar.js +300 -0
- package/dist/filter-bar/filter-bar.js.map +1 -0
- package/dist/filter-bar/index.d.ts +2 -0
- package/dist/filter-bar/index.js +2 -0
- package/dist/floating-button-group/floating-button-group.d.ts +61 -0
- package/dist/floating-button-group/floating-button-group.js +340 -0
- package/dist/floating-button-group/floating-button-group.js.map +1 -0
- package/dist/floating-button-group/index.d.ts +2 -0
- package/dist/floating-button-group/index.js +2 -0
- package/dist/icon-morph/icon-morph.d.ts +55 -0
- package/dist/icon-morph/icon-morph.js +331 -0
- package/dist/icon-morph/icon-morph.js.map +1 -0
- package/dist/icon-morph/index.d.ts +2 -0
- package/dist/icon-morph/index.js +2 -0
- package/dist/index.d.ts +24 -2
- package/dist/index.js +29 -7
- package/dist/input/input.d.ts +22 -0
- package/dist/input/input.js +36 -12
- package/dist/input/input.js.map +1 -1
- package/dist/lib/count-to.js +113 -0
- package/dist/lib/count-to.js.map +1 -0
- package/dist/lib/eye-morph.js +62 -0
- package/dist/lib/eye-morph.js.map +1 -0
- package/dist/lib/field-message.js +336 -0
- package/dist/lib/field-message.js.map +1 -0
- package/dist/lib/field-shell.js +81 -0
- package/dist/lib/field-shell.js.map +1 -0
- package/dist/lib/field-surface.js +4 -2
- package/dist/lib/field-surface.js.map +1 -1
- package/dist/lib/flip.js +80 -0
- package/dist/lib/flip.js.map +1 -0
- package/dist/lib/height-follow.js +59 -0
- package/dist/lib/height-follow.js.map +1 -0
- package/dist/lib/icon-swap.js +47 -0
- package/dist/lib/icon-swap.js.map +1 -0
- package/dist/lib/leaving-list.js +57 -0
- package/dist/lib/leaving-list.js.map +1 -0
- package/dist/lib/merge-refs.js +3 -3
- package/dist/lib/merge-refs.js.map +1 -1
- package/dist/lib/morph-text.js +128 -0
- package/dist/lib/morph-text.js.map +1 -0
- package/dist/lib/motion.js +162 -0
- package/dist/lib/motion.js.map +1 -0
- package/dist/lib/pausable-timeout.js +67 -0
- package/dist/lib/pausable-timeout.js.map +1 -0
- package/dist/lib/rolling-value.js +31 -0
- package/dist/lib/rolling-value.js.map +1 -0
- package/dist/lib/spring-progress.js +114 -0
- package/dist/lib/spring-progress.js.map +1 -0
- package/dist/lib/svg-id.js +10 -0
- package/dist/lib/svg-id.js.map +1 -0
- package/dist/lib/swap-text.js +77 -0
- package/dist/lib/swap-text.js.map +1 -0
- package/dist/lib/use-copy-feedback.js +36 -0
- package/dist/lib/use-copy-feedback.js.map +1 -0
- package/dist/page-header/index.d.ts +2 -0
- package/dist/page-header/index.js +2 -0
- package/dist/page-header/page-header.d.ts +75 -0
- package/dist/page-header/page-header.js +83 -0
- package/dist/page-header/page-header.js.map +1 -0
- package/dist/password-field/index.d.ts +2 -0
- package/dist/password-field/index.js +2 -0
- package/dist/password-field/password-field.d.ts +37 -0
- package/dist/password-field/password-field.js +101 -0
- package/dist/password-field/password-field.js.map +1 -0
- package/dist/password-strength/index.d.ts +2 -0
- package/dist/password-strength/index.js +2 -0
- package/dist/password-strength/password-strength.d.ts +127 -0
- package/dist/password-strength/password-strength.js +455 -0
- package/dist/password-strength/password-strength.js.map +1 -0
- package/dist/popover/popover.d.ts +8 -0
- package/dist/popover/popover.js +3 -3
- package/dist/popover/popover.js.map +1 -1
- package/dist/progress/progress.js +46 -5
- package/dist/progress/progress.js.map +1 -1
- package/dist/radio-group/radio-group.js +7 -6
- package/dist/radio-group/radio-group.js.map +1 -1
- package/dist/scroll-area/scroll-area.d.ts +10 -0
- package/dist/scroll-area/scroll-area.js +25 -3
- package/dist/scroll-area/scroll-area.js.map +1 -1
- package/dist/search-field/index.d.ts +2 -0
- package/dist/search-field/index.js +2 -0
- package/dist/search-field/search-field.d.ts +26 -0
- package/dist/search-field/search-field.js +155 -0
- package/dist/search-field/search-field.js.map +1 -0
- package/dist/select/select.js +79 -21
- package/dist/select/select.js.map +1 -1
- package/dist/sheet/sheet.js +2 -1
- package/dist/sheet/sheet.js.map +1 -1
- package/dist/side-nav/index.d.ts +2 -0
- package/dist/side-nav/index.js +2 -0
- package/dist/side-nav/side-nav.d.ts +72 -0
- package/dist/side-nav/side-nav.js +142 -0
- package/dist/side-nav/side-nav.js.map +1 -0
- package/dist/skeleton/skeleton.d.ts +8 -2
- package/dist/skeleton/skeleton.js +6 -2
- package/dist/skeleton/skeleton.js.map +1 -1
- package/dist/slider/slider.js +61 -7
- package/dist/slider/slider.js.map +1 -1
- package/dist/split-button/index.d.ts +2 -0
- package/dist/split-button/index.js +2 -0
- package/dist/split-button/split-button.d.ts +46 -0
- package/dist/split-button/split-button.js +122 -0
- package/dist/split-button/split-button.js.map +1 -0
- package/dist/stat-card/index.d.ts +2 -0
- package/dist/stat-card/index.js +2 -0
- package/dist/stat-card/stat-card.d.ts +81 -0
- package/dist/stat-card/stat-card.js +152 -0
- package/dist/stat-card/stat-card.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/switch/switch.js +9 -8
- package/dist/switch/switch.js.map +1 -1
- package/dist/table/index.d.ts +2 -2
- package/dist/table/index.js +2 -2
- package/dist/table/table.d.ts +36 -5
- package/dist/table/table.js +112 -11
- package/dist/table/table.js.map +1 -1
- package/dist/tabs/tabs.js +142 -6
- package/dist/tabs/tabs.js.map +1 -1
- package/dist/textarea/textarea.d.ts +23 -0
- package/dist/textarea/textarea.js +24 -2
- package/dist/textarea/textarea.js.map +1 -1
- package/dist/theme-switch/index.d.ts +2 -0
- package/dist/theme-switch/index.js +2 -0
- package/dist/theme-switch/theme-switch.d.ts +37 -0
- package/dist/theme-switch/theme-switch.js +123 -0
- package/dist/theme-switch/theme-switch.js.map +1 -0
- package/dist/time-picker/index.d.ts +2 -0
- package/dist/time-picker/index.js +2 -0
- package/dist/time-picker/time-picker.d.ts +54 -0
- package/dist/time-picker/time-picker.js +201 -0
- package/dist/time-picker/time-picker.js.map +1 -0
- package/dist/toast/toast.js +58 -38
- package/dist/toast/toast.js.map +1 -1
- package/dist/tooltip/tooltip.d.ts +1 -1
- package/dist/tooltip/tooltip.js +63 -14
- package/dist/tooltip/tooltip.js.map +1 -1
- package/package.json +83 -3
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { prefersReducedMotion, useIsomorphicLayoutEffect } from "./motion.js";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
//#region src/lib/height-follow.ts
|
|
4
|
+
/**
|
|
5
|
+
* Keeps `frame` as tall as `content`, and lets it spring when the content changes
|
|
6
|
+
* on purpose. Height stays `auto` at rest, so a passive reflow (a resize, a font
|
|
7
|
+
* swap) follows at once. When `key` changes and the content's height changes within
|
|
8
|
+
* 120ms of it, the old height is pinned before that frame paints, then released to
|
|
9
|
+
* the new one on a transition (the frame's own `data-following` turns it on) and
|
|
10
|
+
* returned to `auto` when it ends. Reduced motion never animates.
|
|
11
|
+
*
|
|
12
|
+
* `frame` clips only while it moves (`overflow: clip` under `data-following`), so a
|
|
13
|
+
* focus ring is never cut at rest.
|
|
14
|
+
*/
|
|
15
|
+
function useHeightFollow(frame, content, key) {
|
|
16
|
+
const changedAt = useRef(0);
|
|
17
|
+
useIsomorphicLayoutEffect(() => {
|
|
18
|
+
changedAt.current = performance.now();
|
|
19
|
+
}, [key]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const box = frame.current;
|
|
22
|
+
const node = content.current;
|
|
23
|
+
if (!box || !node || typeof ResizeObserver === "undefined") return;
|
|
24
|
+
let last;
|
|
25
|
+
const settle = () => {
|
|
26
|
+
delete box.dataset.following;
|
|
27
|
+
box.style.height = "";
|
|
28
|
+
};
|
|
29
|
+
const onEnd = (event) => {
|
|
30
|
+
if (event.target === box && event.propertyName === "height") settle();
|
|
31
|
+
};
|
|
32
|
+
box.addEventListener("transitionend", onEnd);
|
|
33
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
34
|
+
if (!entry) return;
|
|
35
|
+
const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
|
|
36
|
+
const from = last;
|
|
37
|
+
last = next;
|
|
38
|
+
if (prefersReducedMotion() || from === void 0 || from === next || performance.now() - changedAt.current > 120) {
|
|
39
|
+
settle();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
box.style.transition = "none";
|
|
43
|
+
box.style.height = `${from}px`;
|
|
44
|
+
box.offsetHeight;
|
|
45
|
+
box.style.transition = "";
|
|
46
|
+
box.dataset.following = "";
|
|
47
|
+
box.style.height = `${next}px`;
|
|
48
|
+
});
|
|
49
|
+
observer.observe(node);
|
|
50
|
+
return () => {
|
|
51
|
+
observer.disconnect();
|
|
52
|
+
box.removeEventListener("transitionend", onEnd);
|
|
53
|
+
};
|
|
54
|
+
}, [frame, content]);
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { useHeightFollow };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=height-follow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"height-follow.js","names":[],"sources":["../../src/lib/height-follow.ts"],"sourcesContent":["import { useEffect, useRef, type RefObject } from \"react\";\n\nimport { prefersReducedMotion, useIsomorphicLayoutEffect } from \"./motion.js\";\n\n/**\n * Keeps `frame` as tall as `content`, and lets it spring when the content changes\n * on purpose. Height stays `auto` at rest, so a passive reflow (a resize, a font\n * swap) follows at once. When `key` changes and the content's height changes within\n * 120ms of it, the old height is pinned before that frame paints, then released to\n * the new one on a transition (the frame's own `data-following` turns it on) and\n * returned to `auto` when it ends. Reduced motion never animates.\n *\n * `frame` clips only while it moves (`overflow: clip` under `data-following`), so a\n * focus ring is never cut at rest.\n */\nexport function useHeightFollow(\n frame: RefObject<HTMLElement | null>,\n content: RefObject<HTMLElement | null>,\n key: string,\n) {\n const changedAt = useRef(0);\n useIsomorphicLayoutEffect(() => {\n changedAt.current = performance.now();\n }, [key]);\n useEffect(() => {\n const box = frame.current;\n const node = content.current;\n if (!box || !node || typeof ResizeObserver === \"undefined\") return;\n let last: number | undefined;\n const settle = () => {\n delete box.dataset.following;\n box.style.height = \"\";\n };\n const onEnd = (event: TransitionEvent) => {\n if (event.target === box && event.propertyName === \"height\") settle();\n };\n box.addEventListener(\"transitionend\", onEnd);\n const observer = new ResizeObserver(([entry]) => {\n if (!entry) return;\n const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n const from = last;\n last = next;\n if (\n prefersReducedMotion() ||\n from === undefined ||\n from === next ||\n performance.now() - changedAt.current > 120\n ) {\n settle();\n return;\n }\n // Pin the old height before this frame paints, then release to the new one.\n box.style.transition = \"none\";\n box.style.height = `${from}px`;\n void box.offsetHeight;\n box.style.transition = \"\";\n box.dataset.following = \"\";\n box.style.height = `${next}px`;\n });\n observer.observe(node);\n return () => {\n observer.disconnect();\n box.removeEventListener(\"transitionend\", onEnd);\n };\n }, [frame, content]);\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,gBACd,OACA,SACA,KACA;CACA,MAAM,YAAY,OAAO,CAAC;CAC1B,gCAAgC;EAC9B,UAAU,UAAU,YAAY,IAAI;CACtC,GAAG,CAAC,GAAG,CAAC;CACR,gBAAgB;EACd,MAAM,MAAM,MAAM;EAClB,MAAM,OAAO,QAAQ;EACrB,IAAI,CAAC,OAAO,CAAC,QAAQ,OAAO,mBAAmB,aAAa;EAC5D,IAAI;EACJ,MAAM,eAAe;GACnB,OAAO,IAAI,QAAQ;GACnB,IAAI,MAAM,SAAS;EACrB;EACA,MAAM,SAAS,UAA2B;GACxC,IAAI,MAAM,WAAW,OAAO,MAAM,iBAAiB,UAAU,OAAO;EACtE;EACA,IAAI,iBAAiB,iBAAiB,KAAK;EAC3C,MAAM,WAAW,IAAI,gBAAgB,CAAC,WAAW;GAC/C,IAAI,CAAC,OAAO;GACZ,MAAM,OAAO,MAAM,gBAAgB,EAAE,EAAE,aAAa,KAAK;GACzD,MAAM,OAAO;GACb,OAAO;GACP,IACE,qBAAqB,KACrB,SAAS,KAAA,KACT,SAAS,QACT,YAAY,IAAI,IAAI,UAAU,UAAU,KACxC;IACA,OAAO;IACP;GACF;GAEA,IAAI,MAAM,aAAa;GACvB,IAAI,MAAM,SAAS,GAAG,KAAK;GAC3B,IAAS;GACT,IAAI,MAAM,aAAa;GACvB,IAAI,QAAQ,YAAY;GACxB,IAAI,MAAM,SAAS,GAAG,KAAK;EAC7B,CAAC;EACD,SAAS,QAAQ,IAAI;EACrB,aAAa;GACX,SAAS,WAAW;GACpB,IAAI,oBAAoB,iBAAiB,KAAK;EAChD;CACF,GAAG,CAAC,OAAO,OAAO,CAAC;AACrB"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { useExitBackstop, useLeavingItems } from "./motion.js";
|
|
3
|
+
import { useCallback, useRef } from "react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/lib/icon-swap.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Swaps one icon for another: the old one scales away with a little blur while
|
|
8
|
+
* the new one scales in on the snappy spring. `iconKey` says when it is a
|
|
9
|
+
* different icon (a re-render of the same one stays still), and nothing animates
|
|
10
|
+
* on first mount. The icons share one grid cell, so a leaving one needs no
|
|
11
|
+
* position: it stays under the new one.
|
|
12
|
+
*/
|
|
13
|
+
function IconSwap({ iconKey, children, className, calm = false, tight = false, fill = false }) {
|
|
14
|
+
const phaseRef = useRef(null);
|
|
15
|
+
const { leaving, changed, remove } = useLeavingItems(iconKey, children, () => phaseRef.current);
|
|
16
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
17
|
+
className: cn("inline-grid shrink-0 place-items-center", className),
|
|
18
|
+
children: [leaving.map((item) => /* @__PURE__ */ jsx(LeavingIcon, {
|
|
19
|
+
id: item.id,
|
|
20
|
+
onDone: remove,
|
|
21
|
+
calm,
|
|
22
|
+
tight,
|
|
23
|
+
fill,
|
|
24
|
+
children: item.value
|
|
25
|
+
}, item.id)), /* @__PURE__ */ jsx("span", {
|
|
26
|
+
ref: phaseRef,
|
|
27
|
+
className: cn("col-start-1 row-start-1 inline-flex items-center justify-center" + (fill ? " size-full" : ""), changed && (calm ? "motion-safe:animate-glyph-in-calm motion-reduce:animate-fade-in" : tight ? "motion-safe:animate-icon-in-tight motion-reduce:animate-fade-in" : "motion-safe:animate-glyph-in motion-reduce:animate-fade-in")),
|
|
28
|
+
children
|
|
29
|
+
}, iconKey)]
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function LeavingIcon({ id, onDone, calm, tight, fill, children }) {
|
|
33
|
+
const remove = useCallback(() => onDone(id), [onDone, id]);
|
|
34
|
+
useExitBackstop(true, remove);
|
|
35
|
+
return /* @__PURE__ */ jsx("span", {
|
|
36
|
+
"aria-hidden": "true",
|
|
37
|
+
onAnimationEnd: (event) => {
|
|
38
|
+
if (event.target === event.currentTarget) remove();
|
|
39
|
+
},
|
|
40
|
+
className: cn("col-start-1 row-start-1 inline-flex items-center justify-center" + (fill ? " size-full" : ""), calm ? "motion-safe:animate-glyph-out-calm motion-reduce:animate-fade-out" : tight ? "motion-safe:animate-icon-out-tight motion-reduce:animate-fade-out" : "motion-safe:animate-glyph-out motion-reduce:animate-fade-out"),
|
|
41
|
+
children
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { IconSwap };
|
|
46
|
+
|
|
47
|
+
//# sourceMappingURL=icon-swap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"icon-swap.js","names":[],"sources":["../../src/lib/icon-swap.tsx"],"sourcesContent":["import { useCallback, useRef, type ReactNode } from \"react\";\n\nimport { cn } from \"./cn.js\";\nimport { useExitBackstop, useLeavingItems } from \"./motion.js\";\n\n/**\n * Swaps one icon for another: the old one scales away with a little blur while\n * the new one scales in on the snappy spring. `iconKey` says when it is a\n * different icon (a re-render of the same one stays still), and nothing animates\n * on first mount. The icons share one grid cell, so a leaving one needs no\n * position: it stays under the new one.\n */\nexport function IconSwap({\n iconKey,\n children,\n className,\n calm = false,\n tight = false,\n fill = false,\n}: {\n iconKey: string;\n children: ReactNode;\n className?: string;\n /** Slower, softer motion, for feedback that should never read as busy. */\n calm?: boolean;\n /** Scales from 0.55 rather than 0.6, for a small icon in a small box. */\n tight?: boolean;\n /** The icons fill the box they sit in, so an svg at width 100% has something to be 100% of. */\n fill?: boolean;\n}) {\n const phaseRef = useRef<HTMLSpanElement>(null);\n const { leaving, changed, remove } = useLeavingItems(iconKey, children, () => phaseRef.current);\n return (\n <span className={cn(\"inline-grid shrink-0 place-items-center\", className)}>\n {leaving.map((item) => (\n <LeavingIcon\n key={item.id}\n id={item.id}\n onDone={remove}\n calm={calm}\n tight={tight}\n fill={fill}\n >\n {item.value}\n </LeavingIcon>\n ))}\n <span\n key={iconKey}\n ref={phaseRef}\n className={cn(\n \"col-start-1 row-start-1 inline-flex items-center justify-center\" +\n (fill ? \" size-full\" : \"\"),\n changed &&\n (calm\n ? \"motion-safe:animate-glyph-in-calm motion-reduce:animate-fade-in\"\n : tight\n ? \"motion-safe:animate-icon-in-tight motion-reduce:animate-fade-in\"\n : \"motion-safe:animate-glyph-in motion-reduce:animate-fade-in\"),\n )}\n >\n {children}\n </span>\n </span>\n );\n}\n\nfunction LeavingIcon({\n id,\n onDone,\n calm,\n tight,\n fill,\n children,\n}: {\n id: number;\n onDone: (id: number) => void;\n calm: boolean;\n tight: boolean;\n fill: boolean;\n children: ReactNode;\n}) {\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(true, remove);\n return (\n <span\n aria-hidden=\"true\"\n onAnimationEnd={(event) => {\n if (event.target === event.currentTarget) remove();\n }}\n className={cn(\n \"col-start-1 row-start-1 inline-flex items-center justify-center\" +\n (fill ? \" size-full\" : \"\"),\n calm\n ? \"motion-safe:animate-glyph-out-calm motion-reduce:animate-fade-out\"\n : tight\n ? \"motion-safe:animate-icon-out-tight motion-reduce:animate-fade-out\"\n : \"motion-safe:animate-glyph-out motion-reduce:animate-fade-out\",\n )}\n >\n {children}\n </span>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAYA,SAAgB,SAAS,EACvB,SACA,UACA,WACA,OAAO,OACP,QAAQ,OACR,OAAO,SAWN;CACD,MAAM,WAAW,OAAwB,IAAI;CAC7C,MAAM,EAAE,SAAS,SAAS,WAAW,gBAAgB,SAAS,gBAAgB,SAAS,OAAO;CAC9F,OACE,qBAAC,QAAD;EAAM,WAAW,GAAG,2CAA2C,SAAS;EAAxE,UAAA,CACG,QAAQ,KAAK,SACZ,oBAAC,aAAD;GAEE,IAAI,KAAK;GACT,QAAQ;GACF;GACC;GACD;GAEL,UAAA,KAAK;EACK,GARN,KAAK,EAQC,CACd,GACD,oBAAC,QAAD;GAEE,KAAK;GACL,WAAW,GACT,qEACG,OAAO,eAAe,KACzB,YACG,OACG,oEACA,QACE,oEACA,6DACV;GAEC;EACG,GAdC,OAcD,CACF;;AAEV;AAEA,SAAS,YAAY,EACnB,IACA,QACA,MACA,OACA,MACA,YAQC;CACD,MAAM,SAAS,kBAAkB,OAAO,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC;CACzD,gBAAgB,MAAM,MAAM;CAC5B,OACE,oBAAC,QAAD;EACE,eAAY;EACZ,iBAAiB,UAAU;GACzB,IAAI,MAAM,WAAW,MAAM,eAAe,OAAO;EACnD;EACA,WAAW,GACT,qEACG,OAAO,eAAe,KACzB,OACI,sEACA,QACE,sEACA,8DACR;EAEC;CACG,CAAA;AAEV"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
//#region src/lib/leaving-list.ts
|
|
3
|
+
/**
|
|
4
|
+
* A list whose removed items stay on screen while they leave. Pass the current
|
|
5
|
+
* items (each with a stable `id`); the result is what to render, in order: every
|
|
6
|
+
* current item, with each removed one kept where it stood, flagged `leaving`.
|
|
7
|
+
* The caller plays the exit and calls `done(id)` when it ends. An item that comes
|
|
8
|
+
* back stops leaving. Nothing is kept on first render.
|
|
9
|
+
*/
|
|
10
|
+
function useLeavingList(items) {
|
|
11
|
+
const key = items.map((item) => item.id).join("\0");
|
|
12
|
+
const [seen, setSeen] = useState({
|
|
13
|
+
key,
|
|
14
|
+
items
|
|
15
|
+
});
|
|
16
|
+
const [gone, setGone] = useState([]);
|
|
17
|
+
if (seen.key !== key) {
|
|
18
|
+
const now = new Set(items.map((item) => item.id));
|
|
19
|
+
const added = seen.items.map((item, index) => now.has(item.id) ? null : {
|
|
20
|
+
item,
|
|
21
|
+
after: index > 0 ? seen.items[index - 1].id : null
|
|
22
|
+
}).filter((entry) => entry !== null);
|
|
23
|
+
setSeen({
|
|
24
|
+
key,
|
|
25
|
+
items
|
|
26
|
+
});
|
|
27
|
+
setGone((list) => [...list.filter((entry) => !now.has(entry.item.id) && !added.some((a) => a.item.id === entry.item.id)), ...added]);
|
|
28
|
+
}
|
|
29
|
+
const done = useCallback((id) => setGone((list) => list.filter((entry) => entry.item.id !== id)), []);
|
|
30
|
+
const out = [];
|
|
31
|
+
const place = (after) => {
|
|
32
|
+
for (const entry of gone) if (entry.after === after) out.push({
|
|
33
|
+
item: entry.item,
|
|
34
|
+
leaving: true
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
place(null);
|
|
38
|
+
for (const item of items) {
|
|
39
|
+
out.push({
|
|
40
|
+
item,
|
|
41
|
+
leaving: false
|
|
42
|
+
});
|
|
43
|
+
place(item.id);
|
|
44
|
+
}
|
|
45
|
+
for (const entry of gone) if (!out.some((o) => o.item.id === entry.item.id)) out.push({
|
|
46
|
+
item: entry.item,
|
|
47
|
+
leaving: true
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
list: out,
|
|
51
|
+
done
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
export { useLeavingList };
|
|
56
|
+
|
|
57
|
+
//# sourceMappingURL=leaving-list.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"leaving-list.js","names":[],"sources":["../../src/lib/leaving-list.ts"],"sourcesContent":["import { useCallback, useState } from \"react\";\n\ninterface Gone<T> {\n item: T;\n /** The id of the item that stood before it, so it leaves where it was. `null`: it was first. */\n after: string | null;\n}\n\n/**\n * A list whose removed items stay on screen while they leave. Pass the current\n * items (each with a stable `id`); the result is what to render, in order: every\n * current item, with each removed one kept where it stood, flagged `leaving`.\n * The caller plays the exit and calls `done(id)` when it ends. An item that comes\n * back stops leaving. Nothing is kept on first render.\n */\nexport function useLeavingList<T extends { id: string }>(items: readonly T[]) {\n const key = items.map((item) => item.id).join(\"\\u0000\");\n const [seen, setSeen] = useState({ key, items });\n const [gone, setGone] = useState<Gone<T>[]>([]);\n if (seen.key !== key) {\n const now = new Set(items.map((item) => item.id));\n const added = seen.items\n .map((item, index): Gone<T> | null =>\n now.has(item.id) ? null : { item, after: index > 0 ? seen.items[index - 1]!.id : null },\n )\n .filter((entry): entry is Gone<T> => entry !== null);\n setSeen({ key, items });\n setGone((list) => [\n ...list.filter((entry) => !now.has(entry.item.id) && !added.some((a) => a.item.id === entry.item.id)),\n ...added,\n ]);\n }\n const done = useCallback(\n (id: string) => setGone((list) => list.filter((entry) => entry.item.id !== id)),\n [],\n );\n\n const out: { item: T; leaving: boolean }[] = [];\n const place = (after: string | null) => {\n for (const entry of gone) if (entry.after === after) out.push({ item: entry.item, leaving: true });\n };\n place(null);\n for (const item of items) {\n out.push({ item, leaving: false });\n place(item.id);\n }\n // A leaver whose neighbour is gone too has nowhere to stand: it goes at the end.\n for (const entry of gone) {\n if (!out.some((o) => o.item.id === entry.item.id)) out.push({ item: entry.item, leaving: true });\n }\n return { list: out, done };\n}\n"],"mappings":";;;;;;;;;AAeA,SAAgB,eAAyC,OAAqB;CAC5E,MAAM,MAAM,MAAM,KAAK,SAAS,KAAK,EAAE,CAAC,CAAC,KAAK,IAAQ;CACtD,MAAM,CAAC,MAAM,WAAW,SAAS;EAAE;EAAK;CAAM,CAAC;CAC/C,MAAM,CAAC,MAAM,WAAW,SAAoB,CAAC,CAAC;CAC9C,IAAI,KAAK,QAAQ,KAAK;EACpB,MAAM,MAAM,IAAI,IAAI,MAAM,KAAK,SAAS,KAAK,EAAE,CAAC;EAChD,MAAM,QAAQ,KAAK,MAChB,KAAK,MAAM,UACV,IAAI,IAAI,KAAK,EAAE,IAAI,OAAO;GAAE;GAAM,OAAO,QAAQ,IAAI,KAAK,MAAM,QAAQ,EAAE,CAAE,KAAK;EAAK,CACxF,CAAC,CACA,QAAQ,UAA4B,UAAU,IAAI;EACrD,QAAQ;GAAE;GAAK;EAAM,CAAC;EACtB,SAAS,SAAS,CAChB,GAAG,KAAK,QAAQ,UAAU,CAAC,IAAI,IAAI,MAAM,KAAK,EAAE,KAAK,CAAC,MAAM,MAAM,MAAM,EAAE,KAAK,OAAO,MAAM,KAAK,EAAE,CAAC,GACpG,GAAG,KACL,CAAC;CACH;CACA,MAAM,OAAO,aACV,OAAe,SAAS,SAAS,KAAK,QAAQ,UAAU,MAAM,KAAK,OAAO,EAAE,CAAC,GAC9E,CAAC,CACH;CAEA,MAAM,MAAuC,CAAC;CAC9C,MAAM,SAAS,UAAyB;EACtC,KAAK,MAAM,SAAS,MAAM,IAAI,MAAM,UAAU,OAAO,IAAI,KAAK;GAAE,MAAM,MAAM;GAAM,SAAS;EAAK,CAAC;CACnG;CACA,MAAM,IAAI;CACV,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,KAAK;GAAE;GAAM,SAAS;EAAM,CAAC;EACjC,MAAM,KAAK,EAAE;CACf;CAEA,KAAK,MAAM,SAAS,MAClB,IAAI,CAAC,IAAI,MAAM,MAAM,EAAE,KAAK,OAAO,MAAM,KAAK,EAAE,GAAG,IAAI,KAAK;EAAE,MAAM,MAAM;EAAM,SAAS;CAAK,CAAC;CAEjG,OAAO;EAAE,MAAM;EAAK;CAAK;AAC3B"}
|
package/dist/lib/merge-refs.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Combines a caller-supplied ref with a ref this component already needs for
|
|
4
4
|
* its own purposes, so both receive the same node.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* same node the
|
|
6
|
+
* Three call sites need this -- Avatar's `measure` (image decode-state),
|
|
7
|
+
* ScrollArea's `region` (scrollTop reset) and TabList's overflow watch --
|
|
8
|
+
* each already attached to the same node the forwarded ref also targets. A shared five-line
|
|
9
9
|
* helper for two call sites is the Rule of Two; anything more general (a
|
|
10
10
|
* `useMergedRef` hook, React 19 ref-cleanup support) is speculative until a
|
|
11
11
|
* third caller needs it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"merge-refs.js","names":[],"sources":["../../src/lib/merge-refs.ts"],"sourcesContent":["import type { Ref, RefCallback } from \"react\";\n\n/**\n * Combines a caller-supplied ref with a ref this component already needs for\n * its own purposes, so both receive the same node.\n *\n *
|
|
1
|
+
{"version":3,"file":"merge-refs.js","names":[],"sources":["../../src/lib/merge-refs.ts"],"sourcesContent":["import type { Ref, RefCallback } from \"react\";\n\n/**\n * Combines a caller-supplied ref with a ref this component already needs for\n * its own purposes, so both receive the same node.\n *\n * Three call sites need this -- Avatar's `measure` (image decode-state),\n * ScrollArea's `region` (scrollTop reset) and TabList's overflow watch --\n * each already attached to the same node the forwarded ref also targets. A shared five-line\n * helper for two call sites is the Rule of Two; anything more general (a\n * `useMergedRef` hook, React 19 ref-cleanup support) is speculative until a\n * third caller needs it.\n */\nexport function mergeRefs<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> {\n return (node) => {\n for (const ref of refs) {\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as { current: T | null }).current = node;\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;AAaA,SAAgB,UAAa,GAAG,MAA8C;CAC5E,QAAQ,SAAS;EACf,KAAK,MAAM,OAAO,MAChB,IAAI,OAAO,QAAQ,YAAY,IAAI,IAAI;OAClC,IAAI,KAAK,IAA+B,UAAU;CAE3D;AACF"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { cn } from "./cn.js";
|
|
2
|
+
import { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect, useMorphWidth } from "./motion.js";
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/lib/morph-text.tsx
|
|
6
|
+
const toGlyphs = (chars, seq) => chars.map((char, order) => ({
|
|
7
|
+
id: seq + ":" + order,
|
|
8
|
+
char,
|
|
9
|
+
order,
|
|
10
|
+
seq
|
|
11
|
+
}));
|
|
12
|
+
/** Shared leading and trailing letters keep their identity, so only the changed run of text is replaced. */
|
|
13
|
+
function useGlyphs(text) {
|
|
14
|
+
const [state, setState] = useState(() => ({
|
|
15
|
+
text,
|
|
16
|
+
seq: 0,
|
|
17
|
+
glyphs: toGlyphs([...text], 0)
|
|
18
|
+
}));
|
|
19
|
+
if (state.text === text) return state.glyphs;
|
|
20
|
+
const prev = [...state.text];
|
|
21
|
+
const next = [...text];
|
|
22
|
+
let start = 0;
|
|
23
|
+
let end = 0;
|
|
24
|
+
while (start < prev.length && start < next.length && prev[start] === next[start]) start++;
|
|
25
|
+
while (end < prev.length - start && end < next.length - start && prev[prev.length - 1 - end] === next[next.length - 1 - end]) end++;
|
|
26
|
+
if (start < 2) start = 0;
|
|
27
|
+
if (end < 2) end = 0;
|
|
28
|
+
const seq = state.seq + 1;
|
|
29
|
+
const glyphs = [
|
|
30
|
+
...state.glyphs.slice(0, start),
|
|
31
|
+
...toGlyphs(next.slice(start, next.length - end), seq),
|
|
32
|
+
...state.glyphs.slice(state.glyphs.length - end)
|
|
33
|
+
];
|
|
34
|
+
setState({
|
|
35
|
+
text,
|
|
36
|
+
seq,
|
|
37
|
+
glyphs
|
|
38
|
+
});
|
|
39
|
+
return glyphs;
|
|
40
|
+
}
|
|
41
|
+
function LeavingLetter({ glyph, onDone, calm }) {
|
|
42
|
+
const { id } = glyph;
|
|
43
|
+
const remove = useCallback(() => onDone(id), [onDone, id]);
|
|
44
|
+
useExitBackstop(true, remove);
|
|
45
|
+
return /* @__PURE__ */ jsx("span", {
|
|
46
|
+
"aria-hidden": "true",
|
|
47
|
+
onAnimationEnd: (event) => {
|
|
48
|
+
if (event.target === event.currentTarget) remove();
|
|
49
|
+
},
|
|
50
|
+
style: glyph.rect,
|
|
51
|
+
className: calm ? "absolute inline-block motion-safe:animate-letter-out-calm motion-reduce:animate-fade-out" : "absolute inline-block motion-safe:animate-letter-out motion-reduce:animate-fade-out",
|
|
52
|
+
children: glyph.char
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One label morphing into the next: letters both labels share glide into their
|
|
57
|
+
* new place, new ones rise in from a soft blur, removed ones lift away, and the
|
|
58
|
+
* width follows on a spring. The glide is a FLIP: the letter is moved back to
|
|
59
|
+
* where it was, then transitioned to where it is.
|
|
60
|
+
*/
|
|
61
|
+
function MorphText({ text, morphWidth = true, calm = false }) {
|
|
62
|
+
const glyphs = useGlyphs(text);
|
|
63
|
+
const rowRef = useRef(null);
|
|
64
|
+
const before = useRef(/* @__PURE__ */ new Map());
|
|
65
|
+
const [leaving, setLeaving] = useState([]);
|
|
66
|
+
useMorphWidth(rowRef, text, morphWidth);
|
|
67
|
+
useIsomorphicLayoutEffect(() => {
|
|
68
|
+
const row = rowRef.current;
|
|
69
|
+
if (!row) return;
|
|
70
|
+
const now = /* @__PURE__ */ new Map();
|
|
71
|
+
const reduced = prefersReducedMotion();
|
|
72
|
+
for (const node of row.querySelectorAll("[data-glyph]")) {
|
|
73
|
+
const id = node.dataset.glyph;
|
|
74
|
+
const rect = {
|
|
75
|
+
left: node.offsetLeft,
|
|
76
|
+
top: node.offsetTop,
|
|
77
|
+
width: node.offsetWidth,
|
|
78
|
+
height: node.offsetHeight
|
|
79
|
+
};
|
|
80
|
+
const was = before.current.get(id);
|
|
81
|
+
now.set(id, {
|
|
82
|
+
rect,
|
|
83
|
+
char: node.textContent ?? ""
|
|
84
|
+
});
|
|
85
|
+
if (was && !reduced) {
|
|
86
|
+
const dx = was.rect.left - rect.left;
|
|
87
|
+
if (Math.abs(dx) > .5) {
|
|
88
|
+
node.style.transition = "none";
|
|
89
|
+
node.style.translate = dx + "px 0";
|
|
90
|
+
node.offsetWidth;
|
|
91
|
+
node.style.transition = calm ? "translate var(--duration-calm) var(--ease-calm)" : "translate var(--duration-morph) var(--ease-morph)";
|
|
92
|
+
node.style.translate = "0 0";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const gone = [];
|
|
97
|
+
for (const [id, was] of before.current) if (!now.has(id)) gone.push({
|
|
98
|
+
id,
|
|
99
|
+
char: was.char,
|
|
100
|
+
rect: was.rect
|
|
101
|
+
});
|
|
102
|
+
before.current = now;
|
|
103
|
+
if (gone.length) setLeaving((list) => [...list, ...gone]);
|
|
104
|
+
});
|
|
105
|
+
const finish = useCallback((id) => setLeaving((list) => list.filter((glyph) => glyph.id !== id)), []);
|
|
106
|
+
return /* @__PURE__ */ jsx("span", {
|
|
107
|
+
className: morphWidth ? "button-slot [--morph-clip:inset(-.6em_0_-.6em_-.3em)]" : "inline-flex min-w-0",
|
|
108
|
+
"aria-hidden": "true",
|
|
109
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
110
|
+
ref: rowRef,
|
|
111
|
+
className: "relative inline-flex flex-none whitespace-pre",
|
|
112
|
+
children: [leaving.map((glyph) => /* @__PURE__ */ jsx(LeavingLetter, {
|
|
113
|
+
glyph,
|
|
114
|
+
onDone: finish,
|
|
115
|
+
calm
|
|
116
|
+
}, glyph.id)), glyphs.map((glyph) => /* @__PURE__ */ jsx("span", {
|
|
117
|
+
"data-glyph": glyph.id,
|
|
118
|
+
style: { "--letter-delay": (calm ? Math.min(glyph.order * 20, 120) : Math.min(glyph.order * 16, 100)) + "ms" },
|
|
119
|
+
className: cn("inline-block", glyph.seq > 0 && (calm ? "motion-safe:animate-letter-in-calm motion-safe:[animation-delay:var(--letter-delay)] motion-reduce:animate-fade-in" : "motion-safe:animate-letter-in motion-safe:[animation-delay:var(--letter-delay)] motion-reduce:animate-fade-in")),
|
|
120
|
+
children: glyph.char
|
|
121
|
+
}, glyph.id))]
|
|
122
|
+
})
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { MorphText };
|
|
127
|
+
|
|
128
|
+
//# sourceMappingURL=morph-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"morph-text.js","names":[],"sources":["../../src/lib/morph-text.tsx"],"sourcesContent":["import { useCallback, useRef, useState, type CSSProperties } from \"react\";\n\nimport { cn } from \"./cn.js\";\nimport {\n prefersReducedMotion,\n useExitBackstop,\n useIsomorphicLayoutEffect,\n useMorphWidth,\n type Rect,\n} from \"./motion.js\";\n\ninterface Glyph {\n id: string;\n char: string;\n /** Position within the replaced run, which staggers its entrance. */\n order: number;\n /** 0 for the letters the component mounted with, which do not animate in. */\n seq: number;\n}\n\nconst toGlyphs = (chars: string[], seq: number): Glyph[] =>\n chars.map((char, order) => ({ id: seq + \":\" + order, char, order, seq }));\n\n/** Shared leading and trailing letters keep their identity, so only the changed run of text is replaced. */\nfunction useGlyphs(text: string) {\n const [state, setState] = useState(() => ({ text, seq: 0, glyphs: toGlyphs([...text], 0) }));\n if (state.text === text) return state.glyphs;\n const prev = [...state.text];\n const next = [...text];\n let start = 0;\n let end = 0;\n while (start < prev.length && start < next.length && prev[start] === next[start]) start++;\n while (\n end < prev.length - start &&\n end < next.length - start &&\n prev[prev.length - 1 - end] === next[next.length - 1 - end]\n )\n end++;\n if (start < 2) start = 0;\n if (end < 2) end = 0;\n const seq = state.seq + 1;\n const glyphs = [\n ...state.glyphs.slice(0, start),\n ...toGlyphs(next.slice(start, next.length - end), seq),\n ...state.glyphs.slice(state.glyphs.length - end),\n ];\n setState({ text, seq, glyphs });\n return glyphs;\n}\n\ninterface LeavingGlyph {\n id: string;\n char: string;\n rect: Rect;\n}\n\nfunction LeavingLetter({\n glyph,\n onDone,\n calm,\n}: {\n glyph: LeavingGlyph;\n onDone: (id: string) => void;\n calm: boolean;\n}) {\n const { id } = glyph;\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(true, remove);\n return (\n <span\n aria-hidden=\"true\"\n onAnimationEnd={(event) => {\n if (event.target === event.currentTarget) remove();\n }}\n style={glyph.rect}\n className={\n calm\n ? \"absolute inline-block motion-safe:animate-letter-out-calm motion-reduce:animate-fade-out\"\n : \"absolute inline-block motion-safe:animate-letter-out motion-reduce:animate-fade-out\"\n }\n >\n {glyph.char}\n </span>\n );\n}\n\n/**\n * One label morphing into the next: letters both labels share glide into their\n * new place, new ones rise in from a soft blur, removed ones lift away, and the\n * width follows on a spring. The glide is a FLIP: the letter is moved back to\n * where it was, then transitioned to where it is.\n */\nexport function MorphText({\n text,\n morphWidth = true,\n calm = false,\n}: {\n text: string;\n /** Spring the width to the new text. Off when the caller reserves the widest text or springs something wider. */\n morphWidth?: boolean;\n /** Slower, softer motion, for feedback that should never read as busy. */\n calm?: boolean;\n}) {\n const glyphs = useGlyphs(text);\n const rowRef = useRef<HTMLSpanElement>(null);\n const before = useRef(new Map<string, { rect: Rect; char: string }>());\n const [leaving, setLeaving] = useState<LeavingGlyph[]>([]);\n\n // The width slot is optional: a caller that wraps this in something wider (an icon beside it) springs that instead.\n useMorphWidth(rowRef, text, morphWidth);\n\n useIsomorphicLayoutEffect(() => {\n const row = rowRef.current;\n if (!row) return;\n const now = new Map<string, { rect: Rect; char: string }>();\n const reduced = prefersReducedMotion();\n for (const node of row.querySelectorAll<HTMLElement>(\"[data-glyph]\")) {\n const id = node.dataset.glyph!;\n const rect = {\n left: node.offsetLeft,\n top: node.offsetTop,\n width: node.offsetWidth,\n height: node.offsetHeight,\n };\n const was = before.current.get(id);\n now.set(id, { rect, char: node.textContent ?? \"\" });\n if (was && !reduced) {\n const dx = was.rect.left - rect.left;\n if (Math.abs(dx) > 0.5) {\n node.style.transition = \"none\";\n node.style.translate = dx + \"px 0\";\n void node.offsetWidth;\n node.style.transition = calm\n ? \"translate var(--duration-calm) var(--ease-calm)\"\n : \"translate var(--duration-morph) var(--ease-morph)\";\n node.style.translate = \"0 0\";\n }\n }\n }\n const gone: LeavingGlyph[] = [];\n for (const [id, was] of before.current)\n if (!now.has(id)) gone.push({ id, char: was.char, rect: was.rect });\n before.current = now;\n if (gone.length) setLeaving((list) => [...list, ...gone]);\n });\n\n const finish = useCallback(\n (id: string) => setLeaving((list) => list.filter((glyph) => glyph.id !== id)),\n [],\n );\n\n return (\n <span\n className={\n morphWidth ? \"button-slot [--morph-clip:inset(-.6em_0_-.6em_-.3em)]\" : \"inline-flex min-w-0\"\n }\n aria-hidden=\"true\"\n >\n <span ref={rowRef} className=\"relative inline-flex flex-none whitespace-pre\">\n {leaving.map((glyph) => (\n <LeavingLetter key={glyph.id} glyph={glyph} onDone={finish} calm={calm} />\n ))}\n {glyphs.map((glyph) => (\n <span\n key={glyph.id}\n data-glyph={glyph.id}\n style={\n {\n \"--letter-delay\":\n (calm ? Math.min(glyph.order * 20, 120) : Math.min(glyph.order * 16, 100)) + \"ms\",\n } as CSSProperties\n }\n className={cn(\n \"inline-block\",\n glyph.seq > 0 &&\n (calm\n ? \"motion-safe:animate-letter-in-calm motion-safe:[animation-delay:var(--letter-delay)] motion-reduce:animate-fade-in\"\n : \"motion-safe:animate-letter-in motion-safe:[animation-delay:var(--letter-delay)] motion-reduce:animate-fade-in\"),\n )}\n >\n {glyph.char}\n </span>\n ))}\n </span>\n </span>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
//#region src/lib/motion.ts
|
|
3
|
+
/** useLayoutEffect warns when rendered on the server in React 18, which this package supports; there it falls back to useEffect. */
|
|
4
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6
|
+
/**
|
|
7
|
+
* Springs the slot to the natural width of the incoming label when it changes,
|
|
8
|
+
* so a new label never snaps the button's size. Only a resize within 700ms of a
|
|
9
|
+
* label change morphs; any other resize (a late web font, a parent reflow),
|
|
10
|
+
* the first measurement and reduced motion jump straight to the new width, so
|
|
11
|
+
* nothing wobbles on first paint. data-morphing turns on the slot's width
|
|
12
|
+
* transition and its clip-path (button-slot).
|
|
13
|
+
*/
|
|
14
|
+
function useMorphWidth(content, key, enabled = true) {
|
|
15
|
+
const lastKey = useRef(key);
|
|
16
|
+
const armedUntil = useRef(0);
|
|
17
|
+
useIsomorphicLayoutEffect(() => {
|
|
18
|
+
if (lastKey.current === key) return;
|
|
19
|
+
lastKey.current = key;
|
|
20
|
+
armedUntil.current = performance.now() + 700;
|
|
21
|
+
}, [key]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const node = content.current;
|
|
24
|
+
const slot = node?.parentElement;
|
|
25
|
+
if (!enabled || !node || !slot || typeof ResizeObserver === "undefined") return;
|
|
26
|
+
let measured = false;
|
|
27
|
+
let settle;
|
|
28
|
+
const done = () => {
|
|
29
|
+
clearTimeout(settle);
|
|
30
|
+
delete slot.dataset.morphing;
|
|
31
|
+
};
|
|
32
|
+
const onEnd = (event) => {
|
|
33
|
+
if (event.target === slot && event.propertyName === "width") done();
|
|
34
|
+
};
|
|
35
|
+
slot.addEventListener("transitionend", onEnd);
|
|
36
|
+
const apply = (next, at) => {
|
|
37
|
+
if (!next || !measured || prefersReducedMotion() || at > armedUntil.current) {
|
|
38
|
+
measured = next > 0;
|
|
39
|
+
slot.style.width = next ? next + "px" : "";
|
|
40
|
+
done();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
slot.dataset.morphing = "";
|
|
44
|
+
slot.style.width = next + "px";
|
|
45
|
+
clearTimeout(settle);
|
|
46
|
+
settle = setTimeout(done, 900);
|
|
47
|
+
};
|
|
48
|
+
let frame = 0;
|
|
49
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
50
|
+
if (!entry) return;
|
|
51
|
+
const next = entry.contentRect.width;
|
|
52
|
+
const at = performance.now();
|
|
53
|
+
cancelAnimationFrame(frame);
|
|
54
|
+
frame = requestAnimationFrame(() => apply(next, at));
|
|
55
|
+
});
|
|
56
|
+
observer.observe(node);
|
|
57
|
+
return () => {
|
|
58
|
+
cancelAnimationFrame(frame);
|
|
59
|
+
observer.disconnect();
|
|
60
|
+
slot.removeEventListener("transitionend", onEnd);
|
|
61
|
+
clearTimeout(settle);
|
|
62
|
+
};
|
|
63
|
+
}, [content, enabled]);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Runs remove after a backstop delay, in case an exit animation never ends (a
|
|
67
|
+
* consumer who disables animation). The delay must outlast the animation:
|
|
68
|
+
* 600ms covers the quick exits, a spring that settles in 736ms needs more.
|
|
69
|
+
*/
|
|
70
|
+
function useExitBackstop(active, remove, delay = 600) {
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!active) return;
|
|
73
|
+
const timer = setTimeout(remove, delay);
|
|
74
|
+
return () => clearTimeout(timer);
|
|
75
|
+
}, [
|
|
76
|
+
active,
|
|
77
|
+
remove,
|
|
78
|
+
delay
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
/** A press goes a little deeper on an icon button and a little less on a wide one, so every size reads as the same push. */
|
|
82
|
+
function pressScaleFor(width) {
|
|
83
|
+
return width > 220 ? "0.985" : width && width <= 48 ? "0.96" : "0.97";
|
|
84
|
+
}
|
|
85
|
+
/** True for a trigger that anchors a menu, popover or dialog; those never scale. */
|
|
86
|
+
function anchorsLayer(props) {
|
|
87
|
+
const { "aria-haspopup": popup, role, "data-state": state } = props;
|
|
88
|
+
return popup !== void 0 && popup !== false && popup !== "false" || role === "combobox" || state !== void 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Keeps the outgoing version of something on screen while its replacement
|
|
92
|
+
* enters, which CSS alone cannot do: a node React unmounts is gone.
|
|
93
|
+
*
|
|
94
|
+
* `key` says when it has changed. On a change the previous `value` joins
|
|
95
|
+
* `leaving`, stamped with the place it last occupied (read from `measure`,
|
|
96
|
+
* the CURRENT node, after every commit); the caller renders each leaving item
|
|
97
|
+
* out of flow at that rect with an exit animation and calls `remove` when it
|
|
98
|
+
* ends. `changed` turns true on the first change, so nothing animates on first
|
|
99
|
+
* mount. It must be a layout effect: the old node's position is DOM state that
|
|
100
|
+
* only exists until the next paint.
|
|
101
|
+
*/
|
|
102
|
+
function useLeavingItems(key, value, measure) {
|
|
103
|
+
const previous = useRef({
|
|
104
|
+
key,
|
|
105
|
+
value
|
|
106
|
+
});
|
|
107
|
+
const rect = useRef({
|
|
108
|
+
left: 0,
|
|
109
|
+
top: 0,
|
|
110
|
+
width: 0,
|
|
111
|
+
height: 0
|
|
112
|
+
});
|
|
113
|
+
const nextId = useRef(0);
|
|
114
|
+
const [leaving, setLeaving] = useState([]);
|
|
115
|
+
const [changed, setChanged] = useState(false);
|
|
116
|
+
useIsomorphicLayoutEffect(() => {
|
|
117
|
+
const old = previous.current;
|
|
118
|
+
if (old.key === key) return;
|
|
119
|
+
setLeaving((list) => [...list, {
|
|
120
|
+
id: nextId.current++,
|
|
121
|
+
value: old.value,
|
|
122
|
+
rect: rect.current
|
|
123
|
+
}]);
|
|
124
|
+
setChanged(true);
|
|
125
|
+
}, [key]);
|
|
126
|
+
useIsomorphicLayoutEffect(() => {
|
|
127
|
+
previous.current = {
|
|
128
|
+
key,
|
|
129
|
+
value
|
|
130
|
+
};
|
|
131
|
+
const node = measure();
|
|
132
|
+
if (node) rect.current = {
|
|
133
|
+
left: node.offsetLeft,
|
|
134
|
+
top: node.offsetTop,
|
|
135
|
+
width: node.offsetWidth,
|
|
136
|
+
height: node.offsetHeight
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
leaving,
|
|
141
|
+
changed,
|
|
142
|
+
remove: useCallback((id) => setLeaving((list) => list.filter((item) => item.id !== id)), [])
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* React Aria sets aria-disabled and data-pending on a pending button but
|
|
147
|
+
* forwards no aria-busy, so a pending button was not announced as busy. Put the
|
|
148
|
+
* returned ref on the button (merge it with any other); nothing else manages
|
|
149
|
+
* this attribute on the element.
|
|
150
|
+
*/
|
|
151
|
+
function useAriaBusy(pending) {
|
|
152
|
+
const ref = useRef(null);
|
|
153
|
+
useIsomorphicLayoutEffect(() => {
|
|
154
|
+
if (pending) ref.current?.setAttribute("aria-busy", "true");
|
|
155
|
+
else ref.current?.removeAttribute("aria-busy");
|
|
156
|
+
}, [pending]);
|
|
157
|
+
return ref;
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
export { anchorsLayer, prefersReducedMotion, pressScaleFor, useAriaBusy, useExitBackstop, useIsomorphicLayoutEffect, useLeavingItems, useMorphWidth };
|
|
161
|
+
|
|
162
|
+
//# sourceMappingURL=motion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"motion.js","names":[],"sources":["../../src/lib/motion.ts"],"sourcesContent":["import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from \"react\";\n\n/*\n * Motion helpers shared by every component with button-like motion: the press\n * depth, the width that springs to fit new content, and the backstop for an\n * exit animation that never ends. Plain React and CSS, no animation library;\n * the animations themselves are keyframes and transitions in\n * @seekalfred/styles motion.css and the button-* utilities in styles.css.\n */\n\n/** useLayoutEffect warns when rendered on the server in React 18, which this package supports; there it falls back to useEffect. */\nexport const useIsomorphicLayoutEffect =\n typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport const prefersReducedMotion = () =>\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n/**\n * Springs the slot to the natural width of the incoming label when it changes,\n * so a new label never snaps the button's size. Only a resize within 700ms of a\n * label change morphs; any other resize (a late web font, a parent reflow),\n * the first measurement and reduced motion jump straight to the new width, so\n * nothing wobbles on first paint. data-morphing turns on the slot's width\n * transition and its clip-path (button-slot).\n */\nexport function useMorphWidth(content: RefObject<HTMLElement | null>, key: string, enabled = true) {\n const lastKey = useRef(key);\n const armedUntil = useRef(0);\n useIsomorphicLayoutEffect(() => {\n if (lastKey.current === key) return;\n lastKey.current = key;\n armedUntil.current = performance.now() + 700;\n }, [key]);\n useEffect(() => {\n const node = content.current;\n const slot = node?.parentElement;\n if (!enabled || !node || !slot || typeof ResizeObserver === \"undefined\") return;\n let measured = false;\n let settle: ReturnType<typeof setTimeout> | undefined;\n const done = () => {\n clearTimeout(settle);\n delete slot.dataset.morphing;\n };\n const onEnd = (event: TransitionEvent) => {\n if (event.target === slot && event.propertyName === \"width\") done();\n };\n slot.addEventListener(\"transitionend\", onEnd);\n const apply = (next: number, at: number) => {\n if (!next || !measured || prefersReducedMotion() || at > armedUntil.current) {\n measured = next > 0;\n slot.style.width = next ? next + \"px\" : \"\";\n done();\n return;\n }\n slot.dataset.morphing = \"\";\n slot.style.width = next + \"px\";\n // A morph that ends without a width transition (nothing to animate) would\n // leave the clip-path on; this is the backstop.\n clearTimeout(settle);\n settle = setTimeout(done, 900);\n };\n // The write is deferred a frame. Setting the slot's width resizes whatever\n // contains it, and if that is observed too (a segment in a button group) doing\n // it inside this notification reports a ResizeObserver loop error.\n let frame = 0;\n const observer = new ResizeObserver(([entry]) => {\n if (!entry) return;\n const next = entry.contentRect.width;\n const at = performance.now();\n cancelAnimationFrame(frame);\n frame = requestAnimationFrame(() => apply(next, at));\n });\n observer.observe(node);\n return () => {\n cancelAnimationFrame(frame);\n observer.disconnect();\n slot.removeEventListener(\"transitionend\", onEnd);\n clearTimeout(settle);\n };\n }, [content, enabled]);\n}\n\n/**\n * Runs remove after a backstop delay, in case an exit animation never ends (a\n * consumer who disables animation). The delay must outlast the animation:\n * 600ms covers the quick exits, a spring that settles in 736ms needs more.\n */\nexport function useExitBackstop(active: boolean, remove: () => void, delay = 600) {\n useEffect(() => {\n if (!active) return;\n const timer = setTimeout(remove, delay);\n return () => clearTimeout(timer);\n }, [active, remove, delay]);\n}\n\n/** A press goes a little deeper on an icon button and a little less on a wide one, so every size reads as the same push. */\nexport function pressScaleFor(width: number) {\n return width > 220 ? \"0.985\" : width && width <= 48 ? \"0.96\" : \"0.97\";\n}\n\n/** True for a trigger that anchors a menu, popover or dialog; those never scale. */\nexport function anchorsLayer(props: object) {\n const { \"aria-haspopup\": popup, role, \"data-state\": state } = props as Record<string, unknown>;\n return (\n (popup !== undefined && popup !== false && popup !== \"false\") ||\n role === \"combobox\" ||\n state !== undefined\n );\n}\n\n/** Where a node sat inside its offset parent, in px. */\nexport interface Rect {\n left: number;\n top: number;\n width: number;\n height: number;\n}\n\nexport interface LeavingItem<T> {\n id: number;\n value: T;\n /** The place the node last occupied, so it can be popped out of flow right there. */\n rect: Rect;\n}\n\n/**\n * Keeps the outgoing version of something on screen while its replacement\n * enters, which CSS alone cannot do: a node React unmounts is gone.\n *\n * `key` says when it has changed. On a change the previous `value` joins\n * `leaving`, stamped with the place it last occupied (read from `measure`,\n * the CURRENT node, after every commit); the caller renders each leaving item\n * out of flow at that rect with an exit animation and calls `remove` when it\n * ends. `changed` turns true on the first change, so nothing animates on first\n * mount. It must be a layout effect: the old node's position is DOM state that\n * only exists until the next paint.\n */\nexport function useLeavingItems<T>(key: string, value: T, measure: () => HTMLElement | null) {\n const previous = useRef({ key, value });\n const rect = useRef<Rect>({ left: 0, top: 0, width: 0, height: 0 });\n const nextId = useRef(0);\n const [leaving, setLeaving] = useState<LeavingItem<T>[]>([]);\n const [changed, setChanged] = useState(false);\n\n // Declared before the effect below, which overwrites the record this reads.\n useIsomorphicLayoutEffect(() => {\n const old = previous.current;\n if (old.key === key) return;\n setLeaving((list) => [...list, { id: nextId.current++, value: old.value, rect: rect.current }]);\n setChanged(true);\n }, [key]);\n\n useIsomorphicLayoutEffect(() => {\n previous.current = { key, value };\n const node = measure();\n if (node)\n rect.current = {\n left: node.offsetLeft,\n top: node.offsetTop,\n width: node.offsetWidth,\n height: node.offsetHeight,\n };\n });\n\n const remove = useCallback(\n (id: number) => setLeaving((list) => list.filter((item) => item.id !== id)),\n [],\n );\n return { leaving, changed, remove };\n}\n\n/**\n * React Aria sets aria-disabled and data-pending on a pending button but\n * forwards no aria-busy, so a pending button was not announced as busy. Put the\n * returned ref on the button (merge it with any other); nothing else manages\n * this attribute on the element.\n */\nexport function useAriaBusy(pending: boolean) {\n const ref = useRef<HTMLElement | null>(null);\n useIsomorphicLayoutEffect(() => {\n if (pending) ref.current?.setAttribute(\"aria-busy\", \"true\");\n else ref.current?.removeAttribute(\"aria-busy\");\n }, [pending]);\n return ref;\n}\n"],"mappings":";;;AAWA,MAAa,4BACX,OAAO,WAAW,cAAc,YAAY;AAE9C,MAAa,6BACX,OAAO,WAAW,kCAAkC,CAAC,CAAC;;;;;;;;;AAUxD,SAAgB,cAAc,SAAwC,KAAa,UAAU,MAAM;CACjG,MAAM,UAAU,OAAO,GAAG;CAC1B,MAAM,aAAa,OAAO,CAAC;CAC3B,gCAAgC;EAC9B,IAAI,QAAQ,YAAY,KAAK;EAC7B,QAAQ,UAAU;EAClB,WAAW,UAAU,YAAY,IAAI,IAAI;CAC3C,GAAG,CAAC,GAAG,CAAC;CACR,gBAAgB;EACd,MAAM,OAAO,QAAQ;EACrB,MAAM,OAAO,MAAM;EACnB,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,QAAQ,OAAO,mBAAmB,aAAa;EACzE,IAAI,WAAW;EACf,IAAI;EACJ,MAAM,aAAa;GACjB,aAAa,MAAM;GACnB,OAAO,KAAK,QAAQ;EACtB;EACA,MAAM,SAAS,UAA2B;GACxC,IAAI,MAAM,WAAW,QAAQ,MAAM,iBAAiB,SAAS,KAAK;EACpE;EACA,KAAK,iBAAiB,iBAAiB,KAAK;EAC5C,MAAM,SAAS,MAAc,OAAe;GAC1C,IAAI,CAAC,QAAQ,CAAC,YAAY,qBAAqB,KAAK,KAAK,WAAW,SAAS;IAC3E,WAAW,OAAO;IAClB,KAAK,MAAM,QAAQ,OAAO,OAAO,OAAO;IACxC,KAAK;IACL;GACF;GACA,KAAK,QAAQ,WAAW;GACxB,KAAK,MAAM,QAAQ,OAAO;GAG1B,aAAa,MAAM;GACnB,SAAS,WAAW,MAAM,GAAG;EAC/B;EAIA,IAAI,QAAQ;EACZ,MAAM,WAAW,IAAI,gBAAgB,CAAC,WAAW;GAC/C,IAAI,CAAC,OAAO;GACZ,MAAM,OAAO,MAAM,YAAY;GAC/B,MAAM,KAAK,YAAY,IAAI;GAC3B,qBAAqB,KAAK;GAC1B,QAAQ,4BAA4B,MAAM,MAAM,EAAE,CAAC;EACrD,CAAC;EACD,SAAS,QAAQ,IAAI;EACrB,aAAa;GACX,qBAAqB,KAAK;GAC1B,SAAS,WAAW;GACpB,KAAK,oBAAoB,iBAAiB,KAAK;GAC/C,aAAa,MAAM;EACrB;CACF,GAAG,CAAC,SAAS,OAAO,CAAC;AACvB;;;;;;AAOA,SAAgB,gBAAgB,QAAiB,QAAoB,QAAQ,KAAK;CAChF,gBAAgB;EACd,IAAI,CAAC,QAAQ;EACb,MAAM,QAAQ,WAAW,QAAQ,KAAK;EACtC,aAAa,aAAa,KAAK;CACjC,GAAG;EAAC;EAAQ;EAAQ;CAAK,CAAC;AAC5B;;AAGA,SAAgB,cAAc,OAAe;CAC3C,OAAO,QAAQ,MAAM,UAAU,SAAS,SAAS,KAAK,SAAS;AACjE;;AAGA,SAAgB,aAAa,OAAe;CAC1C,MAAM,EAAE,iBAAiB,OAAO,MAAM,cAAc,UAAU;CAC9D,OACG,UAAU,KAAA,KAAa,UAAU,SAAS,UAAU,WACrD,SAAS,cACT,UAAU,KAAA;AAEd;;;;;;;;;;;;;AA6BA,SAAgB,gBAAmB,KAAa,OAAU,SAAmC;CAC3F,MAAM,WAAW,OAAO;EAAE;EAAK;CAAM,CAAC;CACtC,MAAM,OAAO,OAAa;EAAE,MAAM;EAAG,KAAK;EAAG,OAAO;EAAG,QAAQ;CAAE,CAAC;CAClE,MAAM,SAAS,OAAO,CAAC;CACvB,MAAM,CAAC,SAAS,cAAc,SAA2B,CAAC,CAAC;CAC3D,MAAM,CAAC,SAAS,cAAc,SAAS,KAAK;CAG5C,gCAAgC;EAC9B,MAAM,MAAM,SAAS;EACrB,IAAI,IAAI,QAAQ,KAAK;EACrB,YAAY,SAAS,CAAC,GAAG,MAAM;GAAE,IAAI,OAAO;GAAW,OAAO,IAAI;GAAO,MAAM,KAAK;EAAQ,CAAC,CAAC;EAC9F,WAAW,IAAI;CACjB,GAAG,CAAC,GAAG,CAAC;CAER,gCAAgC;EAC9B,SAAS,UAAU;GAAE;GAAK;EAAM;EAChC,MAAM,OAAO,QAAQ;EACrB,IAAI,MACF,KAAK,UAAU;GACb,MAAM,KAAK;GACX,KAAK,KAAK;GACV,OAAO,KAAK;GACZ,QAAQ,KAAK;EACf;CACJ,CAAC;CAMD,OAAO;EAAE;EAAS;EAAS,QAJZ,aACZ,OAAe,YAAY,SAAS,KAAK,QAAQ,SAAS,KAAK,OAAO,EAAE,CAAC,GAC1E,CAAC,CAE6B;CAAE;AACpC;;;;;;;AAQA,SAAgB,YAAY,SAAkB;CAC5C,MAAM,MAAM,OAA2B,IAAI;CAC3C,gCAAgC;EAC9B,IAAI,SAAS,IAAI,SAAS,aAAa,aAAa,MAAM;OACrD,IAAI,SAAS,gBAAgB,WAAW;CAC/C,GAAG,CAAC,OAAO,CAAC;CACZ,OAAO;AACT"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
//#region src/lib/pausable-timeout.ts
|
|
3
|
+
/**
|
|
4
|
+
* Calls `onExpire` after `ms`, unless held: while `hold()` is in effect the
|
|
5
|
+
* clock stops and resumes where it was. `key` restarts it (a new state of
|
|
6
|
+
* whatever is waiting). `ms` of 0 turns it off.
|
|
7
|
+
*
|
|
8
|
+
* Returns `setHeld`, which the caller drives from whatever should pause it (a
|
|
9
|
+
* pointer resting on the control, a hidden tab).
|
|
10
|
+
*/
|
|
11
|
+
function usePausableTimeout(ms, key, onExpire) {
|
|
12
|
+
const expire = useRef(onExpire);
|
|
13
|
+
const held = useRef({
|
|
14
|
+
hover: false,
|
|
15
|
+
hidden: false
|
|
16
|
+
});
|
|
17
|
+
const clock = useRef(null);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
expire.current = onExpire;
|
|
20
|
+
});
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!ms) return;
|
|
23
|
+
let remaining = ms;
|
|
24
|
+
let startedAt = performance.now();
|
|
25
|
+
let timer;
|
|
26
|
+
let running = false;
|
|
27
|
+
const run = () => {
|
|
28
|
+
startedAt = performance.now();
|
|
29
|
+
running = true;
|
|
30
|
+
timer = setTimeout(() => {
|
|
31
|
+
running = false;
|
|
32
|
+
clock.current = null;
|
|
33
|
+
expire.current();
|
|
34
|
+
}, remaining);
|
|
35
|
+
};
|
|
36
|
+
const sync = () => {
|
|
37
|
+
const isHeld = held.current.hover || held.current.hidden;
|
|
38
|
+
if (isHeld && running) {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
remaining -= performance.now() - startedAt;
|
|
41
|
+
running = false;
|
|
42
|
+
} else if (!isHeld && !running) run();
|
|
43
|
+
};
|
|
44
|
+
clock.current = { sync };
|
|
45
|
+
sync();
|
|
46
|
+
return () => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
clock.current = null;
|
|
49
|
+
};
|
|
50
|
+
}, [ms, key]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const onVisibility = () => {
|
|
53
|
+
held.current.hidden = document.hidden;
|
|
54
|
+
clock.current?.sync();
|
|
55
|
+
};
|
|
56
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
57
|
+
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
58
|
+
}, []);
|
|
59
|
+
return useCallback((hover) => {
|
|
60
|
+
held.current.hover = hover;
|
|
61
|
+
clock.current?.sync();
|
|
62
|
+
}, []);
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
export { usePausableTimeout };
|
|
66
|
+
|
|
67
|
+
//# sourceMappingURL=pausable-timeout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pausable-timeout.js","names":[],"sources":["../../src/lib/pausable-timeout.ts"],"sourcesContent":["import { useCallback, useEffect, useRef } from \"react\";\n\n/**\n * Calls `onExpire` after `ms`, unless held: while `hold()` is in effect the\n * clock stops and resumes where it was. `key` restarts it (a new state of\n * whatever is waiting). `ms` of 0 turns it off.\n *\n * Returns `setHeld`, which the caller drives from whatever should pause it (a\n * pointer resting on the control, a hidden tab).\n */\nexport function usePausableTimeout(ms: number, key: unknown, onExpire: () => void) {\n const expire = useRef(onExpire);\n const held = useRef({ hover: false, hidden: false });\n const clock = useRef<{ sync: () => void } | null>(null);\n\n useEffect(() => {\n expire.current = onExpire;\n });\n\n useEffect(() => {\n if (!ms) return;\n let remaining = ms;\n let startedAt = performance.now();\n let timer: ReturnType<typeof setTimeout> | undefined;\n let running = false;\n const run = () => {\n startedAt = performance.now();\n running = true;\n timer = setTimeout(() => {\n running = false;\n clock.current = null;\n expire.current();\n }, remaining);\n };\n const sync = () => {\n const isHeld = held.current.hover || held.current.hidden;\n if (isHeld && running) {\n clearTimeout(timer);\n remaining -= performance.now() - startedAt;\n running = false;\n } else if (!isHeld && !running) {\n run();\n }\n };\n clock.current = { sync };\n sync();\n return () => {\n clearTimeout(timer);\n clock.current = null;\n };\n }, [ms, key]);\n\n useEffect(() => {\n const onVisibility = () => {\n held.current.hidden = document.hidden;\n clock.current?.sync();\n };\n document.addEventListener(\"visibilitychange\", onVisibility);\n return () => document.removeEventListener(\"visibilitychange\", onVisibility);\n }, []);\n\n return useCallback((hover: boolean) => {\n held.current.hover = hover;\n clock.current?.sync();\n }, []);\n}\n"],"mappings":";;;;;;;;;;AAUA,SAAgB,mBAAmB,IAAY,KAAc,UAAsB;CACjF,MAAM,SAAS,OAAO,QAAQ;CAC9B,MAAM,OAAO,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAM,CAAC;CACnD,MAAM,QAAQ,OAAoC,IAAI;CAEtD,gBAAgB;EACd,OAAO,UAAU;CACnB,CAAC;CAED,gBAAgB;EACd,IAAI,CAAC,IAAI;EACT,IAAI,YAAY;EAChB,IAAI,YAAY,YAAY,IAAI;EAChC,IAAI;EACJ,IAAI,UAAU;EACd,MAAM,YAAY;GAChB,YAAY,YAAY,IAAI;GAC5B,UAAU;GACV,QAAQ,iBAAiB;IACvB,UAAU;IACV,MAAM,UAAU;IAChB,OAAO,QAAQ;GACjB,GAAG,SAAS;EACd;EACA,MAAM,aAAa;GACjB,MAAM,SAAS,KAAK,QAAQ,SAAS,KAAK,QAAQ;GAClD,IAAI,UAAU,SAAS;IACrB,aAAa,KAAK;IAClB,aAAa,YAAY,IAAI,IAAI;IACjC,UAAU;GACZ,OAAO,IAAI,CAAC,UAAU,CAAC,SACrB,IAAI;EAER;EACA,MAAM,UAAU,EAAE,KAAK;EACvB,KAAK;EACL,aAAa;GACX,aAAa,KAAK;GAClB,MAAM,UAAU;EAClB;CACF,GAAG,CAAC,IAAI,GAAG,CAAC;CAEZ,gBAAgB;EACd,MAAM,qBAAqB;GACzB,KAAK,QAAQ,SAAS,SAAS;GAC/B,MAAM,SAAS,KAAK;EACtB;EACA,SAAS,iBAAiB,oBAAoB,YAAY;EAC1D,aAAa,SAAS,oBAAoB,oBAAoB,YAAY;CAC5E,GAAG,CAAC,CAAC;CAEL,OAAO,aAAa,UAAmB;EACrC,KAAK,QAAQ,QAAQ;EACrB,MAAM,SAAS,KAAK;CACtB,GAAG,CAAC,CAAC;AACP"}
|