diff --git a/README.md b/README.md index 1dfa548..ea84e68 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ SSR-safe tree-shakable ESM + CJS - 41 hooks + 65 hooks

@@ -119,6 +119,30 @@ function Component() { - **[`useScript`](https://hookli.vercel.app/docs/use-script)** — Load an external script and report its load status. - **[`useFetch`](https://hookli.vercel.app/docs/use-fetch)** — Declarative fetch with loading and error status. - **[`useGeoLocation`](https://hookli.vercel.app/docs/use-geo-location)** — Browser geolocation state. +- **[`usePrevious`](https://hookli.vercel.app/docs/use-previous)** — Track a value from the previous render. +- **[`useList`](https://hookli.vercel.app/docs/use-list)** — Array state with push, insert, update, remove, and clear helpers. +- **[`useSet`](https://hookli.vercel.app/docs/use-set)** — Set state with add, remove, toggle, has, and clear helpers. +- **[`useThrottle`](https://hookli.vercel.app/docs/use-throttle)** — Throttle a fast-changing value to at most one update per interval. +- **[`useUpdateEffect`](https://hookli.vercel.app/docs/use-update-effect)** — A useEffect that skips the initial mount and runs only on updates. +- **[`useEffectOnce`](https://hookli.vercel.app/docs/use-effect-once)** — Run an effect exactly once, on mount. +- **[`useKeyPress`](https://hookli.vercel.app/docs/use-key-press)** — Track whether a specific key is currently held down. +- **[`useWindowScroll`](https://hookli.vercel.app/docs/use-window-scroll)** — Track the window scroll position reactively. +- **[`useAsync`](https://hookli.vercel.app/docs/use-async)** — Run an async function and track its loading, error, and value state. +- **[`useMutation`](https://hookli.vercel.app/docs/use-mutation)** — Run an async write action on demand and track status, data, and error. +- **[`usePagination`](https://hookli.vercel.app/docs/use-pagination)** — Page, page size, total pages, navigation helpers, and the current item range. +- **[`useNetworkState`](https://hookli.vercel.app/docs/use-network-state)** — Track online/offline status and connection details. +- **[`usePageVisibility`](https://hookli.vercel.app/docs/use-page-visibility)** — Track whether the page/tab is currently visible. +- **[`useIdle`](https://hookli.vercel.app/docs/use-idle)** — Detect user inactivity after a configurable threshold. +- **[`useQueue`](https://hookli.vercel.app/docs/use-queue)** — FIFO queue state with add, remove, clear, and first/last/size. +- **[`useDefault`](https://hookli.vercel.app/docs/use-default)** — useState that falls back to a default when the value is nullish. +- **[`useRafState`](https://hookli.vercel.app/docs/use-raf-state)** — useState whose updates are batched to the next animation frame. +- **[`useDeepCompareEffect`](https://hookli.vercel.app/docs/use-deep-compare-effect)** — useEffect that compares dependencies by deep structural equality. +- **[`useTextSelection`](https://hookli.vercel.app/docs/use-text-selection)** — Track the text the user has currently selected on the page. +- **[`useLongPress`](https://hookli.vercel.app/docs/use-long-press)** — Detect a long press (mouse or touch) via spreadable handlers. +- **[`useHotkeys`](https://hookli.vercel.app/docs/use-hotkeys)** — Bind a keyboard shortcut combo (e.g. ctrl+k) to a callback. +- **[`useFullscreen`](https://hookli.vercel.app/docs/use-fullscreen)** — Control the Fullscreen API for an element and track its state. +- **[`useBattery`](https://hookli.vercel.app/docs/use-battery)** — Read device battery level and charging state (where supported). +- **[`usePermission`](https://hookli.vercel.app/docs/use-permission)** — Query a Permissions API permission and track its state. ## 🧪 TypeScript diff --git a/apps/docs/public/hookli-banner.png b/apps/docs/public/hookli-banner.png index d1cb185..711d473 100644 Binary files a/apps/docs/public/hookli-banner.png and b/apps/docs/public/hookli-banner.png differ diff --git a/apps/docs/public/hookli-banner.svg b/apps/docs/public/hookli-banner.svg index f634013..51ee9db 100644 --- a/apps/docs/public/hookli-banner.svg +++ b/apps/docs/public/hookli-banner.svg @@ -1,54 +1,65 @@ - - - + - - - - - - + + + + + + - - + + + useToggle + useForm + useLocalStorage + useLocalStorageWithExpiry + useSessionStorage + useReadLocalStorage + useDarkMode + useTernaryDarkMode + useBoolean + useCounter + useStep + useCountdown + useMap + useDebounce + useDebounceValue + useDebounceCallback + useInterval + useTimeout + useIsomorphicLayoutEffect + useEventCallback + useUnmount + useIsClient + useIsMounted + useDocumentTitle + useEventListener + useClickOutside + useMousePosition + useInfiniteScroll + useExpandableText + useHover + useIntersectionObserver + - - - + + + + + + hookli. - - - - - - hookli. + 65 React hooks. + One import. - - + Zero deps · SSR-safe · fully tree-shakable · TypeScript-first - - type-safe React hooks - SSR-safe · tree-shakable · zero dependencies - - - - 41 HOOKS - - - - - - - - import { useLocalStorage } from 'hookli' - + + npm i hookli + hookli.vercel.app + \ No newline at end of file diff --git a/assets/hookli-banner.png b/assets/hookli-banner.png index d1cb185..711d473 100644 Binary files a/assets/hookli-banner.png and b/assets/hookli-banner.png differ diff --git a/assets/hookli-banner.svg b/assets/hookli-banner.svg index f634013..51ee9db 100644 --- a/assets/hookli-banner.svg +++ b/assets/hookli-banner.svg @@ -1,54 +1,65 @@ - - - + - - - - - - + + + + + + - - + + + useToggle + useForm + useLocalStorage + useLocalStorageWithExpiry + useSessionStorage + useReadLocalStorage + useDarkMode + useTernaryDarkMode + useBoolean + useCounter + useStep + useCountdown + useMap + useDebounce + useDebounceValue + useDebounceCallback + useInterval + useTimeout + useIsomorphicLayoutEffect + useEventCallback + useUnmount + useIsClient + useIsMounted + useDocumentTitle + useEventListener + useClickOutside + useMousePosition + useInfiniteScroll + useExpandableText + useHover + useIntersectionObserver + - - - + + + + + + hookli. - - - - - - hookli. + 65 React hooks. + One import. - - + Zero deps · SSR-safe · fully tree-shakable · TypeScript-first - - type-safe React hooks - SSR-safe · tree-shakable · zero dependencies - - - - 41 HOOKS - - - - - - - - import { useLocalStorage } from 'hookli' - + + npm i hookli + hookli.vercel.app + \ No newline at end of file diff --git a/packages/hookli/README.md b/packages/hookli/README.md index ea52f77..4dce0c2 100644 --- a/packages/hookli/README.md +++ b/packages/hookli/README.md @@ -27,7 +27,7 @@ SSR-safe tree-shakable ESM + CJS - 41 hooks + 65 hooks

@@ -117,6 +117,30 @@ function Component() { - **[`useScript`](https://hookli.vercel.app/docs/use-script)** — Load an external script and report its load status. - **[`useFetch`](https://hookli.vercel.app/docs/use-fetch)** — Declarative fetch with loading and error status. - **[`useGeoLocation`](https://hookli.vercel.app/docs/use-geo-location)** — Browser geolocation state. +- **[`usePrevious`](https://hookli.vercel.app/docs/use-previous)** — Track a value from the previous render. +- **[`useList`](https://hookli.vercel.app/docs/use-list)** — Array state with push, insert, update, remove, and clear helpers. +- **[`useSet`](https://hookli.vercel.app/docs/use-set)** — Set state with add, remove, toggle, has, and clear helpers. +- **[`useThrottle`](https://hookli.vercel.app/docs/use-throttle)** — Throttle a fast-changing value to at most one update per interval. +- **[`useUpdateEffect`](https://hookli.vercel.app/docs/use-update-effect)** — A useEffect that skips the initial mount and runs only on updates. +- **[`useEffectOnce`](https://hookli.vercel.app/docs/use-effect-once)** — Run an effect exactly once, on mount. +- **[`useKeyPress`](https://hookli.vercel.app/docs/use-key-press)** — Track whether a specific key is currently held down. +- **[`useWindowScroll`](https://hookli.vercel.app/docs/use-window-scroll)** — Track the window scroll position reactively. +- **[`useAsync`](https://hookli.vercel.app/docs/use-async)** — Run an async function and track its loading, error, and value state. +- **[`useMutation`](https://hookli.vercel.app/docs/use-mutation)** — Run an async write action on demand and track status, data, and error. +- **[`usePagination`](https://hookli.vercel.app/docs/use-pagination)** — Page, page size, total pages, navigation helpers, and the current item range. +- **[`useNetworkState`](https://hookli.vercel.app/docs/use-network-state)** — Track online/offline status and connection details. +- **[`usePageVisibility`](https://hookli.vercel.app/docs/use-page-visibility)** — Track whether the page/tab is currently visible. +- **[`useIdle`](https://hookli.vercel.app/docs/use-idle)** — Detect user inactivity after a configurable threshold. +- **[`useQueue`](https://hookli.vercel.app/docs/use-queue)** — FIFO queue state with add, remove, clear, and first/last/size. +- **[`useDefault`](https://hookli.vercel.app/docs/use-default)** — useState that falls back to a default when the value is nullish. +- **[`useRafState`](https://hookli.vercel.app/docs/use-raf-state)** — useState whose updates are batched to the next animation frame. +- **[`useDeepCompareEffect`](https://hookli.vercel.app/docs/use-deep-compare-effect)** — useEffect that compares dependencies by deep structural equality. +- **[`useTextSelection`](https://hookli.vercel.app/docs/use-text-selection)** — Track the text the user has currently selected on the page. +- **[`useLongPress`](https://hookli.vercel.app/docs/use-long-press)** — Detect a long press (mouse or touch) via spreadable handlers. +- **[`useHotkeys`](https://hookli.vercel.app/docs/use-hotkeys)** — Bind a keyboard shortcut combo (e.g. ctrl+k) to a callback. +- **[`useFullscreen`](https://hookli.vercel.app/docs/use-fullscreen)** — Control the Fullscreen API for an element and track its state. +- **[`useBattery`](https://hookli.vercel.app/docs/use-battery)** — Read device battery level and charging state (where supported). +- **[`usePermission`](https://hookli.vercel.app/docs/use-permission)** — Query a Permissions API permission and track its state. ## 🧪 TypeScript diff --git a/packages/hookli/ROADMAP.md b/packages/hookli/ROADMAP.md new file mode 100644 index 0000000..6597852 --- /dev/null +++ b/packages/hookli/ROADMAP.md @@ -0,0 +1,69 @@ +# hookli — Roadmap + +Tracking doc for growing the hook catalog and the package DX. Work proceeds in **tiers**; +check items off as they land. Each hook has three tracks: + +- **lib** — hook + colocated vitest test + barrel + manifest entry (ships to npm) +- **docs** — rich doc-page entry in `apps/docs/lib/hook-docs.ts` (usage, API tables, live demo) +- *(the docs sidebar/index/count auto-derive from the manifest — a hook appears the moment `lib` lands; `docs` just adds the demo + usage)* + +> Count: **41 → 65** (Tiers 1–3 lib all shipped ✅). Next open track: per-hook **docs demos**. + +--- + +## Tier 1 — expected-pair gaps (the "why isn't this here?" hooks) + +| hook | category | lib | docs | notes | +|---|---|:--:|:--:|---| +| `useThrottle` | effects | ✅ | ⬜ | the twin of `useDebounce` | +| `usePrevious` | state | ✅ | ⬜ | previous render's value | +| `useUpdateEffect` | effects | ✅ | ⬜ | effect that skips first render | +| `useEffectOnce` | effects | ✅ | ⬜ | run-once effect | +| `useList` | state | ✅ | ⬜ | array state (push/remove/clear) — sibling of `useMap` | +| `useSet` | state | ✅ | ⬜ | Set state — completes map/set/list trio | +| `useKeyPress` | dom | ✅ | ⬜ | true while a key is held | +| `useWindowScroll` | dom | ✅ | ⬜ | reactive `{ x, y }` scroll position | + +> **Tier 1 lib: ✅ shipped** (41 → 49 hooks, +20 tests, gate green). Docs demos pending. + +## Tier 2 — fill the thin `data` category (2 → 8) + +| hook | category | lib | docs | notes | +|---|---|:--:|:--:|---| +| `useAsync` | data | ✅ | ⬜ | run an async fn → `{ loading, error, value }` | +| `useMutation` | data | ✅ | ⬜ | async write action with status | +| `usePagination` | data | ✅ | ⬜ | page/limit/offset state helper | +| `useNetworkState` | data | ✅ | ⬜ | online/offline + connection info | +| `usePageVisibility` | effects | ✅ | ⬜ | tab focus/blur via `visibilitychange` | +| `useIdle` | dom | ✅ | ⬜ | user-inactivity detection | + +> **Tier 2 lib: ✅ shipped** (49 → 55; `data` category 2 → 8). + +## Tier 3 — rounding-out + +| hook | category | lib | docs | notes | +|---|---|:--:|:--:|---| +| `useQueue` | state | ✅ | ⬜ | FIFO queue | +| `useDefault` | state | ✅ | ⬜ | state with nullish fallback | +| `useRafState` | state | ✅ | ⬜ | state batched to rAF | +| `useDeepCompareEffect` | effects | ✅ | ⬜ | effect with deep-equal deps | +| `useTextSelection` | dom | ✅ | ⬜ | current selected text | +| `useLongPress` | dom | ✅ | ⬜ | long-press handlers | +| `useHotkeys` | dom | ✅ | ⬜ | keyboard combos (`ctrl+k`) | +| `useFullscreen` | dom | ✅ | ⬜ | Fullscreen API control | +| `useBattery` | data | ✅ | ⬜ | battery level/charging | +| `usePermission` | data | ✅ | ⬜ | Permissions API state | + +> **Tier 3 lib: ✅ shipped** (55 → 65). All three tiers' hooks are on npm-ready `main` +> once merged; per-hook **docs demos** remain the open `docs` track across all tiers. + +--- + +## Package & DX enhancements (not hooks) + +- ⬜ `size-limit` in CI + a per-hook gzipped-size badge ("every hook < 0.5 kB") +- ⬜ Hook generator script (`pnpm new:hook use-foo`) — scaffolds folder + test + manifest entry +- ⬜ Per-hook subpath exports (`import { useToggle } from "hookli/use-toggle"`) +- ⬜ Changesets — automated versioning + changelog (also fixes git-tag ↔ npm drift) +- ⬜ "Compare vs react-use / usehooks-ts" table in the docs +- ⬜ StackBlitz "open in playground" per hook page diff --git a/packages/hookli/hooks.manifest.json b/packages/hookli/hooks.manifest.json index 468ba53..29e018a 100644 --- a/packages/hookli/hooks.manifest.json +++ b/packages/hookli/hooks.manifest.json @@ -287,6 +287,174 @@ "description": "Browser geolocation state.", "category": "data", "signature": "useGeoLocation(): { location: GeolocationPosition | null; error: GeolocationError | Error | null }" + }, + { + "slug": "use-previous", + "name": "usePrevious", + "description": "Track a value from the previous render.", + "category": "state", + "signature": "usePrevious(value: T): T | undefined" + }, + { + "slug": "use-list", + "name": "useList", + "description": "Array state with push, insert, update, remove, and clear helpers.", + "category": "state", + "signature": "useList(initial?: T[]): [T[], UseListActions]" + }, + { + "slug": "use-set", + "name": "useSet", + "description": "Set state with add, remove, toggle, has, and clear helpers.", + "category": "state", + "signature": "useSet(initial?: Iterable): [Set, UseSetActions]" + }, + { + "slug": "use-throttle", + "name": "useThrottle", + "description": "Throttle a fast-changing value to at most one update per interval.", + "category": "effects", + "signature": "useThrottle(value: T, interval?: number): T" + }, + { + "slug": "use-update-effect", + "name": "useUpdateEffect", + "description": "A useEffect that skips the initial mount and runs only on updates.", + "category": "effects", + "signature": "useUpdateEffect(effect: EffectCallback, deps?: DependencyList): void" + }, + { + "slug": "use-effect-once", + "name": "useEffectOnce", + "description": "Run an effect exactly once, on mount.", + "category": "effects", + "signature": "useEffectOnce(effect: EffectCallback): void" + }, + { + "slug": "use-key-press", + "name": "useKeyPress", + "description": "Track whether a specific key is currently held down.", + "category": "dom", + "signature": "useKeyPress(targetKey: string): boolean" + }, + { + "slug": "use-window-scroll", + "name": "useWindowScroll", + "description": "Track the window scroll position reactively.", + "category": "dom", + "signature": "useWindowScroll(): { x: number; y: number }" + }, + { + "slug": "use-async", + "name": "useAsync", + "description": "Run an async function and track its loading, error, and value state.", + "category": "data", + "signature": "useAsync(asyncFunction: () => Promise, immediate?: boolean): UseAsyncReturn" + }, + { + "slug": "use-mutation", + "name": "useMutation", + "description": "Run an async write action on demand and track status, data, and error.", + "category": "data", + "signature": "useMutation(mutationFn: (...args: TArgs) => Promise): UseMutationReturn" + }, + { + "slug": "use-pagination", + "name": "usePagination", + "description": "Page, page size, total pages, navigation helpers, and the current item range.", + "category": "data", + "signature": "usePagination(options?: UsePaginationOptions): UsePaginationReturn" + }, + { + "slug": "use-network-state", + "name": "useNetworkState", + "description": "Track online/offline status and connection details.", + "category": "data", + "signature": "useNetworkState(): NetworkState" + }, + { + "slug": "use-page-visibility", + "name": "usePageVisibility", + "description": "Track whether the page/tab is currently visible.", + "category": "effects", + "signature": "usePageVisibility(): boolean" + }, + { + "slug": "use-idle", + "name": "useIdle", + "description": "Detect user inactivity after a configurable threshold.", + "category": "dom", + "signature": "useIdle(ms?: number): boolean" + }, + { + "slug": "use-queue", + "name": "useQueue", + "description": "FIFO queue state with add, remove, clear, and first/last/size.", + "category": "state", + "signature": "useQueue(initial?: T[]): UseQueueReturn" + }, + { + "slug": "use-default", + "name": "useDefault", + "description": "useState that falls back to a default when the value is nullish.", + "category": "state", + "signature": "useDefault(initialValue: T | null | undefined, defaultValue: T): [T, (value: T | null | undefined) => void]" + }, + { + "slug": "use-raf-state", + "name": "useRafState", + "description": "useState whose updates are batched to the next animation frame.", + "category": "state", + "signature": "useRafState(initialState: T | (() => T)): [T, Dispatch>]" + }, + { + "slug": "use-deep-compare-effect", + "name": "useDeepCompareEffect", + "description": "useEffect that compares dependencies by deep structural equality.", + "category": "effects", + "signature": "useDeepCompareEffect(effect: EffectCallback, deps: DependencyList): void" + }, + { + "slug": "use-text-selection", + "name": "useTextSelection", + "description": "Track the text the user has currently selected on the page.", + "category": "dom", + "signature": "useTextSelection(): string" + }, + { + "slug": "use-long-press", + "name": "useLongPress", + "description": "Detect a long press (mouse or touch) via spreadable handlers.", + "category": "dom", + "signature": "useLongPress(callback: (event: MouseEvent | TouchEvent) => void, options?: UseLongPressOptions): UseLongPressHandlers" + }, + { + "slug": "use-hotkeys", + "name": "useHotkeys", + "description": "Bind a keyboard shortcut combo (e.g. ctrl+k) to a callback.", + "category": "dom", + "signature": "useHotkeys(keys: string, callback: (event: KeyboardEvent) => void): void" + }, + { + "slug": "use-fullscreen", + "name": "useFullscreen", + "description": "Control the Fullscreen API for an element and track its state.", + "category": "dom", + "signature": "useFullscreen(ref: RefObject): UseFullscreenReturn" + }, + { + "slug": "use-battery", + "name": "useBattery", + "description": "Read device battery level and charging state (where supported).", + "category": "data", + "signature": "useBattery(): BatteryState" + }, + { + "slug": "use-permission", + "name": "usePermission", + "description": "Query a Permissions API permission and track its state.", + "category": "data", + "signature": "usePermission(name: PermissionName): UsePermissionResult" } ] } diff --git a/packages/hookli/package.json b/packages/hookli/package.json index 168c1e5..6c9df57 100644 --- a/packages/hookli/package.json +++ b/packages/hookli/package.json @@ -1,7 +1,7 @@ { "name": "hookli", - "version": "1.5.0", - "description": "hookli — all the React.js custom hooks you frequently use.", + "version": "1.6.0", + "description": "Every React hook you reach for, in one typed, zero-dependency, tree-shakable package — state, effects, DOM & data. SSR-safe, ESM + CJS.", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -19,15 +19,26 @@ }, "keywords": [ "hookli", + "react", "react-hooks", "hooks", - "react.js", "custom-hooks", - "Custom Hooks", - "node.js", - "Javascript", - "Frontend Development", - "Productivity" + "usehooks", + "typescript", + "typed", + "ssr", + "nextjs", + "tree-shakable", + "esm", + "zero-dependency", + "useDebounce", + "useThrottle", + "useLocalStorage", + "useAsync", + "useHotkeys", + "usePrevious", + "useMediaQuery", + "frontend" ], "author": "Saif Mohamed (https://linkedin.com/in/saifmohamedsv/)", "license": "ISC", diff --git a/packages/hookli/src/hooks/index.ts b/packages/hookli/src/hooks/index.ts index f3da7c8..539c700 100644 --- a/packages/hookli/src/hooks/index.ts +++ b/packages/hookli/src/hooks/index.ts @@ -39,3 +39,27 @@ export * from "./use-timeout"; export * from "./use-toggle"; export * from "./use-unmount"; export * from "./use-window-size"; +export * from "./use-previous"; +export * from "./use-throttle"; +export * from "./use-update-effect"; +export * from "./use-effect-once"; +export * from "./use-list"; +export * from "./use-set"; +export * from "./use-key-press"; +export * from "./use-window-scroll"; +export * from "./use-async"; +export * from "./use-mutation"; +export * from "./use-pagination"; +export * from "./use-network-state"; +export * from "./use-page-visibility"; +export * from "./use-idle"; +export * from "./use-queue"; +export * from "./use-default"; +export * from "./use-raf-state"; +export * from "./use-deep-compare-effect"; +export * from "./use-text-selection"; +export * from "./use-long-press"; +export * from "./use-hotkeys"; +export * from "./use-fullscreen"; +export * from "./use-battery"; +export * from "./use-permission"; diff --git a/packages/hookli/src/hooks/use-async/index.ts b/packages/hookli/src/hooks/use-async/index.ts new file mode 100644 index 0000000..818b0a0 --- /dev/null +++ b/packages/hookli/src/hooks/use-async/index.ts @@ -0,0 +1 @@ +export * from "./use-async"; diff --git a/packages/hookli/src/hooks/use-async/use-async.test.ts b/packages/hookli/src/hooks/use-async/use-async.test.ts new file mode 100644 index 0000000..b4787a8 --- /dev/null +++ b/packages/hookli/src/hooks/use-async/use-async.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useAsync } from "./use-async"; + +describe("useAsync", () => { + it("runs on mount and sets the resolved value", async () => { + const { result } = renderHook(() => useAsync(() => Promise.resolve(42))); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.value).toBe(42); + expect(result.current.error).toBeNull(); + }); + + it("captures errors", async () => { + const { result } = renderHook(() => + useAsync(() => Promise.reject(new Error("boom"))), + ); + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.error?.message).toBe("boom"); + }); + + it("does not run on mount when immediate is false", async () => { + const { result } = renderHook(() => + useAsync(() => Promise.resolve(1), false), + ); + expect(result.current.loading).toBe(false); + await act(async () => { + await result.current.execute(); + }); + expect(result.current.value).toBe(1); + }); +}); diff --git a/packages/hookli/src/hooks/use-async/use-async.ts b/packages/hookli/src/hooks/use-async/use-async.ts new file mode 100644 index 0000000..ade574e --- /dev/null +++ b/packages/hookli/src/hooks/use-async/use-async.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * The state returned by {@link useAsync}. + */ +export interface UseAsyncReturn { + /** Whether the async function is currently running. */ + loading: boolean; + /** The error thrown by the last run, or `null`. */ + error: Error | null; + /** The value resolved by the last successful run, or `null`. */ + value: T | null; + /** Run the async function again. */ + execute: () => Promise; +} + +/** + * Runs an async function and tracks its `{ loading, error, value }` state. By + * default it runs on mount; pass `immediate = false` to run it only via the + * returned `execute`. State is never set after unmount, so it won't leak or warn. + * + * @param asyncFunction - The async function to run. + * @param immediate - Run on mount. Defaults to `true`. + * @returns `{ loading, error, value, execute }`. + */ +export const useAsync = ( + asyncFunction: () => Promise, + immediate: boolean = true, +): UseAsyncReturn => { + const [loading, setLoading] = useState(immediate); + const [error, setError] = useState(null); + const [value, setValue] = useState(null); + + const fnRef = useRef(asyncFunction); + fnRef.current = asyncFunction; + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const execute = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fnRef.current(); + if (mounted.current) setValue(result); + } catch (err) { + if (mounted.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mounted.current) setLoading(false); + } + }, []); + + useEffect(() => { + if (immediate) void execute(); + }, [execute, immediate]); + + return { loading, error, value, execute }; +}; diff --git a/packages/hookli/src/hooks/use-battery/index.ts b/packages/hookli/src/hooks/use-battery/index.ts new file mode 100644 index 0000000..5017407 --- /dev/null +++ b/packages/hookli/src/hooks/use-battery/index.ts @@ -0,0 +1 @@ +export * from "./use-battery"; diff --git a/packages/hookli/src/hooks/use-battery/use-battery.test.ts b/packages/hookli/src/hooks/use-battery/use-battery.test.ts new file mode 100644 index 0000000..c4789f4 --- /dev/null +++ b/packages/hookli/src/hooks/use-battery/use-battery.test.ts @@ -0,0 +1,12 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useBattery } from "./use-battery"; + +describe("useBattery", () => { + it("reports unsupported when the Battery API is missing", () => { + const { result } = renderHook(() => useBattery()); + expect(result.current.supported).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.level).toBeNull(); + }); +}); diff --git a/packages/hookli/src/hooks/use-battery/use-battery.ts b/packages/hookli/src/hooks/use-battery/use-battery.ts new file mode 100644 index 0000000..75091bd --- /dev/null +++ b/packages/hookli/src/hooks/use-battery/use-battery.ts @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; + +/** The battery snapshot returned by {@link useBattery}. */ +export interface BatteryState { + /** Whether the Battery Status API is available. */ + supported: boolean; + /** Whether the first reading is still pending. */ + loading: boolean; + /** Charge level from `0` to `1`, or `null`. */ + level: number | null; + /** Whether the device is charging, or `null`. */ + charging: boolean | null; + /** Seconds until fully charged, or `null`. */ + chargingTime: number | null; + /** Seconds until empty, or `null`. */ + dischargingTime: number | null; +} + +interface BatteryManagerLike { + level: number; + charging: boolean; + chargingTime: number; + dischargingTime: number; + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; +} + +const EVENTS = [ + "levelchange", + "chargingchange", + "chargingtimechange", + "dischargingtimechange", +]; + +/** + * Reads the device battery via the Battery Status API and keeps it updated. + * SSR-safe and degrades gracefully: where the API is missing, `supported` is + * `false` and the numeric fields stay `null`. + * + * @returns The current {@link BatteryState}. + */ +export const useBattery = (): BatteryState => { + const [state, setState] = useState({ + supported: + typeof navigator !== "undefined" && "getBattery" in navigator, + loading: true, + level: null, + charging: null, + chargingTime: null, + dischargingTime: null, + }); + + useEffect(() => { + const getBattery = ( + navigator as unknown as { + getBattery?: () => Promise; + } + ).getBattery; + + if (!getBattery) { + setState((prev) => ({ ...prev, supported: false, loading: false })); + return; + } + + let battery: BatteryManagerLike | null = null; + let cancelled = false; + const update = () => { + if (!battery) return; + setState({ + supported: true, + loading: false, + level: battery.level, + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + }); + }; + + void getBattery.call(navigator).then((result) => { + if (cancelled) return; + battery = result; + update(); + for (const event of EVENTS) battery.addEventListener(event, update); + }); + + return () => { + cancelled = true; + if (battery) { + for (const event of EVENTS) battery.removeEventListener(event, update); + } + }; + }, []); + + return state; +}; diff --git a/packages/hookli/src/hooks/use-deep-compare-effect/index.ts b/packages/hookli/src/hooks/use-deep-compare-effect/index.ts new file mode 100644 index 0000000..ba40e61 --- /dev/null +++ b/packages/hookli/src/hooks/use-deep-compare-effect/index.ts @@ -0,0 +1 @@ +export * from "./use-deep-compare-effect"; diff --git a/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.test.ts b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.test.ts new file mode 100644 index 0000000..610a069 --- /dev/null +++ b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.test.ts @@ -0,0 +1,29 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useDeepCompareEffect } from "./use-deep-compare-effect"; + +describe("useDeepCompareEffect", () => { + it("runs on mount", () => { + const effect = vi.fn(); + renderHook(() => useDeepCompareEffect(effect, [{ a: 1 }])); + expect(effect).toHaveBeenCalledTimes(1); + }); + + it("does not re-run for deeply-equal deps", () => { + const effect = vi.fn(); + const { rerender } = renderHook(({ deps }) => useDeepCompareEffect(effect, deps), { + initialProps: { deps: [{ a: 1 }] as unknown[] }, + }); + rerender({ deps: [{ a: 1 }] }); // new reference, same shape + expect(effect).toHaveBeenCalledTimes(1); + }); + + it("re-runs when deps deeply change", () => { + const effect = vi.fn(); + const { rerender } = renderHook(({ deps }) => useDeepCompareEffect(effect, deps), { + initialProps: { deps: [{ a: 1 }] as unknown[] }, + }); + rerender({ deps: [{ a: 2 }] }); + expect(effect).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.ts b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.ts new file mode 100644 index 0000000..859eaad --- /dev/null +++ b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef, type DependencyList, type EffectCallback } from "react"; + +/** Minimal structural deep-equality for dependency lists (no external deps). */ +const deepEqual = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { + return false; + } + const aKeys = Object.keys(a as object); + const bKeys = Object.keys(b as object); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + deepEqual( + (a as Record)[key], + (b as Record)[key], + ), + ); +}; + +/** + * A `useEffect` that compares its dependencies by **deep** structural equality + * instead of reference. Use it when deps are objects/arrays recreated on every + * render (so a normal `useEffect` would fire every time). + * + * @param effect - Imperative effect callback; may return a cleanup function. + * @param deps - Dependency list, compared deeply. + */ +export const useDeepCompareEffect = ( + effect: EffectCallback, + deps: DependencyList, +): void => { + const ref = useRef(undefined); + const signal = useRef(0); + + if (ref.current === undefined || !deepEqual(deps, ref.current)) { + ref.current = deps; + signal.current += 1; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(effect, [signal.current]); +}; diff --git a/packages/hookli/src/hooks/use-default/index.ts b/packages/hookli/src/hooks/use-default/index.ts new file mode 100644 index 0000000..b3288f8 --- /dev/null +++ b/packages/hookli/src/hooks/use-default/index.ts @@ -0,0 +1 @@ +export * from "./use-default"; diff --git a/packages/hookli/src/hooks/use-default/use-default.test.ts b/packages/hookli/src/hooks/use-default/use-default.test.ts new file mode 100644 index 0000000..c109a51 --- /dev/null +++ b/packages/hookli/src/hooks/use-default/use-default.test.ts @@ -0,0 +1,21 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useDefault } from "./use-default"; + +describe("useDefault", () => { + it("returns the value when set", () => { + const { result } = renderHook(() => useDefault("a", "fallback")); + expect(result.current[0]).toBe("a"); + }); + + it("falls back to the default when nullish", () => { + const { result } = renderHook(() => useDefault(null, "fallback")); + expect(result.current[0]).toBe("fallback"); + + act(() => result.current[1]("b")); + expect(result.current[0]).toBe("b"); + + act(() => result.current[1](undefined)); + expect(result.current[0]).toBe("fallback"); + }); +}); diff --git a/packages/hookli/src/hooks/use-default/use-default.ts b/packages/hookli/src/hooks/use-default/use-default.ts new file mode 100644 index 0000000..fd58c43 --- /dev/null +++ b/packages/hookli/src/hooks/use-default/use-default.ts @@ -0,0 +1,18 @@ +import { useState } from "react"; + +/** + * Like `useState`, but reads back the `defaultValue` whenever the state is + * `null` or `undefined`. The setter still accepts `null`/`undefined` — the + * fallback is applied only on read. + * + * @param initialValue - The initial value. + * @param defaultValue - The value returned whenever the state is nullish. + * @returns A `[value, setValue]` tuple. + */ +export const useDefault = ( + initialValue: T | null | undefined, + defaultValue: T, +): [T, (value: T | null | undefined) => void] => { + const [value, setValue] = useState(initialValue); + return [value === null || value === undefined ? defaultValue : value, setValue]; +}; diff --git a/packages/hookli/src/hooks/use-effect-once/index.ts b/packages/hookli/src/hooks/use-effect-once/index.ts new file mode 100644 index 0000000..56b3746 --- /dev/null +++ b/packages/hookli/src/hooks/use-effect-once/index.ts @@ -0,0 +1 @@ +export * from "./use-effect-once"; diff --git a/packages/hookli/src/hooks/use-effect-once/use-effect-once.test.ts b/packages/hookli/src/hooks/use-effect-once/use-effect-once.test.ts new file mode 100644 index 0000000..684a29f --- /dev/null +++ b/packages/hookli/src/hooks/use-effect-once/use-effect-once.test.ts @@ -0,0 +1,21 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useEffectOnce } from "./use-effect-once"; + +describe("useEffectOnce", () => { + it("runs once on mount and not on re-render", () => { + const effect = vi.fn(); + const { rerender } = renderHook(() => useEffectOnce(effect)); + rerender(); + rerender(); + expect(effect).toHaveBeenCalledTimes(1); + }); + + it("runs the cleanup on unmount", () => { + const cleanup = vi.fn(); + const { unmount } = renderHook(() => useEffectOnce(() => cleanup)); + expect(cleanup).not.toHaveBeenCalled(); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/hookli/src/hooks/use-effect-once/use-effect-once.ts b/packages/hookli/src/hooks/use-effect-once/use-effect-once.ts new file mode 100644 index 0000000..7867f6d --- /dev/null +++ b/packages/hookli/src/hooks/use-effect-once/use-effect-once.ts @@ -0,0 +1,14 @@ +import { useEffect, type EffectCallback } from "react"; + +/** + * Runs an effect exactly once, on mount. A convenience wrapper around + * `useEffect(effect, [])` that makes the intent explicit and contains the + * exhaustive-deps exception in one place. + * + * @param effect - Imperative effect callback; may return a cleanup function that + * runs on unmount. + */ +export const useEffectOnce = (effect: EffectCallback): void => { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(effect, []); +}; diff --git a/packages/hookli/src/hooks/use-fullscreen/index.ts b/packages/hookli/src/hooks/use-fullscreen/index.ts new file mode 100644 index 0000000..4b2406b --- /dev/null +++ b/packages/hookli/src/hooks/use-fullscreen/index.ts @@ -0,0 +1 @@ +export * from "./use-fullscreen"; diff --git a/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.test.ts b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.test.ts new file mode 100644 index 0000000..9d965f1 --- /dev/null +++ b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.test.ts @@ -0,0 +1,17 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useFullscreen } from "./use-fullscreen"; + +describe("useFullscreen", () => { + it("defaults to not fullscreen and exposes controls", async () => { + const ref = { current: document.createElement("div") }; + const { result } = renderHook(() => useFullscreen(ref)); + expect(result.current.isFullscreen).toBe(false); + expect(typeof result.current.enter).toBe("function"); + expect(typeof result.current.toggle).toBe("function"); + // no-op where the Fullscreen API is unavailable (jsdom) — should not throw + await act(async () => { + await result.current.enter(); + }); + }); +}); diff --git a/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.ts b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.ts new file mode 100644 index 0000000..16b1daa --- /dev/null +++ b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState, type RefObject } from "react"; + +/** The value returned by {@link useFullscreen}. */ +export interface UseFullscreenReturn { + /** Whether the referenced element is currently fullscreen. */ + isFullscreen: boolean; + /** Request fullscreen for the referenced element. */ + enter: () => Promise; + /** Exit fullscreen (if this element is fullscreen). */ + exit: () => Promise; + /** Toggle fullscreen for the referenced element. */ + toggle: () => Promise; +} + +/** + * Controls the Fullscreen API for a referenced element and tracks whether it is + * currently fullscreen (via `fullscreenchange`). Listeners attach in an effect, + * so the hook is SSR-safe; calls are no-ops where the API is unavailable. + * + * @param ref - A ref to the element to make fullscreen. + * @returns `{ isFullscreen, enter, exit, toggle }`. + */ +export const useFullscreen = ( + ref: RefObject, +): UseFullscreenReturn => { + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const onChange = () => + setIsFullscreen(document.fullscreenElement === ref.current); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, [ref]); + + const enter = useCallback(async () => { + await ref.current?.requestFullscreen?.(); + }, [ref]); + + const exit = useCallback(async () => { + if (typeof document !== "undefined" && document.fullscreenElement) { + await document.exitFullscreen?.(); + } + }, []); + + const toggle = useCallback(async () => { + if (document.fullscreenElement === ref.current) await exit(); + else await enter(); + }, [enter, exit, ref]); + + return { isFullscreen, enter, exit, toggle }; +}; diff --git a/packages/hookli/src/hooks/use-hotkeys/index.ts b/packages/hookli/src/hooks/use-hotkeys/index.ts new file mode 100644 index 0000000..7994a23 --- /dev/null +++ b/packages/hookli/src/hooks/use-hotkeys/index.ts @@ -0,0 +1 @@ +export * from "./use-hotkeys"; diff --git a/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.test.ts b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.test.ts new file mode 100644 index 0000000..084ef7a --- /dev/null +++ b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useHotkeys } from "./use-hotkeys"; + +describe("useHotkeys", () => { + it("fires on the matching combo", () => { + const cb = vi.fn(); + renderHook(() => useHotkeys("ctrl+k", cb)); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true })); + }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("ignores the key without its modifier", () => { + const cb = vi.fn(); + renderHook(() => useHotkeys("ctrl+k", cb)); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "k" })); + }); + expect(cb).not.toHaveBeenCalled(); + }); + + it("matches a single key", () => { + const cb = vi.fn(); + renderHook(() => useHotkeys("Escape", cb)); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.ts b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.ts new file mode 100644 index 0000000..ca33c39 --- /dev/null +++ b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; + +const MODIFIERS = [ + "ctrl", + "control", + "meta", + "cmd", + "command", + "shift", + "alt", + "option", +]; + +/** + * Binds a keyboard shortcut to a callback. Accepts a `"+"`-separated combo such + * as `"ctrl+k"`, `"meta+shift+p"`, or a single key like `"Escape"`. The listener + * attaches to `window` in an effect (SSR-safe) and calls `preventDefault()` on a + * match. The callback is held in a ref, so re-renders don't re-bind. + * + * @param keys - The combo, e.g. `"ctrl+k"` (case-insensitive). + * @param callback - Called with the `KeyboardEvent` when the combo matches. + */ +export const useHotkeys = ( + keys: string, + callback: (event: KeyboardEvent) => void, +): void => { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + const parts = keys.toLowerCase().split("+").map((part) => part.trim()); + const needCtrl = parts.includes("ctrl") || parts.includes("control"); + const needMeta = + parts.includes("meta") || + parts.includes("cmd") || + parts.includes("command"); + const needShift = parts.includes("shift"); + const needAlt = parts.includes("alt") || parts.includes("option"); + const mainKey = parts.find((part) => !MODIFIERS.includes(part)); + + const handler = (event: KeyboardEvent) => { + if (!mainKey) return; + if ( + event.ctrlKey === needCtrl && + event.metaKey === needMeta && + event.shiftKey === needShift && + event.altKey === needAlt && + event.key.toLowerCase() === mainKey + ) { + event.preventDefault(); + callbackRef.current(event); + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [keys]); +}; diff --git a/packages/hookli/src/hooks/use-idle/index.ts b/packages/hookli/src/hooks/use-idle/index.ts new file mode 100644 index 0000000..433b1aa --- /dev/null +++ b/packages/hookli/src/hooks/use-idle/index.ts @@ -0,0 +1 @@ +export * from "./use-idle"; diff --git a/packages/hookli/src/hooks/use-idle/use-idle.test.ts b/packages/hookli/src/hooks/use-idle/use-idle.test.ts new file mode 100644 index 0000000..d8670d4 --- /dev/null +++ b/packages/hookli/src/hooks/use-idle/use-idle.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useIdle } from "./use-idle"; + +describe("useIdle", () => { + afterEach(() => vi.useRealTimers()); + + it("becomes idle after the threshold", () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useIdle(1000)); + expect(result.current).toBe(false); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(true); + }); + + it("resets on activity", () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useIdle(1000)); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(true); + + act(() => { + window.dispatchEvent(new Event("mousemove")); + }); + expect(result.current).toBe(false); + }); +}); diff --git a/packages/hookli/src/hooks/use-idle/use-idle.ts b/packages/hookli/src/hooks/use-idle/use-idle.ts new file mode 100644 index 0000000..bbcdbbe --- /dev/null +++ b/packages/hookli/src/hooks/use-idle/use-idle.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; + +const ACTIVITY_EVENTS = [ + "mousemove", + "mousedown", + "keydown", + "touchstart", + "scroll", + "wheel", +] as const; + +/** + * Reports whether the user has been idle (no activity) for at least `ms` + * milliseconds. Any mouse/keyboard/touch/scroll activity resets the timer. + * Listeners attach in an effect, so it is SSR-safe. + * + * @param ms - Inactivity threshold in milliseconds. Defaults to `60000` (1 min). + * @returns `true` once the user has been idle for `ms`, `false` while active. + */ +export const useIdle = (ms: number = 60_000): boolean => { + const [idle, setIdle] = useState(false); + + useEffect(() => { + let timeoutId: ReturnType; + + const reset = () => { + setIdle(false); + clearTimeout(timeoutId); + timeoutId = setTimeout(() => setIdle(true), ms); + }; + + reset(); + for (const event of ACTIVITY_EVENTS) { + window.addEventListener(event, reset, { passive: true }); + } + return () => { + clearTimeout(timeoutId); + for (const event of ACTIVITY_EVENTS) { + window.removeEventListener(event, reset); + } + }; + }, [ms]); + + return idle; +}; diff --git a/packages/hookli/src/hooks/use-key-press/index.ts b/packages/hookli/src/hooks/use-key-press/index.ts new file mode 100644 index 0000000..c595813 --- /dev/null +++ b/packages/hookli/src/hooks/use-key-press/index.ts @@ -0,0 +1 @@ +export * from "./use-key-press"; diff --git a/packages/hookli/src/hooks/use-key-press/use-key-press.test.ts b/packages/hookli/src/hooks/use-key-press/use-key-press.test.ts new file mode 100644 index 0000000..e7e6a49 --- /dev/null +++ b/packages/hookli/src/hooks/use-key-press/use-key-press.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useKeyPress } from "./use-key-press"; + +describe("useKeyPress", () => { + it("is false initially", () => { + const { result } = renderHook(() => useKeyPress("a")); + expect(result.current).toBe(false); + }); + + it("tracks keydown / keyup for the target key", () => { + const { result } = renderHook(() => useKeyPress("a")); + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" })); + }); + expect(result.current).toBe(true); + + act(() => { + window.dispatchEvent(new KeyboardEvent("keyup", { key: "a" })); + }); + expect(result.current).toBe(false); + }); + + it("ignores other keys", () => { + const { result } = renderHook(() => useKeyPress("a")); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "b" })); + }); + expect(result.current).toBe(false); + }); +}); diff --git a/packages/hookli/src/hooks/use-key-press/use-key-press.ts b/packages/hookli/src/hooks/use-key-press/use-key-press.ts new file mode 100644 index 0000000..6eb2b29 --- /dev/null +++ b/packages/hookli/src/hooks/use-key-press/use-key-press.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; + +/** + * Tracks whether a specific key is currently held down. Listens for `keydown` + * and `keyup` on `window` (attached in an effect, so SSR-safe) and removes the + * listeners on unmount. + * + * @param targetKey - The `KeyboardEvent.key` to watch (e.g. `"Enter"`, `"a"`, `"Escape"`). + * @returns `true` while the key is pressed, `false` otherwise. + */ +export const useKeyPress = (targetKey: string): boolean => { + const [pressed, setPressed] = useState(false); + + useEffect(() => { + const down = (event: KeyboardEvent) => { + if (event.key === targetKey) setPressed(true); + }; + const up = (event: KeyboardEvent) => { + if (event.key === targetKey) setPressed(false); + }; + window.addEventListener("keydown", down); + window.addEventListener("keyup", up); + return () => { + window.removeEventListener("keydown", down); + window.removeEventListener("keyup", up); + }; + }, [targetKey]); + + return pressed; +}; diff --git a/packages/hookli/src/hooks/use-list/index.ts b/packages/hookli/src/hooks/use-list/index.ts new file mode 100644 index 0000000..2b009c7 --- /dev/null +++ b/packages/hookli/src/hooks/use-list/index.ts @@ -0,0 +1 @@ +export * from "./use-list"; diff --git a/packages/hookli/src/hooks/use-list/use-list.test.ts b/packages/hookli/src/hooks/use-list/use-list.test.ts new file mode 100644 index 0000000..70a25d3 --- /dev/null +++ b/packages/hookli/src/hooks/use-list/use-list.test.ts @@ -0,0 +1,34 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useList } from "./use-list"; + +describe("useList", () => { + it("defaults to an empty list", () => { + const { result } = renderHook(() => useList()); + expect(result.current[0]).toEqual([]); + }); + + it("push / insertAt / updateAt / removeAt work immutably", () => { + const { result } = renderHook(() => useList([1, 2, 3])); + + act(() => result.current[1].push(4)); + expect(result.current[0]).toEqual([1, 2, 3, 4]); + + act(() => result.current[1].insertAt(1, 9)); + expect(result.current[0]).toEqual([1, 9, 2, 3, 4]); + + act(() => result.current[1].updateAt(0, 0)); + expect(result.current[0]).toEqual([0, 9, 2, 3, 4]); + + act(() => result.current[1].removeAt(1)); + expect(result.current[0]).toEqual([0, 2, 3, 4]); + }); + + it("clear and reset", () => { + const { result } = renderHook(() => useList([1, 2])); + act(() => result.current[1].clear()); + expect(result.current[0]).toEqual([]); + act(() => result.current[1].reset()); + expect(result.current[0]).toEqual([1, 2]); + }); +}); diff --git a/packages/hookli/src/hooks/use-list/use-list.ts b/packages/hookli/src/hooks/use-list/use-list.ts new file mode 100644 index 0000000..423f928 --- /dev/null +++ b/packages/hookli/src/hooks/use-list/use-list.ts @@ -0,0 +1,57 @@ +import { useMemo, useState } from "react"; + +/** + * Actions returned by {@link useList} for manipulating the array immutably. + */ +export interface UseListActions { + /** Replace the whole list. */ + set: (list: T[]) => void; + /** Append one or more items to the end. */ + push: (...items: T[]) => void; + /** Insert an item at `index`, shifting the rest right. */ + insertAt: (index: number, item: T) => void; + /** Replace the item at `index`. */ + updateAt: (index: number, item: T) => void; + /** Remove the item at `index`. */ + removeAt: (index: number) => void; + /** Empty the list. */ + clear: () => void; + /** Reset to the initial list the hook was created with. */ + reset: () => void; +} + +/** + * Manages array state with a set of immutable helpers (`push`, `insertAt`, + * `updateAt`, `removeAt`, `clear`, `reset`). Every helper produces a new array, + * so referential-equality checks and memoization work as expected. + * + * @param initial - The initial array. Defaults to `[]`. + * @returns A tuple of `[list, actions]`. + */ +export const useList = (initial: T[] = []): [T[], UseListActions] => { + const [list, setList] = useState(initial); + + const actions = useMemo>( + () => ({ + set: (next) => setList(next), + push: (...items) => setList((prev) => [...prev, ...items]), + insertAt: (index, item) => + setList((prev) => { + const next = prev.slice(); + next.splice(index, 0, item); + return next; + }), + updateAt: (index, item) => + setList((prev) => prev.map((value, i) => (i === index ? item : value))), + removeAt: (index) => + setList((prev) => prev.filter((_, i) => i !== index)), + clear: () => setList([]), + reset: () => setList(initial), + }), + // `initial` is intentionally captured once (reset restores the original list). + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + return [list, actions]; +}; diff --git a/packages/hookli/src/hooks/use-long-press/index.ts b/packages/hookli/src/hooks/use-long-press/index.ts new file mode 100644 index 0000000..a4a9857 --- /dev/null +++ b/packages/hookli/src/hooks/use-long-press/index.ts @@ -0,0 +1 @@ +export * from "./use-long-press"; diff --git a/packages/hookli/src/hooks/use-long-press/use-long-press.test.ts b/packages/hookli/src/hooks/use-long-press/use-long-press.test.ts new file mode 100644 index 0000000..4c390df --- /dev/null +++ b/packages/hookli/src/hooks/use-long-press/use-long-press.test.ts @@ -0,0 +1,29 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useLongPress } from "./use-long-press"; + +describe("useLongPress", () => { + afterEach(() => vi.useRealTimers()); + + it("fires after the delay", () => { + vi.useFakeTimers(); + const cb = vi.fn(); + const { result } = renderHook(() => useLongPress(cb, { delay: 500 })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + act(() => result.current.onMouseDown({} as any)); + expect(cb).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(500)); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("cancels when released early", () => { + vi.useFakeTimers(); + const cb = vi.fn(); + const { result } = renderHook(() => useLongPress(cb, { delay: 500 })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + act(() => result.current.onMouseDown({} as any)); + act(() => result.current.onMouseUp()); + act(() => vi.advanceTimersByTime(500)); + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hookli/src/hooks/use-long-press/use-long-press.ts b/packages/hookli/src/hooks/use-long-press/use-long-press.ts new file mode 100644 index 0000000..7b3143b --- /dev/null +++ b/packages/hookli/src/hooks/use-long-press/use-long-press.ts @@ -0,0 +1,57 @@ +import { useCallback, useRef, type MouseEvent, type TouchEvent } from "react"; + +/** Options for {@link useLongPress}. */ +export interface UseLongPressOptions { + /** How long the press must be held before firing, in ms. Defaults to `400`. */ + delay?: number; +} + +/** Handlers returned by {@link useLongPress}, spread onto the target element. */ +export interface UseLongPressHandlers { + onMouseDown: (event: MouseEvent) => void; + onMouseUp: () => void; + onMouseLeave: () => void; + onTouchStart: (event: TouchEvent) => void; + onTouchEnd: () => void; +} + +/** + * Detects a long press (mouse or touch). Spread the returned handlers onto an + * element; `callback` fires once the press is held for `delay` ms, and is + * cancelled if the pointer is released or leaves first. + * + * @param callback - Called with the originating event once the press is held. + * @param options - `{ delay }`. + * @returns Event handlers to spread onto the target element. + */ +export const useLongPress = ( + callback: (event: MouseEvent | TouchEvent) => void, + options: UseLongPressOptions = {}, +): UseLongPressHandlers => { + const { delay = 400 } = options; + const timeout = useRef | null>(null); + const callbackRef = useRef(callback); + callbackRef.current = callback; + + const start = useCallback( + (event: MouseEvent | TouchEvent) => { + timeout.current = setTimeout(() => callbackRef.current(event), delay); + }, + [delay], + ); + + const cancel = useCallback(() => { + if (timeout.current) { + clearTimeout(timeout.current); + timeout.current = null; + } + }, []); + + return { + onMouseDown: start, + onMouseUp: cancel, + onMouseLeave: cancel, + onTouchStart: start, + onTouchEnd: cancel, + }; +}; diff --git a/packages/hookli/src/hooks/use-mutation/index.ts b/packages/hookli/src/hooks/use-mutation/index.ts new file mode 100644 index 0000000..d2b7243 --- /dev/null +++ b/packages/hookli/src/hooks/use-mutation/index.ts @@ -0,0 +1 @@ +export * from "./use-mutation"; diff --git a/packages/hookli/src/hooks/use-mutation/use-mutation.test.ts b/packages/hookli/src/hooks/use-mutation/use-mutation.test.ts new file mode 100644 index 0000000..f32c63b --- /dev/null +++ b/packages/hookli/src/hooks/use-mutation/use-mutation.test.ts @@ -0,0 +1,45 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useMutation } from "./use-mutation"; + +describe("useMutation", () => { + it("starts idle", () => { + const { result } = renderHook(() => useMutation(async (n: number) => n)); + expect(result.current.status).toBe("idle"); + expect(result.current.data).toBeNull(); + }); + + it("runs the mutation and tracks success", async () => { + const { result } = renderHook(() => useMutation(async (n: number) => n * 2)); + await act(async () => { + const value = await result.current.mutate(21); + expect(value).toBe(42); + }); + expect(result.current.status).toBe("success"); + expect(result.current.data).toBe(42); + }); + + it("captures errors without throwing", async () => { + const { result } = renderHook(() => + useMutation(async () => { + throw new Error("nope"); + }), + ); + await act(async () => { + const value = await result.current.mutate(); + expect(value).toBeUndefined(); + }); + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toBe("nope"); + }); + + it("resets", async () => { + const { result } = renderHook(() => useMutation(async (n: number) => n)); + await act(async () => { + await result.current.mutate(1); + }); + act(() => result.current.reset()); + expect(result.current.status).toBe("idle"); + expect(result.current.data).toBeNull(); + }); +}); diff --git a/packages/hookli/src/hooks/use-mutation/use-mutation.ts b/packages/hookli/src/hooks/use-mutation/use-mutation.ts new file mode 100644 index 0000000..eb248cf --- /dev/null +++ b/packages/hookli/src/hooks/use-mutation/use-mutation.ts @@ -0,0 +1,70 @@ +import { useCallback, useRef, useState } from "react"; + +/** The lifecycle status of a {@link useMutation}. */ +export type MutationStatus = "idle" | "loading" | "success" | "error"; + +/** + * The value returned by {@link useMutation}. + */ +export interface UseMutationReturn { + /** Run the mutation. Resolves with the data, or `undefined` if it errored. */ + mutate: (...args: TArgs) => Promise; + /** Current lifecycle status. */ + status: MutationStatus; + /** Convenience flag for `status === "loading"`. */ + isLoading: boolean; + /** Data from the last successful run, or `null`. */ + data: TData | null; + /** Error from the last failed run, or `null`. */ + error: Error | null; + /** Reset back to the idle state. */ + reset: () => void; +} + +/** + * Runs an async write action (create/update/delete) on demand and tracks its + * `status`, `data`, and `error` — the write-side companion to {@link useAsync}. + * `mutate` never throws: on failure it records the error and resolves to + * `undefined`. State is not set after unmount. + * + * @param mutationFn - The async function to run when `mutate` is called. + * @returns `{ mutate, status, isLoading, data, error, reset }`. + */ +export const useMutation = ( + mutationFn: (...args: TArgs) => Promise, +): UseMutationReturn => { + const [status, setStatus] = useState("idle"); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + const fnRef = useRef(mutationFn); + fnRef.current = mutationFn; + const mounted = useRef(true); + + const mutate = useCallback(async (...args: TArgs) => { + setStatus("loading"); + setError(null); + try { + const result = await fnRef.current(...args); + if (mounted.current) { + setData(result); + setStatus("success"); + } + return result; + } catch (err) { + if (mounted.current) { + setError(err instanceof Error ? err : new Error(String(err))); + setStatus("error"); + } + return undefined; + } + }, []); + + const reset = useCallback(() => { + setStatus("idle"); + setData(null); + setError(null); + }, []); + + return { mutate, status, isLoading: status === "loading", data, error, reset }; +}; diff --git a/packages/hookli/src/hooks/use-network-state/index.ts b/packages/hookli/src/hooks/use-network-state/index.ts new file mode 100644 index 0000000..6b751fa --- /dev/null +++ b/packages/hookli/src/hooks/use-network-state/index.ts @@ -0,0 +1 @@ +export * from "./use-network-state"; diff --git a/packages/hookli/src/hooks/use-network-state/use-network-state.test.ts b/packages/hookli/src/hooks/use-network-state/use-network-state.test.ts new file mode 100644 index 0000000..cf47515 --- /dev/null +++ b/packages/hookli/src/hooks/use-network-state/use-network-state.test.ts @@ -0,0 +1,25 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useNetworkState } from "./use-network-state"; + +describe("useNetworkState", () => { + afterEach(() => vi.restoreAllMocks()); + + it("reports the current online status", () => { + vi.spyOn(navigator, "onLine", "get").mockReturnValue(true); + const { result } = renderHook(() => useNetworkState()); + expect(result.current.online).toBe(true); + }); + + it("updates when the browser goes offline", () => { + const spy = vi.spyOn(navigator, "onLine", "get").mockReturnValue(true); + const { result } = renderHook(() => useNetworkState()); + expect(result.current.online).toBe(true); + + act(() => { + spy.mockReturnValue(false); + window.dispatchEvent(new Event("offline")); + }); + expect(result.current.online).toBe(false); + }); +}); diff --git a/packages/hookli/src/hooks/use-network-state/use-network-state.ts b/packages/hookli/src/hooks/use-network-state/use-network-state.ts new file mode 100644 index 0000000..19c038f --- /dev/null +++ b/packages/hookli/src/hooks/use-network-state/use-network-state.ts @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; + +/** + * The network snapshot returned by {@link useNetworkState}. Connection details + * come from the Network Information API and are `undefined` where unsupported. + */ +export interface NetworkState { + /** Whether the browser is online (`navigator.onLine`). */ + online: boolean; + /** Effective connection type, e.g. `"4g"` (if supported). */ + effectiveType?: string; + /** Downlink speed estimate in Mbps (if supported). */ + downlink?: number; + /** Round-trip time estimate in ms (if supported). */ + rtt?: number; + /** Whether the user requested reduced data usage (if supported). */ + saveData?: boolean; +} + +type ConnectionLike = { + effectiveType?: string; + downlink?: number; + rtt?: number; + saveData?: boolean; + addEventListener?: (type: "change", listener: () => void) => void; + removeEventListener?: (type: "change", listener: () => void) => void; +}; + +const getConnection = (): ConnectionLike | undefined => { + if (typeof navigator === "undefined") return undefined; + return ( + (navigator as unknown as { connection?: ConnectionLike }).connection ?? + undefined + ); +}; + +const read = (): NetworkState => { + if (typeof navigator === "undefined") return { online: true }; + const c = getConnection(); + return { + online: navigator.onLine, + effectiveType: c?.effectiveType, + downlink: c?.downlink, + rtt: c?.rtt, + saveData: c?.saveData, + }; +}; + +/** + * Tracks the browser's network state — online/offline plus connection details + * where the Network Information API is available. Listeners attach in an effect, + * so it is SSR-safe (returns `{ online: true }` on the server). + * + * @returns The current {@link NetworkState}. + */ +export const useNetworkState = (): NetworkState => { + const [state, setState] = useState(() => + typeof navigator === "undefined" ? { online: true } : read(), + ); + + useEffect(() => { + const update = () => setState(read()); + update(); + window.addEventListener("online", update); + window.addEventListener("offline", update); + const c = getConnection(); + c?.addEventListener?.("change", update); + return () => { + window.removeEventListener("online", update); + window.removeEventListener("offline", update); + c?.removeEventListener?.("change", update); + }; + }, []); + + return state; +}; diff --git a/packages/hookli/src/hooks/use-page-visibility/index.ts b/packages/hookli/src/hooks/use-page-visibility/index.ts new file mode 100644 index 0000000..c14ef49 --- /dev/null +++ b/packages/hookli/src/hooks/use-page-visibility/index.ts @@ -0,0 +1 @@ +export * from "./use-page-visibility"; diff --git a/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.test.ts b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.test.ts new file mode 100644 index 0000000..7a31d0f --- /dev/null +++ b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.test.ts @@ -0,0 +1,21 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePageVisibility } from "./use-page-visibility"; + +describe("usePageVisibility", () => { + afterEach(() => vi.restoreAllMocks()); + + it("is visible by default", () => { + const { result } = renderHook(() => usePageVisibility()); + expect(result.current).toBe(true); + }); + + it("reacts to visibilitychange", () => { + const { result } = renderHook(() => usePageVisibility()); + act(() => { + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(result.current).toBe(false); + }); +}); diff --git a/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.ts b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.ts new file mode 100644 index 0000000..03fc7b5 --- /dev/null +++ b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +/** + * Tracks whether the page/tab is currently visible via the Page Visibility API + * (`document.visibilityState`). The listener attaches in an effect, so it is + * SSR-safe (returns `true` on the server). + * + * @returns `true` while the tab is visible, `false` when hidden/backgrounded. + */ +export const usePageVisibility = (): boolean => { + const [visible, setVisible] = useState(true); + + useEffect(() => { + const update = () => setVisible(document.visibilityState === "visible"); + update(); + document.addEventListener("visibilitychange", update); + return () => document.removeEventListener("visibilitychange", update); + }, []); + + return visible; +}; diff --git a/packages/hookli/src/hooks/use-pagination/index.ts b/packages/hookli/src/hooks/use-pagination/index.ts new file mode 100644 index 0000000..9853d85 --- /dev/null +++ b/packages/hookli/src/hooks/use-pagination/index.ts @@ -0,0 +1 @@ +export * from "./use-pagination"; diff --git a/packages/hookli/src/hooks/use-pagination/use-pagination.test.ts b/packages/hookli/src/hooks/use-pagination/use-pagination.test.ts new file mode 100644 index 0000000..45bb5a8 --- /dev/null +++ b/packages/hookli/src/hooks/use-pagination/use-pagination.test.ts @@ -0,0 +1,37 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { usePagination } from "./use-pagination"; + +describe("usePagination", () => { + it("computes total pages and the current range", () => { + const { result } = renderHook(() => usePagination({ total: 95, pageSize: 10 })); + expect(result.current.totalPages).toBe(10); + expect(result.current.page).toBe(1); + expect(result.current.canPrev).toBe(false); + expect(result.current.range).toEqual({ start: 0, end: 10 }); + }); + + it("navigates and clamps", () => { + const { result } = renderHook(() => usePagination({ total: 95, pageSize: 10 })); + + act(() => result.current.next()); + expect(result.current.page).toBe(2); + expect(result.current.range).toEqual({ start: 10, end: 20 }); + + act(() => result.current.last()); + expect(result.current.page).toBe(10); + expect(result.current.canNext).toBe(false); + expect(result.current.range).toEqual({ start: 90, end: 95 }); + + act(() => result.current.setPage(999)); + expect(result.current.page).toBe(10); + + act(() => result.current.first()); + expect(result.current.page).toBe(1); + }); + + it("always has at least one page", () => { + const { result } = renderHook(() => usePagination()); + expect(result.current.totalPages).toBe(1); + }); +}); diff --git a/packages/hookli/src/hooks/use-pagination/use-pagination.ts b/packages/hookli/src/hooks/use-pagination/use-pagination.ts new file mode 100644 index 0000000..6fe0804 --- /dev/null +++ b/packages/hookli/src/hooks/use-pagination/use-pagination.ts @@ -0,0 +1,96 @@ +import { useCallback, useMemo, useState } from "react"; + +/** Options for {@link usePagination}. */ +export interface UsePaginationOptions { + /** Total number of items. Defaults to `0`. */ + total?: number; + /** Items per page. Defaults to `10`. */ + pageSize?: number; + /** Starting page (1-indexed). Defaults to `1`. */ + initialPage?: number; +} + +/** The value returned by {@link usePagination}. */ +export interface UsePaginationReturn { + /** Current page (1-indexed). */ + page: number; + /** Items per page. */ + pageSize: number; + /** Total number of pages (always ≥ 1). */ + totalPages: number; + /** Jump to a page (clamped to `[1, totalPages]`). */ + setPage: (page: number) => void; + /** Go to the next page (clamped). */ + next: () => void; + /** Go to the previous page (clamped). */ + prev: () => void; + /** Jump to the first page. */ + first: () => void; + /** Jump to the last page. */ + last: () => void; + /** Whether a previous page exists. */ + canPrev: boolean; + /** Whether a next page exists. */ + canNext: boolean; + /** Zero-based item index range for the current page: `[start, end)`. */ + range: { start: number; end: number }; +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +/** + * Manages 1-indexed pagination state — page, page size, total pages, navigation + * helpers, and the current item `range`. Pure state math; no data fetching. + * + * @param options - `{ total, pageSize, initialPage }`. + * @returns Pagination state and navigation helpers. + */ +export const usePagination = ( + options: UsePaginationOptions = {}, +): UsePaginationReturn => { + const { total = 0, pageSize = 10, initialPage = 1 } = options; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const [page, setPageState] = useState(() => + clamp(initialPage, 1, totalPages), + ); + + const setPage = useCallback( + (next: number) => setPageState(clamp(next, 1, totalPages)), + [totalPages], + ); + const next = useCallback( + () => setPageState((p) => clamp(p + 1, 1, totalPages)), + [totalPages], + ); + const prev = useCallback( + () => setPageState((p) => clamp(p - 1, 1, totalPages)), + [totalPages], + ); + const first = useCallback(() => setPageState(1), []); + const last = useCallback(() => setPageState(totalPages), [totalPages]); + + const current = clamp(page, 1, totalPages); + const range = useMemo( + () => ({ + start: (current - 1) * pageSize, + end: Math.min(current * pageSize, total), + }), + [current, pageSize, total], + ); + + return { + page: current, + pageSize, + totalPages, + setPage, + next, + prev, + first, + last, + canPrev: current > 1, + canNext: current < totalPages, + range, + }; +}; diff --git a/packages/hookli/src/hooks/use-permission/index.ts b/packages/hookli/src/hooks/use-permission/index.ts new file mode 100644 index 0000000..9ecc09a --- /dev/null +++ b/packages/hookli/src/hooks/use-permission/index.ts @@ -0,0 +1 @@ +export * from "./use-permission"; diff --git a/packages/hookli/src/hooks/use-permission/use-permission.test.ts b/packages/hookli/src/hooks/use-permission/use-permission.test.ts new file mode 100644 index 0000000..4bd5edb --- /dev/null +++ b/packages/hookli/src/hooks/use-permission/use-permission.test.ts @@ -0,0 +1,14 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { usePermission } from "./use-permission"; + +describe("usePermission", () => { + it("resolves to a valid state (unsupported under jsdom)", async () => { + const { result } = renderHook(() => usePermission("geolocation")); + await waitFor(() => + expect(["granted", "denied", "prompt", "unsupported"]).toContain( + result.current, + ), + ); + }); +}); diff --git a/packages/hookli/src/hooks/use-permission/use-permission.ts b/packages/hookli/src/hooks/use-permission/use-permission.ts new file mode 100644 index 0000000..11675ab --- /dev/null +++ b/packages/hookli/src/hooks/use-permission/use-permission.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; + +/** + * The result of {@link usePermission}: a standard `PermissionState` + * (`"granted" | "denied" | "prompt"`), `"pending"` before the first read, or + * `"unsupported"` where the Permissions API is unavailable. + */ +export type UsePermissionResult = PermissionState | "pending" | "unsupported"; + +/** + * Queries the Permissions API for a permission and tracks its state reactively + * (it updates if the user later changes the setting). SSR-safe; resolves to + * `"unsupported"` where the API — or the specific permission — is unavailable. + * + * @param name - The permission to query, e.g. `"geolocation"`, `"camera"`, `"notifications"`. + * @returns The current permission state. + */ +export const usePermission = (name: PermissionName): UsePermissionResult => { + const [state, setState] = useState("pending"); + + useEffect(() => { + if (typeof navigator === "undefined" || !navigator.permissions) { + setState("unsupported"); + return; + } + + let status: PermissionStatus | null = null; + let cancelled = false; + const onChange = () => { + if (status) setState(status.state); + }; + + navigator.permissions + .query({ name }) + .then((result) => { + if (cancelled) return; + status = result; + setState(result.state); + result.addEventListener("change", onChange); + }) + .catch(() => { + if (!cancelled) setState("unsupported"); + }); + + return () => { + cancelled = true; + status?.removeEventListener("change", onChange); + }; + }, [name]); + + return state; +}; diff --git a/packages/hookli/src/hooks/use-previous/index.ts b/packages/hookli/src/hooks/use-previous/index.ts new file mode 100644 index 0000000..f597283 --- /dev/null +++ b/packages/hookli/src/hooks/use-previous/index.ts @@ -0,0 +1 @@ +export * from "./use-previous"; diff --git a/packages/hookli/src/hooks/use-previous/use-previous.test.ts b/packages/hookli/src/hooks/use-previous/use-previous.test.ts new file mode 100644 index 0000000..4d64ac4 --- /dev/null +++ b/packages/hookli/src/hooks/use-previous/use-previous.test.ts @@ -0,0 +1,20 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { usePrevious } from "./use-previous"; + +describe("usePrevious", () => { + it("is undefined on the first render", () => { + const { result } = renderHook(() => usePrevious(0)); + expect(result.current).toBeUndefined(); + }); + + it("returns the value from the previous render", () => { + const { result, rerender } = renderHook(({ v }) => usePrevious(v), { + initialProps: { v: 0 }, + }); + rerender({ v: 1 }); + expect(result.current).toBe(0); + rerender({ v: 2 }); + expect(result.current).toBe(1); + }); +}); diff --git a/packages/hookli/src/hooks/use-previous/use-previous.ts b/packages/hookli/src/hooks/use-previous/use-previous.ts new file mode 100644 index 0000000..808ad26 --- /dev/null +++ b/packages/hookli/src/hooks/use-previous/use-previous.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from "react"; + +/** + * Tracks the value from the previous render. + * + * Returns `undefined` on the first render, then — on every render after — the + * value the hook was called with on the render before. The ref is written in an + * effect, so it always reflects the last committed value. + * + * @param value - The value to track across renders. + * @returns The value from the previous render (`undefined` on the first render). + */ +export const usePrevious = (value: T): T | undefined => { + const ref = useRef(undefined); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +}; diff --git a/packages/hookli/src/hooks/use-queue/index.ts b/packages/hookli/src/hooks/use-queue/index.ts new file mode 100644 index 0000000..a52d97b --- /dev/null +++ b/packages/hookli/src/hooks/use-queue/index.ts @@ -0,0 +1 @@ +export * from "./use-queue"; diff --git a/packages/hookli/src/hooks/use-queue/use-queue.test.ts b/packages/hookli/src/hooks/use-queue/use-queue.test.ts new file mode 100644 index 0000000..d53896c --- /dev/null +++ b/packages/hookli/src/hooks/use-queue/use-queue.test.ts @@ -0,0 +1,29 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useQueue } from "./use-queue"; + +describe("useQueue", () => { + it("adds and removes in FIFO order", () => { + const { result } = renderHook(() => useQueue([1])); + act(() => result.current.add(2)); + act(() => result.current.add(3)); + expect(result.current.queue).toEqual([1, 2, 3]); + expect(result.current.first).toBe(1); + expect(result.current.last).toBe(3); + expect(result.current.size).toBe(3); + + let removed: number | undefined; + act(() => { + removed = result.current.remove(); + }); + expect(removed).toBe(1); + expect(result.current.queue).toEqual([2, 3]); + }); + + it("clears", () => { + const { result } = renderHook(() => useQueue([1, 2])); + act(() => result.current.clear()); + expect(result.current.queue).toEqual([]); + expect(result.current.first).toBeUndefined(); + }); +}); diff --git a/packages/hookli/src/hooks/use-queue/use-queue.ts b/packages/hookli/src/hooks/use-queue/use-queue.ts new file mode 100644 index 0000000..eeeea0a --- /dev/null +++ b/packages/hookli/src/hooks/use-queue/use-queue.ts @@ -0,0 +1,50 @@ +import { useCallback, useRef, useState } from "react"; + +/** The value returned by {@link useQueue}. */ +export interface UseQueueReturn { + /** The current queue, oldest item first. */ + queue: T[]; + /** Enqueue an item at the end. */ + add: (item: T) => void; + /** Dequeue the oldest item and return it (`undefined` if empty). */ + remove: () => T | undefined; + /** Empty the queue. */ + clear: () => void; + /** The oldest item (next to be removed), or `undefined`. */ + first: T | undefined; + /** The newest item, or `undefined`. */ + last: T | undefined; + /** Number of items in the queue. */ + size: number; +} + +/** + * Manages a FIFO queue with `add` (enqueue) and `remove` (dequeue) helpers. + * `remove` returns the item it removed. Every mutation produces a new array. + * + * @param initial - Initial items. Defaults to `[]`. + * @returns `{ queue, add, remove, clear, first, last, size }`. + */ +export const useQueue = (initial: T[] = []): UseQueueReturn => { + const [queue, setQueue] = useState(initial); + const latest = useRef(queue); + latest.current = queue; + + const add = useCallback((item: T) => setQueue((q) => [...q, item]), []); + const remove = useCallback((): T | undefined => { + const removed = latest.current[0]; + setQueue((q) => q.slice(1)); + return removed; + }, []); + const clear = useCallback(() => setQueue([]), []); + + return { + queue, + add, + remove, + clear, + first: queue[0], + last: queue[queue.length - 1], + size: queue.length, + }; +}; diff --git a/packages/hookli/src/hooks/use-raf-state/index.ts b/packages/hookli/src/hooks/use-raf-state/index.ts new file mode 100644 index 0000000..78d1cae --- /dev/null +++ b/packages/hookli/src/hooks/use-raf-state/index.ts @@ -0,0 +1 @@ +export * from "./use-raf-state"; diff --git a/packages/hookli/src/hooks/use-raf-state/use-raf-state.test.ts b/packages/hookli/src/hooks/use-raf-state/use-raf-state.test.ts new file mode 100644 index 0000000..d43b13c --- /dev/null +++ b/packages/hookli/src/hooks/use-raf-state/use-raf-state.test.ts @@ -0,0 +1,20 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useRafState } from "./use-raf-state"; + +describe("useRafState", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("updates state on the next frame", () => { + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + + const { result } = renderHook(() => useRafState(0)); + expect(result.current[0]).toBe(0); + act(() => result.current[1](5)); + expect(result.current[0]).toBe(5); + }); +}); diff --git a/packages/hookli/src/hooks/use-raf-state/use-raf-state.ts b/packages/hookli/src/hooks/use-raf-state/use-raf-state.ts new file mode 100644 index 0000000..d9afc07 --- /dev/null +++ b/packages/hookli/src/hooks/use-raf-state/use-raf-state.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; + +/** + * A `useState` whose updates are batched to the next animation frame. Useful for + * high-frequency values (scroll, pointer, resize) so React re-renders at most + * once per frame. Any pending frame is cancelled on unmount. + * + * @param initialState - Initial state, or a lazy initializer. + * @returns A `[state, setState]` tuple, same shape as `useState`. + */ +export const useRafState = ( + initialState: T | (() => T), +): [T, Dispatch>] => { + const [state, setState] = useState(initialState); + const frame = useRef(null); + + const setRafState = useCallback((value: SetStateAction) => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + frame.current = requestAnimationFrame(() => { + frame.current = null; + setState(value); + }); + }, []); + + useEffect( + () => () => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + }, + [], + ); + + return [state, setRafState]; +}; diff --git a/packages/hookli/src/hooks/use-set/index.ts b/packages/hookli/src/hooks/use-set/index.ts new file mode 100644 index 0000000..1aab43a --- /dev/null +++ b/packages/hookli/src/hooks/use-set/index.ts @@ -0,0 +1 @@ +export * from "./use-set"; diff --git a/packages/hookli/src/hooks/use-set/use-set.test.ts b/packages/hookli/src/hooks/use-set/use-set.test.ts new file mode 100644 index 0000000..b28b871 --- /dev/null +++ b/packages/hookli/src/hooks/use-set/use-set.test.ts @@ -0,0 +1,34 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useSet } from "./use-set"; + +describe("useSet", () => { + it("initialises from an iterable", () => { + const { result } = renderHook(() => useSet([1, 2])); + expect([...result.current[0]]).toEqual([1, 2]); + }); + + it("add / remove / toggle / has", () => { + const { result } = renderHook(() => useSet()); + + act(() => result.current[1].add(1)); + expect(result.current[1].has(1)).toBe(true); + + act(() => result.current[1].toggle(1)); + expect(result.current[1].has(1)).toBe(false); + + act(() => result.current[1].toggle(2)); + expect(result.current[1].has(2)).toBe(true); + + act(() => result.current[1].remove(2)); + expect([...result.current[0]]).toEqual([]); + }); + + it("clear and reset", () => { + const { result } = renderHook(() => useSet([1, 2])); + act(() => result.current[1].clear()); + expect(result.current[0].size).toBe(0); + act(() => result.current[1].reset()); + expect([...result.current[0]]).toEqual([1, 2]); + }); +}); diff --git a/packages/hookli/src/hooks/use-set/use-set.ts b/packages/hookli/src/hooks/use-set/use-set.ts new file mode 100644 index 0000000..32ab3ea --- /dev/null +++ b/packages/hookli/src/hooks/use-set/use-set.ts @@ -0,0 +1,63 @@ +import { useMemo, useRef, useState } from "react"; + +/** + * Actions returned by {@link useSet} for manipulating the Set immutably. + */ +export interface UseSetActions { + /** Add a value (no-op if already present). */ + add: (value: T) => void; + /** Remove a value (no-op if absent). */ + remove: (value: T) => void; + /** Add the value if missing, remove it if present. */ + toggle: (value: T) => void; + /** Whether the value is currently in the set (reads the latest set). */ + has: (value: T) => boolean; + /** Empty the set. */ + clear: () => void; + /** Reset to the initial values the hook was created with. */ + reset: () => void; +} + +/** + * Manages `Set` state with immutable helpers (`add`, `remove`, `toggle`, `has`, + * `clear`, `reset`). Mutating helpers create a new `Set` so React re-renders, + * and `add`/`remove` return the previous set unchanged when nothing changes to + * avoid needless renders. `has` always reads the latest value. + * + * @param initial - Initial values (any iterable). Defaults to empty. + * @returns A tuple of `[set, actions]`. + */ +export const useSet = (initial?: Iterable): [Set, UseSetActions] => { + const [set, setSet] = useState>(() => new Set(initial)); + const latest = useRef(set); + latest.current = set; + + const actions = useMemo>( + () => ({ + add: (value) => + setSet((prev) => (prev.has(value) ? prev : new Set(prev).add(value))), + remove: (value) => + setSet((prev) => { + if (!prev.has(value)) return prev; + const next = new Set(prev); + next.delete(value); + return next; + }), + toggle: (value) => + setSet((prev) => { + const next = new Set(prev); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }), + has: (value) => latest.current.has(value), + clear: () => setSet(new Set()), + reset: () => setSet(new Set(initial)), + }), + // `initial` is intentionally captured once (reset restores the original set). + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + return [set, actions]; +}; diff --git a/packages/hookli/src/hooks/use-text-selection/index.ts b/packages/hookli/src/hooks/use-text-selection/index.ts new file mode 100644 index 0000000..71597c5 --- /dev/null +++ b/packages/hookli/src/hooks/use-text-selection/index.ts @@ -0,0 +1 @@ +export * from "./use-text-selection"; diff --git a/packages/hookli/src/hooks/use-text-selection/use-text-selection.test.ts b/packages/hookli/src/hooks/use-text-selection/use-text-selection.test.ts new file mode 100644 index 0000000..4cff08d --- /dev/null +++ b/packages/hookli/src/hooks/use-text-selection/use-text-selection.test.ts @@ -0,0 +1,10 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useTextSelection } from "./use-text-selection"; + +describe("useTextSelection", () => { + it("starts empty", () => { + const { result } = renderHook(() => useTextSelection()); + expect(result.current).toBe(""); + }); +}); diff --git a/packages/hookli/src/hooks/use-text-selection/use-text-selection.ts b/packages/hookli/src/hooks/use-text-selection/use-text-selection.ts new file mode 100644 index 0000000..7d44028 --- /dev/null +++ b/packages/hookli/src/hooks/use-text-selection/use-text-selection.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +/** + * Tracks the text the user has currently selected on the page + * (`window.getSelection()`), updating on every `selectionchange`. The listener + * attaches in an effect, so it is SSR-safe (returns `""` on the server). + * + * @returns The selected text, or `""` when nothing is selected. + */ +export const useTextSelection = (): string => { + const [text, setText] = useState(""); + + useEffect(() => { + const update = () => setText(window.getSelection()?.toString() ?? ""); + update(); + document.addEventListener("selectionchange", update); + return () => document.removeEventListener("selectionchange", update); + }, []); + + return text; +}; diff --git a/packages/hookli/src/hooks/use-throttle/index.ts b/packages/hookli/src/hooks/use-throttle/index.ts new file mode 100644 index 0000000..eb2a4f9 --- /dev/null +++ b/packages/hookli/src/hooks/use-throttle/index.ts @@ -0,0 +1 @@ +export * from "./use-throttle"; diff --git a/packages/hookli/src/hooks/use-throttle/use-throttle.test.ts b/packages/hookli/src/hooks/use-throttle/use-throttle.test.ts new file mode 100644 index 0000000..5dc50a6 --- /dev/null +++ b/packages/hookli/src/hooks/use-throttle/use-throttle.test.ts @@ -0,0 +1,27 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useThrottle } from "./use-throttle"; + +describe("useThrottle", () => { + afterEach(() => vi.useRealTimers()); + + it("returns the initial value immediately", () => { + const { result } = renderHook(() => useThrottle("a", 500)); + expect(result.current).toBe("a"); + }); + + it("delays updates to at most once per interval", () => { + vi.useFakeTimers(); + const { result, rerender } = renderHook(({ v }) => useThrottle(v, 500), { + initialProps: { v: "a" }, + }); + + rerender({ v: "b" }); + expect(result.current).toBe("a"); // trailing update still pending + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(result.current).toBe("b"); + }); +}); diff --git a/packages/hookli/src/hooks/use-throttle/use-throttle.ts b/packages/hookli/src/hooks/use-throttle/use-throttle.ts new file mode 100644 index 0000000..dc12d0b --- /dev/null +++ b/packages/hookli/src/hooks/use-throttle/use-throttle.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Throttles a fast-changing value: the returned value updates at most once every + * `interval` milliseconds. A trailing update is scheduled so the latest value is + * never dropped. The timer lives inside an effect, so the hook is SSR-safe and + * cleans up on unmount. + * + * The twin of {@link useDebounce} — throttle emits on a steady cadence while the + * value keeps changing; debounce waits for the changes to stop. + * + * @param value - The value to throttle. + * @param interval - Minimum time between updates, in milliseconds. Defaults to `500`. + * @returns The throttled value. + */ +export const useThrottle = (value: T, interval: number = 500): T => { + const [throttled, setThrottled] = useState(value); + const lastRan = useRef(Date.now()); + + useEffect(() => { + const now = Date.now(); + const remaining = interval - (now - lastRan.current); + + if (remaining <= 0) { + lastRan.current = now; + setThrottled(value); + return; + } + + const id = setTimeout(() => { + lastRan.current = Date.now(); + setThrottled(value); + }, remaining); + return () => clearTimeout(id); + }, [value, interval]); + + return throttled; +}; diff --git a/packages/hookli/src/hooks/use-update-effect/index.ts b/packages/hookli/src/hooks/use-update-effect/index.ts new file mode 100644 index 0000000..d6224e1 --- /dev/null +++ b/packages/hookli/src/hooks/use-update-effect/index.ts @@ -0,0 +1 @@ +export * from "./use-update-effect"; diff --git a/packages/hookli/src/hooks/use-update-effect/use-update-effect.test.ts b/packages/hookli/src/hooks/use-update-effect/use-update-effect.test.ts new file mode 100644 index 0000000..7206ce5 --- /dev/null +++ b/packages/hookli/src/hooks/use-update-effect/use-update-effect.test.ts @@ -0,0 +1,37 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useUpdateEffect } from "./use-update-effect"; + +describe("useUpdateEffect", () => { + it("does not run on the first render", () => { + const effect = vi.fn(); + renderHook(() => useUpdateEffect(effect, [0])); + expect(effect).not.toHaveBeenCalled(); + }); + + it("runs on dependency updates", () => { + const effect = vi.fn(); + const { rerender } = renderHook(({ dep }) => useUpdateEffect(effect, [dep]), { + initialProps: { dep: 0 }, + }); + expect(effect).not.toHaveBeenCalled(); + rerender({ dep: 1 }); + expect(effect).toHaveBeenCalledTimes(1); + rerender({ dep: 2 }); + expect(effect).toHaveBeenCalledTimes(2); + }); + + it("runs the cleanup from the previous run and on unmount", () => { + const cleanup = vi.fn(); + const effect = vi.fn(() => cleanup); + const { rerender, unmount } = renderHook( + ({ dep }) => useUpdateEffect(effect, [dep]), + { initialProps: { dep: 0 } }, + ); + rerender({ dep: 1 }); + rerender({ dep: 2 }); + expect(cleanup).toHaveBeenCalledTimes(1); + unmount(); + expect(cleanup).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/hookli/src/hooks/use-update-effect/use-update-effect.ts b/packages/hookli/src/hooks/use-update-effect/use-update-effect.ts new file mode 100644 index 0000000..399f394 --- /dev/null +++ b/packages/hookli/src/hooks/use-update-effect/use-update-effect.ts @@ -0,0 +1,30 @@ +import { + useEffect, + useRef, + type DependencyList, + type EffectCallback, +} from "react"; + +/** + * A variant of `useEffect` that **skips the initial mount** and only runs on + * subsequent dependency changes. Handy for reacting to updates without the + * effect firing once on first render. + * + * @param effect - Imperative effect callback; may return a cleanup function. + * @param deps - Dependency list, with the same semantics as `useEffect`. + */ +export const useUpdateEffect = ( + effect: EffectCallback, + deps?: DependencyList, +): void => { + const isFirst = useRef(true); + + useEffect(() => { + if (isFirst.current) { + isFirst.current = false; + return; + } + return effect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +}; diff --git a/packages/hookli/src/hooks/use-window-scroll/index.ts b/packages/hookli/src/hooks/use-window-scroll/index.ts new file mode 100644 index 0000000..6f20a1c --- /dev/null +++ b/packages/hookli/src/hooks/use-window-scroll/index.ts @@ -0,0 +1 @@ +export * from "./use-window-scroll"; diff --git a/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.test.ts b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.test.ts new file mode 100644 index 0000000..928a302 --- /dev/null +++ b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.test.ts @@ -0,0 +1,20 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useWindowScroll } from "./use-window-scroll"; + +describe("useWindowScroll", () => { + it("starts at the current scroll position", () => { + const { result } = renderHook(() => useWindowScroll()); + expect(result.current).toEqual({ x: 0, y: 0 }); + }); + + it("updates on scroll", () => { + const { result } = renderHook(() => useWindowScroll()); + act(() => { + Object.defineProperty(window, "scrollX", { value: 10, configurable: true }); + Object.defineProperty(window, "scrollY", { value: 20, configurable: true }); + window.dispatchEvent(new Event("scroll")); + }); + expect(result.current).toEqual({ x: 10, y: 20 }); + }); +}); diff --git a/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.ts b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.ts new file mode 100644 index 0000000..0ce9774 --- /dev/null +++ b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; + +/** + * The window scroll offset reported by {@link useWindowScroll}. + */ +export interface WindowScrollPosition { + /** Horizontal scroll offset in pixels (`window.scrollX`). */ + x: number; + /** Vertical scroll offset in pixels (`window.scrollY`). */ + y: number; +} + +/** + * Tracks the window's scroll position reactively. The listener is registered in + * an effect (SSR-safe) and marked `passive` for scroll performance. Both values + * start at `0` and update on every scroll. + * + * @returns The current `{ x, y }` scroll offset in pixels. + */ +export const useWindowScroll = (): WindowScrollPosition => { + const [position, setPosition] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const onScroll = () => + setPosition({ x: window.scrollX, y: window.scrollY }); + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + return position; +};