@pyreon/hooks 0.11.1 → 0.11.3

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 (64) hide show
  1. package/package.json +8 -7
  2. package/src/__tests__/useBreakpoint.test.ts +148 -0
  3. package/src/__tests__/useClickOutside.test.ts +144 -0
  4. package/src/__tests__/useColorScheme.test.ts +101 -0
  5. package/src/__tests__/useControllableState.test.ts +45 -0
  6. package/src/__tests__/useDebouncedCallback.test.ts +80 -0
  7. package/src/__tests__/useDebouncedValue.test.ts +112 -0
  8. package/src/__tests__/useElementSize.test.ts +190 -0
  9. package/src/__tests__/useFocus.test.ts +52 -0
  10. package/src/__tests__/useFocusTrap.test.ts +162 -0
  11. package/src/__tests__/useHover.test.ts +62 -0
  12. package/src/__tests__/useIntersection.test.ts +154 -0
  13. package/src/__tests__/useInterval.test.ts +48 -0
  14. package/src/__tests__/useIsomorphicLayoutEffect.test.ts +9 -0
  15. package/src/__tests__/useKeyboard.test.ts +144 -0
  16. package/src/__tests__/useLatest.test.ts +24 -0
  17. package/src/__tests__/useMediaQuery.test.ts +143 -0
  18. package/src/__tests__/useMergedRef.test.ts +48 -0
  19. package/src/__tests__/usePrevious.test.ts +70 -0
  20. package/src/__tests__/useReducedMotion.test.ts +96 -0
  21. package/src/__tests__/useRootSize.test.ts +27 -0
  22. package/src/__tests__/useScrollLock.test.ts +123 -0
  23. package/src/__tests__/useSpacing.test.ts +23 -0
  24. package/src/__tests__/useThemeValue.test.ts +12 -0
  25. package/src/__tests__/useThrottledCallback.test.ts +56 -0
  26. package/src/__tests__/useTimeout.test.ts +54 -0
  27. package/src/__tests__/useToggle.test.ts +82 -0
  28. package/src/__tests__/useUpdateEffect.test.ts +48 -0
  29. package/src/__tests__/useWindowResize.test.ts +139 -0
  30. package/src/index.ts +56 -0
  31. package/src/useBreakpoint.ts +52 -0
  32. package/src/useClickOutside.ts +23 -0
  33. package/src/useClipboard.ts +51 -0
  34. package/src/useColorScheme.ts +10 -0
  35. package/src/useControllableState.ts +39 -0
  36. package/src/useDebouncedCallback.ts +57 -0
  37. package/src/useDebouncedValue.ts +24 -0
  38. package/src/useDialog.ts +83 -0
  39. package/src/useElementSize.ts +38 -0
  40. package/src/useEventListener.ts +31 -0
  41. package/src/useFocus.ts +24 -0
  42. package/src/useFocusTrap.ts +42 -0
  43. package/src/useHover.ts +30 -0
  44. package/src/useInfiniteScroll.ts +115 -0
  45. package/src/useIntersection.ts +30 -0
  46. package/src/useInterval.ts +31 -0
  47. package/src/useIsomorphicLayoutEffect.ts +19 -0
  48. package/src/useKeyboard.ts +28 -0
  49. package/src/useLatest.ts +17 -0
  50. package/src/useMediaQuery.ts +27 -0
  51. package/src/useMergedRef.ts +24 -0
  52. package/src/useOnline.ts +31 -0
  53. package/src/usePrevious.ts +18 -0
  54. package/src/useReducedMotion.ts +8 -0
  55. package/src/useRootSize.ts +28 -0
  56. package/src/useScrollLock.ts +37 -0
  57. package/src/useSpacing.ts +26 -0
  58. package/src/useThemeValue.ts +21 -0
  59. package/src/useThrottledCallback.ts +30 -0
  60. package/src/useTimeAgo.ts +134 -0
  61. package/src/useTimeout.ts +42 -0
  62. package/src/useToggle.ts +22 -0
  63. package/src/useUpdateEffect.ts +24 -0
  64. package/src/useWindowResize.ts +39 -0
@@ -0,0 +1,24 @@
1
+ import { onUnmount } from "@pyreon/core"
2
+ import { effect, signal } from "@pyreon/reactivity"
3
+
4
+ /**
5
+ * Return a debounced version of a reactive value.
6
+ */
7
+ export function useDebouncedValue<T>(getter: () => T, delayMs: number): () => T {
8
+ const debounced = signal<T>(getter())
9
+ let timer: ReturnType<typeof setTimeout> | undefined
10
+
11
+ effect(() => {
12
+ const val = getter()
13
+ if (timer !== undefined) clearTimeout(timer)
14
+ timer = setTimeout(() => {
15
+ debounced.set(val)
16
+ }, delayMs)
17
+ })
18
+
19
+ onUnmount(() => {
20
+ if (timer !== undefined) clearTimeout(timer)
21
+ })
22
+
23
+ return debounced
24
+ }
@@ -0,0 +1,83 @@
1
+ import { onCleanup, signal } from "@pyreon/reactivity"
2
+
3
+ export interface UseDialogResult {
4
+ /** Whether the dialog is currently open. */
5
+ open: () => boolean
6
+ /** Open the dialog. */
7
+ show: () => void
8
+ /** Open as modal (with backdrop, traps focus). */
9
+ showModal: () => void
10
+ /** Close the dialog. */
11
+ close: () => void
12
+ /** Toggle open/closed state. */
13
+ toggle: () => void
14
+ /** Ref callback — pass to `ref` prop on a `<dialog>` element. */
15
+ ref: (el: HTMLDialogElement) => void
16
+ }
17
+
18
+ /**
19
+ * Signal-driven dialog management for the native `<dialog>` element.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * const dialog = useDialog()
24
+ *
25
+ * <button onClick={dialog.showModal}>Open</button>
26
+ * <dialog ref={dialog.ref}>
27
+ * <p>Modal content</p>
28
+ * <button onClick={dialog.close}>Close</button>
29
+ * </dialog>
30
+ * ```
31
+ */
32
+ export function useDialog(options?: { onClose?: () => void }): UseDialogResult {
33
+ const open = signal(false)
34
+ let dialogEl: HTMLDialogElement | null = null
35
+ let closeHandler: (() => void) | null = null
36
+
37
+ const show = () => {
38
+ dialogEl?.show()
39
+ open.set(true)
40
+ }
41
+
42
+ const showModal = () => {
43
+ dialogEl?.showModal()
44
+ open.set(true)
45
+ }
46
+
47
+ const close = () => {
48
+ dialogEl?.close()
49
+ open.set(false)
50
+ }
51
+
52
+ const toggle = () => {
53
+ if (open()) close()
54
+ else showModal()
55
+ }
56
+
57
+ // Attach the close listener in the ref callback — guaranteed to have
58
+ // the element. onMount fires at the same time as ref in Pyreon, but
59
+ // ref is more reliable since it's called with the actual element.
60
+ const ref = (el: HTMLDialogElement) => {
61
+ // Clean up previous element if ref is called again
62
+ if (dialogEl && closeHandler) {
63
+ dialogEl.removeEventListener("close", closeHandler)
64
+ }
65
+
66
+ dialogEl = el
67
+
68
+ closeHandler = () => {
69
+ open.set(false)
70
+ options?.onClose?.()
71
+ }
72
+
73
+ el.addEventListener("close", closeHandler)
74
+ }
75
+
76
+ onCleanup(() => {
77
+ if (dialogEl && closeHandler) {
78
+ dialogEl.removeEventListener("close", closeHandler)
79
+ }
80
+ })
81
+
82
+ return { open, show, showModal, close, toggle, ref }
83
+ }
@@ -0,0 +1,38 @@
1
+ import { onMount, onUnmount } from "@pyreon/core"
2
+ import { signal } from "@pyreon/reactivity"
3
+
4
+ export interface Size {
5
+ width: number
6
+ height: number
7
+ }
8
+
9
+ /**
10
+ * Observe element dimensions reactively via ResizeObserver.
11
+ */
12
+ export function useElementSize(getEl: () => HTMLElement | null): () => Size {
13
+ const size = signal<Size>({ width: 0, height: 0 })
14
+ let observer: ResizeObserver | undefined
15
+
16
+ onMount(() => {
17
+ const el = getEl()
18
+ if (!el) return undefined
19
+
20
+ observer = new ResizeObserver(([entry]) => {
21
+ if (!entry) return
22
+ const { width, height } = entry.contentRect
23
+ size.set({ width, height })
24
+ })
25
+ observer.observe(el)
26
+
27
+ // Initial measurement
28
+ const rect = el.getBoundingClientRect()
29
+ size.set({ width: rect.width, height: rect.height })
30
+ return undefined
31
+ })
32
+
33
+ onUnmount(() => {
34
+ observer?.disconnect()
35
+ })
36
+
37
+ return size
38
+ }
@@ -0,0 +1,31 @@
1
+ import { onCleanup } from "@pyreon/reactivity"
2
+
3
+ /**
4
+ * Attach an event listener with automatic cleanup on unmount.
5
+ * Works with Window, Document, HTMLElement, or any EventTarget.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * useEventListener("keydown", (e) => {
10
+ * if (e.key === "Escape") close()
11
+ * })
12
+ *
13
+ * useEventListener("scroll", handleScroll, { passive: true })
14
+ *
15
+ * // On a specific element:
16
+ * useEventListener("click", handler, {}, () => buttonRef.current)
17
+ * ```
18
+ */
19
+ export function useEventListener<K extends keyof WindowEventMap>(
20
+ event: K,
21
+ handler: (e: WindowEventMap[K]) => void,
22
+ options?: boolean | AddEventListenerOptions,
23
+ target?: () => EventTarget | null | undefined,
24
+ ): void {
25
+ const isBrowser = typeof window !== "undefined"
26
+ if (!isBrowser) return
27
+
28
+ const el = target?.() ?? window
29
+ el.addEventListener(event, handler as EventListener, options)
30
+ onCleanup(() => el.removeEventListener(event, handler as EventListener, options))
31
+ }
@@ -0,0 +1,24 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+
3
+ export interface UseFocusResult {
4
+ focused: () => boolean
5
+ props: {
6
+ onFocus: () => void
7
+ onBlur: () => void
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Track focus state reactively.
13
+ */
14
+ export function useFocus(): UseFocusResult {
15
+ const focused = signal(false)
16
+
17
+ return {
18
+ focused,
19
+ props: {
20
+ onFocus: () => focused.set(true),
21
+ onBlur: () => focused.set(false),
22
+ },
23
+ }
24
+ }
@@ -0,0 +1,42 @@
1
+ import { onMount, onUnmount } from "@pyreon/core"
2
+
3
+ const FOCUSABLE =
4
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
5
+
6
+ /**
7
+ * Trap Tab/Shift+Tab focus within a container element.
8
+ */
9
+ export function useFocusTrap(getEl: () => HTMLElement | null): void {
10
+ const listener = (e: KeyboardEvent) => {
11
+ if (e.key !== "Tab") return
12
+ const el = getEl()
13
+ if (!el) return
14
+
15
+ const focusable = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE))
16
+ if (focusable.length === 0) return
17
+
18
+ const first = focusable[0] as HTMLElement
19
+ const last = focusable[focusable.length - 1] as HTMLElement
20
+
21
+ if (e.shiftKey) {
22
+ if (document.activeElement === first) {
23
+ e.preventDefault()
24
+ last.focus()
25
+ }
26
+ } else {
27
+ if (document.activeElement === last) {
28
+ e.preventDefault()
29
+ first.focus()
30
+ }
31
+ }
32
+ }
33
+
34
+ onMount(() => {
35
+ document.addEventListener("keydown", listener)
36
+ return undefined
37
+ })
38
+
39
+ onUnmount(() => {
40
+ document.removeEventListener("keydown", listener)
41
+ })
42
+ }
@@ -0,0 +1,30 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+
3
+ export interface UseHoverResult {
4
+ /** Reactive boolean — true when element is hovered */
5
+ hovered: () => boolean
6
+ /** Props to spread onto the element */
7
+ props: {
8
+ onMouseEnter: () => void
9
+ onMouseLeave: () => void
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Track hover state reactively.
15
+ *
16
+ * @example
17
+ * const { hovered, props } = useHover()
18
+ * h('div', { ...props, class: () => hovered() ? 'active' : '' })
19
+ */
20
+ export function useHover(): UseHoverResult {
21
+ const hovered = signal(false)
22
+
23
+ return {
24
+ hovered,
25
+ props: {
26
+ onMouseEnter: () => hovered.set(true),
27
+ onMouseLeave: () => hovered.set(false),
28
+ },
29
+ }
30
+ }
@@ -0,0 +1,115 @@
1
+ import { onCleanup, signal } from "@pyreon/reactivity"
2
+
3
+ export interface UseInfiniteScrollOptions {
4
+ /** Distance from bottom (px) to trigger load. Default: 100 */
5
+ threshold?: number
6
+ /** Whether loading is in progress (prevents duplicate calls). */
7
+ loading?: () => boolean
8
+ /** Whether there's more data to load. Default: true */
9
+ hasMore?: () => boolean
10
+ /** Scroll direction. Default: "down" */
11
+ direction?: "up" | "down"
12
+ }
13
+
14
+ export interface UseInfiniteScrollResult {
15
+ /** Attach to the scroll container element. */
16
+ ref: (el: HTMLElement | null) => void
17
+ /** Whether the sentinel is currently visible. */
18
+ triggered: () => boolean
19
+ }
20
+
21
+ /**
22
+ * Signal-driven infinite scroll using IntersectionObserver.
23
+ * Calls `onLoadMore` when the user scrolls near the end of the container.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * const items = signal<Item[]>([])
28
+ * const loading = signal(false)
29
+ * const hasMore = signal(true)
30
+ *
31
+ * const { ref } = useInfiniteScroll(() => {
32
+ * loading.set(true)
33
+ * const next = await fetchMore()
34
+ * items.update(prev => [...prev, ...next])
35
+ * hasMore.set(next.length > 0)
36
+ * loading.set(false)
37
+ * }, { loading, hasMore })
38
+ *
39
+ * <div ref={ref} style={{ overflowY: "auto", height: "400px" }}>
40
+ * <For each={items()} by={i => i.id}>
41
+ * {item => <div>{item.name}</div>}
42
+ * </For>
43
+ * </div>
44
+ * ```
45
+ */
46
+ export function useInfiniteScroll(
47
+ onLoadMore: () => void | Promise<void>,
48
+ options?: UseInfiniteScrollOptions,
49
+ ): UseInfiniteScrollResult {
50
+ const threshold = options?.threshold ?? 100
51
+ const direction = options?.direction ?? "down"
52
+ const triggered = signal(false)
53
+ let observer: IntersectionObserver | null = null
54
+ let sentinel: HTMLDivElement | null = null
55
+ let containerEl: HTMLElement | null = null
56
+
57
+ const handleIntersect = (entries: IntersectionObserverEntry[]) => {
58
+ const entry = entries[0]
59
+ if (!entry) return
60
+
61
+ triggered.set(entry.isIntersecting)
62
+
63
+ if (entry.isIntersecting) {
64
+ if (options?.loading?.()) return
65
+ if (options?.hasMore && !options.hasMore()) return
66
+ onLoadMore()
67
+ }
68
+ }
69
+
70
+ const setup = (el: HTMLElement) => {
71
+ cleanup()
72
+ containerEl = el
73
+
74
+ // Create an invisible sentinel element at the scroll boundary
75
+ sentinel = document.createElement("div")
76
+ sentinel.style.height = "1px"
77
+ sentinel.style.width = "100%"
78
+ sentinel.style.pointerEvents = "none"
79
+ sentinel.setAttribute("aria-hidden", "true")
80
+
81
+ if (direction === "down") {
82
+ el.appendChild(sentinel)
83
+ } else {
84
+ el.insertBefore(sentinel, el.firstChild)
85
+ }
86
+
87
+ observer = new IntersectionObserver(handleIntersect, {
88
+ root: el,
89
+ rootMargin:
90
+ direction === "down" ? `0px 0px ${threshold}px 0px` : `${threshold}px 0px 0px 0px`,
91
+ threshold: 0,
92
+ })
93
+ observer.observe(sentinel)
94
+ }
95
+
96
+ const cleanup = () => {
97
+ if (observer) {
98
+ observer.disconnect()
99
+ observer = null
100
+ }
101
+ if (sentinel && containerEl) {
102
+ sentinel.remove()
103
+ sentinel = null
104
+ }
105
+ }
106
+
107
+ const ref = (el: HTMLElement | null) => {
108
+ if (el) setup(el)
109
+ else cleanup()
110
+ }
111
+
112
+ onCleanup(cleanup)
113
+
114
+ return { ref, triggered }
115
+ }
@@ -0,0 +1,30 @@
1
+ import { onMount, onUnmount } from "@pyreon/core"
2
+ import { signal } from "@pyreon/reactivity"
3
+
4
+ /**
5
+ * Observe element intersection reactively.
6
+ */
7
+ export function useIntersection(
8
+ getEl: () => HTMLElement | null,
9
+ options?: IntersectionObserverInit,
10
+ ): () => IntersectionObserverEntry | null {
11
+ const entry = signal<IntersectionObserverEntry | null>(null)
12
+ let observer: IntersectionObserver | undefined
13
+
14
+ onMount(() => {
15
+ const el = getEl()
16
+ if (!el) return undefined
17
+
18
+ observer = new IntersectionObserver(([e]) => {
19
+ if (e) entry.set(e)
20
+ }, options)
21
+ observer.observe(el)
22
+ return undefined
23
+ })
24
+
25
+ onUnmount(() => {
26
+ observer?.disconnect()
27
+ })
28
+
29
+ return entry
30
+ }
@@ -0,0 +1,31 @@
1
+ import { onUnmount } from "@pyreon/core"
2
+
3
+ export type UseInterval = (callback: () => void, delay: number | null) => void
4
+
5
+ /**
6
+ * Declarative `setInterval` with auto-cleanup.
7
+ * Pass `null` as `delay` to pause the interval.
8
+ * Always calls the latest callback (no stale closures).
9
+ */
10
+ export const useInterval: UseInterval = (callback, delay) => {
11
+ const currentCallback = callback
12
+ let intervalId: ReturnType<typeof setInterval> | null = null
13
+
14
+ const start = () => {
15
+ if (delay === null) return
16
+ intervalId = setInterval(() => currentCallback(), delay)
17
+ }
18
+
19
+ const stop = () => {
20
+ if (intervalId != null) {
21
+ clearInterval(intervalId)
22
+ intervalId = null
23
+ }
24
+ }
25
+
26
+ start()
27
+
28
+ onUnmount(() => stop())
29
+ }
30
+
31
+ export default useInterval
@@ -0,0 +1,19 @@
1
+ import { onMount } from "@pyreon/core"
2
+
3
+ /**
4
+ * In Pyreon there is no SSR warning distinction between effect and
5
+ * layout-effect as there is in React.
6
+ *
7
+ * On the client `onMount` fires synchronously after the component is
8
+ * mounted (similar to useLayoutEffect). On the server `effect` is a
9
+ * no-op. This export provides the appropriate primitive for each env.
10
+ *
11
+ * Consumers that need layout-timing should use `onMount` directly.
12
+ * This hook is provided for API parity with the original library.
13
+ */
14
+ export type UseIsomorphicLayoutEffect = typeof onMount
15
+
16
+ const useIsomorphicLayoutEffect: UseIsomorphicLayoutEffect =
17
+ typeof window !== "undefined" ? onMount : onMount
18
+
19
+ export default useIsomorphicLayoutEffect
@@ -0,0 +1,28 @@
1
+ import { onMount, onUnmount } from "@pyreon/core"
2
+
3
+ /**
4
+ * Listen for a specific key press.
5
+ */
6
+ export function useKeyboard(
7
+ key: string,
8
+ handler: (event: KeyboardEvent) => void,
9
+ options?: { event?: "keydown" | "keyup"; target?: EventTarget },
10
+ ): void {
11
+ const eventName = options?.event ?? "keydown"
12
+
13
+ const listener = (e: Event) => {
14
+ const ke = e as KeyboardEvent
15
+ if (ke.key === key) handler(ke)
16
+ }
17
+
18
+ onMount(() => {
19
+ const target = options?.target ?? document
20
+ target.addEventListener(eventName, listener)
21
+ return undefined
22
+ })
23
+
24
+ onUnmount(() => {
25
+ const target = options?.target ?? document
26
+ target.removeEventListener(eventName, listener)
27
+ })
28
+ }
@@ -0,0 +1,17 @@
1
+ export type UseLatest = <T>(value: T) => { readonly current: T }
2
+
3
+ /**
4
+ * Returns a ref-like object that always holds the latest value.
5
+ * Useful to avoid stale closures in callbacks and effects.
6
+ *
7
+ * In Pyreon, since the component body runs once, this simply wraps
8
+ * the value in a mutable object. The caller is expected to call this
9
+ * once and update `.current` manually if needed, or pass a reactive
10
+ * getter to read the latest value.
11
+ */
12
+ export const useLatest: UseLatest = <T>(value: T) => {
13
+ const ref = { current: value }
14
+ return ref
15
+ }
16
+
17
+ export default useLatest
@@ -0,0 +1,27 @@
1
+ import { onMount, onUnmount } from "@pyreon/core"
2
+ import { signal } from "@pyreon/reactivity"
3
+
4
+ /**
5
+ * Subscribe to a CSS media query, returns a reactive boolean.
6
+ */
7
+ export function useMediaQuery(query: string): () => boolean {
8
+ const matches = signal(false)
9
+ let mql: MediaQueryList | undefined
10
+
11
+ const onChange = (e: MediaQueryListEvent) => {
12
+ matches.set(e.matches)
13
+ }
14
+
15
+ onMount(() => {
16
+ mql = window.matchMedia(query)
17
+ matches.set(mql.matches)
18
+ mql.addEventListener("change", onChange)
19
+ return undefined
20
+ })
21
+
22
+ onUnmount(() => {
23
+ mql?.removeEventListener("change", onChange)
24
+ })
25
+
26
+ return matches
27
+ }
@@ -0,0 +1,24 @@
1
+ type RefCallback<T> = (node: T | null) => void
2
+ type RefObject<T> = { current: T | null }
3
+ type Ref<T> = RefCallback<T> | RefObject<T>
4
+
5
+ export type UseMergedRef = <T>(...refs: (Ref<T> | undefined)[]) => (node: T | null) => void
6
+
7
+ /**
8
+ * Merges multiple refs (callback or object) into a single callback ref.
9
+ * Handles undefined, callback refs, and object refs with `.current`.
10
+ */
11
+ export const useMergedRef = <T>(...refs: (Ref<T> | undefined)[]): ((node: T | null) => void) => {
12
+ return (node: T | null) => {
13
+ for (const ref of refs) {
14
+ if (!ref) continue
15
+ if (typeof ref === "function") {
16
+ ref(node)
17
+ } else {
18
+ ;(ref as RefObject<unknown>).current = node
19
+ }
20
+ }
21
+ }
22
+ }
23
+
24
+ export default useMergedRef
@@ -0,0 +1,31 @@
1
+ import { onCleanup, signal } from "@pyreon/reactivity"
2
+
3
+ /**
4
+ * Reactive online/offline status.
5
+ * Tracks `navigator.onLine` and updates on connectivity changes.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * const online = useOnline()
10
+ * <Show when={!online()} fallback={<App />}>
11
+ * <OfflineBanner />
12
+ * </Show>
13
+ * ```
14
+ */
15
+ export function useOnline(): () => boolean {
16
+ const isBrowser = typeof window !== "undefined"
17
+ const online = signal(isBrowser ? navigator.onLine : true)
18
+
19
+ if (isBrowser) {
20
+ const setOnline = () => online.set(true)
21
+ const setOffline = () => online.set(false)
22
+ window.addEventListener("online", setOnline)
23
+ window.addEventListener("offline", setOffline)
24
+ onCleanup(() => {
25
+ window.removeEventListener("online", setOnline)
26
+ window.removeEventListener("offline", setOffline)
27
+ })
28
+ }
29
+
30
+ return online
31
+ }
@@ -0,0 +1,18 @@
1
+ import { effect, signal } from "@pyreon/reactivity"
2
+
3
+ /**
4
+ * Track the previous value of a reactive getter.
5
+ * Returns undefined on first access.
6
+ */
7
+ export function usePrevious<T>(getter: () => T): () => T | undefined {
8
+ const prev = signal<T | undefined>(undefined)
9
+ let current: T | undefined
10
+
11
+ effect(() => {
12
+ const next = getter()
13
+ prev.set(current)
14
+ current = next
15
+ })
16
+
17
+ return prev
18
+ }
@@ -0,0 +1,8 @@
1
+ import { useMediaQuery } from "./useMediaQuery"
2
+
3
+ /**
4
+ * Returns true when the user prefers reduced motion.
5
+ */
6
+ export function useReducedMotion(): () => boolean {
7
+ return useMediaQuery("(prefers-reduced-motion: reduce)")
8
+ }
@@ -0,0 +1,28 @@
1
+ import { useTheme } from "@pyreon/styler"
2
+
3
+ type RootSizeResult = {
4
+ rootSize: number
5
+ pxToRem: (px: number) => string
6
+ remToPx: (rem: number) => number
7
+ }
8
+
9
+ export type UseRootSize = () => RootSizeResult
10
+
11
+ /**
12
+ * Returns `rootSize` from the theme context along with
13
+ * `pxToRem` and `remToPx` conversion utilities.
14
+ *
15
+ * Defaults to `16` when no rootSize is set in the theme.
16
+ */
17
+ export const useRootSize: UseRootSize = () => {
18
+ const theme = useTheme<{ rootSize?: number | undefined }>()
19
+ const rootSize = theme?.rootSize ?? 16
20
+
21
+ return {
22
+ rootSize,
23
+ pxToRem: (px: number) => `${px / rootSize}rem`,
24
+ remToPx: (rem: number) => rem * rootSize,
25
+ }
26
+ }
27
+
28
+ export default useRootSize