@-reddacted-/react-hooks 0.2.0 → 0.3.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/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/useFocusTrap.d.ts +11 -0
- package/dist/useFocusTrap.d.ts.map +1 -0
- package/dist/useFocusTrap.js +74 -0
- package/dist/useOverlayStack.d.ts +10 -0
- package/dist/useOverlayStack.d.ts.map +1 -0
- package/dist/useOverlayStack.js +39 -0
- package/dist/useScrollLock.d.ts +3 -0
- package/dist/useScrollLock.d.ts.map +1 -0
- package/dist/useScrollLock.js +29 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,7 @@ export { useElementSize, type ElementSize } from "./useElementSize.js";
|
|
|
6
6
|
export { useClickOutside } from "./useClickOutside.js";
|
|
7
7
|
export { useEscapeKey } from "./useEscapeKey.js";
|
|
8
8
|
export { useDocumentTitle } from "./useDocumentTitle.js";
|
|
9
|
+
export { useFocusTrap } from "./useFocusTrap.js";
|
|
10
|
+
export { useScrollLock } from "./useScrollLock.js";
|
|
11
|
+
export { useOverlayStack } from "./useOverlayStack.js";
|
|
9
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACtF,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,eAAe,CAAA;AAC5E,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACtF,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,eAAe,CAAA;AAC5E,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,6 @@ export { useElementSize } from "./useElementSize.js";
|
|
|
6
6
|
export { useClickOutside } from "./useClickOutside.js";
|
|
7
7
|
export { useEscapeKey } from "./useEscapeKey.js";
|
|
8
8
|
export { useDocumentTitle } from "./useDocumentTitle.js";
|
|
9
|
+
export { useFocusTrap } from "./useFocusTrap.js";
|
|
10
|
+
export { useScrollLock } from "./useScrollLock.js";
|
|
11
|
+
export { useOverlayStack } from "./useOverlayStack.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Keeps the focus inside `ref` while `enabled`: focuses its first focusable element
|
|
4
|
+
* (or the container itself) on activation, loops Tab / Shift+Tab, pulls back a focus
|
|
5
|
+
* that escaped, and restores the previously focused element on deactivation.
|
|
6
|
+
* The container should have `tabIndex={-1}` so it can receive the focus.
|
|
7
|
+
* `paused` suspends Tab looping and focus rescue without releasing the trap, e.g. while
|
|
8
|
+
* another overlay (menu, drawer) is open above.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useFocusTrap(ref: RefObject<HTMLElement | null>, enabled?: boolean, paused?: boolean): void;
|
|
11
|
+
//# sourceMappingURL=useFocusTrap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useFocusTrap.d.ts","sourceRoot":"","sources":["../src/useFocusTrap.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AActC;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EAAE,OAAO,UAAO,EAAE,MAAM,UAAQ,GAAG,IAAI,CA0DrG"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
const FOCUSABLE = `a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])`;
|
|
3
|
+
// Active traps, last activated last: only that one handles Tab and focus rescue,
|
|
4
|
+
// so nested overlays (a Modal over a Drawer) don't fight over the focus.
|
|
5
|
+
const traps = [];
|
|
6
|
+
function getFocusables(container) {
|
|
7
|
+
return Array.from(container.querySelectorAll(FOCUSABLE)).filter(el => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Keeps the focus inside `ref` while `enabled`: focuses its first focusable element
|
|
11
|
+
* (or the container itself) on activation, loops Tab / Shift+Tab, pulls back a focus
|
|
12
|
+
* that escaped, and restores the previously focused element on deactivation.
|
|
13
|
+
* The container should have `tabIndex={-1}` so it can receive the focus.
|
|
14
|
+
* `paused` suspends Tab looping and focus rescue without releasing the trap, e.g. while
|
|
15
|
+
* another overlay (menu, drawer) is open above.
|
|
16
|
+
*/
|
|
17
|
+
export function useFocusTrap(ref, enabled = true, paused = false) {
|
|
18
|
+
const pausedRef = useRef(paused);
|
|
19
|
+
pausedRef.current = paused;
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!enabled)
|
|
22
|
+
return;
|
|
23
|
+
const key = Symbol("focus-trap");
|
|
24
|
+
traps.push(key);
|
|
25
|
+
const isActive = () => !pausedRef.current && traps[traps.length - 1] === key;
|
|
26
|
+
const previouslyFocused = document.activeElement;
|
|
27
|
+
// Deferred: the container may not be focusable yet in the commit that enables the trap.
|
|
28
|
+
const timeout = setTimeout(() => {
|
|
29
|
+
const container = ref.current;
|
|
30
|
+
if (!container)
|
|
31
|
+
return;
|
|
32
|
+
(getFocusables(container)[0] ?? container).focus();
|
|
33
|
+
}, 0);
|
|
34
|
+
const onKeyDown = (event) => {
|
|
35
|
+
if (event.key !== "Tab" || !isActive())
|
|
36
|
+
return;
|
|
37
|
+
const container = ref.current;
|
|
38
|
+
if (!container)
|
|
39
|
+
return;
|
|
40
|
+
const focusables = getFocusables(container);
|
|
41
|
+
if (focusables.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
const first = focusables[0];
|
|
44
|
+
const last = focusables[focusables.length - 1];
|
|
45
|
+
const active = document.activeElement;
|
|
46
|
+
if (event.shiftKey) {
|
|
47
|
+
if (active === first || !container.contains(active)) {
|
|
48
|
+
last.focus();
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (active === last || !container.contains(active)) {
|
|
53
|
+
first.focus();
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const onFocusIn = (event) => {
|
|
58
|
+
if (!isActive())
|
|
59
|
+
return;
|
|
60
|
+
const container = ref.current;
|
|
61
|
+
if (container && !container.contains(event.target))
|
|
62
|
+
container.focus();
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener("keydown", onKeyDown);
|
|
65
|
+
document.addEventListener("focusin", onFocusIn);
|
|
66
|
+
return () => {
|
|
67
|
+
clearTimeout(timeout);
|
|
68
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
69
|
+
document.removeEventListener("focusin", onFocusIn);
|
|
70
|
+
traps.splice(traps.indexOf(key), 1);
|
|
71
|
+
previouslyFocused?.focus?.();
|
|
72
|
+
};
|
|
73
|
+
}, [enabled, ref]);
|
|
74
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers an overlay in a page-wide stack while `enabled`.
|
|
3
|
+
* `isTop` is true for the last opened overlay only (the one that should react to Escape);
|
|
4
|
+
* `zIndex` puts every overlay above the ones opened before it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useOverlayStack(enabled?: boolean): {
|
|
7
|
+
isTop: boolean;
|
|
8
|
+
zIndex: number;
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=useOverlayStack.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useOverlayStack.d.ts","sourceRoot":"","sources":["../src/useOverlayStack.ts"],"names":[],"mappings":"AAyBA;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,UAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAclF"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useLayoutEffect, useState, useSyncExternalStore } from "react";
|
|
2
|
+
const BASE_Z_INDEX = 1000;
|
|
3
|
+
const Z_INDEX_STEP = 10;
|
|
4
|
+
// Global stack of open overlays, last opened on top.
|
|
5
|
+
let stack = [];
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
function subscribe(listener) {
|
|
8
|
+
listeners.add(listener);
|
|
9
|
+
return () => {
|
|
10
|
+
listeners.delete(listener);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function getSnapshot() {
|
|
14
|
+
return stack;
|
|
15
|
+
}
|
|
16
|
+
function setStack(next) {
|
|
17
|
+
stack = next;
|
|
18
|
+
listeners.forEach(listener => listener());
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Registers an overlay in a page-wide stack while `enabled`.
|
|
22
|
+
* `isTop` is true for the last opened overlay only (the one that should react to Escape);
|
|
23
|
+
* `zIndex` puts every overlay above the ones opened before it.
|
|
24
|
+
*/
|
|
25
|
+
export function useOverlayStack(enabled = true) {
|
|
26
|
+
const [key] = useState(() => Symbol("overlay"));
|
|
27
|
+
// Layout effect: the z-index is known before the overlay is painted.
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
if (!enabled)
|
|
30
|
+
return;
|
|
31
|
+
setStack([...stack, key]);
|
|
32
|
+
return () => setStack(stack.filter(k => k !== key));
|
|
33
|
+
}, [enabled, key]);
|
|
34
|
+
const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
35
|
+
const index = current.indexOf(key);
|
|
36
|
+
if (index === -1)
|
|
37
|
+
return { isTop: false, zIndex: BASE_Z_INDEX };
|
|
38
|
+
return { isTop: index === current.length - 1, zIndex: BASE_Z_INDEX + index * Z_INDEX_STEP };
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useScrollLock.d.ts","sourceRoot":"","sources":["../src/useScrollLock.ts"],"names":[],"mappings":"AAOA,uGAAuG;AACvG,wBAAgB,aAAa,CAAC,OAAO,UAAO,GAAG,IAAI,CAsBlD"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
// Reference count: nested locks only release the page when the last one goes away,
|
|
3
|
+
// whatever the order they are released in.
|
|
4
|
+
let lockCount = 0;
|
|
5
|
+
let saved = null;
|
|
6
|
+
/** Blocks page scrolling while `enabled`, compensating the scrollbar width to avoid a layout shift. */
|
|
7
|
+
export function useScrollLock(enabled = true) {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!enabled)
|
|
10
|
+
return;
|
|
11
|
+
const body = document.body;
|
|
12
|
+
if (lockCount === 0) {
|
|
13
|
+
saved = { overflow: body.style.overflow, paddingRight: body.style.paddingRight };
|
|
14
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
15
|
+
if (scrollbarWidth > 0)
|
|
16
|
+
body.style.paddingRight = `${scrollbarWidth}px`;
|
|
17
|
+
body.style.overflow = "hidden";
|
|
18
|
+
}
|
|
19
|
+
lockCount++;
|
|
20
|
+
return () => {
|
|
21
|
+
lockCount--;
|
|
22
|
+
if (lockCount === 0 && saved) {
|
|
23
|
+
body.style.overflow = saved.overflow;
|
|
24
|
+
body.style.paddingRight = saved.paddingRight;
|
|
25
|
+
saved = null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}, [enabled]);
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@-reddacted-/react-hooks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React hooks: useAsync, useElementSize, useClickOutside, useEscapeKey, useDocumentTitle, useFullscreen, useInstallPrompt, useValueChange",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "React hooks: useAsync, useElementSize, useClickOutside, useEscapeKey, useDocumentTitle, useFullscreen, useInstallPrompt, useValueChange, useFocusTrap, useScrollLock, useOverlayStack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|