@develemit/web-hooks 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -2
  4. package/dist/auto-grow.cjs +60 -0
  5. package/dist/auto-grow.d.cts +14 -0
  6. package/dist/auto-grow.d.ts +14 -0
  7. package/dist/auto-grow.js +9 -0
  8. package/dist/chunk-3CACI4OQ.js +68 -0
  9. package/dist/chunk-3UGRXQIA.js +39 -0
  10. package/dist/chunk-5CA5RIMH.js +34 -0
  11. package/dist/chunk-5KIH2XLV.js +136 -0
  12. package/dist/chunk-6KCRVSLS.js +80 -0
  13. package/dist/chunk-DNU57XEJ.js +82 -0
  14. package/dist/chunk-IWQDAJEP.js +69 -0
  15. package/dist/chunk-LZKAGDER.js +22 -0
  16. package/dist/chunk-NKLEB5TR.js +98 -0
  17. package/dist/chunk-OZ4PU4E3.js +115 -0
  18. package/dist/chunk-S4YKMGQE.js +23 -0
  19. package/dist/chunk-UPVNSGIK.js +72 -0
  20. package/dist/chunk-UZADAINO.js +61 -0
  21. package/dist/clipboard.cjs +94 -0
  22. package/dist/clipboard.d.cts +17 -0
  23. package/dist/clipboard.d.ts +17 -0
  24. package/dist/clipboard.js +9 -0
  25. package/dist/debounce.cjs +96 -0
  26. package/dist/debounce.d.cts +21 -0
  27. package/dist/debounce.d.ts +21 -0
  28. package/dist/debounce.js +11 -0
  29. package/dist/drawer-swipe.cjs +149 -0
  30. package/dist/drawer-swipe.d.cts +26 -0
  31. package/dist/drawer-swipe.d.ts +26 -0
  32. package/dist/drawer-swipe.js +25 -0
  33. package/dist/escape.cjs +48 -0
  34. package/dist/escape.d.cts +3 -0
  35. package/dist/escape.d.ts +3 -0
  36. package/dist/escape.js +7 -0
  37. package/dist/focus-trap.cjs +110 -0
  38. package/dist/focus-trap.d.cts +17 -0
  39. package/dist/focus-trap.d.ts +17 -0
  40. package/dist/focus-trap.js +13 -0
  41. package/dist/hotkey.cjs +127 -0
  42. package/dist/hotkey.d.cts +31 -0
  43. package/dist/hotkey.d.ts +31 -0
  44. package/dist/hotkey.js +15 -0
  45. package/dist/index.cjs +918 -0
  46. package/dist/index.d.cts +14 -0
  47. package/dist/index.d.ts +14 -0
  48. package/dist/index.js +115 -0
  49. package/dist/media-query.cjs +90 -0
  50. package/dist/media-query.d.cts +12 -0
  51. package/dist/media-query.d.ts +12 -0
  52. package/dist/media-query.js +15 -0
  53. package/dist/outside-press.cjs +66 -0
  54. package/dist/outside-press.d.cts +15 -0
  55. package/dist/outside-press.d.ts +15 -0
  56. package/dist/outside-press.js +11 -0
  57. package/dist/scroll-lock.cjs +107 -0
  58. package/dist/scroll-lock.d.cts +8 -0
  59. package/dist/scroll-lock.d.ts +8 -0
  60. package/dist/scroll-lock.js +11 -0
  61. package/dist/storage-state.cjs +161 -0
  62. package/dist/storage-state.d.cts +14 -0
  63. package/dist/storage-state.d.ts +14 -0
  64. package/dist/storage-state.js +7 -0
  65. package/dist/unsaved-guard.cjs +48 -0
  66. package/dist/unsaved-guard.d.cts +5 -0
  67. package/dist/unsaved-guard.d.ts +5 -0
  68. package/dist/unsaved-guard.js +9 -0
  69. package/dist/visibility-polling.cjs +98 -0
  70. package/dist/visibility-polling.d.cts +14 -0
  71. package/dist/visibility-polling.d.ts +14 -0
  72. package/dist/visibility-polling.js +9 -0
  73. package/package.json +126 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 - 2026-11-05
4
+
5
+ - `unsaved-guard`: `useUnsavedChangesGuard(dirty)` and the pure `handleBeforeUnload`. Attached only while dirty and sets `returnValue = ''` as well as `preventDefault()`, which older Safari and Chrome need. `beforeunload` only; in-app navigation stays in the app.
6
+ - `hotkey`: `useHotkey('mod+k', fn, { enabled, allowInInputs, preventDefault })` and the pure `parseHotkey`, `matchHotkey`, `detectApple`. Ignores case and Caps Lock, `mod` is Cmd on Apple and Ctrl elsewhere, exact modifier match, text fields skipped by default.
7
+ - `auto-grow`: `useAutoGrowTextarea(ref, value, { maxHeight })` and the pure `nextHeight`. Re-measures on value change and on `visualViewport` resize (keyboard), with a `window` fallback; `overflowY: auto` only when capped.
8
+ - `debounce`: `useDebouncedValue(value, ms)`, `useDebouncedCallback(fn, ms)` (returns a stable function with `cancel` and `flush`, cancels on unmount, always calls the latest `fn`) and the pure `createDebouncer`.
9
+ - `storage-state`: `useStorageState(key, defaultValue, { storage, serialize, parse })` returning `[value, set, remove]`. Built on `useSyncExternalStore`, so the first client render matches the server (the default) with no hydration mismatch; stays in sync across hooks and tabs; bad JSON gives the default; a refused `setItem` (quota, Safari private mode) is kept in memory so the UI still updates.
10
+ - `visibility-polling`: `useVisibilityPolling(fn, intervalMs, { enabled, immediate, jitter, runOnVisible })` and the pure `nextDelay`. Chained timeouts so a slow run never overlaps, paused while the page is hidden (a locked phone makes no calls), one run on becoming visible, and the listener is always removed on unmount.
11
+ - `outside-press`: `useOutsidePress(refs, onOutsidePress, { enabled })` and the pure `isOutside`. Listens to `pointerdown` in the capture phase so taps on non-clickable elements close menus on iPhone, only while enabled, with the trigger and portalled panels as inside refs.
12
+ - `media-query`: `useMediaQuery(query, { serverDefault })`, `useReducedMotion` and `useCoarsePointer`. Correct on the first client render, no hydration mismatch, safe without `matchMedia`, and one shared `MediaQueryList` per query.
13
+ - `scroll-lock`: `useScrollLock` and `lockScroll()` / `unlockScroll()`. Counted locks, `position: fixed` so iOS Safari and standalone PWAs stop scrolling the background, exact restore of previous inline styles and scroll position, scrollbar-width padding on desktop.
14
+ - `clipboard`: the `execCommand` fallback now puts focus back on the element that had it (and never scrolls to the temporary textarea). It used to leave `<body>` focused after a copy on plain-http PWAs.
15
+ - `focus-trap`: under StrictMode the captured opener was dropped by the simulated effect cleanup, so closing a trap (e.g. a dialog stacked in an open nav) left focus on `<body>`. The opener is now cleared when the trap deactivates.
16
+ - `escape`: `useEscape` ignores Escape pressed during IME composition (`isComposing` or `keyCode` 229), so cancelling a composition no longer closes the menu.
17
+ - The root `@develemit/web-hooks` entry now carries the `"use client"` banner like the subpath entries, so a server component importing from the root fails loudly in Next.
18
+
19
+ ## 0.1.0
20
+
21
+ - `drawer-swipe`: `useDrawerSwipe` and the pure `resolveSwipeIntent`. Swipe right from mid-screen opens a mobile nav drawer, left closes it. The left 60px band is left to the browser's back gesture.
22
+ - `focus-trap`: `useFocusTrap` and the pure `focusableWithin` / `nextFocusTarget`. Reconciles the emit-vision, emit-social, develemit-commerce and centraflow copies: initial focus, Tab wrapping, `returnFocusRef` for Safari, opener captured during render, hidden/disabled/inert items skipped.
23
+ - `escape`: `useEscape`. The callback is read through a ref, so changing its identity doesn't re-bind the listener.
24
+ - `clipboard`: `copyText` and `useCopyFeedback`. Copy works on plain-http PWAs where `navigator.clipboard` is undefined (falls back to `execCommand`) and returns a typed `CopyResult` instead of rejecting. The hook tracks `idle | copied | failed` and clears its timer on unmount and on each new copy.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emit Dutcher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,250 @@
1
- # Temporary Holding Version
1
+ # @develemit/web-hooks
2
+
3
+ Shared React behavior hooks for develemit web apps: the interaction plumbing
4
+ that every app ends up hand-copying from the last one.
5
+
6
+ | Subpath | What it does |
7
+ |---|---|
8
+ | `@develemit/web-hooks/drawer-swipe` | Swipe right from mid-screen to open a mobile nav drawer, left to close it |
9
+ | `@develemit/web-hooks/focus-trap` | Keep Tab focus inside an open dialog or drawer, restore it on close |
10
+ | `@develemit/web-hooks/escape` | Run a callback when Escape is pressed |
11
+ | `@develemit/web-hooks/clipboard` | Copy text, with the fallback that works on plain-http PWAs |
12
+ | `@develemit/web-hooks/scroll-lock` | Lock page scroll behind a modal or drawer, counted, and it works on iOS |
13
+ | `@develemit/web-hooks/media-query` | Track a media query, reduced motion or a coarse pointer, with no hydration mismatch |
14
+ | `@develemit/web-hooks/outside-press` | Close a menu or popover on a press outside it, including taps on iPhone |
15
+ | `@develemit/web-hooks/visibility-polling` | Poll on an interval that pauses while the page is hidden |
16
+ | `@develemit/web-hooks/debounce` | Debounce a value or a callback |
17
+ | `@develemit/web-hooks/storage-state` | State persisted to localStorage or sessionStorage, SSR-safe and synced across tabs |
18
+ | `@develemit/web-hooks/unsaved-guard` | Warn before the tab closes while a form is dirty |
19
+ | `@develemit/web-hooks/hotkey` | Keyboard shortcuts such as `mod+k` |
20
+ | `@develemit/web-hooks/auto-grow` | Grow a textarea with its content, keyboard-aware |
21
+
22
+ Behavior only. No styling and no components: each app keeps its own look.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pnpm add @develemit/web-hooks
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```tsx
33
+ import { useDrawerSwipe } from '@develemit/web-hooks/drawer-swipe';
34
+
35
+ useDrawerSwipe({
36
+ drawerOpen,
37
+ onOpen: () => setDrawerOpen(true),
38
+ onClose: () => setDrawerOpen(false),
39
+ enabled: true, // optional, default true
40
+ maxViewportWidth: 768, // optional; no-op at or above this width
41
+ });
42
+ ```
43
+
44
+ The left 60px band is left to the browser's back gesture, and swipes that start
45
+ inside a horizontally scrolling container stay with that container.
46
+
47
+ ### Drawer with focus trap and Escape
48
+
49
+ ```tsx
50
+ import { useRef } from 'react';
51
+ import { useFocusTrap } from '@develemit/web-hooks/focus-trap';
52
+ import { useEscape } from '@develemit/web-hooks/escape';
53
+
54
+ const drawerRef = useRef<HTMLElement>(null);
55
+ const triggerRef = useRef<HTMLButtonElement>(null);
56
+
57
+ useFocusTrap(drawerRef, {
58
+ active: open,
59
+ initialFocusRef, // optional; default is the first focusable item
60
+ returnFocusRef: triggerRef, // optional; wins over the element focused at open
61
+ restoreFocus: true, // optional, default true
62
+ });
63
+ useEscape(() => setOpen(false), open);
64
+ ```
65
+
66
+ Pass `returnFocusRef` when the opener is a button: Safari doesn't focus a
67
+ button on tap, so the element focused at open can be `<body>`. If nothing
68
+ inside is focusable the container itself gets focus (`tabindex="-1"` is added).
69
+ `useEscape` reads the callback through a ref, so it doesn't need `useCallback`.
70
+ When traps stack, only the most recently activated one handles Tab.
71
+
72
+ ### Copy to clipboard
73
+
74
+ ```tsx
75
+ import { useCopyFeedback } from '@develemit/web-hooks/clipboard';
76
+
77
+ const { copy, status } = useCopyFeedback({ resetMs: 1500 });
78
+ <button onClick={() => copy(token)}>{status === 'copied' ? 'Copied!' : 'Copy'}</button>;
79
+ ```
80
+
81
+ `copyText(text)` is the plain function underneath and resolves to
82
+ `{ ok: true }` or `{ ok: false, reason: 'unavailable' | 'denied' }`; it never
83
+ rejects. On plain-http origins (a PWA over a Tailscale address)
84
+ `navigator.clipboard` doesn't exist, so it falls back to `execCommand('copy')`.
85
+
86
+ ### Lock page scroll behind an overlay
87
+
88
+ ```tsx
89
+ import { useScrollLock } from '@develemit/web-hooks/scroll-lock';
90
+
91
+ useScrollLock(open);
92
+ ```
93
+
94
+ Uses `position: fixed` on `body` because iOS Safari ignores `overflow: hidden`
95
+ for touch scrolling. Locks are counted, so a lightbox closing inside an open
96
+ drawer leaves the page locked. When the last lock releases, the exact previous
97
+ inline styles and the scroll position come back. `lockScroll()` returns a
98
+ release function (safe to call twice) for use outside React.
99
+
100
+ ### Match a media query
101
+
102
+ ```tsx
103
+ import { useMediaQuery, useReducedMotion, useCoarsePointer } from '@develemit/web-hooks/media-query';
104
+
105
+ const narrow = useMediaQuery('(max-width: 767px)', { serverDefault: false });
106
+ const reduce = useReducedMotion();
107
+ const touch = useCoarsePointer();
108
+ ```
109
+
110
+ Built on `useSyncExternalStore`, so the first client render already has the
111
+ real value (no desktop frame on a phone). The server render and hydration use
112
+ `serverDefault` (default `false`); React then re-renders with the real value
113
+ without a mismatch warning. There are no built-in breakpoints: pass your own
114
+ query. Where `matchMedia` is missing (jsdom tests) the hook returns
115
+ `serverDefault` instead of throwing. Hooks with the same query share one
116
+ `MediaQueryList`, and Safari before 14 is supported through `addListener`.
117
+
118
+ ### Close on an outside press
119
+
120
+ ```tsx
121
+ import { useOutsidePress } from '@develemit/web-hooks/outside-press';
122
+ import { useEscape } from '@develemit/web-hooks/escape';
123
+
124
+ const menu = useRef<HTMLDivElement>(null);
125
+ const trigger = useRef<HTMLButtonElement>(null);
126
+ useOutsidePress([menu, trigger], () => setOpen(false), { enabled: open });
127
+ useEscape(() => setOpen(false), open);
128
+ ```
129
+
130
+ Listens to `pointerdown` on `document` in the capture phase, only while
131
+ `enabled`. `mousedown` never fires on iOS for a tap on a non-clickable element,
132
+ so copies built on it leave the menu open. Pass the trigger as well as the
133
+ panel, or tapping the trigger closes and instantly reopens the menu. Pass a
134
+ portalled panel's ref too. Right clicks are ignored, and a target removed from
135
+ the DOM during the event counts as inside. The callback is read through a ref,
136
+ so it needs no `useCallback`.
137
+
138
+ ### visibility-polling
139
+
140
+ ```tsx
141
+ import { useVisibilityPolling } from '@develemit/web-hooks/visibility-polling';
142
+
143
+ useVisibilityPolling(() => refresh(), 5000, { jitter: 0.2 });
144
+ ```
145
+
146
+ Runs `fn` every `intervalMs` only while the page is visible. When the tab is
147
+ hidden (a locked phone) the timer is cleared; when it is visible again `fn`
148
+ runs once right away and the schedule resumes. The next run is scheduled after
149
+ the previous one finishes, so a slow promise never overlaps. Options:
150
+ `enabled` (default true), `immediate` (run on mount, default true), `jitter`
151
+ (0–1 fraction of the interval added at random, default 0) and `runOnVisible`
152
+ (default true). `fn` is read through a ref. A throw or rejection doesn't stop
153
+ the schedule; handle errors inside `fn`. It decides when to run, not caching
154
+ or dedupe.
155
+
156
+ ### debounce
157
+
158
+ ```tsx
159
+ import { useDebouncedValue, useDebouncedCallback } from '@develemit/web-hooks/debounce';
160
+
161
+ const debouncedSearch = useDebouncedValue(search, 300);
162
+
163
+ const run = useDebouncedCallback((q: string) => fetchResults(q), 300);
164
+ run('inv'); // later calls reset the timer; only the last runs
165
+ run.cancel(); // drop the pending call
166
+ run.flush(); // run it now
167
+ ```
168
+
169
+ `useDebouncedValue` returns the value once it has stopped changing for `ms`.
170
+ `useDebouncedCallback` returns one function for the life of the component; it
171
+ always calls the latest `fn` and `ms`, so it needs no `useCallback`, and a
172
+ pending call is cancelled on unmount.
173
+
174
+ ### storage-state
175
+
176
+ ```tsx
177
+ import { useStorageState } from '@develemit/web-hooks/storage-state';
178
+
179
+ const [recent, setRecent, clearRecent] = useStorageState<string[]>('recent', []);
180
+ setRecent((prev) => ['pnpm dev', ...prev]);
181
+ const [draft, setDraft] = useStorageState('draft', '', { storage: 'session' });
182
+ ```
183
+
184
+ The server and the first client render both return `defaultValue`; the stored
185
+ value arrives right after hydration, so there is no mismatch. Hooks with the
186
+ same key stay in sync in this tab, and other tabs through the `storage` event.
187
+ The setter takes a value or an updater. Options: `storage` (`'local'` default,
188
+ or `'session'`), `serialize` and `parse` (JSON by default; a `parse` that
189
+ throws gives `defaultValue`, so legacy non-JSON values and validators both
190
+ work). A refused `setItem` (full quota, Safari private mode) never throws: the
191
+ new value is kept in memory for the rest of the page's life and the UI
192
+ updates. Keep `defaultValue` stable (a constant or a primitive); it is read
193
+ again only when the stored value or key changes.
194
+
195
+ ### Migrating from the per-app copies
196
+
197
+ | App | Old | New |
198
+ |---|---|---|
199
+ | emit-vision | `useFocusTrap(ref, { active, initialFocusRef, restoreFocus })` | Same signature. |
200
+ | emit-social | `useFocusTrap(ref, { active, returnFocusRef })` plus `useEscape(fn, active)` | Same signatures. |
201
+ | develemit-commerce | `const onKeyDown = useFocusTrap(ref, active, onEscape)` | `useFocusTrap(ref, { active })` and `useEscape(onEscape, active)`. Drop the returned `onKeyDown` handler. |
202
+ | centraflow | `const ref = useFocusTrap(active)` | `const ref = useRef<HTMLElement>(null)`, then `useFocusTrap(ref, { active })`. |
203
+
204
+ ### unsaved-guard
205
+
206
+ ```tsx
207
+ import { useUnsavedChangesGuard } from '@develemit/web-hooks/unsaved-guard';
208
+
209
+ useUnsavedChangesGuard(isDirty);
210
+ ```
211
+
212
+ While `dirty` is true the browser asks before the tab closes or reloads. It
213
+ sets both `preventDefault()` and `returnValue = ''`, because older Safari and
214
+ Chrome ignore `preventDefault()` alone. This covers `beforeunload` only:
215
+ Next's App Router has no stable way to block in-app navigation, so that stays
216
+ in the app.
217
+
218
+ ### hotkey
219
+
220
+ ```tsx
221
+ import { useHotkey, matchHotkey } from '@develemit/web-hooks/hotkey';
222
+
223
+ useHotkey('mod+k', () => setOpen((o) => !o));
224
+ useHotkey('mod+k', toggle, { allowInInputs: true, preventDefault: false });
225
+ ```
226
+
227
+ Combos are modifiers (`mod`, `ctrl`, `meta`, `alt`, `shift`) then one key:
228
+ `mod+k`, `shift+/`, `escape`. `mod` is Cmd on Apple devices (iPad in desktop
229
+ mode included) and Ctrl elsewhere. Case and Caps Lock don't matter, and the
230
+ modifier set must match exactly. Text fields (`input`, `textarea`, `select`,
231
+ contenteditable) are skipped unless `allowInInputs`. Options: `enabled`
232
+ (default true), `allowInInputs` (false), `preventDefault` (true). `matchHotkey(event,
233
+ combo, isApple)` is the pure matcher; `detectApple(navigator)` supplies
234
+ `isApple`. No key sequences.
235
+
236
+ ### auto-grow
237
+
238
+ ```tsx
239
+ import { useAutoGrowTextarea } from '@develemit/web-hooks/auto-grow';
240
+
241
+ const ref = useRef<HTMLTextAreaElement>(null);
242
+ useAutoGrowTextarea(ref, value, { maxHeight: 240 });
243
+ ```
244
+
245
+ Resizes the textarea to fit its content on every `value` change and whenever
246
+ the visual viewport resizes (the iPhone keyboard opening or closing), falling
247
+ back to `window` `resize`. At `maxHeight` it stops growing and sets
248
+ `overflowY: auto`. The pure `nextHeight(scrollHeight, maxHeight)` does the
249
+ clamping.
2
250
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/auto-grow/index.ts
22
+ var auto_grow_exports = {};
23
+ __export(auto_grow_exports, {
24
+ nextHeight: () => nextHeight,
25
+ useAutoGrowTextarea: () => useAutoGrowTextarea
26
+ });
27
+ module.exports = __toCommonJS(auto_grow_exports);
28
+
29
+ // src/auto-grow/next-height.ts
30
+ function nextHeight(scrollHeight, maxHeight) {
31
+ const content = Math.ceil(scrollHeight);
32
+ if (maxHeight !== void 0 && content > maxHeight) return { height: maxHeight, capped: true };
33
+ return { height: content, capped: false };
34
+ }
35
+
36
+ // src/auto-grow/use-auto-grow-textarea.ts
37
+ var import_react = require("react");
38
+ var useIsomorphicLayoutEffect = typeof window === "undefined" ? import_react.useEffect : import_react.useLayoutEffect;
39
+ function useAutoGrowTextarea(ref, value, { maxHeight } = {}) {
40
+ const resize = (0, import_react.useCallback)(() => {
41
+ const el = ref.current;
42
+ if (!el) return;
43
+ el.style.height = "auto";
44
+ const { height, capped } = nextHeight(el.scrollHeight, maxHeight);
45
+ el.style.height = `${height}px`;
46
+ el.style.overflowY = capped ? "auto" : "hidden";
47
+ }, [ref, maxHeight]);
48
+ useIsomorphicLayoutEffect(resize, [resize, value]);
49
+ (0, import_react.useEffect)(() => {
50
+ const viewport = window.visualViewport;
51
+ const target = viewport ?? window;
52
+ target.addEventListener("resize", resize);
53
+ return () => target.removeEventListener("resize", resize);
54
+ }, [resize]);
55
+ }
56
+ // Annotate the CommonJS export names for ESM import in node:
57
+ 0 && (module.exports = {
58
+ nextHeight,
59
+ useAutoGrowTextarea
60
+ });
@@ -0,0 +1,14 @@
1
+ import { RefObject } from 'react';
2
+
3
+ interface NextHeight {
4
+ height: number;
5
+ capped: boolean;
6
+ }
7
+ declare function nextHeight(scrollHeight: number, maxHeight?: number): NextHeight;
8
+
9
+ interface UseAutoGrowTextareaOptions {
10
+ maxHeight?: number;
11
+ }
12
+ declare function useAutoGrowTextarea(ref: RefObject<HTMLTextAreaElement | null>, value: string, { maxHeight }?: UseAutoGrowTextareaOptions): void;
13
+
14
+ export { type NextHeight, type UseAutoGrowTextareaOptions, nextHeight, useAutoGrowTextarea };
@@ -0,0 +1,14 @@
1
+ import { RefObject } from 'react';
2
+
3
+ interface NextHeight {
4
+ height: number;
5
+ capped: boolean;
6
+ }
7
+ declare function nextHeight(scrollHeight: number, maxHeight?: number): NextHeight;
8
+
9
+ interface UseAutoGrowTextareaOptions {
10
+ maxHeight?: number;
11
+ }
12
+ declare function useAutoGrowTextarea(ref: RefObject<HTMLTextAreaElement | null>, value: string, { maxHeight }?: UseAutoGrowTextareaOptions): void;
13
+
14
+ export { type NextHeight, type UseAutoGrowTextareaOptions, nextHeight, useAutoGrowTextarea };
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import {
3
+ nextHeight,
4
+ useAutoGrowTextarea
5
+ } from "./chunk-5CA5RIMH.js";
6
+ export {
7
+ nextHeight,
8
+ useAutoGrowTextarea
9
+ };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ // src/clipboard/copy-text.ts
4
+ async function copyText(text) {
5
+ const hasClipboardApi = typeof navigator !== "undefined" && !!navigator.clipboard?.writeText && typeof window !== "undefined" && window.isSecureContext;
6
+ if (hasClipboardApi) {
7
+ try {
8
+ await navigator.clipboard.writeText(text);
9
+ return { ok: true };
10
+ } catch {
11
+ }
12
+ }
13
+ if (execCommandCopy(text)) return { ok: true };
14
+ return { ok: false, reason: hasClipboardApi ? "denied" : "unavailable" };
15
+ }
16
+ function execCommandCopy(text) {
17
+ if (typeof document === "undefined") return false;
18
+ const textarea = document.createElement("textarea");
19
+ textarea.value = text;
20
+ textarea.readOnly = true;
21
+ textarea.style.position = "fixed";
22
+ textarea.style.top = "-1000px";
23
+ textarea.style.left = "-1000px";
24
+ textarea.style.fontSize = "16px";
25
+ const previouslyFocused = document.activeElement;
26
+ document.body.appendChild(textarea);
27
+ try {
28
+ textarea.focus({ preventScroll: true });
29
+ textarea.select();
30
+ textarea.setSelectionRange(0, text.length);
31
+ return document.execCommand("copy");
32
+ } catch {
33
+ return false;
34
+ } finally {
35
+ document.body.removeChild(textarea);
36
+ if (previouslyFocused instanceof HTMLElement && previouslyFocused !== document.body) {
37
+ previouslyFocused.focus({ preventScroll: true });
38
+ }
39
+ }
40
+ }
41
+
42
+ // src/clipboard/use-copy-feedback.ts
43
+ import { useCallback, useEffect, useRef, useState } from "react";
44
+ function useCopyFeedback({ resetMs = 1500 } = {}) {
45
+ const [status, setStatus] = useState("idle");
46
+ const timerRef = useRef(void 0);
47
+ const clearTimer = useCallback(() => {
48
+ if (timerRef.current !== void 0) clearTimeout(timerRef.current);
49
+ timerRef.current = void 0;
50
+ }, []);
51
+ useEffect(() => clearTimer, [clearTimer]);
52
+ const copy = useCallback(
53
+ async (text) => {
54
+ const result = await copyText(text);
55
+ clearTimer();
56
+ setStatus(result.ok ? "copied" : "failed");
57
+ timerRef.current = setTimeout(() => setStatus("idle"), resetMs);
58
+ return result;
59
+ },
60
+ [clearTimer, resetMs]
61
+ );
62
+ return { copy, status };
63
+ }
64
+
65
+ export {
66
+ copyText,
67
+ useCopyFeedback
68
+ };
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ // src/outside-press/outside-press.ts
4
+ function isOutside(target, elements) {
5
+ if (!target) return false;
6
+ if (!target.isConnected) return false;
7
+ return !elements.some((el) => el?.contains(target));
8
+ }
9
+ function isPrimaryPress(event) {
10
+ return !(event.pointerType === "mouse" && event.button !== 0);
11
+ }
12
+
13
+ // src/outside-press/use-outside-press.ts
14
+ import { useEffect, useRef } from "react";
15
+ function useOutsidePress(refs, onOutsidePress, { enabled = true } = {}) {
16
+ const refsRef = useRef(refs);
17
+ const handlerRef = useRef(onOutsidePress);
18
+ useEffect(() => {
19
+ refsRef.current = refs;
20
+ handlerRef.current = onOutsidePress;
21
+ });
22
+ useEffect(() => {
23
+ if (!enabled) return;
24
+ function onPointerDown(event) {
25
+ if (!isPrimaryPress(event)) return;
26
+ const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];
27
+ const elements = list.map((ref) => ref.current);
28
+ if (isOutside(event.target, elements)) handlerRef.current(event);
29
+ }
30
+ document.addEventListener("pointerdown", onPointerDown, true);
31
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
32
+ }, [enabled]);
33
+ }
34
+
35
+ export {
36
+ isOutside,
37
+ isPrimaryPress,
38
+ useOutsidePress
39
+ };
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ // src/auto-grow/next-height.ts
4
+ function nextHeight(scrollHeight, maxHeight) {
5
+ const content = Math.ceil(scrollHeight);
6
+ if (maxHeight !== void 0 && content > maxHeight) return { height: maxHeight, capped: true };
7
+ return { height: content, capped: false };
8
+ }
9
+
10
+ // src/auto-grow/use-auto-grow-textarea.ts
11
+ import { useCallback, useEffect, useLayoutEffect } from "react";
12
+ var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
13
+ function useAutoGrowTextarea(ref, value, { maxHeight } = {}) {
14
+ const resize = useCallback(() => {
15
+ const el = ref.current;
16
+ if (!el) return;
17
+ el.style.height = "auto";
18
+ const { height, capped } = nextHeight(el.scrollHeight, maxHeight);
19
+ el.style.height = `${height}px`;
20
+ el.style.overflowY = capped ? "auto" : "hidden";
21
+ }, [ref, maxHeight]);
22
+ useIsomorphicLayoutEffect(resize, [resize, value]);
23
+ useEffect(() => {
24
+ const viewport = window.visualViewport;
25
+ const target = viewport ?? window;
26
+ target.addEventListener("resize", resize);
27
+ return () => target.removeEventListener("resize", resize);
28
+ }, [resize]);
29
+ }
30
+
31
+ export {
32
+ nextHeight,
33
+ useAutoGrowTextarea
34
+ };