@develemit/web-hooks 0.2.0 → 0.3.1
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 +18 -0
- package/README.md +157 -0
- package/dist/chunk-4PVVTQ5V.js +74 -0
- package/dist/{chunk-OZ4PU4E3.js → chunk-5XRVPZHD.js} +14 -4
- package/dist/{chunk-3UGRXQIA.js → chunk-DQBKU7HI.js} +10 -3
- package/dist/chunk-EXEJI4FK.js +43 -0
- package/dist/{chunk-3CACI4OQ.js → chunk-FE3BQD2I.js} +1 -25
- package/dist/chunk-HTVNN2NM.js +29 -0
- package/dist/chunk-R2PFNE3U.js +154 -0
- package/dist/chunk-TNEJ34T5.js +86 -0
- package/dist/chunk-UVJBJETY.js +121 -0
- package/dist/{chunk-UZADAINO.js → chunk-WVM2LTWQ.js} +2 -21
- 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 +13 -0
- package/dist/clipboard.d.cts +10 -1
- package/dist/clipboard.d.ts +10 -1
- package/dist/clipboard.js +6 -2
- 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 +5 -1
- package/dist/index.cjs +637 -19
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +77 -7
- 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.js +5 -3
- 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 +10 -3
- package/dist/outside-press.d.cts +4 -2
- package/dist/outside-press.d.ts +4 -2
- package/dist/outside-press.js +1 -1
- package/dist/push.cjs +209 -0
- package/dist/push.d.cts +38 -0
- package/dist/push.d.ts +38 -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/service-worker.cjs +148 -0
- package/dist/service-worker.d.cts +24 -0
- package/dist/service-worker.d.ts +24 -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/package.json +31 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1 - 2026-11-06
|
|
4
|
+
|
|
5
|
+
- `service-worker`: `useServiceWorker({ onError })` and `registerServiceWorker({ onError })`. Called with the rejection when `register()` fails (not after unmount or dispose). The latest callback is used; a throwing callback is swallowed. Registration failure still never throws into React.
|
|
6
|
+
- `push`: an empty or whitespace `vapidPublicKey` is "not ready". The on-load re-subscribe waits for the key and runs once when it arrives; `subscribe()` sets `error` to `'push key not ready'` and never prompts or calls `pushManager.subscribe`. `shouldResubscribe` takes an optional `hasKey` (default `true`); new `isKeyReady`.
|
|
7
|
+
- `service-worker`: a worker that skips waiting no longer makes the tab reload unasked on `controllerchange`. The hook reports `updateAvailable` and `applyUpdate()` reloads once. **Behaviour change:** this also replaces the old behaviour of following another tab's update with an automatic reload; that tab now shows the update prompt instead. No app relied on the old behaviour, so it ships as a patch.
|
|
8
|
+
|
|
9
|
+
## 0.3.0 - 2026-11-06
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
- `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.
|
|
13
|
+
- `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()`.
|
|
14
|
+
- `online-status`: `useOnlineStatus()` built on `useSyncExternalStore`; `true` on the server and during hydration, so no mismatch when the device starts offline.
|
|
15
|
+
- `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`.
|
|
16
|
+
- `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.
|
|
17
|
+
- `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`.
|
|
18
|
+
- `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`.
|
|
19
|
+
- `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").
|
|
20
|
+
|
|
3
21
|
## 0.2.0 - 2026-11-05
|
|
4
22
|
|
|
5
23
|
- `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.
|
package/README.md
CHANGED
|
@@ -18,6 +18,12 @@ that every app ends up hand-copying from the last one.
|
|
|
18
18
|
| `@develemit/web-hooks/unsaved-guard` | Warn before the tab closes while a form is dirty |
|
|
19
19
|
| `@develemit/web-hooks/hotkey` | Keyboard shortcuts such as `mod+k` |
|
|
20
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 |
|
|
21
27
|
|
|
22
28
|
Behavior only. No styling and no components: each app keeps its own look.
|
|
23
29
|
|
|
@@ -44,6 +50,11 @@ useDrawerSwipe({
|
|
|
44
50
|
The left 60px band is left to the browser's back gesture, and swipes that start
|
|
45
51
|
inside a horizontally scrolling container stay with that container.
|
|
46
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
|
+
|
|
47
58
|
### Drawer with focus trap and Escape
|
|
48
59
|
|
|
49
60
|
```tsx
|
|
@@ -83,6 +94,11 @@ const { copy, status } = useCopyFeedback({ resetMs: 1500 });
|
|
|
83
94
|
rejects. On plain-http origins (a PWA over a Tailscale address)
|
|
84
95
|
`navigator.clipboard` doesn't exist, so it falls back to `execCommand('copy')`.
|
|
85
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
|
+
|
|
86
102
|
### Lock page scroll behind an overlay
|
|
87
103
|
|
|
88
104
|
```tsx
|
|
@@ -135,6 +151,9 @@ portalled panel's ref too. Right clicks are ignored, and a target removed from
|
|
|
135
151
|
the DOM during the event counts as inside. The callback is read through a ref,
|
|
136
152
|
so it needs no `useCallback`.
|
|
137
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
|
+
|
|
138
157
|
### visibility-polling
|
|
139
158
|
|
|
140
159
|
```tsx
|
|
@@ -248,3 +267,141 @@ back to `window` `resize`. At `maxHeight` it stops growing and sets
|
|
|
248
267
|
`overflowY: auto`. The pure `nextHeight(scrollHeight, maxHeight)` does the
|
|
249
268
|
clamping.
|
|
250
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
|
+
onError: (error) => captureError(error), // registration failed; never throws into React
|
|
305
|
+
});
|
|
306
|
+
if (updateAvailable) showBanner({ onTap: applyUpdate });
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`applyUpdate()` posts `{ type: 'SKIP_WAITING' }` to the waiting worker and the
|
|
310
|
+
page reloads once when the new worker takes control. Your worker must handle
|
|
311
|
+
that message:
|
|
312
|
+
|
|
313
|
+
```js
|
|
314
|
+
self.addEventListener('message', (event) => {
|
|
315
|
+
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Workers that call `self.skipWaiting()` on install (as emit-infra's does) take
|
|
320
|
+
control with no waiting stage. The page is never reloaded unasked: when a new
|
|
321
|
+
worker takes control of a tab that was already controlled, `updateAvailable`
|
|
322
|
+
becomes `true` and `applyUpdate()` reloads the page once. The same applies when
|
|
323
|
+
another tab applied the update.
|
|
324
|
+
|
|
325
|
+
A first install (no controller yet) is not reported as an update. In dev, call
|
|
326
|
+
`unregisterAll()` to clear workers left by a production build. Browsers without
|
|
327
|
+
service workers get `supported: false`.
|
|
328
|
+
|
|
329
|
+
### Share
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
import { useShareFeedback } from '@develemit/web-hooks/share';
|
|
333
|
+
|
|
334
|
+
const { share, status } = useShareFeedback(); // 'idle' | 'shared' | 'copied' | 'cancelled' | 'failed'
|
|
335
|
+
<button onClick={() => void share({ title, text, url })}>Share</button>;
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`shareOrCopy(data)` opens the native share sheet when there is one. Closing the
|
|
339
|
+
sheet is `cancelled`, not a failure, and copies nothing. Any other share error
|
|
340
|
+
(for example `NotAllowedError` when the call isn't from a user gesture), a
|
|
341
|
+
missing `navigator.share`, or `canShare(data) === false` falls back to
|
|
342
|
+
`copyText`, which works over plain http. The copied text is `text` then `url`
|
|
343
|
+
joined by a newline (`url` alone when that is all there is); `title` is not
|
|
344
|
+
copied. Call it directly from the tap handler, before any `await`, so the
|
|
345
|
+
browser still sees a user gesture. `canShareFiles(files)` is detection only.
|
|
346
|
+
|
|
347
|
+
### Keyboard inset
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
import { useKeyboardInset } from '@develemit/web-hooks/keyboard-inset';
|
|
351
|
+
|
|
352
|
+
const inset = useKeyboardInset();
|
|
353
|
+
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`);
|
|
356
|
+
}, [inset]);
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Returns the height of the on-screen keyboard in CSS pixels, `0` when it is
|
|
360
|
+
closed, on the server and when `visualViewport` is missing. It reports an
|
|
361
|
+
inset only while a text field is focused and ignores gaps under `minInset`
|
|
362
|
+
(default 150), because a standalone iPhone PWA reports a ~100pt gap between the
|
|
363
|
+
layout and visual viewport with no keyboard open. The hook returns a number;
|
|
364
|
+
apply it as padding or `bottom` yourself, for example `padding-bottom:
|
|
365
|
+
var(--keyboard-inset)` with the effect above. The pure `computeKeyboardInset`
|
|
366
|
+
and `isEditableElement` are exported too.
|
|
367
|
+
|
|
368
|
+
### Push
|
|
369
|
+
|
|
370
|
+
```tsx
|
|
371
|
+
import { usePushSubscription } from '@develemit/web-hooks/push';
|
|
372
|
+
|
|
373
|
+
const push = usePushSubscription({
|
|
374
|
+
vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
|
|
375
|
+
onSubscribe: (sub) => api.post('/push/subscribe', sub),
|
|
376
|
+
onUnsubscribe: (endpoint) => api.delete('/push/subscribe', { endpoint }),
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (push.support === 'ios-needs-install') return <InstallHint />;
|
|
380
|
+
<button onClick={push.subscribe} disabled={push.busy}>Enable</button>
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
`vapidPublicKey` may be `''` while the app is still fetching it. Until it is
|
|
384
|
+
non-blank, the on-load re-subscribe is skipped (and runs once when the key
|
|
385
|
+
arrives) and `subscribe()` sets `error` to `'push key not ready'` without
|
|
386
|
+
asking for permission or touching the push manager.
|
|
387
|
+
|
|
388
|
+
Returns `{ support, permission, subscribed, busy, error, subscribe, unsubscribe }`.
|
|
389
|
+
`support` is `'supported'`, `'unsupported'` or `'ios-needs-install'`: iOS only
|
|
390
|
+
allows Web Push in an installed PWA (16.4+), so an iPhone Safari tab (or any
|
|
391
|
+
iOS browser outside the home-screen app) gets `ios-needs-install` and
|
|
392
|
+
`subscribe()` does nothing.
|
|
393
|
+
|
|
394
|
+
Call `subscribe()` from a tap handler: the hook never requests permission on
|
|
395
|
+
mount, because browsers (and iOS especially) need a user gesture. `subscribed`
|
|
396
|
+
comes from `pushManager.getSubscription()`, never from a stored flag. The
|
|
397
|
+
service worker comes from `navigator.serviceWorker.ready`; this package never
|
|
398
|
+
registers one for push (use `useServiceWorker`).
|
|
399
|
+
|
|
400
|
+
iOS silently drops push subscriptions. With `resubscribeOnLoad` (default
|
|
401
|
+
`true`), when permission is already `granted` and the browser has no
|
|
402
|
+
subscription, the hook subscribes again without a prompt and calls
|
|
403
|
+
`onSubscribe`. If `onSubscribe` rejects with an error whose `status` is `410`,
|
|
404
|
+
the dead subscription is unsubscribed and one fresh one is made and sent. The
|
|
405
|
+
package never makes HTTP requests: `onSubscribe(subscription:
|
|
406
|
+
PushSubscriptionJSON)` and `onUnsubscribe(endpoint)` are yours. The pure
|
|
407
|
+
`urlBase64ToUint8Array`, `getPushSupport` and `shouldResubscribe` are exported.
|
|
@@ -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
|
+
};
|
|
@@ -11,7 +11,8 @@ var HORIZONTAL_DOMINANCE = 1.5;
|
|
|
11
11
|
function isInOpenZone(x, viewportWidth) {
|
|
12
12
|
return x > BACK_ZONE_PX && x < viewportWidth - FORWARD_ZONE_PX;
|
|
13
13
|
}
|
|
14
|
-
function resolveSwipeIntent(start, end, drawerOpen, viewportWidth) {
|
|
14
|
+
function resolveSwipeIntent(start, end, drawerOpen, viewportWidth, startedInIgnoredRegion = false) {
|
|
15
|
+
if (startedInIgnoredRegion) return null;
|
|
15
16
|
const dx = end.x - start.x;
|
|
16
17
|
const dy = end.y - start.y;
|
|
17
18
|
if (end.time - start.time > MAX_SWIPE_DURATION_MS) return null;
|
|
@@ -33,6 +34,11 @@ function isInsideHorizontalScroller(target) {
|
|
|
33
34
|
}
|
|
34
35
|
return false;
|
|
35
36
|
}
|
|
37
|
+
var DEFAULT_IGNORE_SELECTOR = "[data-no-swipe]";
|
|
38
|
+
function isInIgnoredRegion(target, selector) {
|
|
39
|
+
if (!selector || !(target instanceof Element)) return false;
|
|
40
|
+
return target.closest(selector) !== null;
|
|
41
|
+
}
|
|
36
42
|
|
|
37
43
|
// src/drawer-swipe/use-drawer-swipe.ts
|
|
38
44
|
import { useEffect, useRef } from "react";
|
|
@@ -44,17 +50,19 @@ function useDrawerSwipe({
|
|
|
44
50
|
onOpen,
|
|
45
51
|
onClose,
|
|
46
52
|
enabled = true,
|
|
47
|
-
maxViewportWidth = 768
|
|
53
|
+
maxViewportWidth = 768,
|
|
54
|
+
ignoreSelector = DEFAULT_IGNORE_SELECTOR
|
|
48
55
|
}) {
|
|
49
56
|
const start = useRef(null);
|
|
50
|
-
const handlers = useRef({ drawerOpen, onOpen, onClose, maxViewportWidth });
|
|
51
|
-
handlers.current = { drawerOpen, onOpen, onClose, maxViewportWidth };
|
|
57
|
+
const handlers = useRef({ drawerOpen, onOpen, onClose, maxViewportWidth, ignoreSelector });
|
|
58
|
+
handlers.current = { drawerOpen, onOpen, onClose, maxViewportWidth, ignoreSelector };
|
|
52
59
|
useEffect(() => {
|
|
53
60
|
if (!enabled) return;
|
|
54
61
|
function handleStart(event) {
|
|
55
62
|
start.current = null;
|
|
56
63
|
if (event.touches.length !== 1) return;
|
|
57
64
|
if (isWideViewport(handlers.current.maxViewportWidth)) return;
|
|
65
|
+
if (isInIgnoredRegion(event.target, handlers.current.ignoreSelector)) return;
|
|
58
66
|
if (!handlers.current.drawerOpen && isInsideHorizontalScroller(event.target)) {
|
|
59
67
|
return;
|
|
60
68
|
}
|
|
@@ -111,5 +119,7 @@ export {
|
|
|
111
119
|
isInOpenZone,
|
|
112
120
|
resolveSwipeIntent,
|
|
113
121
|
isInsideHorizontalScroller,
|
|
122
|
+
DEFAULT_IGNORE_SELECTOR,
|
|
123
|
+
isInIgnoredRegion,
|
|
114
124
|
useDrawerSwipe
|
|
115
125
|
};
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
// src/outside-press/outside-press.ts
|
|
4
|
-
function isOutside(target, elements) {
|
|
4
|
+
function isOutside(target, elements, ignoreSelector) {
|
|
5
5
|
if (!target) return false;
|
|
6
6
|
if (!target.isConnected) return false;
|
|
7
|
+
if (ignoreSelector && isInIgnoredRegion(target, ignoreSelector)) return false;
|
|
7
8
|
return !elements.some((el) => el?.contains(target));
|
|
8
9
|
}
|
|
9
10
|
function isPrimaryPress(event) {
|
|
10
11
|
return !(event.pointerType === "mouse" && event.button !== 0);
|
|
11
12
|
}
|
|
13
|
+
function isInIgnoredRegion(target, selector) {
|
|
14
|
+
const element = target instanceof Element ? target : target.parentElement;
|
|
15
|
+
return element?.closest(selector) != null;
|
|
16
|
+
}
|
|
12
17
|
|
|
13
18
|
// src/outside-press/use-outside-press.ts
|
|
14
19
|
import { useEffect, useRef } from "react";
|
|
15
|
-
function useOutsidePress(refs, onOutsidePress, { enabled = true } = {}) {
|
|
20
|
+
function useOutsidePress(refs, onOutsidePress, { enabled = true, ignore } = {}) {
|
|
16
21
|
const refsRef = useRef(refs);
|
|
17
22
|
const handlerRef = useRef(onOutsidePress);
|
|
23
|
+
const ignoreRef = useRef(ignore);
|
|
18
24
|
useEffect(() => {
|
|
25
|
+
ignoreRef.current = ignore;
|
|
19
26
|
refsRef.current = refs;
|
|
20
27
|
handlerRef.current = onOutsidePress;
|
|
21
28
|
});
|
|
@@ -25,7 +32,7 @@ function useOutsidePress(refs, onOutsidePress, { enabled = true } = {}) {
|
|
|
25
32
|
if (!isPrimaryPress(event)) return;
|
|
26
33
|
const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];
|
|
27
34
|
const elements = list.map((ref) => ref.current);
|
|
28
|
-
if (isOutside(event.target, elements)) handlerRef.current(event);
|
|
35
|
+
if (isOutside(event.target, elements, ignoreRef.current)) handlerRef.current(event);
|
|
29
36
|
}
|
|
30
37
|
document.addEventListener("pointerdown", onPointerDown, true);
|
|
31
38
|
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
copyText
|
|
4
|
+
} from "./chunk-FE3BQD2I.js";
|
|
5
|
+
|
|
6
|
+
// src/clipboard/use-copy-feedback.ts
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
|
+
function useCopyFeedback({ resetMs = 1500 } = {}) {
|
|
9
|
+
const [status, setStatus] = useState("idle");
|
|
10
|
+
const timerRef = useRef(void 0);
|
|
11
|
+
const clearTimer = useCallback(() => {
|
|
12
|
+
if (timerRef.current !== void 0) clearTimeout(timerRef.current);
|
|
13
|
+
timerRef.current = void 0;
|
|
14
|
+
}, []);
|
|
15
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
16
|
+
const copy = useCallback(
|
|
17
|
+
async (text) => {
|
|
18
|
+
const result = await copyText(text);
|
|
19
|
+
clearTimer();
|
|
20
|
+
setStatus(result.ok ? "copied" : "failed");
|
|
21
|
+
timerRef.current = setTimeout(() => setStatus("idle"), resetMs);
|
|
22
|
+
return result;
|
|
23
|
+
},
|
|
24
|
+
[clearTimer, resetMs]
|
|
25
|
+
);
|
|
26
|
+
return { copy, status };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/clipboard/read-text.ts
|
|
30
|
+
async function readText() {
|
|
31
|
+
const hasClipboardApi = typeof navigator !== "undefined" && !!navigator.clipboard?.readText && typeof window !== "undefined" && window.isSecureContext;
|
|
32
|
+
if (!hasClipboardApi) return { ok: false, reason: "unavailable" };
|
|
33
|
+
try {
|
|
34
|
+
return { ok: true, text: await navigator.clipboard.readText() };
|
|
35
|
+
} catch {
|
|
36
|
+
return { ok: false, reason: "denied" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
useCopyFeedback,
|
|
42
|
+
readText
|
|
43
|
+
};
|
|
@@ -39,30 +39,6 @@ function execCommandCopy(text) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
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
42
|
export {
|
|
66
|
-
copyText
|
|
67
|
-
useCopyFeedback
|
|
43
|
+
copyText
|
|
68
44
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
COARSE_POINTER_QUERY,
|
|
4
|
+
REDUCED_MOTION_QUERY,
|
|
5
|
+
getMatches,
|
|
6
|
+
subscribe
|
|
7
|
+
} from "./chunk-WVM2LTWQ.js";
|
|
8
|
+
|
|
9
|
+
// src/media-query/use-media-query.ts
|
|
10
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
11
|
+
function useMediaQuery(query, options = {}) {
|
|
12
|
+
const serverDefault = options.serverDefault ?? false;
|
|
13
|
+
const subscribeToQuery = useCallback((onChange) => subscribe(query, onChange), [query]);
|
|
14
|
+
const getSnapshot = useCallback(() => getMatches(query) ?? serverDefault, [query, serverDefault]);
|
|
15
|
+
const getServerSnapshot = useCallback(() => serverDefault, [serverDefault]);
|
|
16
|
+
return useSyncExternalStore(subscribeToQuery, getSnapshot, getServerSnapshot);
|
|
17
|
+
}
|
|
18
|
+
function useReducedMotion(options) {
|
|
19
|
+
return useMediaQuery(REDUCED_MOTION_QUERY, options);
|
|
20
|
+
}
|
|
21
|
+
function useCoarsePointer(options) {
|
|
22
|
+
return useMediaQuery(COARSE_POINTER_QUERY, options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
useMediaQuery,
|
|
27
|
+
useReducedMotion,
|
|
28
|
+
useCoarsePointer
|
|
29
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
isIos,
|
|
4
|
+
isStandalone
|
|
5
|
+
} from "./chunk-YFMMSLMB.js";
|
|
6
|
+
|
|
7
|
+
// src/push/use-push-subscription.ts
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
// src/push/support.ts
|
|
11
|
+
function getPushSupport(env = readPushEnv()) {
|
|
12
|
+
if (env.isIos && !env.isStandalone) return "ios-needs-install";
|
|
13
|
+
if (!env.hasPushManager || !env.hasServiceWorker || !env.hasNotification) return "unsupported";
|
|
14
|
+
return "supported";
|
|
15
|
+
}
|
|
16
|
+
function readPushEnv() {
|
|
17
|
+
const browser = typeof window !== "undefined" && typeof navigator !== "undefined";
|
|
18
|
+
return {
|
|
19
|
+
hasPushManager: browser && "PushManager" in window,
|
|
20
|
+
hasServiceWorker: browser && "serviceWorker" in navigator,
|
|
21
|
+
hasNotification: browser && "Notification" in window,
|
|
22
|
+
isIos: browser && isIos(),
|
|
23
|
+
isStandalone: browser && isStandalone()
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function readPermission() {
|
|
27
|
+
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
|
28
|
+
return Notification.permission;
|
|
29
|
+
}
|
|
30
|
+
function isKeyReady(key) {
|
|
31
|
+
return key.trim().length > 0;
|
|
32
|
+
}
|
|
33
|
+
function shouldResubscribe({ permission, hasSubscription, resubscribeOnLoad, hasKey = true }) {
|
|
34
|
+
return hasKey && resubscribeOnLoad && permission === "granted" && !hasSubscription;
|
|
35
|
+
}
|
|
36
|
+
function isGone(error) {
|
|
37
|
+
return typeof error === "object" && error !== null && error.status === 410;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/push/vapid.ts
|
|
41
|
+
function urlBase64ToUint8Array(key) {
|
|
42
|
+
const padding = "=".repeat((4 - key.length % 4) % 4);
|
|
43
|
+
const base64 = (key + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
44
|
+
const raw = atob(base64);
|
|
45
|
+
const out = new Uint8Array(raw.length);
|
|
46
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/push/register.ts
|
|
51
|
+
function subscribeFresh(reg, vapidPublicKey) {
|
|
52
|
+
return reg.pushManager.subscribe({
|
|
53
|
+
userVisibleOnly: true,
|
|
54
|
+
// lib.dom wants ArrayBufferView<ArrayBuffer>; a Uint8Array satisfies the runtime contract.
|
|
55
|
+
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function ensureSubscribed(reg, { vapidPublicKey, onSubscribe }, existing) {
|
|
59
|
+
let sub = existing ?? await reg.pushManager.getSubscription() ?? await subscribeFresh(reg, vapidPublicKey);
|
|
60
|
+
try {
|
|
61
|
+
await onSubscribe(sub.toJSON());
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!isGone(error)) throw error;
|
|
64
|
+
await sub.unsubscribe();
|
|
65
|
+
sub = await subscribeFresh(reg, vapidPublicKey);
|
|
66
|
+
await onSubscribe(sub.toJSON());
|
|
67
|
+
}
|
|
68
|
+
return sub;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/push/use-push-subscription.ts
|
|
72
|
+
var messageOf = (error, fallback) => error instanceof Error ? error.message : fallback;
|
|
73
|
+
function usePushSubscription(options) {
|
|
74
|
+
const { resubscribeOnLoad = true } = options;
|
|
75
|
+
const [support, setSupport] = useState("unsupported");
|
|
76
|
+
const [permission, setPermission] = useState("unsupported");
|
|
77
|
+
const [subscribed, setSubscribed] = useState(false);
|
|
78
|
+
const [busy, setBusy] = useState(false);
|
|
79
|
+
const [error, setError] = useState(null);
|
|
80
|
+
const hasKey = isKeyReady(options.vapidPublicKey);
|
|
81
|
+
const latest = useRef(options);
|
|
82
|
+
latest.current = options;
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const current = getPushSupport();
|
|
85
|
+
setSupport(current);
|
|
86
|
+
setPermission(readPermission());
|
|
87
|
+
if (current !== "supported") return;
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
(async () => {
|
|
90
|
+
try {
|
|
91
|
+
const reg = await navigator.serviceWorker.ready;
|
|
92
|
+
const existing = await reg.pushManager.getSubscription();
|
|
93
|
+
if (cancelled) return;
|
|
94
|
+
const permissionNow = readPermission();
|
|
95
|
+
setPermission(permissionNow);
|
|
96
|
+
if (existing) return setSubscribed(true);
|
|
97
|
+
if (!shouldResubscribe({ permission: permissionNow, hasSubscription: false, resubscribeOnLoad, hasKey })) return;
|
|
98
|
+
await ensureSubscribed(reg, latest.current);
|
|
99
|
+
if (!cancelled) setSubscribed(true);
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
return () => {
|
|
104
|
+
cancelled = true;
|
|
105
|
+
};
|
|
106
|
+
}, [resubscribeOnLoad, hasKey]);
|
|
107
|
+
const subscribe = useCallback(async () => {
|
|
108
|
+
if (getPushSupport() !== "supported") return;
|
|
109
|
+
if (!isKeyReady(latest.current.vapidPublicKey)) {
|
|
110
|
+
setError("push key not ready");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setError(null);
|
|
114
|
+
setBusy(true);
|
|
115
|
+
try {
|
|
116
|
+
const result = await Notification.requestPermission();
|
|
117
|
+
setPermission(result);
|
|
118
|
+
if (result !== "granted") return;
|
|
119
|
+
const reg = await navigator.serviceWorker.ready;
|
|
120
|
+
await ensureSubscribed(reg, latest.current);
|
|
121
|
+
setSubscribed(true);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
setError(messageOf(e, "subscribe failed"));
|
|
124
|
+
} finally {
|
|
125
|
+
setBusy(false);
|
|
126
|
+
}
|
|
127
|
+
}, []);
|
|
128
|
+
const unsubscribe = useCallback(async () => {
|
|
129
|
+
if (getPushSupport() !== "supported") return;
|
|
130
|
+
setError(null);
|
|
131
|
+
setBusy(true);
|
|
132
|
+
try {
|
|
133
|
+
const reg = await navigator.serviceWorker.ready;
|
|
134
|
+
const sub = await reg.pushManager.getSubscription();
|
|
135
|
+
if (sub) {
|
|
136
|
+
await latest.current.onUnsubscribe(sub.endpoint);
|
|
137
|
+
await sub.unsubscribe();
|
|
138
|
+
}
|
|
139
|
+
setSubscribed(false);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
setError(messageOf(e, "unsubscribe failed"));
|
|
142
|
+
} finally {
|
|
143
|
+
setBusy(false);
|
|
144
|
+
}
|
|
145
|
+
}, []);
|
|
146
|
+
return { support, permission, subscribed, busy, error, subscribe, unsubscribe };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export {
|
|
150
|
+
getPushSupport,
|
|
151
|
+
shouldResubscribe,
|
|
152
|
+
urlBase64ToUint8Array,
|
|
153
|
+
usePushSubscription
|
|
154
|
+
};
|