@pyreon/hooks 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Vit Bokisch
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 ADDED
@@ -0,0 +1,196 @@
1
+ # @pyreon/hooks
2
+
3
+ 16 signal-based reactive utilities for Pyreon UI interactions, DOM observation, accessibility, and theming.
4
+
5
+ All hooks use `signal()` for internal state and return reactive getters. Components are plain functions that run once — no `useCallback`/`useMemo` needed.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @pyreon/hooks
11
+ ```
12
+
13
+ ## Hooks
14
+
15
+ ### Interaction
16
+
17
+ #### useHover
18
+
19
+ Tracks hover state via mouse enter/leave events.
20
+
21
+ ```ts
22
+ import { useHover } from '@pyreon/hooks'
23
+
24
+ const { hover, onMouseEnter, onMouseLeave } = useHover()
25
+ ```
26
+
27
+ #### useFocus
28
+
29
+ Tracks focus state via focus/blur events.
30
+
31
+ ```ts
32
+ import { useFocus } from '@pyreon/hooks'
33
+
34
+ const { focused, onFocus, onBlur } = useFocus()
35
+ ```
36
+
37
+ #### useClickOutside
38
+
39
+ Calls handler when a click occurs outside the referenced element.
40
+
41
+ ```ts
42
+ import { useClickOutside } from '@pyreon/hooks'
43
+
44
+ useClickOutside(elementRef, () => setOpen(false))
45
+ ```
46
+
47
+ #### useScrollLock
48
+
49
+ Locks page scroll by setting `overflow: hidden` on `document.body`.
50
+
51
+ ```ts
52
+ import { useScrollLock } from '@pyreon/hooks'
53
+
54
+ useScrollLock(isModalOpen)
55
+ ```
56
+
57
+ #### useKeyboard
58
+
59
+ Listens for a specific keyboard key.
60
+
61
+ ```ts
62
+ import { useKeyboard } from '@pyreon/hooks'
63
+
64
+ useKeyboard('Escape', () => setOpen(false))
65
+ ```
66
+
67
+ #### useFocusTrap
68
+
69
+ Traps Tab/Shift+Tab focus within a container. Essential for modals and dialogs.
70
+
71
+ ```ts
72
+ import { useFocusTrap } from '@pyreon/hooks'
73
+
74
+ useFocusTrap(containerRef, isOpen)
75
+ ```
76
+
77
+ ### DOM & Observers
78
+
79
+ #### useElementSize
80
+
81
+ Tracks element `width` and `height` via `ResizeObserver`.
82
+
83
+ ```ts
84
+ import { useElementSize } from '@pyreon/hooks'
85
+
86
+ const { ref, width, height } = useElementSize()
87
+ ```
88
+
89
+ #### useIntersection
90
+
91
+ `IntersectionObserver` wrapper for visibility detection.
92
+
93
+ ```ts
94
+ import { useIntersection } from '@pyreon/hooks'
95
+
96
+ const { ref, entry } = useIntersection({ threshold: 0.5 })
97
+ const isVisible = entry?.isIntersecting
98
+ ```
99
+
100
+ #### useWindowResize
101
+
102
+ Tracks viewport dimensions with throttled updates.
103
+
104
+ ```ts
105
+ import { useWindowResize } from '@pyreon/hooks'
106
+
107
+ const { width, height } = useWindowResize({ throttleDelay: 300 })
108
+ ```
109
+
110
+ ### Responsive
111
+
112
+ #### useMediaQuery
113
+
114
+ Subscribes to a CSS media query and returns whether it matches.
115
+
116
+ ```ts
117
+ import { useMediaQuery } from '@pyreon/hooks'
118
+
119
+ const isDesktop = useMediaQuery('(min-width: 1024px)')
120
+ ```
121
+
122
+ #### useBreakpoint
123
+
124
+ Returns the currently active breakpoint name from the theme context.
125
+
126
+ ```ts
127
+ import { useBreakpoint } from '@pyreon/hooks'
128
+
129
+ const bp = useBreakpoint() // "xs" | "sm" | "md" | "lg" | "xl" | undefined
130
+ ```
131
+
132
+ #### useColorScheme
133
+
134
+ Returns the user's preferred color scheme. Pairs with rocketstyle's `mode`.
135
+
136
+ ```ts
137
+ import { useColorScheme } from '@pyreon/hooks'
138
+
139
+ const scheme = useColorScheme() // "light" | "dark"
140
+ ```
141
+
142
+ #### useReducedMotion
143
+
144
+ Returns `true` when the user prefers reduced motion.
145
+
146
+ ```ts
147
+ import { useReducedMotion } from '@pyreon/hooks'
148
+
149
+ const reduced = useReducedMotion()
150
+ const duration = reduced ? 0 : 300
151
+ ```
152
+
153
+ ### State
154
+
155
+ #### useToggle
156
+
157
+ Boolean state with `toggle`, `setTrue`, and `setFalse` helpers.
158
+
159
+ ```ts
160
+ import { useToggle } from '@pyreon/hooks'
161
+
162
+ const { value, toggle, setTrue, setFalse } = useToggle(false)
163
+ ```
164
+
165
+ #### usePrevious
166
+
167
+ Returns the value from the previous evaluation.
168
+
169
+ ```ts
170
+ import { usePrevious } from '@pyreon/hooks'
171
+
172
+ const prev = usePrevious(count)
173
+ ```
174
+
175
+ #### useDebouncedValue
176
+
177
+ Returns a debounced version of the value that only updates after `delay` ms of inactivity.
178
+
179
+ ```ts
180
+ import { useDebouncedValue } from '@pyreon/hooks'
181
+
182
+ const debouncedSearch = useDebouncedValue(searchTerm, 300)
183
+ ```
184
+
185
+ ## Peer Dependencies
186
+
187
+ | Package | Version |
188
+ | ------- | ------- |
189
+ | @pyreon/core | >= 0.0.1 |
190
+ | @pyreon/reactivity | >= 0.0.1 |
191
+ | @pyreon/styler | >= 0.0.1 |
192
+ | @pyreon/ui-core | >= 0.0.1 |
193
+
194
+ ## License
195
+
196
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,434 @@
1
+ import { onMount, onUnmount } from "@pyreon/core";
2
+ import { computed, effect, signal, watch } from "@pyreon/reactivity";
3
+ import { useTheme } from "@pyreon/styler";
4
+ import { get, throttle } from "@pyreon/ui-core";
5
+
6
+ //#region src/useBreakpoint.ts
7
+
8
+ /**
9
+ * Return the currently active breakpoint name as a reactive signal.
10
+ */
11
+ function useBreakpoint(breakpoints = defaultBreakpoints) {
12
+ const sorted = Object.entries(breakpoints).sort(([, a], [, b]) => a - b);
13
+ const active = signal(getActive(sorted));
14
+ let rafId;
15
+ function getActive(bps) {
16
+ if (typeof window === "undefined") return bps[0]?.[0] ?? "";
17
+ const w = window.innerWidth;
18
+ let result = bps[0]?.[0] ?? "";
19
+ for (const [name, min] of bps) if (w >= min) result = name;else break;
20
+ return result;
21
+ }
22
+ function onResize() {
23
+ if (rafId !== void 0) cancelAnimationFrame(rafId);
24
+ rafId = requestAnimationFrame(() => {
25
+ const next = getActive(sorted);
26
+ if (next !== active.peek()) active.set(next);
27
+ });
28
+ }
29
+ onMount(() => {
30
+ window.addEventListener("resize", onResize);
31
+ });
32
+ onUnmount(() => {
33
+ window.removeEventListener("resize", onResize);
34
+ if (rafId !== void 0) cancelAnimationFrame(rafId);
35
+ });
36
+ return active;
37
+ }
38
+
39
+ //#endregion
40
+ //#region src/useClickOutside.ts
41
+ /**
42
+ * Call handler when a click occurs outside the target element.
43
+ */
44
+ function useClickOutside(getEl, handler) {
45
+ const listener = e => {
46
+ const el = getEl();
47
+ if (!el || el.contains(e.target)) return;
48
+ handler();
49
+ };
50
+ onMount(() => {
51
+ document.addEventListener("mousedown", listener, true);
52
+ document.addEventListener("touchstart", listener, true);
53
+ });
54
+ onUnmount(() => {
55
+ document.removeEventListener("mousedown", listener, true);
56
+ document.removeEventListener("touchstart", listener, true);
57
+ });
58
+ }
59
+
60
+ //#endregion
61
+ //#region src/useMediaQuery.ts
62
+ /**
63
+ * Subscribe to a CSS media query, returns a reactive boolean.
64
+ */
65
+ function useMediaQuery(query) {
66
+ const matches = signal(false);
67
+ let mql;
68
+ const onChange = e => {
69
+ matches.set(e.matches);
70
+ };
71
+ onMount(() => {
72
+ mql = window.matchMedia(query);
73
+ matches.set(mql.matches);
74
+ mql.addEventListener("change", onChange);
75
+ });
76
+ onUnmount(() => {
77
+ mql?.removeEventListener("change", onChange);
78
+ });
79
+ return matches;
80
+ }
81
+
82
+ //#endregion
83
+ //#region src/useColorScheme.ts
84
+ /**
85
+ * Returns the OS color scheme preference as 'light' or 'dark'.
86
+ */
87
+ function useColorScheme() {
88
+ const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
89
+ return computed(() => prefersDark() ? "dark" : "light");
90
+ }
91
+
92
+ //#endregion
93
+ //#region src/useControllableState.ts
94
+ /**
95
+ * Unified controlled/uncontrolled state pattern.
96
+ * When `value` is provided the component is controlled; otherwise
97
+ * internal state is used with `defaultValue` as the initial value.
98
+ * The `onChange` callback fires in both modes.
99
+ *
100
+ * Returns [getter, setter] where getter is a reactive function.
101
+ */
102
+
103
+ //#endregion
104
+ //#region src/useDebouncedValue.ts
105
+ /**
106
+ * Return a debounced version of a reactive value.
107
+ */
108
+ function useDebouncedValue(getter, delayMs) {
109
+ const debounced = signal(getter());
110
+ let timer;
111
+ effect(() => {
112
+ const val = getter();
113
+ if (timer !== void 0) clearTimeout(timer);
114
+ timer = setTimeout(() => {
115
+ debounced.set(val);
116
+ }, delayMs);
117
+ });
118
+ onUnmount(() => {
119
+ if (timer !== void 0) clearTimeout(timer);
120
+ });
121
+ return debounced;
122
+ }
123
+
124
+ //#endregion
125
+ //#region src/useElementSize.ts
126
+ /**
127
+ * Observe element dimensions reactively via ResizeObserver.
128
+ */
129
+ function useElementSize(getEl) {
130
+ const size = signal({
131
+ width: 0,
132
+ height: 0
133
+ });
134
+ let observer;
135
+ onMount(() => {
136
+ const el = getEl();
137
+ if (!el) return void 0;
138
+ observer = new ResizeObserver(([entry]) => {
139
+ if (!entry) return;
140
+ const {
141
+ width,
142
+ height
143
+ } = entry.contentRect;
144
+ size.set({
145
+ width,
146
+ height
147
+ });
148
+ });
149
+ observer.observe(el);
150
+ const rect = el.getBoundingClientRect();
151
+ size.set({
152
+ width: rect.width,
153
+ height: rect.height
154
+ });
155
+ });
156
+ onUnmount(() => {
157
+ observer?.disconnect();
158
+ });
159
+ return size;
160
+ }
161
+
162
+ //#endregion
163
+ //#region src/useFocus.ts
164
+ /**
165
+ * Track focus state reactively.
166
+ */
167
+ function useFocus() {
168
+ const focused = signal(false);
169
+ return {
170
+ focused,
171
+ props: {
172
+ onFocus: () => focused.set(true),
173
+ onBlur: () => focused.set(false)
174
+ }
175
+ };
176
+ }
177
+
178
+ //#endregion
179
+ //#region src/useFocusTrap.ts
180
+
181
+ /**
182
+ * Trap Tab/Shift+Tab focus within a container element.
183
+ */
184
+ function useFocusTrap(getEl) {
185
+ const listener = e => {
186
+ if (e.key !== "Tab") return;
187
+ const el = getEl();
188
+ if (!el) return;
189
+ const focusable = Array.from(el.querySelectorAll(FOCUSABLE));
190
+ if (focusable.length === 0) return;
191
+ const first = focusable[0];
192
+ const last = focusable[focusable.length - 1];
193
+ if (e.shiftKey) {
194
+ if (document.activeElement === first) {
195
+ e.preventDefault();
196
+ last.focus();
197
+ }
198
+ } else if (document.activeElement === last) {
199
+ e.preventDefault();
200
+ first.focus();
201
+ }
202
+ };
203
+ onMount(() => {
204
+ document.addEventListener("keydown", listener);
205
+ });
206
+ onUnmount(() => {
207
+ document.removeEventListener("keydown", listener);
208
+ });
209
+ }
210
+
211
+ //#endregion
212
+ //#region src/useHover.ts
213
+ /**
214
+ * Track hover state reactively.
215
+ *
216
+ * @example
217
+ * const { hovered, props } = useHover()
218
+ * h('div', { ...props, class: () => hovered() ? 'active' : '' })
219
+ */
220
+ function useHover() {
221
+ const hovered = signal(false);
222
+ return {
223
+ hovered,
224
+ props: {
225
+ onMouseEnter: () => hovered.set(true),
226
+ onMouseLeave: () => hovered.set(false)
227
+ }
228
+ };
229
+ }
230
+
231
+ //#endregion
232
+ //#region src/useIntersection.ts
233
+ /**
234
+ * Observe element intersection reactively.
235
+ */
236
+ function useIntersection(getEl, options) {
237
+ const entry = signal(null);
238
+ let observer;
239
+ onMount(() => {
240
+ const el = getEl();
241
+ if (!el) return void 0;
242
+ observer = new IntersectionObserver(([e]) => {
243
+ if (e) entry.set(e);
244
+ }, options);
245
+ observer.observe(el);
246
+ });
247
+ onUnmount(() => {
248
+ observer?.disconnect();
249
+ });
250
+ return entry;
251
+ }
252
+
253
+ //#endregion
254
+ //#region src/useInterval.ts
255
+ /**
256
+ * Declarative `setInterval` with auto-cleanup.
257
+ * Pass `null` as `delay` to pause the interval.
258
+ * Always calls the latest callback (no stale closures).
259
+ */
260
+
261
+ //#endregion
262
+ //#region src/useKeyboard.ts
263
+ /**
264
+ * Listen for a specific key press.
265
+ */
266
+ function useKeyboard(key, handler, options) {
267
+ const eventName = options?.event ?? "keydown";
268
+ const listener = e => {
269
+ const ke = e;
270
+ if (ke.key === key) handler(ke);
271
+ };
272
+ onMount(() => {
273
+ (options?.target ?? document).addEventListener(eventName, listener);
274
+ });
275
+ onUnmount(() => {
276
+ (options?.target ?? document).removeEventListener(eventName, listener);
277
+ });
278
+ }
279
+
280
+ //#endregion
281
+ //#region src/useLatest.ts
282
+ /**
283
+ * Returns a ref-like object that always holds the latest value.
284
+ * Useful to avoid stale closures in callbacks and effects.
285
+ *
286
+ * In Pyreon, since the component body runs once, this simply wraps
287
+ * the value in a mutable object. The caller is expected to call this
288
+ * once and update `.current` manually if needed, or pass a reactive
289
+ * getter to read the latest value.
290
+ */
291
+
292
+ //#endregion
293
+ //#region src/usePrevious.ts
294
+ /**
295
+ * Track the previous value of a reactive getter.
296
+ * Returns undefined on first access.
297
+ */
298
+ function usePrevious(getter) {
299
+ const prev = signal(void 0);
300
+ let current;
301
+ effect(() => {
302
+ const next = getter();
303
+ prev.set(current);
304
+ current = next;
305
+ });
306
+ return prev;
307
+ }
308
+
309
+ //#endregion
310
+ //#region src/useReducedMotion.ts
311
+ /**
312
+ * Returns true when the user prefers reduced motion.
313
+ */
314
+ function useReducedMotion() {
315
+ return useMediaQuery("(prefers-reduced-motion: reduce)");
316
+ }
317
+
318
+ //#endregion
319
+ //#region src/useRootSize.ts
320
+ /**
321
+ * Returns `rootSize` from the theme context along with
322
+ * `pxToRem` and `remToPx` conversion utilities.
323
+ *
324
+ * Defaults to `16` when no rootSize is set in the theme.
325
+ */
326
+
327
+ /**
328
+ * Lock page scroll. Uses reference counting for concurrent locks.
329
+ * Returns an unlock function.
330
+ */
331
+ function useScrollLock() {
332
+ let isLocked = false;
333
+ const lock = () => {
334
+ if (isLocked) return;
335
+ isLocked = true;
336
+ if (lockCount === 0) {
337
+ savedOverflow = document.body.style.overflow;
338
+ document.body.style.overflow = "hidden";
339
+ }
340
+ lockCount++;
341
+ };
342
+ const unlock = () => {
343
+ if (!isLocked) return;
344
+ isLocked = false;
345
+ lockCount--;
346
+ if (lockCount === 0) document.body.style.overflow = savedOverflow;
347
+ };
348
+ onUnmount(() => {
349
+ if (isLocked) unlock();
350
+ });
351
+ return {
352
+ lock,
353
+ unlock
354
+ };
355
+ }
356
+
357
+ //#endregion
358
+ //#region src/useSpacing.ts
359
+ /**
360
+ * Returns a `spacing(n)` function that computes spacing values
361
+ * based on `rootSize` from the theme.
362
+ *
363
+ * @param base - Base spacing unit in px (defaults to `rootSize / 2`, i.e. 8px)
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * const spacing = useSpacing()
368
+ * spacing(1) // "8px"
369
+ * spacing(2) // "16px"
370
+ * spacing(0.5) // "4px"
371
+ * ```
372
+ */
373
+
374
+ //#endregion
375
+ //#region src/useToggle.ts
376
+ /**
377
+ * Simple boolean toggle.
378
+ */
379
+ function useToggle(initial = false) {
380
+ const value = signal(initial);
381
+ return {
382
+ value,
383
+ toggle: () => value.update(v => !v),
384
+ setTrue: () => value.set(true),
385
+ setFalse: () => value.set(false)
386
+ };
387
+ }
388
+
389
+ //#endregion
390
+ //#region src/useUpdateEffect.ts
391
+ /**
392
+ * Like `effect` but skips the initial value — only fires on updates.
393
+ *
394
+ * In Pyreon, this is implemented using `watch()` which already skips
395
+ * the initial value by default (immediate defaults to false).
396
+ *
397
+ * @param source - A reactive getter to watch
398
+ * @param callback - Called when source changes, receives (newVal, oldVal)
399
+ */
400
+
401
+ //#endregion
402
+ //#region src/useWindowResize.ts
403
+ /**
404
+ * Track window dimensions reactively with throttling.
405
+ */
406
+ function useWindowResize(throttleMs = 200) {
407
+ const size = signal({
408
+ width: typeof window !== "undefined" ? window.innerWidth : 0,
409
+ height: typeof window !== "undefined" ? window.innerHeight : 0
410
+ });
411
+ let timer;
412
+ function onResize() {
413
+ if (timer !== void 0) return;
414
+ timer = setTimeout(() => {
415
+ timer = void 0;
416
+ size.set({
417
+ width: window.innerWidth,
418
+ height: window.innerHeight
419
+ });
420
+ }, throttleMs);
421
+ }
422
+ onMount(() => {
423
+ window.addEventListener("resize", onResize);
424
+ });
425
+ onUnmount(() => {
426
+ window.removeEventListener("resize", onResize);
427
+ if (timer !== void 0) clearTimeout(timer);
428
+ });
429
+ return size;
430
+ }
431
+
432
+ //#endregion
433
+ export { useBreakpoint, useClickOutside, useColorScheme, useControllableState, useDebouncedCallback, useDebouncedValue, useElementSize, useFocus, useFocusTrap, useHover, useIntersection, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useMediaQuery, useMergedRef, usePrevious, useReducedMotion, useRootSize, useScrollLock, useSpacing, useThemeValue, useThrottledCallback, useTimeout, useToggle, useUpdateEffect, useWindowResize };
434
+ //# sourceMappingURL=index.d.ts.map