@develemit/web-hooks 0.0.0-stage → 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 +24 -0
- package/LICENSE +21 -0
- package/README.md +249 -2
- 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 +94 -0
- package/dist/clipboard.d.cts +17 -0
- package/dist/clipboard.d.ts +17 -0
- package/dist/clipboard.js +9 -0
- 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.cjs +149 -0
- package/dist/drawer-swipe.d.cts +26 -0
- package/dist/drawer-swipe.d.ts +26 -0
- package/dist/drawer-swipe.js +25 -0
- package/dist/escape.cjs +48 -0
- package/dist/escape.d.cts +3 -0
- package/dist/escape.d.ts +3 -0
- package/dist/escape.js +7 -0
- package/dist/focus-trap.cjs +110 -0
- package/dist/focus-trap.d.cts +17 -0
- package/dist/focus-trap.d.ts +17 -0
- package/dist/focus-trap.js +13 -0
- 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 +918 -0
- package/dist/index.d.cts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +115 -0
- 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 +126 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
19
|
+
## 0.1.0
|
|
20
|
+
|
|
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.
|
|
22
|
+
- `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.
|
|
23
|
+
- `escape`: `useEscape`. The callback is read through a ref, so changing its identity doesn't re-bind the listener.
|
|
24
|
+
- `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,250 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @develemit/web-hooks
|
|
2
|
+
|
|
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
|
+
| `@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 |
|
|
21
|
+
|
|
22
|
+
Behavior only. No styling and no components: each app keeps its own look.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm add @develemit/web-hooks
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { useDrawerSwipe } from '@develemit/web-hooks/drawer-swipe';
|
|
34
|
+
|
|
35
|
+
useDrawerSwipe({
|
|
36
|
+
drawerOpen,
|
|
37
|
+
onOpen: () => setDrawerOpen(true),
|
|
38
|
+
onClose: () => setDrawerOpen(false),
|
|
39
|
+
enabled: true, // optional, default true
|
|
40
|
+
maxViewportWidth: 768, // optional; no-op at or above this width
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The left 60px band is left to the browser's back gesture, and swipes that start
|
|
45
|
+
inside a horizontally scrolling container stay with that container.
|
|
46
|
+
|
|
47
|
+
### Drawer with focus trap and Escape
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { useRef } from 'react';
|
|
51
|
+
import { useFocusTrap } from '@develemit/web-hooks/focus-trap';
|
|
52
|
+
import { useEscape } from '@develemit/web-hooks/escape';
|
|
53
|
+
|
|
54
|
+
const drawerRef = useRef<HTMLElement>(null);
|
|
55
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
56
|
+
|
|
57
|
+
useFocusTrap(drawerRef, {
|
|
58
|
+
active: open,
|
|
59
|
+
initialFocusRef, // optional; default is the first focusable item
|
|
60
|
+
returnFocusRef: triggerRef, // optional; wins over the element focused at open
|
|
61
|
+
restoreFocus: true, // optional, default true
|
|
62
|
+
});
|
|
63
|
+
useEscape(() => setOpen(false), open);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Pass `returnFocusRef` when the opener is a button: Safari doesn't focus a
|
|
67
|
+
button on tap, so the element focused at open can be `<body>`. If nothing
|
|
68
|
+
inside is focusable the container itself gets focus (`tabindex="-1"` is added).
|
|
69
|
+
`useEscape` reads the callback through a ref, so it doesn't need `useCallback`.
|
|
70
|
+
When traps stack, only the most recently activated one handles Tab.
|
|
71
|
+
|
|
72
|
+
### Copy to clipboard
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { useCopyFeedback } from '@develemit/web-hooks/clipboard';
|
|
76
|
+
|
|
77
|
+
const { copy, status } = useCopyFeedback({ resetMs: 1500 });
|
|
78
|
+
<button onClick={() => copy(token)}>{status === 'copied' ? 'Copied!' : 'Copy'}</button>;
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`copyText(text)` is the plain function underneath and resolves to
|
|
82
|
+
`{ ok: true }` or `{ ok: false, reason: 'unavailable' | 'denied' }`; it never
|
|
83
|
+
rejects. On plain-http origins (a PWA over a Tailscale address)
|
|
84
|
+
`navigator.clipboard` doesn't exist, so it falls back to `execCommand('copy')`.
|
|
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
|
+
|
|
195
|
+
### Migrating from the per-app copies
|
|
196
|
+
|
|
197
|
+
| App | Old | New |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| emit-vision | `useFocusTrap(ref, { active, initialFocusRef, restoreFocus })` | Same signature. |
|
|
200
|
+
| emit-social | `useFocusTrap(ref, { active, returnFocusRef })` plus `useEscape(fn, active)` | Same signatures. |
|
|
201
|
+
| develemit-commerce | `const onKeyDown = useFocusTrap(ref, active, onEscape)` | `useFocusTrap(ref, { active })` and `useEscape(onEscape, active)`. Drop the returned `onKeyDown` handler. |
|
|
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.
|
|
2
250
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
|
@@ -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
|
+
};
|