@journ/lift 0.1.0 → 0.2.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 +39 -9
- package/dist/env-C0PNnbwt.js +4 -0
- package/dist/logger-Dse1660l.js +33 -0
- package/dist/prefers-reduced-motion.d.ts +3 -2
- package/dist/prefers-reduced-motion.js +1 -3
- package/dist/required-context.d.ts +3 -2
- package/dist/required-context.js +2 -2
- package/dist/try-catch-D4d3N9_W.js +29 -0
- package/dist/types-p0KNRcj3.d.ts +4 -0
- package/dist/use-battery.d.ts +12 -0
- package/dist/use-battery.js +64 -0
- package/dist/use-browser.d.ts +6 -0
- package/dist/use-browser.js +51 -0
- package/dist/use-callback-ref.d.ts +5 -0
- package/dist/use-callback-ref.js +11 -0
- package/dist/use-controllable-state.d.ts +3 -2
- package/dist/use-controllable-state.js +6 -4
- package/dist/use-debounce-state.d.ts +9 -0
- package/dist/use-debounce-state.js +28 -0
- package/dist/use-debounced-callback.d.ts +16 -0
- package/dist/use-debounced-callback.js +68 -0
- package/dist/use-debounced-value.d.ts +3 -2
- package/dist/use-debounced-value.js +3 -3
- package/dist/use-did-update.d.ts +5 -0
- package/dist/use-did-update.js +24 -0
- package/dist/use-disclosure.d.ts +15 -0
- package/dist/use-disclosure.js +16 -0
- package/dist/use-draft.d.ts +7 -6
- package/dist/use-draft.js +2 -2
- package/dist/use-haptic.d.ts +8 -0
- package/dist/use-haptic.js +84 -0
- package/dist/use-headroom.d.ts +18 -0
- package/dist/use-headroom.js +65 -0
- package/dist/use-idle-queue.d.ts +28 -0
- package/dist/use-idle-queue.js +221 -0
- package/dist/use-intersection-observer.d.ts +17 -0
- package/dist/use-intersection-observer.js +50 -0
- package/dist/use-isomorphic-effect.d.ts +5 -0
- package/dist/use-isomorphic-effect.js +5 -0
- package/dist/use-keybindings.d.ts +14 -0
- package/dist/use-keybindings.js +76 -0
- package/dist/use-local-storage.d.ts +13 -0
- package/dist/use-local-storage.js +76 -0
- package/dist/use-logger.d.ts +4 -0
- package/dist/use-logger.js +17 -0
- package/dist/use-long-press.d.ts +12 -0
- package/dist/use-long-press.js +43 -0
- package/dist/use-map.d.ts +4 -0
- package/dist/use-map.js +30 -0
- package/dist/use-measure.d.ts +8 -0
- package/dist/use-measure.js +27 -0
- package/dist/use-media-query.d.ts +14 -0
- package/dist/use-media-query.js +24 -0
- package/dist/use-mouse.d.ts +18 -0
- package/dist/use-mouse.js +40 -0
- package/dist/use-network.d.ts +17 -0
- package/dist/use-network.js +38 -0
- package/dist/use-page-leave.d.ts +4 -0
- package/dist/use-page-leave.js +11 -0
- package/dist/use-pagination.d.ts +19 -0
- package/dist/use-pagination.js +55 -0
- package/dist/use-reduced-motion.d.ts +4 -0
- package/dist/use-reduced-motion.js +5 -0
- package/dist/use-scroll-direction.d.ts +5 -0
- package/dist/use-scroll-direction.js +33 -0
- package/dist/use-uncontrolled.d.ts +11 -0
- package/dist/use-uncontrolled.js +21 -0
- package/dist/use-validated-state.d.ts +10 -0
- package/dist/use-validated-state.js +22 -0
- package/dist/use-window-event.d.ts +5 -0
- package/dist/use-window-event.js +11 -0
- package/dist/use-window-scroll.d.ts +10 -0
- package/dist/use-window-scroll.js +33 -0
- package/package.json +149 -9
- package/dist/index.d.ts +0 -6
- package/dist/index.js +0 -6
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/use-haptic.d.ts
|
|
2
|
+
type Duration = number | number[];
|
|
3
|
+
declare const supportsHaptics: boolean;
|
|
4
|
+
declare const useHaptic: () => ((duration?: Duration) => void);
|
|
5
|
+
declare const useHapticsError: () => (() => void);
|
|
6
|
+
declare const useHapticsConfirm: () => (() => void);
|
|
7
|
+
//#endregion
|
|
8
|
+
export { supportsHaptics, useHaptic, useHapticsConfirm, useHapticsError };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { n as tryCatchSync } from "./try-catch-D4d3N9_W.js";
|
|
2
|
+
import { t as isServerSide } from "./env-C0PNnbwt.js";
|
|
3
|
+
import { t as logger } from "./logger-Dse1660l.js";
|
|
4
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
5
|
+
//#region src/use-haptic.ts
|
|
6
|
+
const supportsVibrate = () => typeof navigator !== "undefined" && "vibrate" in navigator;
|
|
7
|
+
const supportsIOSSwitch = () => {
|
|
8
|
+
if (typeof document === "undefined") return false;
|
|
9
|
+
const result = tryCatchSync(() => {
|
|
10
|
+
const input = document.createElement("input");
|
|
11
|
+
input.setAttribute("switch", "");
|
|
12
|
+
return input.getAttribute("switch") !== null;
|
|
13
|
+
});
|
|
14
|
+
return result.success && result.data;
|
|
15
|
+
};
|
|
16
|
+
const supportsHaptics = !isServerSide() && (supportsVibrate() || supportsIOSSwitch());
|
|
17
|
+
const logFailure = (label, fn) => {
|
|
18
|
+
const result = tryCatchSync(fn);
|
|
19
|
+
if (!result.success) logger.error(label, result.error);
|
|
20
|
+
};
|
|
21
|
+
const appendHidden = (tag) => {
|
|
22
|
+
const element = document.createElement(tag);
|
|
23
|
+
Object.assign(element.style, {
|
|
24
|
+
position: "absolute",
|
|
25
|
+
opacity: "0",
|
|
26
|
+
pointerEvents: "none"
|
|
27
|
+
});
|
|
28
|
+
document.body.appendChild(element);
|
|
29
|
+
return element;
|
|
30
|
+
};
|
|
31
|
+
const triggerIOSHaptic = () => logFailure("Error triggering iOS haptic:", () => {
|
|
32
|
+
const id = `_h${Math.random().toString(36).slice(2)}`;
|
|
33
|
+
const input = appendHidden("input");
|
|
34
|
+
input.type = "checkbox";
|
|
35
|
+
input.id = id;
|
|
36
|
+
input.setAttribute("switch", "");
|
|
37
|
+
const label = appendHidden("label");
|
|
38
|
+
label.htmlFor = id;
|
|
39
|
+
label.click();
|
|
40
|
+
label.remove();
|
|
41
|
+
input.remove();
|
|
42
|
+
});
|
|
43
|
+
const dispatchHaptic = (duration = 50) => {
|
|
44
|
+
if (supportsVibrate()) {
|
|
45
|
+
navigator.vibrate(duration);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(duration)) {
|
|
49
|
+
for (const d of duration) setTimeout(triggerIOSHaptic, Math.max(d, 101));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
triggerIOSHaptic();
|
|
53
|
+
};
|
|
54
|
+
const useHaptic = () => dispatchHaptic;
|
|
55
|
+
const useHapticsError = () => {
|
|
56
|
+
const triggerHaptic = useHaptic();
|
|
57
|
+
const timerIds = useRef([]);
|
|
58
|
+
useEffect(() => () => {
|
|
59
|
+
for (const id of timerIds.current) clearTimeout(id);
|
|
60
|
+
timerIds.current = [];
|
|
61
|
+
}, []);
|
|
62
|
+
return useCallback(() => logFailure("Error triggering error haptic:", () => {
|
|
63
|
+
if (supportsVibrate()) {
|
|
64
|
+
triggerHaptic([
|
|
65
|
+
40,
|
|
66
|
+
60,
|
|
67
|
+
40,
|
|
68
|
+
60,
|
|
69
|
+
40,
|
|
70
|
+
60,
|
|
71
|
+
40
|
|
72
|
+
]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
triggerHaptic();
|
|
76
|
+
timerIds.current.push(setTimeout(() => triggerHaptic(), 100), setTimeout(() => triggerHaptic(), 200), setTimeout(() => triggerHaptic(), 300));
|
|
77
|
+
}), [triggerHaptic]);
|
|
78
|
+
};
|
|
79
|
+
const useHapticsConfirm = () => {
|
|
80
|
+
const triggerHaptic = useHaptic();
|
|
81
|
+
return useCallback(() => logFailure("Error triggering confirm haptic:", () => triggerHaptic([100])), [triggerHaptic]);
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { supportsHaptics, useHaptic, useHapticsConfirm, useHapticsError };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/use-headroom.d.ts
|
|
2
|
+
type UseHeadroomInput = {
|
|
3
|
+
fixedAt?: number;
|
|
4
|
+
scrollDistance?: number;
|
|
5
|
+
onPin?: () => void;
|
|
6
|
+
onFix?: () => void;
|
|
7
|
+
onRelease?: () => void;
|
|
8
|
+
};
|
|
9
|
+
type UseHeadroomReturnValue = {
|
|
10
|
+
pinned: boolean;
|
|
11
|
+
scrollProgress: number;
|
|
12
|
+
};
|
|
13
|
+
declare const isFixed: (current: number, fixedAt: number) => boolean;
|
|
14
|
+
declare const isPinned: (current: number, previous: number) => boolean;
|
|
15
|
+
declare const isReleased: (current: number, previous: number, fixedAt: number) => boolean;
|
|
16
|
+
declare const useHeadroom: ({ fixedAt, scrollDistance, onPin, onFix, onRelease }?: UseHeadroomInput) => UseHeadroomReturnValue;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { type UseHeadroomInput, type UseHeadroomReturnValue, isFixed, isPinned, isReleased, useHeadroom };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useIsomorphicEffect } from "./use-isomorphic-effect.js";
|
|
2
|
+
import { useScrollDirection } from "./use-scroll-direction.js";
|
|
3
|
+
import { useWindowScroll } from "./use-window-scroll.js";
|
|
4
|
+
import { useEffectEvent, useRef, useState } from "react";
|
|
5
|
+
import { clamp } from "@journ/fitter-happier/clamp";
|
|
6
|
+
//#region src/use-headroom.ts
|
|
7
|
+
const isFixed = (current, fixedAt) => current <= fixedAt;
|
|
8
|
+
const isPinned = (current, previous) => current <= previous;
|
|
9
|
+
const isReleased = (current, previous, fixedAt) => !isPinned(current, previous) && !isFixed(current, fixedAt);
|
|
10
|
+
const progressFrom = ({ isScrollingUp, scrollY, progress }, scrollPosition, scrollDistance) => {
|
|
11
|
+
const delta = Math.abs(scrollPosition - scrollY) / scrollDistance;
|
|
12
|
+
return clamp(progress + (isScrollingUp ? delta : -delta), 0, 1);
|
|
13
|
+
};
|
|
14
|
+
const advanceAnchor = (anchor, scrollPosition, previousPosition, fixedAt, isScrollingUp, scrollDistance) => {
|
|
15
|
+
const fixed = isFixed(scrollPosition, fixedAt);
|
|
16
|
+
if (anchor.fixed !== fixed) return {
|
|
17
|
+
fixed,
|
|
18
|
+
isScrollingUp,
|
|
19
|
+
scrollY: fixed ? scrollPosition : fixedAt,
|
|
20
|
+
progress: 1
|
|
21
|
+
};
|
|
22
|
+
if (!fixed && anchor.isScrollingUp !== isScrollingUp) return {
|
|
23
|
+
fixed,
|
|
24
|
+
isScrollingUp,
|
|
25
|
+
scrollY: previousPosition,
|
|
26
|
+
progress: progressFrom(anchor, previousPosition, scrollDistance)
|
|
27
|
+
};
|
|
28
|
+
return anchor;
|
|
29
|
+
};
|
|
30
|
+
const useHeadroom = ({ fixedAt = 0, scrollDistance = 100, onPin, onFix, onRelease } = {}) => {
|
|
31
|
+
const isScrollingUp = useScrollDirection() === "up";
|
|
32
|
+
const [{ y: scrollPosition }] = useWindowScroll();
|
|
33
|
+
const fixed = isFixed(scrollPosition, fixedAt);
|
|
34
|
+
const [anchor, setAnchor] = useState({
|
|
35
|
+
fixed,
|
|
36
|
+
isScrollingUp,
|
|
37
|
+
scrollY: scrollPosition,
|
|
38
|
+
progress: fixed ? 1 : 0
|
|
39
|
+
});
|
|
40
|
+
const [previousPosition, setPreviousPosition] = useState(scrollPosition);
|
|
41
|
+
if (previousPosition !== scrollPosition) setPreviousPosition(scrollPosition);
|
|
42
|
+
const currentAnchor = advanceAnchor(anchor, scrollPosition, previousPosition, fixedAt, isScrollingUp, scrollDistance);
|
|
43
|
+
if (currentAnchor !== anchor) setAnchor(currentAnchor);
|
|
44
|
+
const scrollProgress = fixed ? 1 : progressFrom(currentAnchor, scrollPosition, scrollDistance);
|
|
45
|
+
const pinnedRef = useRef(false);
|
|
46
|
+
const onPinEvent = useEffectEvent(() => onPin?.());
|
|
47
|
+
const onReleaseEvent = useEffectEvent(() => onRelease?.());
|
|
48
|
+
const onFixEvent = useEffectEvent(() => onFix?.());
|
|
49
|
+
useIsomorphicEffect(() => {
|
|
50
|
+
const shouldBePinned = fixed || isScrollingUp;
|
|
51
|
+
if (shouldBePinned === pinnedRef.current) return;
|
|
52
|
+
pinnedRef.current = shouldBePinned;
|
|
53
|
+
if (shouldBePinned) onPinEvent();
|
|
54
|
+
else onReleaseEvent();
|
|
55
|
+
}, [fixed, isScrollingUp]);
|
|
56
|
+
useIsomorphicEffect(() => {
|
|
57
|
+
if (fixed) onFixEvent();
|
|
58
|
+
}, [fixed]);
|
|
59
|
+
return {
|
|
60
|
+
pinned: scrollProgress > 0,
|
|
61
|
+
scrollProgress
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { isFixed, isPinned, isReleased, useHeadroom };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/lib/idle-queue/idle-queue.d.ts
|
|
2
|
+
/** The state captured when a task was queued, passed to it when it runs. */
|
|
3
|
+
interface TaskState {
|
|
4
|
+
time: number;
|
|
5
|
+
visibilityState: DocumentVisibilityState;
|
|
6
|
+
}
|
|
7
|
+
type Task = (state: TaskState) => void;
|
|
8
|
+
interface TaskOptions {
|
|
9
|
+
minTaskTime?: number;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/use-idle-queue.d.ts
|
|
13
|
+
type UseIdleQueueOptions = {
|
|
14
|
+
ensureTasksRun?: boolean;
|
|
15
|
+
minTaskTime?: number;
|
|
16
|
+
onError?: (error: unknown) => void;
|
|
17
|
+
};
|
|
18
|
+
type UseIdleQueueReturnValue = {
|
|
19
|
+
pushTask: (task: Task, options?: TaskOptions) => void;
|
|
20
|
+
unshiftTask: (task: Task, options?: TaskOptions) => void;
|
|
21
|
+
runTasksImmediately: () => void;
|
|
22
|
+
hasPendingTasks: () => boolean;
|
|
23
|
+
clearPendingTasks: () => void;
|
|
24
|
+
getState: () => TaskState | null;
|
|
25
|
+
};
|
|
26
|
+
declare const useIdleQueue: ({ ensureTasksRun, minTaskTime, onError }?: UseIdleQueueOptions) => UseIdleQueueReturnValue;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { type UseIdleQueueOptions, type UseIdleQueueReturnValue, useIdleQueue };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { useEffect, useEffectEvent, useMemo, useRef } from "react";
|
|
2
|
+
//#region src/lib/idle-queue/idle-callback.ts
|
|
3
|
+
/** The budget the shim reports, matching the spec's frame allowance. */
|
|
4
|
+
const SHIM_BUDGET_MS = 50;
|
|
5
|
+
const supportsRequestIdleCallback = () => typeof globalThis.requestIdleCallback === "function" && typeof globalThis.cancelIdleCallback === "function";
|
|
6
|
+
const createIdleDeadline = (initTime) => ({
|
|
7
|
+
didTimeout: false,
|
|
8
|
+
timeRemaining: () => Math.max(0, SHIM_BUDGET_MS - (Date.now() - initTime))
|
|
9
|
+
});
|
|
10
|
+
const requestIdleCallbackShim = (callback) => {
|
|
11
|
+
const deadline = createIdleDeadline(Date.now());
|
|
12
|
+
return setTimeout(() => callback(deadline), 0);
|
|
13
|
+
};
|
|
14
|
+
const cancelIdleCallbackShim = (handle) => {
|
|
15
|
+
clearTimeout(handle);
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Requests an idle callback, falling back to a timeout where the API is
|
|
19
|
+
* missing. Support is checked per call so the module stays importable under
|
|
20
|
+
* SSR, where no global exists at load time.
|
|
21
|
+
*/
|
|
22
|
+
const rIC = (callback, options) => supportsRequestIdleCallback() ? globalThis.requestIdleCallback(callback, options) : requestIdleCallbackShim(callback);
|
|
23
|
+
/** Cancels a callback scheduled by {@link rIC}. */
|
|
24
|
+
const cIC = (handle) => {
|
|
25
|
+
if (supportsRequestIdleCallback()) globalThis.cancelIdleCallback(handle);
|
|
26
|
+
else cancelIdleCallbackShim(handle);
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/lib/idle-queue/queue-microtask.ts
|
|
30
|
+
/** Rethrows asynchronously so a failing task cannot swallow the error. */
|
|
31
|
+
const rethrowAsync = (error) => {
|
|
32
|
+
setTimeout(() => {
|
|
33
|
+
throw error;
|
|
34
|
+
}, 0);
|
|
35
|
+
};
|
|
36
|
+
const viaNativeQueueMicrotask = () => (microtask) => {
|
|
37
|
+
globalThis.queueMicrotask(() => {
|
|
38
|
+
try {
|
|
39
|
+
microtask();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
rethrowAsync(error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
const viaPromise = () => (microtask) => {
|
|
46
|
+
Promise.resolve().then(() => {
|
|
47
|
+
try {
|
|
48
|
+
microtask();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
rethrowAsync(error);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
/** Last resort for engines predating both native scheduling paths. */
|
|
55
|
+
const viaMutationObserver = () => {
|
|
56
|
+
let counter = 0;
|
|
57
|
+
let pending = [];
|
|
58
|
+
const node = document.createTextNode("");
|
|
59
|
+
new MutationObserver(() => {
|
|
60
|
+
const tasks = pending;
|
|
61
|
+
pending = [];
|
|
62
|
+
for (const microtask of tasks) try {
|
|
63
|
+
microtask();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
rethrowAsync(error);
|
|
66
|
+
}
|
|
67
|
+
}).observe(node, { characterData: true });
|
|
68
|
+
return (microtask) => {
|
|
69
|
+
pending.push(microtask);
|
|
70
|
+
node.data = String(++counter % 2);
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const selectScheduler = () => {
|
|
74
|
+
if (typeof globalThis.queueMicrotask === "function") return viaNativeQueueMicrotask();
|
|
75
|
+
if (typeof Promise === "function") return viaPromise();
|
|
76
|
+
return viaMutationObserver();
|
|
77
|
+
};
|
|
78
|
+
let scheduler = null;
|
|
79
|
+
/**
|
|
80
|
+
* Schedules a callback on the microtask queue, rethrowing failures instead of
|
|
81
|
+
* turning them into unhandled rejections.
|
|
82
|
+
*/
|
|
83
|
+
const queueMicrotask = (microtask) => {
|
|
84
|
+
scheduler ??= selectScheduler();
|
|
85
|
+
scheduler(microtask);
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/lib/idle-queue/idle-queue.ts
|
|
89
|
+
const DEFAULT_MIN_TASK_TIME = 0;
|
|
90
|
+
const isBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
|
|
91
|
+
const isSafari = () => isBrowser() && /^((?!chrome|android).)*safari/i.test(window.navigator.userAgent);
|
|
92
|
+
const shouldYield = (deadline, minTaskTime) => !!deadline && deadline.timeRemaining() <= minTaskTime;
|
|
93
|
+
const createTaskState = () => ({
|
|
94
|
+
time: Date.now(),
|
|
95
|
+
visibilityState: isBrowser() ? document.visibilityState : "visible"
|
|
96
|
+
});
|
|
97
|
+
const createIdleQueue = (options = {}) => {
|
|
98
|
+
const { ensureTasksRun = false, minTaskTime: defaultMinTaskTime = DEFAULT_MIN_TASK_TIME, onError } = options;
|
|
99
|
+
let taskQueue = [];
|
|
100
|
+
let idleCallbackHandle = null;
|
|
101
|
+
let isProcessing = false;
|
|
102
|
+
let currentState = null;
|
|
103
|
+
let isDestroyed = false;
|
|
104
|
+
const cancelScheduledRun = () => {
|
|
105
|
+
if (idleCallbackHandle !== null) {
|
|
106
|
+
cIC(idleCallbackHandle);
|
|
107
|
+
idleCallbackHandle = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const runTasks = (deadline) => {
|
|
111
|
+
cancelScheduledRun();
|
|
112
|
+
if (isProcessing) return;
|
|
113
|
+
isProcessing = true;
|
|
114
|
+
try {
|
|
115
|
+
while (taskQueue.length > 0 && !shouldYield(deadline, taskQueue[0]?.minTaskTime ?? 0)) {
|
|
116
|
+
const nextTask = taskQueue.shift();
|
|
117
|
+
if (!nextTask) break;
|
|
118
|
+
currentState = nextTask.state;
|
|
119
|
+
try {
|
|
120
|
+
nextTask.task(nextTask.state);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (onError) onError(error, nextTask.state);
|
|
123
|
+
else setTimeout(() => {
|
|
124
|
+
throw error;
|
|
125
|
+
}, 0);
|
|
126
|
+
} finally {
|
|
127
|
+
currentState = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
isProcessing = false;
|
|
132
|
+
}
|
|
133
|
+
if (taskQueue.length > 0) scheduleTasksToRun();
|
|
134
|
+
};
|
|
135
|
+
const scheduleTasksToRun = () => {
|
|
136
|
+
if (isDestroyed) return;
|
|
137
|
+
if (ensureTasksRun && isBrowser() && document.visibilityState === "hidden") queueMicrotask(() => runTasks());
|
|
138
|
+
else if (idleCallbackHandle === null) idleCallbackHandle = rIC(runTasks);
|
|
139
|
+
};
|
|
140
|
+
const enqueue = (task, taskOptions = {}, position) => {
|
|
141
|
+
if (isDestroyed) return;
|
|
142
|
+
const { minTaskTime = defaultMinTaskTime } = taskOptions;
|
|
143
|
+
const queued = {
|
|
144
|
+
state: createTaskState(),
|
|
145
|
+
task,
|
|
146
|
+
minTaskTime
|
|
147
|
+
};
|
|
148
|
+
if (position === "start") taskQueue.unshift(queued);
|
|
149
|
+
else taskQueue.push(queued);
|
|
150
|
+
scheduleTasksToRun();
|
|
151
|
+
};
|
|
152
|
+
const runTasksImmediately = () => {
|
|
153
|
+
runTasks();
|
|
154
|
+
};
|
|
155
|
+
const onVisibilityChange = () => {
|
|
156
|
+
if (document.visibilityState === "hidden") runTasksImmediately();
|
|
157
|
+
};
|
|
158
|
+
const listensForUnload = ensureTasksRun && isBrowser();
|
|
159
|
+
const needsBeforeUnload = listensForUnload && isSafari();
|
|
160
|
+
if (listensForUnload) {
|
|
161
|
+
addEventListener("visibilitychange", onVisibilityChange, true);
|
|
162
|
+
if (needsBeforeUnload) addEventListener("beforeunload", runTasksImmediately, true);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
/** Queues a task to run after those already pending. */
|
|
166
|
+
pushTask: (task, taskOptions) => {
|
|
167
|
+
enqueue(task, taskOptions, "end");
|
|
168
|
+
},
|
|
169
|
+
/** Queues a task to run before those already pending. */
|
|
170
|
+
unshiftTask: (task, taskOptions) => {
|
|
171
|
+
enqueue(task, taskOptions, "start");
|
|
172
|
+
},
|
|
173
|
+
/** Runs every pending task now, ignoring the idle deadline. */
|
|
174
|
+
runTasksImmediately,
|
|
175
|
+
hasPendingTasks: () => taskQueue.length > 0,
|
|
176
|
+
clearPendingTasks: () => {
|
|
177
|
+
taskQueue = [];
|
|
178
|
+
cancelScheduledRun();
|
|
179
|
+
},
|
|
180
|
+
/** The state of the task currently running, or `null` between tasks. */
|
|
181
|
+
getState: () => currentState,
|
|
182
|
+
/** Drops pending tasks and detaches listeners. The queue is unusable after. */
|
|
183
|
+
destroy: () => {
|
|
184
|
+
isDestroyed = true;
|
|
185
|
+
taskQueue = [];
|
|
186
|
+
cancelScheduledRun();
|
|
187
|
+
if (listensForUnload) {
|
|
188
|
+
removeEventListener("visibilitychange", onVisibilityChange, true);
|
|
189
|
+
if (needsBeforeUnload) removeEventListener("beforeunload", runTasksImmediately, true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/use-idle-queue.ts
|
|
196
|
+
const useIdleQueue = ({ ensureTasksRun = true, minTaskTime = 0, onError } = {}) => {
|
|
197
|
+
const queueRef = useRef(null);
|
|
198
|
+
const reportError = useEffectEvent((error) => onError?.(error));
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const queue = createIdleQueue({
|
|
201
|
+
ensureTasksRun,
|
|
202
|
+
minTaskTime,
|
|
203
|
+
onError: reportError
|
|
204
|
+
});
|
|
205
|
+
queueRef.current = queue;
|
|
206
|
+
return () => {
|
|
207
|
+
queue.destroy();
|
|
208
|
+
queueRef.current = null;
|
|
209
|
+
};
|
|
210
|
+
}, [ensureTasksRun, minTaskTime]);
|
|
211
|
+
return useMemo(() => ({
|
|
212
|
+
pushTask: (task, options) => queueRef.current?.pushTask(task, options),
|
|
213
|
+
unshiftTask: (task, options) => queueRef.current?.unshiftTask(task, options),
|
|
214
|
+
runTasksImmediately: () => queueRef.current?.runTasksImmediately(),
|
|
215
|
+
hasPendingTasks: () => queueRef.current?.hasPendingTasks() ?? false,
|
|
216
|
+
clearPendingTasks: () => queueRef.current?.clearPendingTasks(),
|
|
217
|
+
getState: () => queueRef.current?.getState() ?? null
|
|
218
|
+
}), []);
|
|
219
|
+
};
|
|
220
|
+
//#endregion
|
|
221
|
+
export { useIdleQueue };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/use-intersection-observer.d.ts
|
|
2
|
+
type UseIntersectionObserverOptions = {
|
|
3
|
+
root?: Element | Document | null;
|
|
4
|
+
rootMargin?: string;
|
|
5
|
+
threshold?: number | number[];
|
|
6
|
+
freezeOnceVisible?: boolean;
|
|
7
|
+
onChange?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void;
|
|
8
|
+
initialIsIntersecting?: boolean;
|
|
9
|
+
};
|
|
10
|
+
type IntersectionReturn = [(node?: Element | null) => void, boolean, IntersectionObserverEntry | undefined] & {
|
|
11
|
+
ref: (node?: Element | null) => void;
|
|
12
|
+
isIntersecting: boolean;
|
|
13
|
+
entry?: IntersectionObserverEntry;
|
|
14
|
+
};
|
|
15
|
+
declare const useIntersectionObserver: ({ threshold, root, rootMargin, freezeOnceVisible, initialIsIntersecting, onChange }?: UseIntersectionObserverOptions) => IntersectionReturn;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { useIntersectionObserver };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useCallback, useEffect, useEffectEvent, useState } from "react";
|
|
2
|
+
//#region src/use-intersection-observer.ts
|
|
3
|
+
const checkIntersecting = (entry, thresholds) => entry.isIntersecting && thresholds.some((threshold) => entry.intersectionRatio >= threshold);
|
|
4
|
+
const useIntersectionObserver = ({ threshold = 0, root = null, rootMargin = "0%", freezeOnceVisible = false, initialIsIntersecting = false, onChange } = {}) => {
|
|
5
|
+
const [node, setNode] = useState(null);
|
|
6
|
+
const [state, setState] = useState({ isIntersecting: initialIsIntersecting });
|
|
7
|
+
const notify = useEffectEvent((isIntersecting, entry) => onChange?.(isIntersecting, entry));
|
|
8
|
+
const frozen = freezeOnceVisible && state.entry?.isIntersecting === true;
|
|
9
|
+
const thresholdKey = [threshold].flat().join(",");
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!node || frozen || !("IntersectionObserver" in window)) return;
|
|
12
|
+
const observer = new IntersectionObserver((entries) => {
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
const isIntersecting = checkIntersecting(entry, observer.thresholds);
|
|
15
|
+
setState({
|
|
16
|
+
isIntersecting,
|
|
17
|
+
entry
|
|
18
|
+
});
|
|
19
|
+
notify(isIntersecting, entry);
|
|
20
|
+
}
|
|
21
|
+
}, {
|
|
22
|
+
threshold: thresholdKey.split(",").map(Number),
|
|
23
|
+
root,
|
|
24
|
+
rootMargin
|
|
25
|
+
});
|
|
26
|
+
observer.observe(node);
|
|
27
|
+
return () => observer.disconnect();
|
|
28
|
+
}, [
|
|
29
|
+
node,
|
|
30
|
+
frozen,
|
|
31
|
+
thresholdKey,
|
|
32
|
+
root,
|
|
33
|
+
rootMargin
|
|
34
|
+
]);
|
|
35
|
+
const ref = useCallback((next) => {
|
|
36
|
+
setNode(next ?? null);
|
|
37
|
+
if (!next && !freezeOnceVisible) setState({ isIntersecting: initialIsIntersecting });
|
|
38
|
+
}, [freezeOnceVisible, initialIsIntersecting]);
|
|
39
|
+
return Object.assign([
|
|
40
|
+
ref,
|
|
41
|
+
state.isIntersecting,
|
|
42
|
+
state.entry
|
|
43
|
+
], {
|
|
44
|
+
ref,
|
|
45
|
+
isIntersecting: state.isIntersecting,
|
|
46
|
+
entry: state.entry
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
export { useIntersectionObserver };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/lib/keybindings.d.ts
|
|
2
|
+
type KeyBindingMap = Record<string, (event: KeyboardEvent) => void>;
|
|
3
|
+
type KeyBindingHandlerOptions = {
|
|
4
|
+
timeout?: number;
|
|
5
|
+
};
|
|
6
|
+
type KeyBindingOptions = KeyBindingHandlerOptions & {
|
|
7
|
+
event?: "keydown" | "keyup";
|
|
8
|
+
capture?: boolean;
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/use-keybindings.d.ts
|
|
12
|
+
declare const useKeybindings: (keyBindingMap: KeyBindingMap, target?: Window | HTMLElement, { event, capture, timeout }?: KeyBindingOptions) => void;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { useKeybindings };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useEffectEvent } from "react";
|
|
2
|
+
//#region src/lib/keybindings.ts
|
|
3
|
+
const KEYBINDING_MODIFIER_KEYS = [
|
|
4
|
+
"Shift",
|
|
5
|
+
"Meta",
|
|
6
|
+
"Alt",
|
|
7
|
+
"Control"
|
|
8
|
+
];
|
|
9
|
+
const DEFAULT_TIMEOUT = 1e3;
|
|
10
|
+
const DEFAULT_EVENT = "keydown";
|
|
11
|
+
const PLATFORM = typeof navigator === "object" ? navigator.platform : "";
|
|
12
|
+
const APPLE_DEVICE = /Mac|iPod|iPhone|iPad/.test(PLATFORM);
|
|
13
|
+
const MOD = APPLE_DEVICE ? "Meta" : "Control";
|
|
14
|
+
const ALT_GRAPH_ALIASES = PLATFORM === "Win32" ? ["Control", "Alt"] : APPLE_DEVICE ? ["Alt"] : [];
|
|
15
|
+
const getModifierState = (event, mod) => typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
|
|
16
|
+
const parseKeybinding = (str) => str.trim().split(" ").map((press) => {
|
|
17
|
+
const parts = press.split(/\b\+/);
|
|
18
|
+
const rawKey = parts.at(-1);
|
|
19
|
+
const mods = parts.slice(0, -1).map((mod) => mod === "$mod" ? MOD : mod);
|
|
20
|
+
const match = rawKey.match(/^\((.+)\)$/);
|
|
21
|
+
return [mods, match ? new RegExp(`^${match[1]}$`) : rawKey];
|
|
22
|
+
});
|
|
23
|
+
const matchesKey = (event, key) => key instanceof RegExp ? key.test(event.key) || key.test(event.code) : key.toUpperCase() === event.key.toUpperCase() || key === event.code;
|
|
24
|
+
const hasRequiredModifiers = (event, mods) => mods.every((mod) => getModifierState(event, mod));
|
|
25
|
+
const hasExtraModifiers = (event, mods, key) => KEYBINDING_MODIFIER_KEYS.some((mod) => !mods.includes(mod) && key !== mod && getModifierState(event, mod));
|
|
26
|
+
const matchKeyBindingPress = (event, [mods, key]) => matchesKey(event, key) && hasRequiredModifiers(event, mods) && !hasExtraModifiers(event, mods, key);
|
|
27
|
+
const createKeybindingsHandler = (keyBindingMap, { timeout = DEFAULT_TIMEOUT } = {}) => {
|
|
28
|
+
const keyBindings = Object.entries(keyBindingMap).map(([binding, callback]) => [parseKeybinding(binding), callback]);
|
|
29
|
+
const possibleMatches = /* @__PURE__ */ new Map();
|
|
30
|
+
let timer = null;
|
|
31
|
+
return (event) => {
|
|
32
|
+
if (!(event instanceof KeyboardEvent)) return;
|
|
33
|
+
for (const [sequence, callback] of keyBindings) {
|
|
34
|
+
const remainingExpectedPresses = possibleMatches.get(sequence) ?? sequence;
|
|
35
|
+
const currentExpectedPress = remainingExpectedPresses[0];
|
|
36
|
+
if (!currentExpectedPress) continue;
|
|
37
|
+
if (!matchKeyBindingPress(event, currentExpectedPress)) {
|
|
38
|
+
if (!getModifierState(event, event.key)) possibleMatches.delete(sequence);
|
|
39
|
+
} else if (remainingExpectedPresses.length > 1) possibleMatches.set(sequence, remainingExpectedPresses.slice(1));
|
|
40
|
+
else {
|
|
41
|
+
possibleMatches.delete(sequence);
|
|
42
|
+
callback(event);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (timer) clearTimeout(timer);
|
|
46
|
+
timer = setTimeout(() => possibleMatches.clear(), timeout);
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const keybindings = (target, keyBindingMap, { event = DEFAULT_EVENT, capture, timeout } = {}) => {
|
|
50
|
+
const onKeyEvent = createKeybindingsHandler(keyBindingMap, { timeout });
|
|
51
|
+
target.addEventListener(event, onKeyEvent, capture);
|
|
52
|
+
return () => target.removeEventListener(event, onKeyEvent, capture);
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/use-keybindings.ts
|
|
56
|
+
const useKeybindings = (keyBindingMap, target, { event, capture, timeout } = {}) => {
|
|
57
|
+
const run = useEffectEvent((binding, keyboardEvent) => keyBindingMap[binding]?.(keyboardEvent));
|
|
58
|
+
const bindingsKey = Object.keys(keyBindingMap).join("\n");
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const bindings = bindingsKey === "" ? [] : bindingsKey.split("\n");
|
|
61
|
+
const map = Object.fromEntries(bindings.map((binding) => [binding, (keyboardEvent) => run(binding, keyboardEvent)]));
|
|
62
|
+
return keybindings(target ?? window, map, {
|
|
63
|
+
event,
|
|
64
|
+
capture,
|
|
65
|
+
timeout
|
|
66
|
+
});
|
|
67
|
+
}, [
|
|
68
|
+
bindingsKey,
|
|
69
|
+
target,
|
|
70
|
+
event,
|
|
71
|
+
capture,
|
|
72
|
+
timeout
|
|
73
|
+
]);
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
export { useKeybindings };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/use-local-storage.d.ts
|
|
2
|
+
type UseStorageOptions<T> = {
|
|
3
|
+
key: string;
|
|
4
|
+
defaultValue?: T;
|
|
5
|
+
serialize?: (value: T) => string;
|
|
6
|
+
deserialize?: (value: string) => T;
|
|
7
|
+
};
|
|
8
|
+
type UseStorageReturnValue<T> = [T, (val: T | ((prevState: T) => T)) => void, () => void];
|
|
9
|
+
type UseStorage = <T>(options: UseStorageOptions<T>) => UseStorageReturnValue<T>;
|
|
10
|
+
declare const useLocalStorage: UseStorage;
|
|
11
|
+
declare const useSessionStorage: UseStorage;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { useLocalStorage, useSessionStorage };
|