@develemit/web-hooks 0.0.0-stage → 0.1.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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/clipboard.cjs +89 -0
- package/dist/clipboard.d.cts +17 -0
- package/dist/clipboard.d.ts +17 -0
- package/dist/clipboard.js +62 -0
- package/dist/drawer-swipe.cjs +149 -0
- package/dist/drawer-swipe.d.cts +26 -0
- package/dist/drawer-swipe.d.ts +26 -0
- package/dist/drawer-swipe.js +114 -0
- package/dist/escape.cjs +47 -0
- package/dist/escape.d.cts +3 -0
- package/dist/escape.d.ts +3 -0
- package/dist/escape.js +21 -0
- package/dist/focus-trap.cjs +108 -0
- package/dist/focus-trap.d.cts +17 -0
- package/dist/focus-trap.d.ts +17 -0
- package/dist/focus-trap.js +79 -0
- package/dist/index.cjs +308 -0
- package/dist/index.d.cts +60 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +265 -0
- package/package.json +73 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// src/drawer-swipe/swipe-intent.ts
|
|
2
|
+
var BACK_ZONE_PX = 60;
|
|
3
|
+
var FORWARD_ZONE_PX = 30;
|
|
4
|
+
var OPEN_DISTANCE_PX = 80;
|
|
5
|
+
var CLOSE_DISTANCE_PX = 60;
|
|
6
|
+
var VERTICAL_CANCEL_PX = 40;
|
|
7
|
+
var MAX_SWIPE_DURATION_MS = 450;
|
|
8
|
+
var HORIZONTAL_DOMINANCE = 1.5;
|
|
9
|
+
function isInOpenZone(x, viewportWidth) {
|
|
10
|
+
return x > BACK_ZONE_PX && x < viewportWidth - FORWARD_ZONE_PX;
|
|
11
|
+
}
|
|
12
|
+
function resolveSwipeIntent(start, end, drawerOpen, viewportWidth) {
|
|
13
|
+
const dx = end.x - start.x;
|
|
14
|
+
const dy = end.y - start.y;
|
|
15
|
+
if (end.time - start.time > MAX_SWIPE_DURATION_MS) return null;
|
|
16
|
+
if (Math.abs(dx) < Math.abs(dy) * HORIZONTAL_DOMINANCE) return null;
|
|
17
|
+
if (drawerOpen) {
|
|
18
|
+
return dx <= -CLOSE_DISTANCE_PX ? "close" : null;
|
|
19
|
+
}
|
|
20
|
+
if (dx < OPEN_DISTANCE_PX) return null;
|
|
21
|
+
return isInOpenZone(start.x, viewportWidth) ? "open" : null;
|
|
22
|
+
}
|
|
23
|
+
function isInsideHorizontalScroller(target) {
|
|
24
|
+
let node = target instanceof Element ? target : null;
|
|
25
|
+
while (node && node !== document.body) {
|
|
26
|
+
const { overflowX } = window.getComputedStyle(node);
|
|
27
|
+
if ((overflowX === "auto" || overflowX === "scroll") && node.scrollWidth > node.clientWidth) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
node = node.parentElement;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/drawer-swipe/use-drawer-swipe.ts
|
|
36
|
+
import { useEffect, useRef } from "react";
|
|
37
|
+
function isWideViewport(maxViewportWidth) {
|
|
38
|
+
return window.matchMedia(`(min-width: ${maxViewportWidth}px)`).matches;
|
|
39
|
+
}
|
|
40
|
+
function useDrawerSwipe({
|
|
41
|
+
drawerOpen,
|
|
42
|
+
onOpen,
|
|
43
|
+
onClose,
|
|
44
|
+
enabled = true,
|
|
45
|
+
maxViewportWidth = 768
|
|
46
|
+
}) {
|
|
47
|
+
const start = useRef(null);
|
|
48
|
+
const handlers = useRef({ drawerOpen, onOpen, onClose, maxViewportWidth });
|
|
49
|
+
handlers.current = { drawerOpen, onOpen, onClose, maxViewportWidth };
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!enabled) return;
|
|
52
|
+
function handleStart(event) {
|
|
53
|
+
start.current = null;
|
|
54
|
+
if (event.touches.length !== 1) return;
|
|
55
|
+
if (isWideViewport(handlers.current.maxViewportWidth)) return;
|
|
56
|
+
if (!handlers.current.drawerOpen && isInsideHorizontalScroller(event.target)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const touch = event.touches[0];
|
|
60
|
+
if (!touch) return;
|
|
61
|
+
start.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
|
|
62
|
+
}
|
|
63
|
+
function handleMove(event) {
|
|
64
|
+
const origin = start.current;
|
|
65
|
+
const touch = event.touches[0];
|
|
66
|
+
if (!origin || !touch) return;
|
|
67
|
+
if (Math.abs(touch.clientY - origin.y) > VERTICAL_CANCEL_PX) {
|
|
68
|
+
start.current = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function handleEnd(event) {
|
|
72
|
+
const origin = start.current;
|
|
73
|
+
start.current = null;
|
|
74
|
+
const touch = event.changedTouches[0];
|
|
75
|
+
if (!origin || !touch) return;
|
|
76
|
+
const { drawerOpen: drawerOpen2, onOpen: onOpen2, onClose: onClose2 } = handlers.current;
|
|
77
|
+
const intent = resolveSwipeIntent(
|
|
78
|
+
origin,
|
|
79
|
+
{ x: touch.clientX, y: touch.clientY, time: Date.now() },
|
|
80
|
+
drawerOpen2,
|
|
81
|
+
window.innerWidth
|
|
82
|
+
);
|
|
83
|
+
if (intent === "open") onOpen2();
|
|
84
|
+
if (intent === "close") onClose2();
|
|
85
|
+
}
|
|
86
|
+
function handleCancel() {
|
|
87
|
+
start.current = null;
|
|
88
|
+
}
|
|
89
|
+
document.addEventListener("touchstart", handleStart, { passive: true });
|
|
90
|
+
document.addEventListener("touchmove", handleMove, { passive: true });
|
|
91
|
+
document.addEventListener("touchend", handleEnd, { passive: true });
|
|
92
|
+
document.addEventListener("touchcancel", handleCancel, { passive: true });
|
|
93
|
+
return () => {
|
|
94
|
+
document.removeEventListener("touchstart", handleStart);
|
|
95
|
+
document.removeEventListener("touchmove", handleMove);
|
|
96
|
+
document.removeEventListener("touchend", handleEnd);
|
|
97
|
+
document.removeEventListener("touchcancel", handleCancel);
|
|
98
|
+
};
|
|
99
|
+
}, [enabled]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/focus-trap/focusables.ts
|
|
103
|
+
var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
104
|
+
function focusableWithin(container) {
|
|
105
|
+
return Array.from(
|
|
106
|
+
container.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
107
|
+
).filter((el) => !el.closest("[hidden], [inert]"));
|
|
108
|
+
}
|
|
109
|
+
function nextFocusTarget(items, current, shiftKey, container) {
|
|
110
|
+
const first = items[0];
|
|
111
|
+
const last = items[items.length - 1];
|
|
112
|
+
if (!first || !last) return null;
|
|
113
|
+
if (!current || !container.contains(current)) return shiftKey ? last : first;
|
|
114
|
+
if (shiftKey && (current === first || current === container)) return last;
|
|
115
|
+
if (!shiftKey && current === last) return first;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/focus-trap/use-focus-trap.ts
|
|
120
|
+
import { useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
121
|
+
var activeTraps = [];
|
|
122
|
+
function useFocusTrap(containerRef, {
|
|
123
|
+
active,
|
|
124
|
+
initialFocusRef,
|
|
125
|
+
returnFocusRef,
|
|
126
|
+
restoreFocus = true
|
|
127
|
+
}) {
|
|
128
|
+
const openerRef = useRef2(null);
|
|
129
|
+
if (active && !openerRef.current && typeof document !== "undefined") {
|
|
130
|
+
openerRef.current = document.activeElement;
|
|
131
|
+
}
|
|
132
|
+
useEffect2(() => {
|
|
133
|
+
const container = containerRef.current;
|
|
134
|
+
if (!active || !container) return;
|
|
135
|
+
const token = {};
|
|
136
|
+
activeTraps.push(token);
|
|
137
|
+
if (!container.contains(document.activeElement)) {
|
|
138
|
+
const target = initialFocusRef?.current ?? focusableWithin(container)[0] ?? container;
|
|
139
|
+
if (target === container && !container.hasAttribute("tabindex")) {
|
|
140
|
+
container.setAttribute("tabindex", "-1");
|
|
141
|
+
}
|
|
142
|
+
target.focus();
|
|
143
|
+
}
|
|
144
|
+
function onKeyDown(event) {
|
|
145
|
+
if (event.key !== "Tab" || !container) return;
|
|
146
|
+
if (activeTraps[activeTraps.length - 1] !== token) return;
|
|
147
|
+
const items = focusableWithin(container);
|
|
148
|
+
if (items.length === 0) {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const target = nextFocusTarget(
|
|
153
|
+
items,
|
|
154
|
+
document.activeElement,
|
|
155
|
+
event.shiftKey,
|
|
156
|
+
container
|
|
157
|
+
);
|
|
158
|
+
if (target) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
target.focus();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
document.addEventListener("keydown", onKeyDown);
|
|
164
|
+
return () => {
|
|
165
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
166
|
+
activeTraps.splice(activeTraps.indexOf(token), 1);
|
|
167
|
+
const opener = returnFocusRef?.current ?? openerRef.current;
|
|
168
|
+
openerRef.current = null;
|
|
169
|
+
if (restoreFocus && opener?.isConnected) opener.focus();
|
|
170
|
+
};
|
|
171
|
+
}, [active, containerRef, initialFocusRef, returnFocusRef, restoreFocus]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/escape/use-escape.ts
|
|
175
|
+
import { useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
176
|
+
function useEscape(onEscape, active = true) {
|
|
177
|
+
const handlerRef = useRef3(onEscape);
|
|
178
|
+
useEffect3(() => {
|
|
179
|
+
handlerRef.current = onEscape;
|
|
180
|
+
});
|
|
181
|
+
useEffect3(() => {
|
|
182
|
+
if (!active) return;
|
|
183
|
+
function onKeyDown(event) {
|
|
184
|
+
if (event.key === "Escape") handlerRef.current();
|
|
185
|
+
}
|
|
186
|
+
document.addEventListener("keydown", onKeyDown);
|
|
187
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
188
|
+
}, [active]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/clipboard/copy-text.ts
|
|
192
|
+
async function copyText(text) {
|
|
193
|
+
const hasClipboardApi = typeof navigator !== "undefined" && !!navigator.clipboard?.writeText && typeof window !== "undefined" && window.isSecureContext;
|
|
194
|
+
if (hasClipboardApi) {
|
|
195
|
+
try {
|
|
196
|
+
await navigator.clipboard.writeText(text);
|
|
197
|
+
return { ok: true };
|
|
198
|
+
} catch {
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (execCommandCopy(text)) return { ok: true };
|
|
202
|
+
return { ok: false, reason: hasClipboardApi ? "denied" : "unavailable" };
|
|
203
|
+
}
|
|
204
|
+
function execCommandCopy(text) {
|
|
205
|
+
if (typeof document === "undefined") return false;
|
|
206
|
+
const textarea = document.createElement("textarea");
|
|
207
|
+
textarea.value = text;
|
|
208
|
+
textarea.readOnly = true;
|
|
209
|
+
textarea.style.position = "fixed";
|
|
210
|
+
textarea.style.top = "-1000px";
|
|
211
|
+
textarea.style.left = "-1000px";
|
|
212
|
+
textarea.style.fontSize = "16px";
|
|
213
|
+
document.body.appendChild(textarea);
|
|
214
|
+
try {
|
|
215
|
+
textarea.select();
|
|
216
|
+
textarea.setSelectionRange(0, text.length);
|
|
217
|
+
return document.execCommand("copy");
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
} finally {
|
|
221
|
+
document.body.removeChild(textarea);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/clipboard/use-copy-feedback.ts
|
|
226
|
+
import { useCallback, useEffect as useEffect4, useRef as useRef4, useState } from "react";
|
|
227
|
+
function useCopyFeedback({ resetMs = 1500 } = {}) {
|
|
228
|
+
const [status, setStatus] = useState("idle");
|
|
229
|
+
const timerRef = useRef4(void 0);
|
|
230
|
+
const clearTimer = useCallback(() => {
|
|
231
|
+
if (timerRef.current !== void 0) clearTimeout(timerRef.current);
|
|
232
|
+
timerRef.current = void 0;
|
|
233
|
+
}, []);
|
|
234
|
+
useEffect4(() => clearTimer, [clearTimer]);
|
|
235
|
+
const copy = useCallback(
|
|
236
|
+
async (text) => {
|
|
237
|
+
const result = await copyText(text);
|
|
238
|
+
clearTimer();
|
|
239
|
+
setStatus(result.ok ? "copied" : "failed");
|
|
240
|
+
timerRef.current = setTimeout(() => setStatus("idle"), resetMs);
|
|
241
|
+
return result;
|
|
242
|
+
},
|
|
243
|
+
[clearTimer, resetMs]
|
|
244
|
+
);
|
|
245
|
+
return { copy, status };
|
|
246
|
+
}
|
|
247
|
+
export {
|
|
248
|
+
BACK_ZONE_PX,
|
|
249
|
+
CLOSE_DISTANCE_PX,
|
|
250
|
+
FOCUSABLE_SELECTOR,
|
|
251
|
+
FORWARD_ZONE_PX,
|
|
252
|
+
MAX_SWIPE_DURATION_MS,
|
|
253
|
+
OPEN_DISTANCE_PX,
|
|
254
|
+
VERTICAL_CANCEL_PX,
|
|
255
|
+
copyText,
|
|
256
|
+
focusableWithin,
|
|
257
|
+
isInOpenZone,
|
|
258
|
+
isInsideHorizontalScroller,
|
|
259
|
+
nextFocusTarget,
|
|
260
|
+
resolveSwipeIntent,
|
|
261
|
+
useCopyFeedback,
|
|
262
|
+
useDrawerSwipe,
|
|
263
|
+
useEscape,
|
|
264
|
+
useFocusTrap
|
|
265
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@develemit/web-hooks",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared React behavior hooks for develemit web apps: drawer swipe, focus trap, escape key and clipboard.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Emit Dutcher",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
},
|
|
18
|
+
"./drawer-swipe": {
|
|
19
|
+
"types": "./dist/drawer-swipe.d.ts",
|
|
20
|
+
"import": "./dist/drawer-swipe.js",
|
|
21
|
+
"require": "./dist/drawer-swipe.cjs"
|
|
22
|
+
},
|
|
23
|
+
"./focus-trap": {
|
|
24
|
+
"types": "./dist/focus-trap.d.ts",
|
|
25
|
+
"import": "./dist/focus-trap.js",
|
|
26
|
+
"require": "./dist/focus-trap.cjs"
|
|
27
|
+
},
|
|
28
|
+
"./escape": {
|
|
29
|
+
"types": "./dist/escape.d.ts",
|
|
30
|
+
"import": "./dist/escape.js",
|
|
31
|
+
"require": "./dist/escape.cjs"
|
|
32
|
+
},
|
|
33
|
+
"./clipboard": {
|
|
34
|
+
"types": "./dist/clipboard.d.ts",
|
|
35
|
+
"import": "./dist/clipboard.js",
|
|
36
|
+
"require": "./dist/clipboard.cjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"main": "./dist/index.cjs",
|
|
40
|
+
"module": "./dist/index.js",
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"CHANGELOG.md"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@eslint/js": "^9.0.0",
|
|
55
|
+
"@testing-library/react": "^16.0.0",
|
|
56
|
+
"@types/react": "^19.0.0",
|
|
57
|
+
"@types/react-dom": "^19.0.0",
|
|
58
|
+
"eslint": "^9.0.0",
|
|
59
|
+
"jsdom": "^26.0.0",
|
|
60
|
+
"react": "^19.0.0",
|
|
61
|
+
"react-dom": "^19.0.0",
|
|
62
|
+
"tsup": "^8.5.1",
|
|
63
|
+
"typescript": "^5.9.0",
|
|
64
|
+
"typescript-eslint": "^8.0.0",
|
|
65
|
+
"vitest": "^3.0.0"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"typecheck": "tsc --noEmit",
|
|
69
|
+
"lint": "eslint .",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"build": "rm -rf dist && tsup",
|
|
72
|
+
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
|
|
73
|
+
"release": "pnpm check && pnpm publish --access public",
|
|
74
|
+
"release:dry": "pnpm publish --access public --dry-run"
|
|
75
|
+
}
|
|
6
76
|
}
|