@develemit/web-hooks 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/CHANGELOG.md +16 -0
- package/README.md +166 -0
- package/dist/auto-grow.cjs +60 -0
- package/dist/auto-grow.d.cts +14 -0
- package/dist/auto-grow.d.ts +14 -0
- package/dist/auto-grow.js +9 -0
- package/dist/chunk-3CACI4OQ.js +68 -0
- package/dist/chunk-3UGRXQIA.js +39 -0
- package/dist/chunk-5CA5RIMH.js +34 -0
- package/dist/chunk-5KIH2XLV.js +136 -0
- package/dist/chunk-6KCRVSLS.js +80 -0
- package/dist/chunk-DNU57XEJ.js +82 -0
- package/dist/chunk-IWQDAJEP.js +69 -0
- package/dist/chunk-LZKAGDER.js +22 -0
- package/dist/chunk-NKLEB5TR.js +98 -0
- package/dist/chunk-OZ4PU4E3.js +115 -0
- package/dist/chunk-S4YKMGQE.js +23 -0
- package/dist/chunk-UPVNSGIK.js +72 -0
- package/dist/chunk-UZADAINO.js +61 -0
- package/dist/clipboard.cjs +5 -0
- package/dist/clipboard.js +4 -57
- package/dist/debounce.cjs +96 -0
- package/dist/debounce.d.cts +21 -0
- package/dist/debounce.d.ts +21 -0
- package/dist/debounce.js +11 -0
- package/dist/drawer-swipe.js +12 -101
- package/dist/escape.cjs +1 -0
- package/dist/escape.js +3 -17
- package/dist/focus-trap.cjs +3 -1
- package/dist/focus-trap.js +6 -72
- package/dist/hotkey.cjs +127 -0
- package/dist/hotkey.d.cts +31 -0
- package/dist/hotkey.d.ts +31 -0
- package/dist/hotkey.js +15 -0
- package/dist/index.cjs +613 -3
- package/dist/index.d.cts +14 -60
- package/dist/index.d.ts +14 -60
- package/dist/index.js +97 -247
- package/dist/media-query.cjs +90 -0
- package/dist/media-query.d.cts +12 -0
- package/dist/media-query.d.ts +12 -0
- package/dist/media-query.js +15 -0
- package/dist/outside-press.cjs +66 -0
- package/dist/outside-press.d.cts +15 -0
- package/dist/outside-press.d.ts +15 -0
- package/dist/outside-press.js +11 -0
- package/dist/scroll-lock.cjs +107 -0
- package/dist/scroll-lock.d.cts +8 -0
- package/dist/scroll-lock.d.ts +8 -0
- package/dist/scroll-lock.js +11 -0
- package/dist/storage-state.cjs +161 -0
- package/dist/storage-state.d.cts +14 -0
- package/dist/storage-state.d.ts +14 -0
- package/dist/storage-state.js +7 -0
- package/dist/unsaved-guard.cjs +48 -0
- package/dist/unsaved-guard.d.cts +5 -0
- package/dist/unsaved-guard.d.ts +5 -0
- package/dist/unsaved-guard.js +9 -0
- package/dist/visibility-polling.cjs +98 -0
- package/dist/visibility-polling.d.cts +14 -0
- package/dist/visibility-polling.d.ts +14 -0
- package/dist/visibility-polling.js +9 -0
- package/package.json +55 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-11-05
|
|
4
|
+
|
|
5
|
+
- `unsaved-guard`: `useUnsavedChangesGuard(dirty)` and the pure `handleBeforeUnload`. Attached only while dirty and sets `returnValue = ''` as well as `preventDefault()`, which older Safari and Chrome need. `beforeunload` only; in-app navigation stays in the app.
|
|
6
|
+
- `hotkey`: `useHotkey('mod+k', fn, { enabled, allowInInputs, preventDefault })` and the pure `parseHotkey`, `matchHotkey`, `detectApple`. Ignores case and Caps Lock, `mod` is Cmd on Apple and Ctrl elsewhere, exact modifier match, text fields skipped by default.
|
|
7
|
+
- `auto-grow`: `useAutoGrowTextarea(ref, value, { maxHeight })` and the pure `nextHeight`. Re-measures on value change and on `visualViewport` resize (keyboard), with a `window` fallback; `overflowY: auto` only when capped.
|
|
8
|
+
- `debounce`: `useDebouncedValue(value, ms)`, `useDebouncedCallback(fn, ms)` (returns a stable function with `cancel` and `flush`, cancels on unmount, always calls the latest `fn`) and the pure `createDebouncer`.
|
|
9
|
+
- `storage-state`: `useStorageState(key, defaultValue, { storage, serialize, parse })` returning `[value, set, remove]`. Built on `useSyncExternalStore`, so the first client render matches the server (the default) with no hydration mismatch; stays in sync across hooks and tabs; bad JSON gives the default; a refused `setItem` (quota, Safari private mode) is kept in memory so the UI still updates.
|
|
10
|
+
- `visibility-polling`: `useVisibilityPolling(fn, intervalMs, { enabled, immediate, jitter, runOnVisible })` and the pure `nextDelay`. Chained timeouts so a slow run never overlaps, paused while the page is hidden (a locked phone makes no calls), one run on becoming visible, and the listener is always removed on unmount.
|
|
11
|
+
- `outside-press`: `useOutsidePress(refs, onOutsidePress, { enabled })` and the pure `isOutside`. Listens to `pointerdown` in the capture phase so taps on non-clickable elements close menus on iPhone, only while enabled, with the trigger and portalled panels as inside refs.
|
|
12
|
+
- `media-query`: `useMediaQuery(query, { serverDefault })`, `useReducedMotion` and `useCoarsePointer`. Correct on the first client render, no hydration mismatch, safe without `matchMedia`, and one shared `MediaQueryList` per query.
|
|
13
|
+
- `scroll-lock`: `useScrollLock` and `lockScroll()` / `unlockScroll()`. Counted locks, `position: fixed` so iOS Safari and standalone PWAs stop scrolling the background, exact restore of previous inline styles and scroll position, scrollbar-width padding on desktop.
|
|
14
|
+
- `clipboard`: the `execCommand` fallback now puts focus back on the element that had it (and never scrolls to the temporary textarea). It used to leave `<body>` focused after a copy on plain-http PWAs.
|
|
15
|
+
- `focus-trap`: under StrictMode the captured opener was dropped by the simulated effect cleanup, so closing a trap (e.g. a dialog stacked in an open nav) left focus on `<body>`. The opener is now cleared when the trap deactivates.
|
|
16
|
+
- `escape`: `useEscape` ignores Escape pressed during IME composition (`isComposing` or `keyCode` 229), so cancelling a composition no longer closes the menu.
|
|
17
|
+
- The root `@develemit/web-hooks` entry now carries the `"use client"` banner like the subpath entries, so a server component importing from the root fails loudly in Next.
|
|
18
|
+
|
|
3
19
|
## 0.1.0
|
|
4
20
|
|
|
5
21
|
- `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.
|
package/README.md
CHANGED
|
@@ -9,6 +9,15 @@ that every app ends up hand-copying from the last one.
|
|
|
9
9
|
| `@develemit/web-hooks/focus-trap` | Keep Tab focus inside an open dialog or drawer, restore it on close |
|
|
10
10
|
| `@develemit/web-hooks/escape` | Run a callback when Escape is pressed |
|
|
11
11
|
| `@develemit/web-hooks/clipboard` | Copy text, with the fallback that works on plain-http PWAs |
|
|
12
|
+
| `@develemit/web-hooks/scroll-lock` | Lock page scroll behind a modal or drawer, counted, and it works on iOS |
|
|
13
|
+
| `@develemit/web-hooks/media-query` | Track a media query, reduced motion or a coarse pointer, with no hydration mismatch |
|
|
14
|
+
| `@develemit/web-hooks/outside-press` | Close a menu or popover on a press outside it, including taps on iPhone |
|
|
15
|
+
| `@develemit/web-hooks/visibility-polling` | Poll on an interval that pauses while the page is hidden |
|
|
16
|
+
| `@develemit/web-hooks/debounce` | Debounce a value or a callback |
|
|
17
|
+
| `@develemit/web-hooks/storage-state` | State persisted to localStorage or sessionStorage, SSR-safe and synced across tabs |
|
|
18
|
+
| `@develemit/web-hooks/unsaved-guard` | Warn before the tab closes while a form is dirty |
|
|
19
|
+
| `@develemit/web-hooks/hotkey` | Keyboard shortcuts such as `mod+k` |
|
|
20
|
+
| `@develemit/web-hooks/auto-grow` | Grow a textarea with its content, keyboard-aware |
|
|
12
21
|
|
|
13
22
|
Behavior only. No styling and no components: each app keeps its own look.
|
|
14
23
|
|
|
@@ -74,6 +83,115 @@ const { copy, status } = useCopyFeedback({ resetMs: 1500 });
|
|
|
74
83
|
rejects. On plain-http origins (a PWA over a Tailscale address)
|
|
75
84
|
`navigator.clipboard` doesn't exist, so it falls back to `execCommand('copy')`.
|
|
76
85
|
|
|
86
|
+
### Lock page scroll behind an overlay
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { useScrollLock } from '@develemit/web-hooks/scroll-lock';
|
|
90
|
+
|
|
91
|
+
useScrollLock(open);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Uses `position: fixed` on `body` because iOS Safari ignores `overflow: hidden`
|
|
95
|
+
for touch scrolling. Locks are counted, so a lightbox closing inside an open
|
|
96
|
+
drawer leaves the page locked. When the last lock releases, the exact previous
|
|
97
|
+
inline styles and the scroll position come back. `lockScroll()` returns a
|
|
98
|
+
release function (safe to call twice) for use outside React.
|
|
99
|
+
|
|
100
|
+
### Match a media query
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { useMediaQuery, useReducedMotion, useCoarsePointer } from '@develemit/web-hooks/media-query';
|
|
104
|
+
|
|
105
|
+
const narrow = useMediaQuery('(max-width: 767px)', { serverDefault: false });
|
|
106
|
+
const reduce = useReducedMotion();
|
|
107
|
+
const touch = useCoarsePointer();
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Built on `useSyncExternalStore`, so the first client render already has the
|
|
111
|
+
real value (no desktop frame on a phone). The server render and hydration use
|
|
112
|
+
`serverDefault` (default `false`); React then re-renders with the real value
|
|
113
|
+
without a mismatch warning. There are no built-in breakpoints: pass your own
|
|
114
|
+
query. Where `matchMedia` is missing (jsdom tests) the hook returns
|
|
115
|
+
`serverDefault` instead of throwing. Hooks with the same query share one
|
|
116
|
+
`MediaQueryList`, and Safari before 14 is supported through `addListener`.
|
|
117
|
+
|
|
118
|
+
### Close on an outside press
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { useOutsidePress } from '@develemit/web-hooks/outside-press';
|
|
122
|
+
import { useEscape } from '@develemit/web-hooks/escape';
|
|
123
|
+
|
|
124
|
+
const menu = useRef<HTMLDivElement>(null);
|
|
125
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
126
|
+
useOutsidePress([menu, trigger], () => setOpen(false), { enabled: open });
|
|
127
|
+
useEscape(() => setOpen(false), open);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Listens to `pointerdown` on `document` in the capture phase, only while
|
|
131
|
+
`enabled`. `mousedown` never fires on iOS for a tap on a non-clickable element,
|
|
132
|
+
so copies built on it leave the menu open. Pass the trigger as well as the
|
|
133
|
+
panel, or tapping the trigger closes and instantly reopens the menu. Pass a
|
|
134
|
+
portalled panel's ref too. Right clicks are ignored, and a target removed from
|
|
135
|
+
the DOM during the event counts as inside. The callback is read through a ref,
|
|
136
|
+
so it needs no `useCallback`.
|
|
137
|
+
|
|
138
|
+
### visibility-polling
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { useVisibilityPolling } from '@develemit/web-hooks/visibility-polling';
|
|
142
|
+
|
|
143
|
+
useVisibilityPolling(() => refresh(), 5000, { jitter: 0.2 });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Runs `fn` every `intervalMs` only while the page is visible. When the tab is
|
|
147
|
+
hidden (a locked phone) the timer is cleared; when it is visible again `fn`
|
|
148
|
+
runs once right away and the schedule resumes. The next run is scheduled after
|
|
149
|
+
the previous one finishes, so a slow promise never overlaps. Options:
|
|
150
|
+
`enabled` (default true), `immediate` (run on mount, default true), `jitter`
|
|
151
|
+
(0–1 fraction of the interval added at random, default 0) and `runOnVisible`
|
|
152
|
+
(default true). `fn` is read through a ref. A throw or rejection doesn't stop
|
|
153
|
+
the schedule; handle errors inside `fn`. It decides when to run, not caching
|
|
154
|
+
or dedupe.
|
|
155
|
+
|
|
156
|
+
### debounce
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { useDebouncedValue, useDebouncedCallback } from '@develemit/web-hooks/debounce';
|
|
160
|
+
|
|
161
|
+
const debouncedSearch = useDebouncedValue(search, 300);
|
|
162
|
+
|
|
163
|
+
const run = useDebouncedCallback((q: string) => fetchResults(q), 300);
|
|
164
|
+
run('inv'); // later calls reset the timer; only the last runs
|
|
165
|
+
run.cancel(); // drop the pending call
|
|
166
|
+
run.flush(); // run it now
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`useDebouncedValue` returns the value once it has stopped changing for `ms`.
|
|
170
|
+
`useDebouncedCallback` returns one function for the life of the component; it
|
|
171
|
+
always calls the latest `fn` and `ms`, so it needs no `useCallback`, and a
|
|
172
|
+
pending call is cancelled on unmount.
|
|
173
|
+
|
|
174
|
+
### storage-state
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { useStorageState } from '@develemit/web-hooks/storage-state';
|
|
178
|
+
|
|
179
|
+
const [recent, setRecent, clearRecent] = useStorageState<string[]>('recent', []);
|
|
180
|
+
setRecent((prev) => ['pnpm dev', ...prev]);
|
|
181
|
+
const [draft, setDraft] = useStorageState('draft', '', { storage: 'session' });
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The server and the first client render both return `defaultValue`; the stored
|
|
185
|
+
value arrives right after hydration, so there is no mismatch. Hooks with the
|
|
186
|
+
same key stay in sync in this tab, and other tabs through the `storage` event.
|
|
187
|
+
The setter takes a value or an updater. Options: `storage` (`'local'` default,
|
|
188
|
+
or `'session'`), `serialize` and `parse` (JSON by default; a `parse` that
|
|
189
|
+
throws gives `defaultValue`, so legacy non-JSON values and validators both
|
|
190
|
+
work). A refused `setItem` (full quota, Safari private mode) never throws: the
|
|
191
|
+
new value is kept in memory for the rest of the page's life and the UI
|
|
192
|
+
updates. Keep `defaultValue` stable (a constant or a primitive); it is read
|
|
193
|
+
again only when the stored value or key changes.
|
|
194
|
+
|
|
77
195
|
### Migrating from the per-app copies
|
|
78
196
|
|
|
79
197
|
| App | Old | New |
|
|
@@ -82,3 +200,51 @@ rejects. On plain-http origins (a PWA over a Tailscale address)
|
|
|
82
200
|
| emit-social | `useFocusTrap(ref, { active, returnFocusRef })` plus `useEscape(fn, active)` | Same signatures. |
|
|
83
201
|
| develemit-commerce | `const onKeyDown = useFocusTrap(ref, active, onEscape)` | `useFocusTrap(ref, { active })` and `useEscape(onEscape, active)`. Drop the returned `onKeyDown` handler. |
|
|
84
202
|
| centraflow | `const ref = useFocusTrap(active)` | `const ref = useRef<HTMLElement>(null)`, then `useFocusTrap(ref, { active })`. |
|
|
203
|
+
|
|
204
|
+
### unsaved-guard
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
import { useUnsavedChangesGuard } from '@develemit/web-hooks/unsaved-guard';
|
|
208
|
+
|
|
209
|
+
useUnsavedChangesGuard(isDirty);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
While `dirty` is true the browser asks before the tab closes or reloads. It
|
|
213
|
+
sets both `preventDefault()` and `returnValue = ''`, because older Safari and
|
|
214
|
+
Chrome ignore `preventDefault()` alone. This covers `beforeunload` only:
|
|
215
|
+
Next's App Router has no stable way to block in-app navigation, so that stays
|
|
216
|
+
in the app.
|
|
217
|
+
|
|
218
|
+
### hotkey
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
import { useHotkey, matchHotkey } from '@develemit/web-hooks/hotkey';
|
|
222
|
+
|
|
223
|
+
useHotkey('mod+k', () => setOpen((o) => !o));
|
|
224
|
+
useHotkey('mod+k', toggle, { allowInInputs: true, preventDefault: false });
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Combos are modifiers (`mod`, `ctrl`, `meta`, `alt`, `shift`) then one key:
|
|
228
|
+
`mod+k`, `shift+/`, `escape`. `mod` is Cmd on Apple devices (iPad in desktop
|
|
229
|
+
mode included) and Ctrl elsewhere. Case and Caps Lock don't matter, and the
|
|
230
|
+
modifier set must match exactly. Text fields (`input`, `textarea`, `select`,
|
|
231
|
+
contenteditable) are skipped unless `allowInInputs`. Options: `enabled`
|
|
232
|
+
(default true), `allowInInputs` (false), `preventDefault` (true). `matchHotkey(event,
|
|
233
|
+
combo, isApple)` is the pure matcher; `detectApple(navigator)` supplies
|
|
234
|
+
`isApple`. No key sequences.
|
|
235
|
+
|
|
236
|
+
### auto-grow
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
import { useAutoGrowTextarea } from '@develemit/web-hooks/auto-grow';
|
|
240
|
+
|
|
241
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
242
|
+
useAutoGrowTextarea(ref, value, { maxHeight: 240 });
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Resizes the textarea to fit its content on every `value` change and whenever
|
|
246
|
+
the visual viewport resizes (the iPhone keyboard opening or closing), falling
|
|
247
|
+
back to `window` `resize`. At `maxHeight` it stops growing and sets
|
|
248
|
+
`overflowY: auto`. The pure `nextHeight(scrollHeight, maxHeight)` does the
|
|
249
|
+
clamping.
|
|
250
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
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/auto-grow/index.ts
|
|
22
|
+
var auto_grow_exports = {};
|
|
23
|
+
__export(auto_grow_exports, {
|
|
24
|
+
nextHeight: () => nextHeight,
|
|
25
|
+
useAutoGrowTextarea: () => useAutoGrowTextarea
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(auto_grow_exports);
|
|
28
|
+
|
|
29
|
+
// src/auto-grow/next-height.ts
|
|
30
|
+
function nextHeight(scrollHeight, maxHeight) {
|
|
31
|
+
const content = Math.ceil(scrollHeight);
|
|
32
|
+
if (maxHeight !== void 0 && content > maxHeight) return { height: maxHeight, capped: true };
|
|
33
|
+
return { height: content, capped: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/auto-grow/use-auto-grow-textarea.ts
|
|
37
|
+
var import_react = require("react");
|
|
38
|
+
var useIsomorphicLayoutEffect = typeof window === "undefined" ? import_react.useEffect : import_react.useLayoutEffect;
|
|
39
|
+
function useAutoGrowTextarea(ref, value, { maxHeight } = {}) {
|
|
40
|
+
const resize = (0, import_react.useCallback)(() => {
|
|
41
|
+
const el = ref.current;
|
|
42
|
+
if (!el) return;
|
|
43
|
+
el.style.height = "auto";
|
|
44
|
+
const { height, capped } = nextHeight(el.scrollHeight, maxHeight);
|
|
45
|
+
el.style.height = `${height}px`;
|
|
46
|
+
el.style.overflowY = capped ? "auto" : "hidden";
|
|
47
|
+
}, [ref, maxHeight]);
|
|
48
|
+
useIsomorphicLayoutEffect(resize, [resize, value]);
|
|
49
|
+
(0, import_react.useEffect)(() => {
|
|
50
|
+
const viewport = window.visualViewport;
|
|
51
|
+
const target = viewport ?? window;
|
|
52
|
+
target.addEventListener("resize", resize);
|
|
53
|
+
return () => target.removeEventListener("resize", resize);
|
|
54
|
+
}, [resize]);
|
|
55
|
+
}
|
|
56
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
57
|
+
0 && (module.exports = {
|
|
58
|
+
nextHeight,
|
|
59
|
+
useAutoGrowTextarea
|
|
60
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
interface NextHeight {
|
|
4
|
+
height: number;
|
|
5
|
+
capped: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare function nextHeight(scrollHeight: number, maxHeight?: number): NextHeight;
|
|
8
|
+
|
|
9
|
+
interface UseAutoGrowTextareaOptions {
|
|
10
|
+
maxHeight?: number;
|
|
11
|
+
}
|
|
12
|
+
declare function useAutoGrowTextarea(ref: RefObject<HTMLTextAreaElement | null>, value: string, { maxHeight }?: UseAutoGrowTextareaOptions): void;
|
|
13
|
+
|
|
14
|
+
export { type NextHeight, type UseAutoGrowTextareaOptions, nextHeight, useAutoGrowTextarea };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
interface NextHeight {
|
|
4
|
+
height: number;
|
|
5
|
+
capped: boolean;
|
|
6
|
+
}
|
|
7
|
+
declare function nextHeight(scrollHeight: number, maxHeight?: number): NextHeight;
|
|
8
|
+
|
|
9
|
+
interface UseAutoGrowTextareaOptions {
|
|
10
|
+
maxHeight?: number;
|
|
11
|
+
}
|
|
12
|
+
declare function useAutoGrowTextarea(ref: RefObject<HTMLTextAreaElement | null>, value: string, { maxHeight }?: UseAutoGrowTextareaOptions): void;
|
|
13
|
+
|
|
14
|
+
export { type NextHeight, type UseAutoGrowTextareaOptions, nextHeight, useAutoGrowTextarea };
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
const previouslyFocused = document.activeElement;
|
|
26
|
+
document.body.appendChild(textarea);
|
|
27
|
+
try {
|
|
28
|
+
textarea.focus({ preventScroll: true });
|
|
29
|
+
textarea.select();
|
|
30
|
+
textarea.setSelectionRange(0, text.length);
|
|
31
|
+
return document.execCommand("copy");
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
} finally {
|
|
35
|
+
document.body.removeChild(textarea);
|
|
36
|
+
if (previouslyFocused instanceof HTMLElement && previouslyFocused !== document.body) {
|
|
37
|
+
previouslyFocused.focus({ preventScroll: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/clipboard/use-copy-feedback.ts
|
|
43
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
44
|
+
function useCopyFeedback({ resetMs = 1500 } = {}) {
|
|
45
|
+
const [status, setStatus] = useState("idle");
|
|
46
|
+
const timerRef = useRef(void 0);
|
|
47
|
+
const clearTimer = useCallback(() => {
|
|
48
|
+
if (timerRef.current !== void 0) clearTimeout(timerRef.current);
|
|
49
|
+
timerRef.current = void 0;
|
|
50
|
+
}, []);
|
|
51
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
52
|
+
const copy = useCallback(
|
|
53
|
+
async (text) => {
|
|
54
|
+
const result = await copyText(text);
|
|
55
|
+
clearTimer();
|
|
56
|
+
setStatus(result.ok ? "copied" : "failed");
|
|
57
|
+
timerRef.current = setTimeout(() => setStatus("idle"), resetMs);
|
|
58
|
+
return result;
|
|
59
|
+
},
|
|
60
|
+
[clearTimer, resetMs]
|
|
61
|
+
);
|
|
62
|
+
return { copy, status };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
copyText,
|
|
67
|
+
useCopyFeedback
|
|
68
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/outside-press/outside-press.ts
|
|
4
|
+
function isOutside(target, elements) {
|
|
5
|
+
if (!target) return false;
|
|
6
|
+
if (!target.isConnected) return false;
|
|
7
|
+
return !elements.some((el) => el?.contains(target));
|
|
8
|
+
}
|
|
9
|
+
function isPrimaryPress(event) {
|
|
10
|
+
return !(event.pointerType === "mouse" && event.button !== 0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/outside-press/use-outside-press.ts
|
|
14
|
+
import { useEffect, useRef } from "react";
|
|
15
|
+
function useOutsidePress(refs, onOutsidePress, { enabled = true } = {}) {
|
|
16
|
+
const refsRef = useRef(refs);
|
|
17
|
+
const handlerRef = useRef(onOutsidePress);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
refsRef.current = refs;
|
|
20
|
+
handlerRef.current = onOutsidePress;
|
|
21
|
+
});
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!enabled) return;
|
|
24
|
+
function onPointerDown(event) {
|
|
25
|
+
if (!isPrimaryPress(event)) return;
|
|
26
|
+
const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];
|
|
27
|
+
const elements = list.map((ref) => ref.current);
|
|
28
|
+
if (isOutside(event.target, elements)) handlerRef.current(event);
|
|
29
|
+
}
|
|
30
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
31
|
+
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
|
32
|
+
}, [enabled]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
isOutside,
|
|
37
|
+
isPrimaryPress,
|
|
38
|
+
useOutsidePress
|
|
39
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/auto-grow/next-height.ts
|
|
4
|
+
function nextHeight(scrollHeight, maxHeight) {
|
|
5
|
+
const content = Math.ceil(scrollHeight);
|
|
6
|
+
if (maxHeight !== void 0 && content > maxHeight) return { height: maxHeight, capped: true };
|
|
7
|
+
return { height: content, capped: false };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/auto-grow/use-auto-grow-textarea.ts
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect } from "react";
|
|
12
|
+
var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
13
|
+
function useAutoGrowTextarea(ref, value, { maxHeight } = {}) {
|
|
14
|
+
const resize = useCallback(() => {
|
|
15
|
+
const el = ref.current;
|
|
16
|
+
if (!el) return;
|
|
17
|
+
el.style.height = "auto";
|
|
18
|
+
const { height, capped } = nextHeight(el.scrollHeight, maxHeight);
|
|
19
|
+
el.style.height = `${height}px`;
|
|
20
|
+
el.style.overflowY = capped ? "auto" : "hidden";
|
|
21
|
+
}, [ref, maxHeight]);
|
|
22
|
+
useIsomorphicLayoutEffect(resize, [resize, value]);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const viewport = window.visualViewport;
|
|
25
|
+
const target = viewport ?? window;
|
|
26
|
+
target.addEventListener("resize", resize);
|
|
27
|
+
return () => target.removeEventListener("resize", resize);
|
|
28
|
+
}, [resize]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
nextHeight,
|
|
33
|
+
useAutoGrowTextarea
|
|
34
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/storage-state/use-storage-state.ts
|
|
4
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
5
|
+
|
|
6
|
+
// src/storage-state/storage-store.ts
|
|
7
|
+
function getStorage(kind) {
|
|
8
|
+
try {
|
|
9
|
+
if (typeof window === "undefined") return null;
|
|
10
|
+
return kind === "session" ? window.sessionStorage : window.localStorage;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function readRaw(storage, key) {
|
|
16
|
+
try {
|
|
17
|
+
return storage ? storage.getItem(key) : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeRaw(storage, key, value) {
|
|
23
|
+
try {
|
|
24
|
+
if (!storage) return false;
|
|
25
|
+
storage.setItem(key, value);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function removeRaw(storage, key) {
|
|
32
|
+
try {
|
|
33
|
+
if (!storage) return false;
|
|
34
|
+
storage.removeItem(key);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function parseOr(raw, parse, fallback) {
|
|
41
|
+
if (raw === null) return fallback;
|
|
42
|
+
try {
|
|
43
|
+
return parse(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
var slot = (kind, key) => `${kind}\0${key}`;
|
|
49
|
+
var memory = /* @__PURE__ */ new Map();
|
|
50
|
+
var listeners = /* @__PURE__ */ new Map();
|
|
51
|
+
function readCurrent(kind, key) {
|
|
52
|
+
const held = memory.get(slot(kind, key));
|
|
53
|
+
if (held !== void 0) return held;
|
|
54
|
+
return readRaw(getStorage(kind), key);
|
|
55
|
+
}
|
|
56
|
+
function notify(id) {
|
|
57
|
+
for (const listener of [...listeners.get(id) ?? []]) listener();
|
|
58
|
+
}
|
|
59
|
+
function writeCurrent(kind, key, raw) {
|
|
60
|
+
const id = slot(kind, key);
|
|
61
|
+
if (writeRaw(getStorage(kind), key, raw)) memory.delete(id);
|
|
62
|
+
else memory.set(id, raw);
|
|
63
|
+
notify(id);
|
|
64
|
+
}
|
|
65
|
+
function removeCurrent(kind, key) {
|
|
66
|
+
const id = slot(kind, key);
|
|
67
|
+
if (removeRaw(getStorage(kind), key)) memory.delete(id);
|
|
68
|
+
else memory.set(id, null);
|
|
69
|
+
notify(id);
|
|
70
|
+
}
|
|
71
|
+
function onStorageEvent(event) {
|
|
72
|
+
const kinds = [];
|
|
73
|
+
if (!event.storageArea || event.storageArea === getStorage("local")) kinds.push("local");
|
|
74
|
+
if (!event.storageArea || event.storageArea === getStorage("session")) kinds.push("session");
|
|
75
|
+
for (const id of [...listeners.keys()]) {
|
|
76
|
+
const [kind, key] = id.split("\0");
|
|
77
|
+
if (!kinds.includes(kind)) continue;
|
|
78
|
+
if (event.key !== null && event.key !== key) continue;
|
|
79
|
+
memory.delete(id);
|
|
80
|
+
notify(id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function subscribeToKey(kind, key, listener) {
|
|
84
|
+
const id = slot(kind, key);
|
|
85
|
+
const wasEmpty = listeners.size === 0;
|
|
86
|
+
let set = listeners.get(id);
|
|
87
|
+
if (!set) listeners.set(id, set = /* @__PURE__ */ new Set());
|
|
88
|
+
set.add(listener);
|
|
89
|
+
if (wasEmpty) window.addEventListener("storage", onStorageEvent);
|
|
90
|
+
return () => {
|
|
91
|
+
const current = listeners.get(id);
|
|
92
|
+
current?.delete(listener);
|
|
93
|
+
if (current?.size === 0) listeners.delete(id);
|
|
94
|
+
if (listeners.size === 0) window.removeEventListener("storage", onStorageEvent);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/storage-state/use-storage-state.ts
|
|
99
|
+
function useStorageState(key, defaultValue, options = {}) {
|
|
100
|
+
const kind = options.storage ?? "local";
|
|
101
|
+
const serialize = options.serialize ?? JSON.stringify;
|
|
102
|
+
const parse = options.parse ?? JSON.parse;
|
|
103
|
+
const latest = useRef({ serialize, parse, defaultValue });
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
latest.current = { serialize, parse, defaultValue };
|
|
106
|
+
});
|
|
107
|
+
const cache = useRef(null);
|
|
108
|
+
const subscribe = useCallback((onChange) => subscribeToKey(kind, key, onChange), [kind, key]);
|
|
109
|
+
const getSnapshot = useCallback(() => {
|
|
110
|
+
const raw = readCurrent(kind, key);
|
|
111
|
+
const hit = cache.current;
|
|
112
|
+
if (hit && hit.kind === kind && hit.key === key && hit.raw === raw && hit.parse === parse) return hit.value;
|
|
113
|
+
const value2 = parseOr(raw, parse, defaultValue);
|
|
114
|
+
cache.current = { kind, key, raw, parse, value: value2 };
|
|
115
|
+
return value2;
|
|
116
|
+
}, [kind, key, parse, defaultValue]);
|
|
117
|
+
const getServerSnapshot = useCallback(() => defaultValue, [defaultValue]);
|
|
118
|
+
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
119
|
+
const set = useCallback(
|
|
120
|
+
(next) => {
|
|
121
|
+
const { serialize: encode, parse: decode, defaultValue: fallback } = latest.current;
|
|
122
|
+
try {
|
|
123
|
+
const resolved = typeof next === "function" ? next(parseOr(readCurrent(kind, key), decode, fallback)) : next;
|
|
124
|
+
writeCurrent(kind, key, encode(resolved));
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[kind, key]
|
|
129
|
+
);
|
|
130
|
+
const remove = useCallback(() => removeCurrent(kind, key), [kind, key]);
|
|
131
|
+
return [value, set, remove];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export {
|
|
135
|
+
useStorageState
|
|
136
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/scroll-lock/use-scroll-lock.ts
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
// src/scroll-lock/lock-styles.ts
|
|
7
|
+
var LOCKED_PROPERTIES = [
|
|
8
|
+
"position",
|
|
9
|
+
"top",
|
|
10
|
+
"left",
|
|
11
|
+
"right",
|
|
12
|
+
"width",
|
|
13
|
+
"overflow",
|
|
14
|
+
"paddingRight"
|
|
15
|
+
];
|
|
16
|
+
function computeLockStyles({ scrollY, scrollbarWidth, existingPaddingRight }) {
|
|
17
|
+
const styles = {
|
|
18
|
+
position: "fixed",
|
|
19
|
+
top: `-${scrollY}px`,
|
|
20
|
+
left: "0",
|
|
21
|
+
right: "0",
|
|
22
|
+
width: "100%",
|
|
23
|
+
overflow: "hidden"
|
|
24
|
+
};
|
|
25
|
+
if (scrollbarWidth > 0) styles.paddingRight = `${existingPaddingRight + scrollbarWidth}px`;
|
|
26
|
+
return styles;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/scroll-lock/scroll-lock.ts
|
|
30
|
+
var count = 0;
|
|
31
|
+
var saved = null;
|
|
32
|
+
function applyFirstLock() {
|
|
33
|
+
const { body, documentElement } = document;
|
|
34
|
+
const inline = {};
|
|
35
|
+
for (const property of LOCKED_PROPERTIES) inline[property] = body.style[property];
|
|
36
|
+
saved = { scrollY: window.scrollY, inline };
|
|
37
|
+
const styles = computeLockStyles({
|
|
38
|
+
scrollY: saved.scrollY,
|
|
39
|
+
scrollbarWidth: Math.max(0, window.innerWidth - documentElement.clientWidth),
|
|
40
|
+
existingPaddingRight: parseFloat(getComputedStyle(body).paddingRight) || 0
|
|
41
|
+
});
|
|
42
|
+
Object.assign(body.style, styles);
|
|
43
|
+
}
|
|
44
|
+
function restoreLastLock() {
|
|
45
|
+
if (!saved) return;
|
|
46
|
+
const { scrollY, inline } = saved;
|
|
47
|
+
saved = null;
|
|
48
|
+
Object.assign(document.body.style, inline);
|
|
49
|
+
window.scrollTo(0, scrollY);
|
|
50
|
+
}
|
|
51
|
+
function lockScroll() {
|
|
52
|
+
if (count === 0) applyFirstLock();
|
|
53
|
+
count += 1;
|
|
54
|
+
let released = false;
|
|
55
|
+
return () => {
|
|
56
|
+
if (released) return;
|
|
57
|
+
released = true;
|
|
58
|
+
count -= 1;
|
|
59
|
+
if (count === 0) restoreLastLock();
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function unlockScroll() {
|
|
63
|
+
if (count === 0) return;
|
|
64
|
+
count -= 1;
|
|
65
|
+
if (count === 0) restoreLastLock();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/scroll-lock/use-scroll-lock.ts
|
|
69
|
+
function useScrollLock(active = true) {
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!active) return;
|
|
72
|
+
return lockScroll();
|
|
73
|
+
}, [active]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
lockScroll,
|
|
78
|
+
unlockScroll,
|
|
79
|
+
useScrollLock
|
|
80
|
+
};
|