@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- `drawer-swipe`: `useDrawerSwipe` and the pure `resolveSwipeIntent`. Swipe right from mid-screen opens a mobile nav drawer, left closes it. The left 60px band is left to the browser's back gesture.
|
|
6
|
+
- `focus-trap`: `useFocusTrap` and the pure `focusableWithin` / `nextFocusTarget`. Reconciles the emit-vision, emit-social, develemit-commerce and centraflow copies: initial focus, Tab wrapping, `returnFocusRef` for Safari, opener captured during render, hidden/disabled/inert items skipped.
|
|
7
|
+
- `escape`: `useEscape`. The callback is read through a ref, so changing its identity doesn't re-bind the listener.
|
|
8
|
+
- `clipboard`: `copyText` and `useCopyFeedback`. Copy works on plain-http PWAs where `navigator.clipboard` is undefined (falls back to `execCommand`) and returns a typed `CopyResult` instead of rejecting. The hook tracks `idle | copied | failed` and clears its timer on unmount and on each new copy.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Emit Dutcher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @develemit/web-hooks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared React behavior hooks for develemit web apps: the interaction plumbing
|
|
4
|
+
that every app ends up hand-copying from the last one.
|
|
5
|
+
|
|
6
|
+
| Subpath | What it does |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `@develemit/web-hooks/drawer-swipe` | Swipe right from mid-screen to open a mobile nav drawer, left to close it |
|
|
9
|
+
| `@develemit/web-hooks/focus-trap` | Keep Tab focus inside an open dialog or drawer, restore it on close |
|
|
10
|
+
| `@develemit/web-hooks/escape` | Run a callback when Escape is pressed |
|
|
11
|
+
| `@develemit/web-hooks/clipboard` | Copy text, with the fallback that works on plain-http PWAs |
|
|
12
|
+
|
|
13
|
+
Behavior only. No styling and no components: each app keeps its own look.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm add @develemit/web-hooks
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import { useDrawerSwipe } from '@develemit/web-hooks/drawer-swipe';
|
|
25
|
+
|
|
26
|
+
useDrawerSwipe({
|
|
27
|
+
drawerOpen,
|
|
28
|
+
onOpen: () => setDrawerOpen(true),
|
|
29
|
+
onClose: () => setDrawerOpen(false),
|
|
30
|
+
enabled: true, // optional, default true
|
|
31
|
+
maxViewportWidth: 768, // optional; no-op at or above this width
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The left 60px band is left to the browser's back gesture, and swipes that start
|
|
36
|
+
inside a horizontally scrolling container stay with that container.
|
|
37
|
+
|
|
38
|
+
### Drawer with focus trap and Escape
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { useRef } from 'react';
|
|
42
|
+
import { useFocusTrap } from '@develemit/web-hooks/focus-trap';
|
|
43
|
+
import { useEscape } from '@develemit/web-hooks/escape';
|
|
44
|
+
|
|
45
|
+
const drawerRef = useRef<HTMLElement>(null);
|
|
46
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
47
|
+
|
|
48
|
+
useFocusTrap(drawerRef, {
|
|
49
|
+
active: open,
|
|
50
|
+
initialFocusRef, // optional; default is the first focusable item
|
|
51
|
+
returnFocusRef: triggerRef, // optional; wins over the element focused at open
|
|
52
|
+
restoreFocus: true, // optional, default true
|
|
53
|
+
});
|
|
54
|
+
useEscape(() => setOpen(false), open);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Pass `returnFocusRef` when the opener is a button: Safari doesn't focus a
|
|
58
|
+
button on tap, so the element focused at open can be `<body>`. If nothing
|
|
59
|
+
inside is focusable the container itself gets focus (`tabindex="-1"` is added).
|
|
60
|
+
`useEscape` reads the callback through a ref, so it doesn't need `useCallback`.
|
|
61
|
+
When traps stack, only the most recently activated one handles Tab.
|
|
62
|
+
|
|
63
|
+
### Copy to clipboard
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { useCopyFeedback } from '@develemit/web-hooks/clipboard';
|
|
67
|
+
|
|
68
|
+
const { copy, status } = useCopyFeedback({ resetMs: 1500 });
|
|
69
|
+
<button onClick={() => copy(token)}>{status === 'copied' ? 'Copied!' : 'Copy'}</button>;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`copyText(text)` is the plain function underneath and resolves to
|
|
73
|
+
`{ ok: true }` or `{ ok: false, reason: 'unavailable' | 'denied' }`; it never
|
|
74
|
+
rejects. On plain-http origins (a PWA over a Tailscale address)
|
|
75
|
+
`navigator.clipboard` doesn't exist, so it falls back to `execCommand('copy')`.
|
|
76
|
+
|
|
77
|
+
### Migrating from the per-app copies
|
|
78
|
+
|
|
79
|
+
| App | Old | New |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| emit-vision | `useFocusTrap(ref, { active, initialFocusRef, restoreFocus })` | Same signature. |
|
|
82
|
+
| emit-social | `useFocusTrap(ref, { active, returnFocusRef })` plus `useEscape(fn, active)` | Same signatures. |
|
|
83
|
+
| develemit-commerce | `const onKeyDown = useFocusTrap(ref, active, onEscape)` | `useFocusTrap(ref, { active })` and `useEscape(onEscape, active)`. Drop the returned `onKeyDown` handler. |
|
|
84
|
+
| centraflow | `const ref = useFocusTrap(active)` | `const ref = useRef<HTMLElement>(null)`, then `useFocusTrap(ref, { active })`. |
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/clipboard/index.ts
|
|
22
|
+
var clipboard_exports = {};
|
|
23
|
+
__export(clipboard_exports, {
|
|
24
|
+
copyText: () => copyText,
|
|
25
|
+
useCopyFeedback: () => useCopyFeedback
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(clipboard_exports);
|
|
28
|
+
|
|
29
|
+
// src/clipboard/copy-text.ts
|
|
30
|
+
async function copyText(text) {
|
|
31
|
+
const hasClipboardApi = typeof navigator !== "undefined" && !!navigator.clipboard?.writeText && typeof window !== "undefined" && window.isSecureContext;
|
|
32
|
+
if (hasClipboardApi) {
|
|
33
|
+
try {
|
|
34
|
+
await navigator.clipboard.writeText(text);
|
|
35
|
+
return { ok: true };
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (execCommandCopy(text)) return { ok: true };
|
|
40
|
+
return { ok: false, reason: hasClipboardApi ? "denied" : "unavailable" };
|
|
41
|
+
}
|
|
42
|
+
function execCommandCopy(text) {
|
|
43
|
+
if (typeof document === "undefined") return false;
|
|
44
|
+
const textarea = document.createElement("textarea");
|
|
45
|
+
textarea.value = text;
|
|
46
|
+
textarea.readOnly = true;
|
|
47
|
+
textarea.style.position = "fixed";
|
|
48
|
+
textarea.style.top = "-1000px";
|
|
49
|
+
textarea.style.left = "-1000px";
|
|
50
|
+
textarea.style.fontSize = "16px";
|
|
51
|
+
document.body.appendChild(textarea);
|
|
52
|
+
try {
|
|
53
|
+
textarea.select();
|
|
54
|
+
textarea.setSelectionRange(0, text.length);
|
|
55
|
+
return document.execCommand("copy");
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
} finally {
|
|
59
|
+
document.body.removeChild(textarea);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/clipboard/use-copy-feedback.ts
|
|
64
|
+
var import_react = require("react");
|
|
65
|
+
function useCopyFeedback({ resetMs = 1500 } = {}) {
|
|
66
|
+
const [status, setStatus] = (0, import_react.useState)("idle");
|
|
67
|
+
const timerRef = (0, import_react.useRef)(void 0);
|
|
68
|
+
const clearTimer = (0, import_react.useCallback)(() => {
|
|
69
|
+
if (timerRef.current !== void 0) clearTimeout(timerRef.current);
|
|
70
|
+
timerRef.current = void 0;
|
|
71
|
+
}, []);
|
|
72
|
+
(0, import_react.useEffect)(() => clearTimer, [clearTimer]);
|
|
73
|
+
const copy = (0, import_react.useCallback)(
|
|
74
|
+
async (text) => {
|
|
75
|
+
const result = await copyText(text);
|
|
76
|
+
clearTimer();
|
|
77
|
+
setStatus(result.ok ? "copied" : "failed");
|
|
78
|
+
timerRef.current = setTimeout(() => setStatus("idle"), resetMs);
|
|
79
|
+
return result;
|
|
80
|
+
},
|
|
81
|
+
[clearTimer, resetMs]
|
|
82
|
+
);
|
|
83
|
+
return { copy, status };
|
|
84
|
+
}
|
|
85
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
86
|
+
0 && (module.exports = {
|
|
87
|
+
copyText,
|
|
88
|
+
useCopyFeedback
|
|
89
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type CopyResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
} | {
|
|
4
|
+
ok: false;
|
|
5
|
+
reason: 'unavailable' | 'denied';
|
|
6
|
+
};
|
|
7
|
+
declare function copyText(text: string): Promise<CopyResult>;
|
|
8
|
+
|
|
9
|
+
type CopyStatus = 'idle' | 'copied' | 'failed';
|
|
10
|
+
declare function useCopyFeedback({ resetMs }?: {
|
|
11
|
+
resetMs?: number;
|
|
12
|
+
}): {
|
|
13
|
+
copy: (text: string) => Promise<CopyResult>;
|
|
14
|
+
status: CopyStatus;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { type CopyResult, type CopyStatus, copyText, useCopyFeedback };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type CopyResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
} | {
|
|
4
|
+
ok: false;
|
|
5
|
+
reason: 'unavailable' | 'denied';
|
|
6
|
+
};
|
|
7
|
+
declare function copyText(text: string): Promise<CopyResult>;
|
|
8
|
+
|
|
9
|
+
type CopyStatus = 'idle' | 'copied' | 'failed';
|
|
10
|
+
declare function useCopyFeedback({ resetMs }?: {
|
|
11
|
+
resetMs?: number;
|
|
12
|
+
}): {
|
|
13
|
+
copy: (text: string) => Promise<CopyResult>;
|
|
14
|
+
status: CopyStatus;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export { type CopyResult, type CopyStatus, copyText, useCopyFeedback };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/clipboard/copy-text.ts
|
|
4
|
+
async function copyText(text) {
|
|
5
|
+
const hasClipboardApi = typeof navigator !== "undefined" && !!navigator.clipboard?.writeText && typeof window !== "undefined" && window.isSecureContext;
|
|
6
|
+
if (hasClipboardApi) {
|
|
7
|
+
try {
|
|
8
|
+
await navigator.clipboard.writeText(text);
|
|
9
|
+
return { ok: true };
|
|
10
|
+
} catch {
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (execCommandCopy(text)) return { ok: true };
|
|
14
|
+
return { ok: false, reason: hasClipboardApi ? "denied" : "unavailable" };
|
|
15
|
+
}
|
|
16
|
+
function execCommandCopy(text) {
|
|
17
|
+
if (typeof document === "undefined") return false;
|
|
18
|
+
const textarea = document.createElement("textarea");
|
|
19
|
+
textarea.value = text;
|
|
20
|
+
textarea.readOnly = true;
|
|
21
|
+
textarea.style.position = "fixed";
|
|
22
|
+
textarea.style.top = "-1000px";
|
|
23
|
+
textarea.style.left = "-1000px";
|
|
24
|
+
textarea.style.fontSize = "16px";
|
|
25
|
+
document.body.appendChild(textarea);
|
|
26
|
+
try {
|
|
27
|
+
textarea.select();
|
|
28
|
+
textarea.setSelectionRange(0, text.length);
|
|
29
|
+
return document.execCommand("copy");
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
} finally {
|
|
33
|
+
document.body.removeChild(textarea);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/clipboard/use-copy-feedback.ts
|
|
38
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
39
|
+
function useCopyFeedback({ resetMs = 1500 } = {}) {
|
|
40
|
+
const [status, setStatus] = useState("idle");
|
|
41
|
+
const timerRef = useRef(void 0);
|
|
42
|
+
const clearTimer = useCallback(() => {
|
|
43
|
+
if (timerRef.current !== void 0) clearTimeout(timerRef.current);
|
|
44
|
+
timerRef.current = void 0;
|
|
45
|
+
}, []);
|
|
46
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
47
|
+
const copy = useCallback(
|
|
48
|
+
async (text) => {
|
|
49
|
+
const result = await copyText(text);
|
|
50
|
+
clearTimer();
|
|
51
|
+
setStatus(result.ok ? "copied" : "failed");
|
|
52
|
+
timerRef.current = setTimeout(() => setStatus("idle"), resetMs);
|
|
53
|
+
return result;
|
|
54
|
+
},
|
|
55
|
+
[clearTimer, resetMs]
|
|
56
|
+
);
|
|
57
|
+
return { copy, status };
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
copyText,
|
|
61
|
+
useCopyFeedback
|
|
62
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/drawer-swipe/index.ts
|
|
22
|
+
var drawer_swipe_exports = {};
|
|
23
|
+
__export(drawer_swipe_exports, {
|
|
24
|
+
BACK_ZONE_PX: () => BACK_ZONE_PX,
|
|
25
|
+
CLOSE_DISTANCE_PX: () => CLOSE_DISTANCE_PX,
|
|
26
|
+
FORWARD_ZONE_PX: () => FORWARD_ZONE_PX,
|
|
27
|
+
MAX_SWIPE_DURATION_MS: () => MAX_SWIPE_DURATION_MS,
|
|
28
|
+
OPEN_DISTANCE_PX: () => OPEN_DISTANCE_PX,
|
|
29
|
+
VERTICAL_CANCEL_PX: () => VERTICAL_CANCEL_PX,
|
|
30
|
+
isInOpenZone: () => isInOpenZone,
|
|
31
|
+
isInsideHorizontalScroller: () => isInsideHorizontalScroller,
|
|
32
|
+
resolveSwipeIntent: () => resolveSwipeIntent,
|
|
33
|
+
useDrawerSwipe: () => useDrawerSwipe
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(drawer_swipe_exports);
|
|
36
|
+
|
|
37
|
+
// src/drawer-swipe/swipe-intent.ts
|
|
38
|
+
var BACK_ZONE_PX = 60;
|
|
39
|
+
var FORWARD_ZONE_PX = 30;
|
|
40
|
+
var OPEN_DISTANCE_PX = 80;
|
|
41
|
+
var CLOSE_DISTANCE_PX = 60;
|
|
42
|
+
var VERTICAL_CANCEL_PX = 40;
|
|
43
|
+
var MAX_SWIPE_DURATION_MS = 450;
|
|
44
|
+
var HORIZONTAL_DOMINANCE = 1.5;
|
|
45
|
+
function isInOpenZone(x, viewportWidth) {
|
|
46
|
+
return x > BACK_ZONE_PX && x < viewportWidth - FORWARD_ZONE_PX;
|
|
47
|
+
}
|
|
48
|
+
function resolveSwipeIntent(start, end, drawerOpen, viewportWidth) {
|
|
49
|
+
const dx = end.x - start.x;
|
|
50
|
+
const dy = end.y - start.y;
|
|
51
|
+
if (end.time - start.time > MAX_SWIPE_DURATION_MS) return null;
|
|
52
|
+
if (Math.abs(dx) < Math.abs(dy) * HORIZONTAL_DOMINANCE) return null;
|
|
53
|
+
if (drawerOpen) {
|
|
54
|
+
return dx <= -CLOSE_DISTANCE_PX ? "close" : null;
|
|
55
|
+
}
|
|
56
|
+
if (dx < OPEN_DISTANCE_PX) return null;
|
|
57
|
+
return isInOpenZone(start.x, viewportWidth) ? "open" : null;
|
|
58
|
+
}
|
|
59
|
+
function isInsideHorizontalScroller(target) {
|
|
60
|
+
let node = target instanceof Element ? target : null;
|
|
61
|
+
while (node && node !== document.body) {
|
|
62
|
+
const { overflowX } = window.getComputedStyle(node);
|
|
63
|
+
if ((overflowX === "auto" || overflowX === "scroll") && node.scrollWidth > node.clientWidth) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
node = node.parentElement;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/drawer-swipe/use-drawer-swipe.ts
|
|
72
|
+
var import_react = require("react");
|
|
73
|
+
function isWideViewport(maxViewportWidth) {
|
|
74
|
+
return window.matchMedia(`(min-width: ${maxViewportWidth}px)`).matches;
|
|
75
|
+
}
|
|
76
|
+
function useDrawerSwipe({
|
|
77
|
+
drawerOpen,
|
|
78
|
+
onOpen,
|
|
79
|
+
onClose,
|
|
80
|
+
enabled = true,
|
|
81
|
+
maxViewportWidth = 768
|
|
82
|
+
}) {
|
|
83
|
+
const start = (0, import_react.useRef)(null);
|
|
84
|
+
const handlers = (0, import_react.useRef)({ drawerOpen, onOpen, onClose, maxViewportWidth });
|
|
85
|
+
handlers.current = { drawerOpen, onOpen, onClose, maxViewportWidth };
|
|
86
|
+
(0, import_react.useEffect)(() => {
|
|
87
|
+
if (!enabled) return;
|
|
88
|
+
function handleStart(event) {
|
|
89
|
+
start.current = null;
|
|
90
|
+
if (event.touches.length !== 1) return;
|
|
91
|
+
if (isWideViewport(handlers.current.maxViewportWidth)) return;
|
|
92
|
+
if (!handlers.current.drawerOpen && isInsideHorizontalScroller(event.target)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const touch = event.touches[0];
|
|
96
|
+
if (!touch) return;
|
|
97
|
+
start.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
|
|
98
|
+
}
|
|
99
|
+
function handleMove(event) {
|
|
100
|
+
const origin = start.current;
|
|
101
|
+
const touch = event.touches[0];
|
|
102
|
+
if (!origin || !touch) return;
|
|
103
|
+
if (Math.abs(touch.clientY - origin.y) > VERTICAL_CANCEL_PX) {
|
|
104
|
+
start.current = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function handleEnd(event) {
|
|
108
|
+
const origin = start.current;
|
|
109
|
+
start.current = null;
|
|
110
|
+
const touch = event.changedTouches[0];
|
|
111
|
+
if (!origin || !touch) return;
|
|
112
|
+
const { drawerOpen: drawerOpen2, onOpen: onOpen2, onClose: onClose2 } = handlers.current;
|
|
113
|
+
const intent = resolveSwipeIntent(
|
|
114
|
+
origin,
|
|
115
|
+
{ x: touch.clientX, y: touch.clientY, time: Date.now() },
|
|
116
|
+
drawerOpen2,
|
|
117
|
+
window.innerWidth
|
|
118
|
+
);
|
|
119
|
+
if (intent === "open") onOpen2();
|
|
120
|
+
if (intent === "close") onClose2();
|
|
121
|
+
}
|
|
122
|
+
function handleCancel() {
|
|
123
|
+
start.current = null;
|
|
124
|
+
}
|
|
125
|
+
document.addEventListener("touchstart", handleStart, { passive: true });
|
|
126
|
+
document.addEventListener("touchmove", handleMove, { passive: true });
|
|
127
|
+
document.addEventListener("touchend", handleEnd, { passive: true });
|
|
128
|
+
document.addEventListener("touchcancel", handleCancel, { passive: true });
|
|
129
|
+
return () => {
|
|
130
|
+
document.removeEventListener("touchstart", handleStart);
|
|
131
|
+
document.removeEventListener("touchmove", handleMove);
|
|
132
|
+
document.removeEventListener("touchend", handleEnd);
|
|
133
|
+
document.removeEventListener("touchcancel", handleCancel);
|
|
134
|
+
};
|
|
135
|
+
}, [enabled]);
|
|
136
|
+
}
|
|
137
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
138
|
+
0 && (module.exports = {
|
|
139
|
+
BACK_ZONE_PX,
|
|
140
|
+
CLOSE_DISTANCE_PX,
|
|
141
|
+
FORWARD_ZONE_PX,
|
|
142
|
+
MAX_SWIPE_DURATION_MS,
|
|
143
|
+
OPEN_DISTANCE_PX,
|
|
144
|
+
VERTICAL_CANCEL_PX,
|
|
145
|
+
isInOpenZone,
|
|
146
|
+
isInsideHorizontalScroller,
|
|
147
|
+
resolveSwipeIntent,
|
|
148
|
+
useDrawerSwipe
|
|
149
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type SwipePoint = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
time: number;
|
|
5
|
+
};
|
|
6
|
+
declare const BACK_ZONE_PX = 60;
|
|
7
|
+
declare const FORWARD_ZONE_PX = 30;
|
|
8
|
+
declare const OPEN_DISTANCE_PX = 80;
|
|
9
|
+
declare const CLOSE_DISTANCE_PX = 60;
|
|
10
|
+
declare const VERTICAL_CANCEL_PX = 40;
|
|
11
|
+
declare const MAX_SWIPE_DURATION_MS = 450;
|
|
12
|
+
type SwipeIntent = 'open' | 'close' | null;
|
|
13
|
+
declare function isInOpenZone(x: number, viewportWidth: number): boolean;
|
|
14
|
+
declare function resolveSwipeIntent(start: SwipePoint, end: SwipePoint, drawerOpen: boolean, viewportWidth: number): SwipeIntent;
|
|
15
|
+
declare function isInsideHorizontalScroller(target: EventTarget | null): boolean;
|
|
16
|
+
|
|
17
|
+
type UseDrawerSwipeOptions = {
|
|
18
|
+
drawerOpen: boolean;
|
|
19
|
+
onOpen: () => void;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
enabled?: boolean;
|
|
22
|
+
maxViewportWidth?: number;
|
|
23
|
+
};
|
|
24
|
+
declare function useDrawerSwipe({ drawerOpen, onOpen, onClose, enabled, maxViewportWidth, }: UseDrawerSwipeOptions): void;
|
|
25
|
+
|
|
26
|
+
export { BACK_ZONE_PX, CLOSE_DISTANCE_PX, FORWARD_ZONE_PX, MAX_SWIPE_DURATION_MS, OPEN_DISTANCE_PX, type SwipeIntent, type SwipePoint, type UseDrawerSwipeOptions, VERTICAL_CANCEL_PX, isInOpenZone, isInsideHorizontalScroller, resolveSwipeIntent, useDrawerSwipe };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type SwipePoint = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
time: number;
|
|
5
|
+
};
|
|
6
|
+
declare const BACK_ZONE_PX = 60;
|
|
7
|
+
declare const FORWARD_ZONE_PX = 30;
|
|
8
|
+
declare const OPEN_DISTANCE_PX = 80;
|
|
9
|
+
declare const CLOSE_DISTANCE_PX = 60;
|
|
10
|
+
declare const VERTICAL_CANCEL_PX = 40;
|
|
11
|
+
declare const MAX_SWIPE_DURATION_MS = 450;
|
|
12
|
+
type SwipeIntent = 'open' | 'close' | null;
|
|
13
|
+
declare function isInOpenZone(x: number, viewportWidth: number): boolean;
|
|
14
|
+
declare function resolveSwipeIntent(start: SwipePoint, end: SwipePoint, drawerOpen: boolean, viewportWidth: number): SwipeIntent;
|
|
15
|
+
declare function isInsideHorizontalScroller(target: EventTarget | null): boolean;
|
|
16
|
+
|
|
17
|
+
type UseDrawerSwipeOptions = {
|
|
18
|
+
drawerOpen: boolean;
|
|
19
|
+
onOpen: () => void;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
enabled?: boolean;
|
|
22
|
+
maxViewportWidth?: number;
|
|
23
|
+
};
|
|
24
|
+
declare function useDrawerSwipe({ drawerOpen, onOpen, onClose, enabled, maxViewportWidth, }: UseDrawerSwipeOptions): void;
|
|
25
|
+
|
|
26
|
+
export { BACK_ZONE_PX, CLOSE_DISTANCE_PX, FORWARD_ZONE_PX, MAX_SWIPE_DURATION_MS, OPEN_DISTANCE_PX, type SwipeIntent, type SwipePoint, type UseDrawerSwipeOptions, VERTICAL_CANCEL_PX, isInOpenZone, isInsideHorizontalScroller, resolveSwipeIntent, useDrawerSwipe };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/drawer-swipe/swipe-intent.ts
|
|
4
|
+
var BACK_ZONE_PX = 60;
|
|
5
|
+
var FORWARD_ZONE_PX = 30;
|
|
6
|
+
var OPEN_DISTANCE_PX = 80;
|
|
7
|
+
var CLOSE_DISTANCE_PX = 60;
|
|
8
|
+
var VERTICAL_CANCEL_PX = 40;
|
|
9
|
+
var MAX_SWIPE_DURATION_MS = 450;
|
|
10
|
+
var HORIZONTAL_DOMINANCE = 1.5;
|
|
11
|
+
function isInOpenZone(x, viewportWidth) {
|
|
12
|
+
return x > BACK_ZONE_PX && x < viewportWidth - FORWARD_ZONE_PX;
|
|
13
|
+
}
|
|
14
|
+
function resolveSwipeIntent(start, end, drawerOpen, viewportWidth) {
|
|
15
|
+
const dx = end.x - start.x;
|
|
16
|
+
const dy = end.y - start.y;
|
|
17
|
+
if (end.time - start.time > MAX_SWIPE_DURATION_MS) return null;
|
|
18
|
+
if (Math.abs(dx) < Math.abs(dy) * HORIZONTAL_DOMINANCE) return null;
|
|
19
|
+
if (drawerOpen) {
|
|
20
|
+
return dx <= -CLOSE_DISTANCE_PX ? "close" : null;
|
|
21
|
+
}
|
|
22
|
+
if (dx < OPEN_DISTANCE_PX) return null;
|
|
23
|
+
return isInOpenZone(start.x, viewportWidth) ? "open" : null;
|
|
24
|
+
}
|
|
25
|
+
function isInsideHorizontalScroller(target) {
|
|
26
|
+
let node = target instanceof Element ? target : null;
|
|
27
|
+
while (node && node !== document.body) {
|
|
28
|
+
const { overflowX } = window.getComputedStyle(node);
|
|
29
|
+
if ((overflowX === "auto" || overflowX === "scroll") && node.scrollWidth > node.clientWidth) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
node = node.parentElement;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/drawer-swipe/use-drawer-swipe.ts
|
|
38
|
+
import { useEffect, useRef } from "react";
|
|
39
|
+
function isWideViewport(maxViewportWidth) {
|
|
40
|
+
return window.matchMedia(`(min-width: ${maxViewportWidth}px)`).matches;
|
|
41
|
+
}
|
|
42
|
+
function useDrawerSwipe({
|
|
43
|
+
drawerOpen,
|
|
44
|
+
onOpen,
|
|
45
|
+
onClose,
|
|
46
|
+
enabled = true,
|
|
47
|
+
maxViewportWidth = 768
|
|
48
|
+
}) {
|
|
49
|
+
const start = useRef(null);
|
|
50
|
+
const handlers = useRef({ drawerOpen, onOpen, onClose, maxViewportWidth });
|
|
51
|
+
handlers.current = { drawerOpen, onOpen, onClose, maxViewportWidth };
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!enabled) return;
|
|
54
|
+
function handleStart(event) {
|
|
55
|
+
start.current = null;
|
|
56
|
+
if (event.touches.length !== 1) return;
|
|
57
|
+
if (isWideViewport(handlers.current.maxViewportWidth)) return;
|
|
58
|
+
if (!handlers.current.drawerOpen && isInsideHorizontalScroller(event.target)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const touch = event.touches[0];
|
|
62
|
+
if (!touch) return;
|
|
63
|
+
start.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
|
|
64
|
+
}
|
|
65
|
+
function handleMove(event) {
|
|
66
|
+
const origin = start.current;
|
|
67
|
+
const touch = event.touches[0];
|
|
68
|
+
if (!origin || !touch) return;
|
|
69
|
+
if (Math.abs(touch.clientY - origin.y) > VERTICAL_CANCEL_PX) {
|
|
70
|
+
start.current = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function handleEnd(event) {
|
|
74
|
+
const origin = start.current;
|
|
75
|
+
start.current = null;
|
|
76
|
+
const touch = event.changedTouches[0];
|
|
77
|
+
if (!origin || !touch) return;
|
|
78
|
+
const { drawerOpen: drawerOpen2, onOpen: onOpen2, onClose: onClose2 } = handlers.current;
|
|
79
|
+
const intent = resolveSwipeIntent(
|
|
80
|
+
origin,
|
|
81
|
+
{ x: touch.clientX, y: touch.clientY, time: Date.now() },
|
|
82
|
+
drawerOpen2,
|
|
83
|
+
window.innerWidth
|
|
84
|
+
);
|
|
85
|
+
if (intent === "open") onOpen2();
|
|
86
|
+
if (intent === "close") onClose2();
|
|
87
|
+
}
|
|
88
|
+
function handleCancel() {
|
|
89
|
+
start.current = null;
|
|
90
|
+
}
|
|
91
|
+
document.addEventListener("touchstart", handleStart, { passive: true });
|
|
92
|
+
document.addEventListener("touchmove", handleMove, { passive: true });
|
|
93
|
+
document.addEventListener("touchend", handleEnd, { passive: true });
|
|
94
|
+
document.addEventListener("touchcancel", handleCancel, { passive: true });
|
|
95
|
+
return () => {
|
|
96
|
+
document.removeEventListener("touchstart", handleStart);
|
|
97
|
+
document.removeEventListener("touchmove", handleMove);
|
|
98
|
+
document.removeEventListener("touchend", handleEnd);
|
|
99
|
+
document.removeEventListener("touchcancel", handleCancel);
|
|
100
|
+
};
|
|
101
|
+
}, [enabled]);
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
BACK_ZONE_PX,
|
|
105
|
+
CLOSE_DISTANCE_PX,
|
|
106
|
+
FORWARD_ZONE_PX,
|
|
107
|
+
MAX_SWIPE_DURATION_MS,
|
|
108
|
+
OPEN_DISTANCE_PX,
|
|
109
|
+
VERTICAL_CANCEL_PX,
|
|
110
|
+
isInOpenZone,
|
|
111
|
+
isInsideHorizontalScroller,
|
|
112
|
+
resolveSwipeIntent,
|
|
113
|
+
useDrawerSwipe
|
|
114
|
+
};
|
package/dist/escape.cjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/escape/index.ts
|
|
22
|
+
var escape_exports = {};
|
|
23
|
+
__export(escape_exports, {
|
|
24
|
+
useEscape: () => useEscape
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(escape_exports);
|
|
27
|
+
|
|
28
|
+
// src/escape/use-escape.ts
|
|
29
|
+
var import_react = require("react");
|
|
30
|
+
function useEscape(onEscape, active = true) {
|
|
31
|
+
const handlerRef = (0, import_react.useRef)(onEscape);
|
|
32
|
+
(0, import_react.useEffect)(() => {
|
|
33
|
+
handlerRef.current = onEscape;
|
|
34
|
+
});
|
|
35
|
+
(0, import_react.useEffect)(() => {
|
|
36
|
+
if (!active) return;
|
|
37
|
+
function onKeyDown(event) {
|
|
38
|
+
if (event.key === "Escape") handlerRef.current();
|
|
39
|
+
}
|
|
40
|
+
document.addEventListener("keydown", onKeyDown);
|
|
41
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
42
|
+
}, [active]);
|
|
43
|
+
}
|
|
44
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
45
|
+
0 && (module.exports = {
|
|
46
|
+
useEscape
|
|
47
|
+
});
|
package/dist/escape.d.ts
ADDED