@develemit/web-hooks 0.2.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +145 -0
  3. package/dist/chunk-4PVVTQ5V.js +74 -0
  4. package/dist/{chunk-OZ4PU4E3.js → chunk-5XRVPZHD.js} +14 -4
  5. package/dist/{chunk-3UGRXQIA.js → chunk-DQBKU7HI.js} +10 -3
  6. package/dist/chunk-EXEJI4FK.js +43 -0
  7. package/dist/{chunk-3CACI4OQ.js → chunk-FE3BQD2I.js} +1 -25
  8. package/dist/chunk-HTVNN2NM.js +29 -0
  9. package/dist/chunk-JG3GPTMY.js +101 -0
  10. package/dist/chunk-TNEJ34T5.js +86 -0
  11. package/dist/chunk-UJ366T3Y.js +146 -0
  12. package/dist/{chunk-UZADAINO.js → chunk-WVM2LTWQ.js} +2 -21
  13. package/dist/chunk-WXL22EWK.js +21 -0
  14. package/dist/chunk-YFMMSLMB.js +41 -0
  15. package/dist/chunk-Z3X27PCA.js +108 -0
  16. package/dist/clipboard.cjs +13 -0
  17. package/dist/clipboard.d.cts +10 -1
  18. package/dist/clipboard.d.ts +10 -1
  19. package/dist/clipboard.js +6 -2
  20. package/dist/drawer-swipe.cjs +16 -4
  21. package/dist/drawer-swipe.d.cts +7 -3
  22. package/dist/drawer-swipe.d.ts +7 -3
  23. package/dist/drawer-swipe.js +5 -1
  24. package/dist/index.cjs +609 -19
  25. package/dist/index.d.cts +8 -2
  26. package/dist/index.d.ts +8 -2
  27. package/dist/index.js +77 -7
  28. package/dist/keyboard-inset.cjs +113 -0
  29. package/dist/keyboard-inset.d.cts +25 -0
  30. package/dist/keyboard-inset.d.ts +25 -0
  31. package/dist/keyboard-inset.js +11 -0
  32. package/dist/media-query.js +5 -3
  33. package/dist/online-status.cjs +46 -0
  34. package/dist/online-status.d.cts +3 -0
  35. package/dist/online-status.d.ts +3 -0
  36. package/dist/online-status.js +7 -0
  37. package/dist/outside-press.cjs +10 -3
  38. package/dist/outside-press.d.cts +4 -2
  39. package/dist/outside-press.d.ts +4 -2
  40. package/dist/outside-press.js +1 -1
  41. package/dist/push.cjs +201 -0
  42. package/dist/push.d.cts +37 -0
  43. package/dist/push.d.ts +37 -0
  44. package/dist/push.js +14 -0
  45. package/dist/pwa.cjs +198 -0
  46. package/dist/pwa.d.cts +35 -0
  47. package/dist/pwa.d.ts +35 -0
  48. package/dist/pwa.js +22 -0
  49. package/dist/service-worker.cjs +128 -0
  50. package/dist/service-worker.d.cts +23 -0
  51. package/dist/service-worker.d.ts +23 -0
  52. package/dist/service-worker.js +11 -0
  53. package/dist/share.cjs +139 -0
  54. package/dist/share.d.cts +23 -0
  55. package/dist/share.d.ts +23 -0
  56. package/dist/share.js +16 -0
  57. package/package.json +31 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 0.2.0 - 2026-11-05
4
16
 
5
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.
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,129 @@ 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
+ });
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,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,101 @@
1
+ "use client";
2
+
3
+ // src/service-worker/use-service-worker.ts
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+
6
+ // src/service-worker/update-state.ts
7
+ function deriveUpdateState({ hasController: hasController2, waiting, installingState }) {
8
+ if (!hasController2) return false;
9
+ return waiting || installingState === "installed";
10
+ }
11
+
12
+ // src/service-worker/register.ts
13
+ var reloaded = false;
14
+ function isServiceWorkerSupported() {
15
+ return typeof navigator !== "undefined" && "serviceWorker" in navigator;
16
+ }
17
+ var hasController = () => Boolean(navigator.serviceWorker.controller);
18
+ async function unregisterAll() {
19
+ if (!isServiceWorkerSupported()) return;
20
+ try {
21
+ const registrations = await navigator.serviceWorker.getRegistrations();
22
+ await Promise.all(registrations.map((r) => r.unregister()));
23
+ } catch {
24
+ }
25
+ }
26
+ function registerServiceWorker({ url, scope, onUpdateAvailable }, { reload = () => window.location.reload() } = {}) {
27
+ let registration;
28
+ let disposed = false;
29
+ let applying = false;
30
+ const cleanups = [];
31
+ const controlledAtStart = hasController();
32
+ const onControllerChange = () => {
33
+ if (!(applying || controlledAtStart) || reloaded) return;
34
+ reloaded = true;
35
+ reload();
36
+ };
37
+ navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
38
+ cleanups.push(() => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange));
39
+ const watchInstalling = (reg) => {
40
+ const worker = reg.installing;
41
+ if (!worker) return;
42
+ const onState = () => {
43
+ if (deriveUpdateState({ hasController: hasController(), waiting: false, installingState: worker.state })) {
44
+ onUpdateAvailable();
45
+ }
46
+ };
47
+ worker.addEventListener("statechange", onState);
48
+ cleanups.push(() => worker.removeEventListener("statechange", onState));
49
+ };
50
+ navigator.serviceWorker.register(url, scope === void 0 ? void 0 : { scope }).then((reg) => {
51
+ if (disposed) return;
52
+ registration = reg;
53
+ if (deriveUpdateState({ hasController: hasController(), waiting: Boolean(reg.waiting) })) {
54
+ onUpdateAvailable();
55
+ }
56
+ const onUpdateFound = () => watchInstalling(reg);
57
+ reg.addEventListener("updatefound", onUpdateFound);
58
+ cleanups.push(() => reg.removeEventListener("updatefound", onUpdateFound));
59
+ }).catch(() => {
60
+ });
61
+ return {
62
+ applyUpdate() {
63
+ const waiting = registration?.waiting;
64
+ if (!waiting) return;
65
+ applying = true;
66
+ waiting.postMessage({ type: "SKIP_WAITING" });
67
+ },
68
+ dispose() {
69
+ disposed = true;
70
+ cleanups.splice(0).forEach((fn) => fn());
71
+ }
72
+ };
73
+ }
74
+
75
+ // src/service-worker/use-service-worker.ts
76
+ function useServiceWorker({ url, scope, enabled = true }) {
77
+ const [supported, setSupported] = useState(true);
78
+ const [updateAvailable, setUpdateAvailable] = useState(false);
79
+ const handle = useRef(void 0);
80
+ useEffect(() => {
81
+ if (!enabled) return;
82
+ if (!isServiceWorkerSupported()) {
83
+ setSupported(false);
84
+ return;
85
+ }
86
+ const registered = registerServiceWorker({ url, scope, onUpdateAvailable: () => setUpdateAvailable(true) });
87
+ handle.current = registered;
88
+ return () => {
89
+ registered.dispose();
90
+ handle.current = void 0;
91
+ };
92
+ }, [url, scope, enabled]);
93
+ const applyUpdate = useCallback(() => handle.current?.applyUpdate(), []);
94
+ return { supported, updateAvailable, applyUpdate };
95
+ }
96
+
97
+ export {
98
+ deriveUpdateState,
99
+ unregisterAll,
100
+ useServiceWorker
101
+ };
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ // src/keyboard-inset/use-keyboard-inset.ts
4
+ import { useEffect, useState } from "react";
5
+
6
+ // src/keyboard-inset/compute-inset.ts
7
+ var DEFAULT_MIN_INSET = 150;
8
+ function computeKeyboardInset(r) {
9
+ if (!r.editableFocused) return 0;
10
+ const occluded = r.layoutHeight - r.visualHeight - r.visualOffsetTop;
11
+ return occluded >= (r.minInset ?? DEFAULT_MIN_INSET) ? Math.round(occluded) : 0;
12
+ }
13
+ var NON_TEXT_INPUT_TYPES = /* @__PURE__ */ new Set([
14
+ "button",
15
+ "checkbox",
16
+ "radio",
17
+ "range",
18
+ "submit",
19
+ "reset",
20
+ "file",
21
+ "color",
22
+ "image",
23
+ "hidden"
24
+ ]);
25
+ function isEditableElement(el) {
26
+ if (!el) return false;
27
+ const tag = el.tagName.toLowerCase();
28
+ if (tag === "select") return !el.disabled;
29
+ if (tag === "textarea" || tag === "input") {
30
+ const field = el;
31
+ if (field.readOnly || field.disabled) return false;
32
+ return tag === "textarea" || !NON_TEXT_INPUT_TYPES.has(field.type);
33
+ }
34
+ if (el.isContentEditable === true) return true;
35
+ const attr = el.getAttribute("contenteditable");
36
+ return attr !== null && attr !== "false";
37
+ }
38
+
39
+ // src/keyboard-inset/use-keyboard-inset.ts
40
+ function useKeyboardInset({ minInset = DEFAULT_MIN_INSET, enabled = true } = {}) {
41
+ const [inset, setInset] = useState(0);
42
+ useEffect(() => {
43
+ const vv = typeof window === "undefined" ? null : window.visualViewport;
44
+ if (!enabled || !vv) {
45
+ setInset(0);
46
+ return;
47
+ }
48
+ const update = () => setInset(
49
+ computeKeyboardInset({
50
+ layoutHeight: window.innerHeight,
51
+ visualHeight: vv.height,
52
+ visualOffsetTop: vv.offsetTop,
53
+ editableFocused: isEditableElement(document.activeElement),
54
+ minInset
55
+ })
56
+ );
57
+ let frame = 0;
58
+ const onFocusOut = () => {
59
+ cancelAnimationFrame(frame);
60
+ frame = requestAnimationFrame(update);
61
+ };
62
+ const onFocusIn = () => {
63
+ cancelAnimationFrame(frame);
64
+ update();
65
+ };
66
+ vv.addEventListener("resize", update);
67
+ vv.addEventListener("scroll", update);
68
+ document.addEventListener("focusin", onFocusIn);
69
+ document.addEventListener("focusout", onFocusOut);
70
+ update();
71
+ return () => {
72
+ cancelAnimationFrame(frame);
73
+ vv.removeEventListener("resize", update);
74
+ vv.removeEventListener("scroll", update);
75
+ document.removeEventListener("focusin", onFocusIn);
76
+ document.removeEventListener("focusout", onFocusOut);
77
+ };
78
+ }, [enabled, minInset]);
79
+ return inset;
80
+ }
81
+
82
+ export {
83
+ computeKeyboardInset,
84
+ isEditableElement,
85
+ useKeyboardInset
86
+ };