@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.
- package/package.json +8 -7
- package/src/__tests__/useBreakpoint.test.ts +148 -0
- package/src/__tests__/useClickOutside.test.ts +144 -0
- package/src/__tests__/useColorScheme.test.ts +101 -0
- package/src/__tests__/useControllableState.test.ts +45 -0
- package/src/__tests__/useDebouncedCallback.test.ts +80 -0
- package/src/__tests__/useDebouncedValue.test.ts +112 -0
- package/src/__tests__/useElementSize.test.ts +190 -0
- package/src/__tests__/useFocus.test.ts +52 -0
- package/src/__tests__/useFocusTrap.test.ts +162 -0
- package/src/__tests__/useHover.test.ts +62 -0
- package/src/__tests__/useIntersection.test.ts +154 -0
- package/src/__tests__/useInterval.test.ts +48 -0
- package/src/__tests__/useIsomorphicLayoutEffect.test.ts +9 -0
- package/src/__tests__/useKeyboard.test.ts +144 -0
- package/src/__tests__/useLatest.test.ts +24 -0
- package/src/__tests__/useMediaQuery.test.ts +143 -0
- package/src/__tests__/useMergedRef.test.ts +48 -0
- package/src/__tests__/usePrevious.test.ts +70 -0
- package/src/__tests__/useReducedMotion.test.ts +96 -0
- package/src/__tests__/useRootSize.test.ts +27 -0
- package/src/__tests__/useScrollLock.test.ts +123 -0
- package/src/__tests__/useSpacing.test.ts +23 -0
- package/src/__tests__/useThemeValue.test.ts +12 -0
- package/src/__tests__/useThrottledCallback.test.ts +56 -0
- package/src/__tests__/useTimeout.test.ts +54 -0
- package/src/__tests__/useToggle.test.ts +82 -0
- package/src/__tests__/useUpdateEffect.test.ts +48 -0
- package/src/__tests__/useWindowResize.test.ts +139 -0
- package/src/index.ts +56 -0
- package/src/useBreakpoint.ts +52 -0
- package/src/useClickOutside.ts +23 -0
- package/src/useClipboard.ts +51 -0
- package/src/useColorScheme.ts +10 -0
- package/src/useControllableState.ts +39 -0
- package/src/useDebouncedCallback.ts +57 -0
- package/src/useDebouncedValue.ts +24 -0
- package/src/useDialog.ts +83 -0
- package/src/useElementSize.ts +38 -0
- package/src/useEventListener.ts +31 -0
- package/src/useFocus.ts +24 -0
- package/src/useFocusTrap.ts +42 -0
- package/src/useHover.ts +30 -0
- package/src/useInfiniteScroll.ts +115 -0
- package/src/useIntersection.ts +30 -0
- package/src/useInterval.ts +31 -0
- package/src/useIsomorphicLayoutEffect.ts +19 -0
- package/src/useKeyboard.ts +28 -0
- package/src/useLatest.ts +17 -0
- package/src/useMediaQuery.ts +27 -0
- package/src/useMergedRef.ts +24 -0
- package/src/useOnline.ts +31 -0
- package/src/usePrevious.ts +18 -0
- package/src/useReducedMotion.ts +8 -0
- package/src/useRootSize.ts +28 -0
- package/src/useScrollLock.ts +37 -0
- package/src/useSpacing.ts +26 -0
- package/src/useThemeValue.ts +21 -0
- package/src/useThrottledCallback.ts +30 -0
- package/src/useTimeAgo.ts +134 -0
- package/src/useTimeout.ts +42 -0
- package/src/useToggle.ts +22 -0
- package/src/useUpdateEffect.ts +24 -0
- 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
|
+
}
|
package/src/useDialog.ts
ADDED
|
@@ -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
|
+
}
|
package/src/useFocus.ts
ADDED
|
@@ -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
|
+
}
|
package/src/useHover.ts
ADDED
|
@@ -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
|
+
}
|
package/src/useLatest.ts
ADDED
|
@@ -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
|
package/src/useOnline.ts
ADDED
|
@@ -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,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
|