@develemit/web-hooks 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +311 -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-4PVVTQ5V.js +74 -0
- package/dist/chunk-5CA5RIMH.js +34 -0
- package/dist/chunk-5KIH2XLV.js +136 -0
- package/dist/chunk-5XRVPZHD.js +125 -0
- package/dist/chunk-6KCRVSLS.js +80 -0
- package/dist/chunk-DNU57XEJ.js +82 -0
- package/dist/chunk-DQBKU7HI.js +46 -0
- package/dist/chunk-EXEJI4FK.js +43 -0
- package/dist/chunk-FE3BQD2I.js +44 -0
- package/dist/chunk-HTVNN2NM.js +29 -0
- package/dist/chunk-IWQDAJEP.js +69 -0
- package/dist/chunk-JG3GPTMY.js +101 -0
- package/dist/chunk-LZKAGDER.js +22 -0
- package/dist/chunk-NKLEB5TR.js +98 -0
- package/dist/chunk-S4YKMGQE.js +23 -0
- package/dist/chunk-TNEJ34T5.js +86 -0
- package/dist/chunk-UJ366T3Y.js +146 -0
- package/dist/chunk-UPVNSGIK.js +72 -0
- package/dist/chunk-WVM2LTWQ.js +42 -0
- package/dist/chunk-WXL22EWK.js +21 -0
- package/dist/chunk-YFMMSLMB.js +41 -0
- package/dist/chunk-Z3X27PCA.js +108 -0
- package/dist/clipboard.cjs +18 -0
- package/dist/clipboard.d.cts +10 -1
- package/dist/clipboard.d.ts +10 -1
- package/dist/clipboard.js +8 -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.cjs +16 -4
- package/dist/drawer-swipe.d.cts +7 -3
- package/dist/drawer-swipe.d.ts +7 -3
- package/dist/drawer-swipe.js +16 -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 +1207 -7
- package/dist/index.d.cts +20 -60
- package/dist/index.d.ts +20 -60
- package/dist/index.js +167 -247
- package/dist/keyboard-inset.cjs +113 -0
- package/dist/keyboard-inset.d.cts +25 -0
- package/dist/keyboard-inset.d.ts +25 -0
- package/dist/keyboard-inset.js +11 -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 +17 -0
- package/dist/online-status.cjs +46 -0
- package/dist/online-status.d.cts +3 -0
- package/dist/online-status.d.ts +3 -0
- package/dist/online-status.js +7 -0
- package/dist/outside-press.cjs +73 -0
- package/dist/outside-press.d.cts +17 -0
- package/dist/outside-press.d.ts +17 -0
- package/dist/outside-press.js +11 -0
- package/dist/push.cjs +201 -0
- package/dist/push.d.cts +37 -0
- package/dist/push.d.ts +37 -0
- package/dist/push.js +14 -0
- package/dist/pwa.cjs +198 -0
- package/dist/pwa.d.cts +35 -0
- package/dist/pwa.d.ts +35 -0
- package/dist/pwa.js +22 -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/service-worker.cjs +128 -0
- package/dist/service-worker.d.cts +23 -0
- package/dist/service-worker.d.ts +23 -0
- package/dist/service-worker.js +11 -0
- package/dist/share.cjs +139 -0
- package/dist/share.d.cts +23 -0
- package/dist/share.d.ts +23 -0
- package/dist/share.js +16 -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 +85 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-11-06
|
|
4
|
+
|
|
5
|
+
- `push`: `usePushSubscription({ vapidPublicKey, onSubscribe, onUnsubscribe, resubscribeOnLoad = true })` returning `{ support, permission, subscribed, busy, error, subscribe, unsubscribe }`, plus `urlBase64ToUint8Array`, `getPushSupport` (`'supported' | 'unsupported' | 'ios-needs-install'`) and the pure `shouldResubscribe`. The VAPID key is always converted to a `Uint8Array`, the browser's `pushManager.getSubscription()` is the source of truth, mounting never asks for permission, a dropped iOS subscription is re-made silently when permission is already granted, and an `onSubscribe` rejection with `status` 410 unsubscribes and subscribes once more. The package makes no HTTP requests; the app's callbacks do.
|
|
6
|
+
- `keyboard-inset`: `useKeyboardInset({ minInset = 150, enabled = true })` returns the on-screen keyboard height in CSS px (0 when closed), plus the pure `computeKeyboardInset` and `isEditableElement`. Reports only while a text field is focused and ignores gaps under `minInset`, which removes the phantom ~100pt gap in a standalone iPhone PWA. After a blur it re-reads on the next animation frame so moving between fields doesn't flicker to 0.
|
|
7
|
+
- `share`: `shareOrCopy({ title, text, url })` returning `{ status: 'shared' | 'copied' | 'cancelled' | 'failed' }`. Uses the native share sheet when present (honouring `navigator.canShare`), treats an `AbortError` as `cancelled` with no copy, and otherwise falls back to the clipboard module's `copyText`, so it works over plain http. Also `shareTextFor` (copies `text` then `url`, never `title`), `isShareCancel`, `canShareFiles` (detection only) and `useShareFeedback()`.
|
|
8
|
+
- `online-status`: `useOnlineStatus()` built on `useSyncExternalStore`; `true` on the server and during hydration, so no mismatch when the device starts offline.
|
|
9
|
+
- `service-worker`: `useServiceWorker({ url, scope, enabled })` returning `{ supported, updateAvailable, applyUpdate }`, plus `unregisterAll()` and the pure `deriveUpdateState`. A first install is never reported as an update; `applyUpdate()` posts `{ type: 'SKIP_WAITING' }` and the page reloads once on `controllerchange` (a module-level guard prevents loops; other tabs follow when an old worker was in control). The app's worker must handle `SKIP_WAITING`. The package never reads `process.env`; pass `enabled`.
|
|
10
|
+
- `pwa`: `useInstallPrompt()` (`canPrompt`, `prompt()` returning `accepted | dismissed | unavailable` from `userChoice`, `installed`, `needsIosInstructions`), `useIsStandalone()`, `startInstallPromptCapture()` for the `beforeinstallprompt` that fires before hydration, and the pure `detectPlatform`, `isStandalone`, `isIos`, `isIosSafari`. iPadOS in desktop mode counts as iOS; Chrome, Firefox, Edge, Opera and in-app webviews on iOS are not iOS Safari. Both listeners are removed on unmount. Dismissal storage is left to the app.
|
|
11
|
+
- `outside-press`: `useOutsidePress(refs, fn, { ignore })` takes a CSS selector. A press whose target is inside a matching element counts as inside, for popups that are found with `closest(selector)` and have no ref. New optional third argument to the pure `isOutside`.
|
|
12
|
+
- `drawer-swipe`: `useDrawerSwipe({ ignoreSelector })` (default `'[data-no-swipe]'`, `null` disables). A swipe that starts inside a match, or inside a descendant of one, never opens or closes the drawer. New pure `isInIgnoredRegion`, and `resolveSwipeIntent` takes an optional `startedInIgnoredRegion`.
|
|
13
|
+
- `clipboard`: `readText()` resolves to `{ ok: true, text }` or `{ ok: false, reason: 'unavailable' | 'denied' }` and never throws. Over plain http there is no way to read the clipboard, so it returns `unavailable` (show "paste manually").
|
|
14
|
+
|
|
15
|
+
## 0.2.0 - 2026-11-05
|
|
16
|
+
|
|
17
|
+
- `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.
|
|
18
|
+
- `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.
|
|
19
|
+
- `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.
|
|
20
|
+
- `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`.
|
|
21
|
+
- `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.
|
|
22
|
+
- `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.
|
|
23
|
+
- `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.
|
|
24
|
+
- `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.
|
|
25
|
+
- `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.
|
|
26
|
+
- `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.
|
|
27
|
+
- `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.
|
|
28
|
+
- `escape`: `useEscape` ignores Escape pressed during IME composition (`isComposing` or `keyCode` 229), so cancelling a composition no longer closes the menu.
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
3
31
|
## 0.1.0
|
|
4
32
|
|
|
5
33
|
- `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,21 @@ 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 |
|
|
21
|
+
| `@develemit/web-hooks/pwa` | Detect iOS and installed-PWA mode, and drive the install prompt |
|
|
22
|
+
| `@develemit/web-hooks/online-status` | Track online/offline, hydration-safe |
|
|
23
|
+
| `@develemit/web-hooks/service-worker` | Register a service worker and apply updates with one reload |
|
|
24
|
+
| `@develemit/web-hooks/share` | Native share sheet with a clipboard fallback that works over plain http |
|
|
25
|
+
| `@develemit/web-hooks/keyboard-inset` | On-screen keyboard height, without the phantom gap in standalone iPhone PWAs |
|
|
26
|
+
| `@develemit/web-hooks/push` | Web push subscription, with iOS install detection and silent re-subscribe |
|
|
12
27
|
|
|
13
28
|
Behavior only. No styling and no components: each app keeps its own look.
|
|
14
29
|
|
|
@@ -35,6 +50,11 @@ useDrawerSwipe({
|
|
|
35
50
|
The left 60px band is left to the browser's back gesture, and swipes that start
|
|
36
51
|
inside a horizontally scrolling container stay with that container.
|
|
37
52
|
|
|
53
|
+
Mark a region that scrolls or gestures on its own (a terminal, a code view)
|
|
54
|
+
with `data-no-swipe`, and swipes starting inside it are ignored. Pass
|
|
55
|
+
`ignoreSelector: '.my-region'` to use a different selector, or `null` to turn
|
|
56
|
+
the opt-out off.
|
|
57
|
+
|
|
38
58
|
### Drawer with focus trap and Escape
|
|
39
59
|
|
|
40
60
|
```tsx
|
|
@@ -74,6 +94,123 @@ const { copy, status } = useCopyFeedback({ resetMs: 1500 });
|
|
|
74
94
|
rejects. On plain-http origins (a PWA over a Tailscale address)
|
|
75
95
|
`navigator.clipboard` doesn't exist, so it falls back to `execCommand('copy')`.
|
|
76
96
|
|
|
97
|
+
`readText()` is the paste side. It resolves to `{ ok: true, text }` or
|
|
98
|
+
`{ ok: false, reason: 'unavailable' | 'denied' }` and never rejects. Reading has
|
|
99
|
+
no fallback: over plain http it returns `unavailable`, so show a "paste
|
|
100
|
+
manually" hint.
|
|
101
|
+
|
|
102
|
+
### Lock page scroll behind an overlay
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
import { useScrollLock } from '@develemit/web-hooks/scroll-lock';
|
|
106
|
+
|
|
107
|
+
useScrollLock(open);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Uses `position: fixed` on `body` because iOS Safari ignores `overflow: hidden`
|
|
111
|
+
for touch scrolling. Locks are counted, so a lightbox closing inside an open
|
|
112
|
+
drawer leaves the page locked. When the last lock releases, the exact previous
|
|
113
|
+
inline styles and the scroll position come back. `lockScroll()` returns a
|
|
114
|
+
release function (safe to call twice) for use outside React.
|
|
115
|
+
|
|
116
|
+
### Match a media query
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import { useMediaQuery, useReducedMotion, useCoarsePointer } from '@develemit/web-hooks/media-query';
|
|
120
|
+
|
|
121
|
+
const narrow = useMediaQuery('(max-width: 767px)', { serverDefault: false });
|
|
122
|
+
const reduce = useReducedMotion();
|
|
123
|
+
const touch = useCoarsePointer();
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Built on `useSyncExternalStore`, so the first client render already has the
|
|
127
|
+
real value (no desktop frame on a phone). The server render and hydration use
|
|
128
|
+
`serverDefault` (default `false`); React then re-renders with the real value
|
|
129
|
+
without a mismatch warning. There are no built-in breakpoints: pass your own
|
|
130
|
+
query. Where `matchMedia` is missing (jsdom tests) the hook returns
|
|
131
|
+
`serverDefault` instead of throwing. Hooks with the same query share one
|
|
132
|
+
`MediaQueryList`, and Safari before 14 is supported through `addListener`.
|
|
133
|
+
|
|
134
|
+
### Close on an outside press
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import { useOutsidePress } from '@develemit/web-hooks/outside-press';
|
|
138
|
+
import { useEscape } from '@develemit/web-hooks/escape';
|
|
139
|
+
|
|
140
|
+
const menu = useRef<HTMLDivElement>(null);
|
|
141
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
142
|
+
useOutsidePress([menu, trigger], () => setOpen(false), { enabled: open });
|
|
143
|
+
useEscape(() => setOpen(false), open);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Listens to `pointerdown` on `document` in the capture phase, only while
|
|
147
|
+
`enabled`. `mousedown` never fires on iOS for a tap on a non-clickable element,
|
|
148
|
+
so copies built on it leave the menu open. Pass the trigger as well as the
|
|
149
|
+
panel, or tapping the trigger closes and instantly reopens the menu. Pass a
|
|
150
|
+
portalled panel's ref too. Right clicks are ignored, and a target removed from
|
|
151
|
+
the DOM during the event counts as inside. The callback is read through a ref,
|
|
152
|
+
so it needs no `useCallback`.
|
|
153
|
+
|
|
154
|
+
When the popup has no ref, pass `{ ignore: '.inline-cell-search' }`: a press
|
|
155
|
+
inside any element matching the selector counts as inside (unreleased).
|
|
156
|
+
|
|
157
|
+
### visibility-polling
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
import { useVisibilityPolling } from '@develemit/web-hooks/visibility-polling';
|
|
161
|
+
|
|
162
|
+
useVisibilityPolling(() => refresh(), 5000, { jitter: 0.2 });
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Runs `fn` every `intervalMs` only while the page is visible. When the tab is
|
|
166
|
+
hidden (a locked phone) the timer is cleared; when it is visible again `fn`
|
|
167
|
+
runs once right away and the schedule resumes. The next run is scheduled after
|
|
168
|
+
the previous one finishes, so a slow promise never overlaps. Options:
|
|
169
|
+
`enabled` (default true), `immediate` (run on mount, default true), `jitter`
|
|
170
|
+
(0–1 fraction of the interval added at random, default 0) and `runOnVisible`
|
|
171
|
+
(default true). `fn` is read through a ref. A throw or rejection doesn't stop
|
|
172
|
+
the schedule; handle errors inside `fn`. It decides when to run, not caching
|
|
173
|
+
or dedupe.
|
|
174
|
+
|
|
175
|
+
### debounce
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
import { useDebouncedValue, useDebouncedCallback } from '@develemit/web-hooks/debounce';
|
|
179
|
+
|
|
180
|
+
const debouncedSearch = useDebouncedValue(search, 300);
|
|
181
|
+
|
|
182
|
+
const run = useDebouncedCallback((q: string) => fetchResults(q), 300);
|
|
183
|
+
run('inv'); // later calls reset the timer; only the last runs
|
|
184
|
+
run.cancel(); // drop the pending call
|
|
185
|
+
run.flush(); // run it now
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`useDebouncedValue` returns the value once it has stopped changing for `ms`.
|
|
189
|
+
`useDebouncedCallback` returns one function for the life of the component; it
|
|
190
|
+
always calls the latest `fn` and `ms`, so it needs no `useCallback`, and a
|
|
191
|
+
pending call is cancelled on unmount.
|
|
192
|
+
|
|
193
|
+
### storage-state
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
import { useStorageState } from '@develemit/web-hooks/storage-state';
|
|
197
|
+
|
|
198
|
+
const [recent, setRecent, clearRecent] = useStorageState<string[]>('recent', []);
|
|
199
|
+
setRecent((prev) => ['pnpm dev', ...prev]);
|
|
200
|
+
const [draft, setDraft] = useStorageState('draft', '', { storage: 'session' });
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The server and the first client render both return `defaultValue`; the stored
|
|
204
|
+
value arrives right after hydration, so there is no mismatch. Hooks with the
|
|
205
|
+
same key stay in sync in this tab, and other tabs through the `storage` event.
|
|
206
|
+
The setter takes a value or an updater. Options: `storage` (`'local'` default,
|
|
207
|
+
or `'session'`), `serialize` and `parse` (JSON by default; a `parse` that
|
|
208
|
+
throws gives `defaultValue`, so legacy non-JSON values and validators both
|
|
209
|
+
work). A refused `setItem` (full quota, Safari private mode) never throws: the
|
|
210
|
+
new value is kept in memory for the rest of the page's life and the UI
|
|
211
|
+
updates. Keep `defaultValue` stable (a constant or a primitive); it is read
|
|
212
|
+
again only when the stored value or key changes.
|
|
213
|
+
|
|
77
214
|
### Migrating from the per-app copies
|
|
78
215
|
|
|
79
216
|
| App | Old | New |
|
|
@@ -82,3 +219,177 @@ rejects. On plain-http origins (a PWA over a Tailscale address)
|
|
|
82
219
|
| emit-social | `useFocusTrap(ref, { active, returnFocusRef })` plus `useEscape(fn, active)` | Same signatures. |
|
|
83
220
|
| develemit-commerce | `const onKeyDown = useFocusTrap(ref, active, onEscape)` | `useFocusTrap(ref, { active })` and `useEscape(onEscape, active)`. Drop the returned `onKeyDown` handler. |
|
|
84
221
|
| centraflow | `const ref = useFocusTrap(active)` | `const ref = useRef<HTMLElement>(null)`, then `useFocusTrap(ref, { active })`. |
|
|
222
|
+
|
|
223
|
+
### unsaved-guard
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
import { useUnsavedChangesGuard } from '@develemit/web-hooks/unsaved-guard';
|
|
227
|
+
|
|
228
|
+
useUnsavedChangesGuard(isDirty);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
While `dirty` is true the browser asks before the tab closes or reloads. It
|
|
232
|
+
sets both `preventDefault()` and `returnValue = ''`, because older Safari and
|
|
233
|
+
Chrome ignore `preventDefault()` alone. This covers `beforeunload` only:
|
|
234
|
+
Next's App Router has no stable way to block in-app navigation, so that stays
|
|
235
|
+
in the app.
|
|
236
|
+
|
|
237
|
+
### hotkey
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
240
|
+
import { useHotkey, matchHotkey } from '@develemit/web-hooks/hotkey';
|
|
241
|
+
|
|
242
|
+
useHotkey('mod+k', () => setOpen((o) => !o));
|
|
243
|
+
useHotkey('mod+k', toggle, { allowInInputs: true, preventDefault: false });
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Combos are modifiers (`mod`, `ctrl`, `meta`, `alt`, `shift`) then one key:
|
|
247
|
+
`mod+k`, `shift+/`, `escape`. `mod` is Cmd on Apple devices (iPad in desktop
|
|
248
|
+
mode included) and Ctrl elsewhere. Case and Caps Lock don't matter, and the
|
|
249
|
+
modifier set must match exactly. Text fields (`input`, `textarea`, `select`,
|
|
250
|
+
contenteditable) are skipped unless `allowInInputs`. Options: `enabled`
|
|
251
|
+
(default true), `allowInInputs` (false), `preventDefault` (true). `matchHotkey(event,
|
|
252
|
+
combo, isApple)` is the pure matcher; `detectApple(navigator)` supplies
|
|
253
|
+
`isApple`. No key sequences.
|
|
254
|
+
|
|
255
|
+
### auto-grow
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
import { useAutoGrowTextarea } from '@develemit/web-hooks/auto-grow';
|
|
259
|
+
|
|
260
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
261
|
+
useAutoGrowTextarea(ref, value, { maxHeight: 240 });
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Resizes the textarea to fit its content on every `value` change and whenever
|
|
265
|
+
the visual viewport resizes (the iPhone keyboard opening or closing), falling
|
|
266
|
+
back to `window` `resize`. At `maxHeight` it stops growing and sets
|
|
267
|
+
`overflowY: auto`. The pure `nextHeight(scrollHeight, maxHeight)` does the
|
|
268
|
+
clamping.
|
|
269
|
+
|
|
270
|
+
### Install prompt and platform detection
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
import { startInstallPromptCapture, useInstallPrompt, useIsStandalone } from '@develemit/web-hooks/pwa';
|
|
274
|
+
|
|
275
|
+
// Root client component, outside React's lifecycle: Android Chrome often fires
|
|
276
|
+
// `beforeinstallprompt` before hydration, and it fires only once.
|
|
277
|
+
startInstallPromptCapture();
|
|
278
|
+
|
|
279
|
+
const { canPrompt, prompt, installed, needsIosInstructions } = useInstallPrompt();
|
|
280
|
+
if (canPrompt) await prompt(); // 'accepted' | 'dismissed' | 'unavailable'
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`needsIosInstructions` is true in iOS Safari (not Chrome, Firefox, Edge, Opera
|
|
284
|
+
or an in-app browser) when the app isn't installed. iPads in desktop mode count
|
|
285
|
+
as iOS. The pure `detectPlatform(env)`, `isIos()`, `isIosSafari()` and
|
|
286
|
+
`isStandalone()` are exported too; they read the globals when called and
|
|
287
|
+
return `false` on the server.
|
|
288
|
+
|
|
289
|
+
The package doesn't store dismissal: the key and snooze rules are product
|
|
290
|
+
decisions. Combine the hook with `useStorageState` from `storage-state`.
|
|
291
|
+
|
|
292
|
+
### Online status and service worker
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
import { useOnlineStatus } from '@develemit/web-hooks/online-status';
|
|
296
|
+
import { useServiceWorker } from '@develemit/web-hooks/service-worker';
|
|
297
|
+
|
|
298
|
+
const online = useOnlineStatus(); // true on the server and during hydration
|
|
299
|
+
|
|
300
|
+
const { updateAvailable, applyUpdate, supported } = useServiceWorker({
|
|
301
|
+
url: '/sw.js',
|
|
302
|
+
scope: '/',
|
|
303
|
+
enabled: process.env.NODE_ENV === 'production',
|
|
304
|
+
});
|
|
305
|
+
if (updateAvailable) showBanner({ onTap: applyUpdate });
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`applyUpdate()` posts `{ type: 'SKIP_WAITING' }` to the waiting worker and the
|
|
309
|
+
page reloads once when the new worker takes control. Your worker must handle
|
|
310
|
+
that message:
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
self.addEventListener('message', (event) => {
|
|
314
|
+
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
A first install (no controller yet) is not reported as an update. In dev, call
|
|
319
|
+
`unregisterAll()` to clear workers left by a production build. Browsers without
|
|
320
|
+
service workers get `supported: false`.
|
|
321
|
+
|
|
322
|
+
### Share
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
import { useShareFeedback } from '@develemit/web-hooks/share';
|
|
326
|
+
|
|
327
|
+
const { share, status } = useShareFeedback(); // 'idle' | 'shared' | 'copied' | 'cancelled' | 'failed'
|
|
328
|
+
<button onClick={() => void share({ title, text, url })}>Share</button>;
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`shareOrCopy(data)` opens the native share sheet when there is one. Closing the
|
|
332
|
+
sheet is `cancelled`, not a failure, and copies nothing. Any other share error
|
|
333
|
+
(for example `NotAllowedError` when the call isn't from a user gesture), a
|
|
334
|
+
missing `navigator.share`, or `canShare(data) === false` falls back to
|
|
335
|
+
`copyText`, which works over plain http. The copied text is `text` then `url`
|
|
336
|
+
joined by a newline (`url` alone when that is all there is); `title` is not
|
|
337
|
+
copied. Call it directly from the tap handler, before any `await`, so the
|
|
338
|
+
browser still sees a user gesture. `canShareFiles(files)` is detection only.
|
|
339
|
+
|
|
340
|
+
### Keyboard inset
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
import { useKeyboardInset } from '@develemit/web-hooks/keyboard-inset';
|
|
344
|
+
|
|
345
|
+
const inset = useKeyboardInset();
|
|
346
|
+
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`);
|
|
349
|
+
}, [inset]);
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Returns the height of the on-screen keyboard in CSS pixels, `0` when it is
|
|
353
|
+
closed, on the server and when `visualViewport` is missing. It reports an
|
|
354
|
+
inset only while a text field is focused and ignores gaps under `minInset`
|
|
355
|
+
(default 150), because a standalone iPhone PWA reports a ~100pt gap between the
|
|
356
|
+
layout and visual viewport with no keyboard open. The hook returns a number;
|
|
357
|
+
apply it as padding or `bottom` yourself, for example `padding-bottom:
|
|
358
|
+
var(--keyboard-inset)` with the effect above. The pure `computeKeyboardInset`
|
|
359
|
+
and `isEditableElement` are exported too.
|
|
360
|
+
|
|
361
|
+
### Push
|
|
362
|
+
|
|
363
|
+
```tsx
|
|
364
|
+
import { usePushSubscription } from '@develemit/web-hooks/push';
|
|
365
|
+
|
|
366
|
+
const push = usePushSubscription({
|
|
367
|
+
vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
|
|
368
|
+
onSubscribe: (sub) => api.post('/push/subscribe', sub),
|
|
369
|
+
onUnsubscribe: (endpoint) => api.delete('/push/subscribe', { endpoint }),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (push.support === 'ios-needs-install') return <InstallHint />;
|
|
373
|
+
<button onClick={push.subscribe} disabled={push.busy}>Enable</button>
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Returns `{ support, permission, subscribed, busy, error, subscribe, unsubscribe }`.
|
|
377
|
+
`support` is `'supported'`, `'unsupported'` or `'ios-needs-install'`: iOS only
|
|
378
|
+
allows Web Push in an installed PWA (16.4+), so an iPhone Safari tab (or any
|
|
379
|
+
iOS browser outside the home-screen app) gets `ios-needs-install` and
|
|
380
|
+
`subscribe()` does nothing.
|
|
381
|
+
|
|
382
|
+
Call `subscribe()` from a tap handler: the hook never requests permission on
|
|
383
|
+
mount, because browsers (and iOS especially) need a user gesture. `subscribed`
|
|
384
|
+
comes from `pushManager.getSubscription()`, never from a stored flag. The
|
|
385
|
+
service worker comes from `navigator.serviceWorker.ready`; this package never
|
|
386
|
+
registers one for push (use `useServiceWorker`).
|
|
387
|
+
|
|
388
|
+
iOS silently drops push subscriptions. With `resubscribeOnLoad` (default
|
|
389
|
+
`true`), when permission is already `granted` and the browser has no
|
|
390
|
+
subscription, the hook subscribes again without a prompt and calls
|
|
391
|
+
`onSubscribe`. If `onSubscribe` rejects with an error whose `status` is `410`,
|
|
392
|
+
the dead subscription is unsubscribed and one fresh one is made and sent. The
|
|
393
|
+
package never makes HTTP requests: `onSubscribe(subscription:
|
|
394
|
+
PushSubscriptionJSON)` and `onUnsubscribe(endpoint)` are yours. The pure
|
|
395
|
+
`urlBase64ToUint8Array`, `getPushSupport` and `shouldResubscribe` are exported.
|
|
@@ -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,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
copyText
|
|
4
|
+
} from "./chunk-FE3BQD2I.js";
|
|
5
|
+
|
|
6
|
+
// src/share/share-text.ts
|
|
7
|
+
function shareTextFor({ text, url }) {
|
|
8
|
+
return [text, url].filter((part) => !!part).join("\n");
|
|
9
|
+
}
|
|
10
|
+
function isShareCancel(error) {
|
|
11
|
+
return typeof error === "object" && error !== null && error.name === "AbortError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/share/share-or-copy.ts
|
|
15
|
+
async function shareOrCopy(data) {
|
|
16
|
+
if (canShareNatively(data)) {
|
|
17
|
+
try {
|
|
18
|
+
await navigator.share(data);
|
|
19
|
+
return { status: "shared" };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (isShareCancel(error)) return { status: "cancelled" };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const result = await copyText(shareTextFor(data));
|
|
25
|
+
return { status: result.ok ? "copied" : "failed" };
|
|
26
|
+
}
|
|
27
|
+
function canShareFiles(files) {
|
|
28
|
+
if (typeof navigator === "undefined" || typeof navigator.canShare !== "function") return false;
|
|
29
|
+
try {
|
|
30
|
+
return navigator.canShare({ files });
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function canShareNatively(data) {
|
|
36
|
+
if (typeof navigator === "undefined" || typeof navigator.share !== "function") return false;
|
|
37
|
+
if (typeof navigator.canShare !== "function") return true;
|
|
38
|
+
try {
|
|
39
|
+
return navigator.canShare(data);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/share/use-share-feedback.ts
|
|
46
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
47
|
+
function useShareFeedback({ resetMs = 1500 } = {}) {
|
|
48
|
+
const [status, setStatus] = useState("idle");
|
|
49
|
+
const timerRef = useRef(void 0);
|
|
50
|
+
const clearTimer = useCallback(() => {
|
|
51
|
+
if (timerRef.current !== void 0) clearTimeout(timerRef.current);
|
|
52
|
+
timerRef.current = void 0;
|
|
53
|
+
}, []);
|
|
54
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
55
|
+
const share = useCallback(
|
|
56
|
+
async (data) => {
|
|
57
|
+
const result = await shareOrCopy(data);
|
|
58
|
+
clearTimer();
|
|
59
|
+
setStatus(result.status);
|
|
60
|
+
timerRef.current = setTimeout(() => setStatus("idle"), resetMs);
|
|
61
|
+
return result;
|
|
62
|
+
},
|
|
63
|
+
[clearTimer, resetMs]
|
|
64
|
+
);
|
|
65
|
+
return { share, status };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
shareTextFor,
|
|
70
|
+
isShareCancel,
|
|
71
|
+
shareOrCopy,
|
|
72
|
+
canShareFiles,
|
|
73
|
+
useShareFeedback
|
|
74
|
+
};
|
|
@@ -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
|
+
};
|