@jbpark/use-hooks 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -67,24 +67,25 @@ function MyComponent() {
67
67
 
68
68
  ## 사용 가능한 훅
69
69
 
70
- | 훅 | 설명 |
71
- | ------------------------- | ----------------------------------------------------------------- |
72
- | `useLocalStorage` | 에러 핸들링이 포함된 JSON 기반 영속 상태 (SSR 안전) |
73
- | `useWindowScroll` | 윈도우 스크롤 위치 및 백분율 추적 (iOS visualViewport 대응) |
74
- | `useElementScroll` | ResizeObserver를 사용한 특정 요소의 스크롤 상태 추적 |
75
- | `useElementPosition` | 스크롤/리사이즈 시 요소의 바운딩 렉트 모니터링 (요소 참조 지원) |
76
- | `useResponsiveSize` | Tailwind 유사 브레이크포인트를 포함한 요소 크기 추적 (debounce) |
77
- | `useBodyScrollLock` | 스타일 보존을 포함한 바디 스크롤 잠금/해제 (iOS 특별 처리) |
78
- | `useClickOutside` | 참조한 요소 바깥을 클릭/터치하면 콜백 실행 |
79
- | `useScrollToElements` | 인덱스별로 특정 요소로 스크롤 (오프셋 조절 가능) |
80
- | `useImage` | 이미지 사전로드 및 로딩/에러 상태 노출 |
81
- | `useRecursiveTimeout` | 비동기/동기 콜백을 재귀적으로 스케줄링 |
82
- | `useViewport` | visualViewport 지원, 인앱 모드 옵션, debounce 포함 |
83
- | `useDebounce` | 함수 실행을 지연해 과도한 업데이트를 방지 (autoInvoke 지원) |
84
- | `useThrottle` | 값 업데이트를 일정 간격으로 제한 |
85
- | `useHistoryState` | 히스토리 개수 제한을 설정할 수 있는 실행 취소/다시 실행 상태 관리 |
86
- | `useIntersectionObserver` | 콜백 ref로 요소의 뷰포트 교차 여부 추적 |
87
- | `useMultiSelect` | shift-클릭 범위 선택을 지원하는 체크박스 스타일 다중 선택 |
70
+ | 훅 | 설명 |
71
+ | ------------------------- | ----------------------------------------------------------------------- |
72
+ | `useLocalStorage` | 에러 핸들링이 포함된 JSON 기반 영속 상태 (SSR 안전) |
73
+ | `useWindowScroll` | 윈도우 스크롤 위치 및 백분율 추적 (iOS visualViewport 대응) |
74
+ | `useElementScroll` | ResizeObserver를 사용한 특정 요소의 스크롤 상태 추적 |
75
+ | `useElementPosition` | 스크롤/리사이즈 시 요소의 바운딩 렉트 모니터링 (요소 참조 지원) |
76
+ | `useResponsiveSize` | Tailwind 유사 브레이크포인트를 포함한 요소 크기 추적 (debounce) |
77
+ | `useBodyScrollLock` | 스타일 보존을 포함한 바디 스크롤 잠금/해제 (iOS 특별 처리) |
78
+ | `useClickOutside` | 참조한 요소 바깥을 클릭/터치하면 콜백 실행 |
79
+ | `useScrollToElements` | 인덱스별로 특정 요소로 스크롤 (오프셋 조절 가능) |
80
+ | `useImage` | 이미지 사전로드 및 로딩/에러 상태 노출 |
81
+ | `useRecursiveTimeout` | 비동기/동기 콜백을 재귀적으로 스케줄링 |
82
+ | `useViewport` | visualViewport 지원, 인앱 모드 옵션, debounce 포함 |
83
+ | `useDebounce` | 함수 실행을 지연해 과도한 업데이트를 방지 (autoInvoke 지원) |
84
+ | `useThrottle` | 값 업데이트를 일정 간격으로 제한 |
85
+ | `useHistoryState` | 히스토리 개수 제한을 설정할 수 있는 실행 취소/다시 실행 상태 관리 |
86
+ | `useIntersectionObserver` | 콜백 ref로 요소의 뷰포트 교차 여부 추적 |
87
+ | `useMultiSelect` | shift-클릭 범위 선택을 지원하는 체크박스 스타일 다중 선택 |
88
+ | `useControllableState` | value/defaultValue/onChange prop 조합을 훅 하나로 제어/비제어 상태 관리 |
88
89
 
89
90
  ## 개발
90
91
 
package/README.md CHANGED
@@ -67,24 +67,25 @@ function MyComponent() {
67
67
 
68
68
  ## Available Hooks
69
69
 
70
- | Hook | Description |
71
- | ------------------------- | --------------------------------------------------------------------------- |
72
- | `useLocalStorage` | JSON-based persistent state with error handling (SSR-safe) |
73
- | `useWindowScroll` | Track window scroll position and percentage (iOS visualViewport compatible) |
74
- | `useElementScroll` | Monitor scroll state of specific elements using ResizeObserver |
75
- | `useElementPosition` | Monitor element bounding rect on scroll/resize (element ref support) |
76
- | `useResponsiveSize` | Track element size with Tailwind-like breakpoints (debounced) |
77
- | `useBodyScrollLock` | Lock/unlock body scroll with style preservation (iOS-specific handling) |
78
- | `useClickOutside` | Run a callback when a click/touch happens outside a referenced element |
79
- | `useScrollToElements` | Scroll to specific elements by index (adjustable offset) |
80
- | `useImage` | Preload images and expose loading/error states |
81
- | `useRecursiveTimeout` | Recursively schedule async/sync callbacks |
82
- | `useViewport` | visualViewport support with in-app mode option and debounce |
83
- | `useDebounce` | Delay function execution to prevent excessive updates (autoInvoke support) |
84
- | `useThrottle` | Throttle value updates to a fixed interval |
85
- | `useHistoryState` | Undo/redo state management with a configurable history limit |
86
- | `useIntersectionObserver` | Track an element's viewport intersection via a callback ref |
87
- | `useMultiSelect` | Checkbox-style multi-select for a list, with shift-click range selection |
70
+ | Hook | Description |
71
+ | ------------------------- | ---------------------------------------------------------------------------------------- |
72
+ | `useLocalStorage` | JSON-based persistent state with error handling (SSR-safe) |
73
+ | `useWindowScroll` | Track window scroll position and percentage (iOS visualViewport compatible) |
74
+ | `useElementScroll` | Monitor scroll state of specific elements using ResizeObserver |
75
+ | `useElementPosition` | Monitor element bounding rect on scroll/resize (element ref support) |
76
+ | `useResponsiveSize` | Track element size with Tailwind-like breakpoints (debounced) |
77
+ | `useBodyScrollLock` | Lock/unlock body scroll with style preservation (iOS-specific handling) |
78
+ | `useClickOutside` | Run a callback when a click/touch happens outside a referenced element |
79
+ | `useScrollToElements` | Scroll to specific elements by index (adjustable offset) |
80
+ | `useImage` | Preload images and expose loading/error states |
81
+ | `useRecursiveTimeout` | Recursively schedule async/sync callbacks |
82
+ | `useViewport` | visualViewport support with in-app mode option and debounce |
83
+ | `useDebounce` | Delay function execution to prevent excessive updates (autoInvoke support) |
84
+ | `useThrottle` | Throttle value updates to a fixed interval |
85
+ | `useHistoryState` | Undo/redo state management with a configurable history limit |
86
+ | `useIntersectionObserver` | Track an element's viewport intersection via a callback ref |
87
+ | `useMultiSelect` | Checkbox-style multi-select for a list, with shift-click range selection |
88
+ | `useControllableState` | Back a value/defaultValue/onChange prop pair with one controlled/uncontrolled state hook |
88
89
 
89
90
  ## Development
90
91
 
@@ -1,6 +1,7 @@
1
1
  import { useDebounce } from "./use-debounce.mjs";
2
2
  import { useBodyScrollLock } from "./use-body-scroll-lock.mjs";
3
3
  import { useClickOutside } from "./use-click-outside.mjs";
4
+ import { useControllableState } from "./use-controllable-state.mjs";
4
5
  import { useElementPosition } from "./use-element-position.mjs";
5
6
  import { useElementScroll } from "./use-element-scroll.mjs";
6
7
  import { useFileToDataUrl } from "./use-file-to-data-url.mjs";
@@ -1,6 +1,7 @@
1
1
  import useDebounce from "./use-debounce.mjs";
2
2
  import useBodyScrollLock from "./use-body-scroll-lock.mjs";
3
3
  import useClickOutside from "./use-click-outside.mjs";
4
+ import useControllableState from "./use-controllable-state.mjs";
4
5
  import useElementPosition from "./use-element-position.mjs";
5
6
  import useElementScroll from "./use-element-scroll.mjs";
6
7
  import useFileToDataUrl from "./use-file-to-data-url.mjs";
@@ -0,0 +1,14 @@
1
+ //#region src/hooks/use-controllable-state.d.ts
2
+ interface UseControllableStateProps<T> {
3
+ value?: T;
4
+ defaultValue?: T;
5
+ onChange?: (value: T) => void;
6
+ }
7
+ declare const useControllableState: <T>({
8
+ value,
9
+ defaultValue,
10
+ onChange
11
+ }: UseControllableStateProps<T>) => [T, (next: T) => void];
12
+ //#endregion
13
+ export { useControllableState };
14
+ //# sourceMappingURL=use-controllable-state.d.mts.map
@@ -0,0 +1,15 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ //#region src/hooks/use-controllable-state.ts
4
+ const useControllableState = ({ value, defaultValue, onChange }) => {
5
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
6
+ const isControlled = value !== void 0;
7
+ return [isControlled ? value : uncontrolledValue, useCallback((next) => {
8
+ if (!isControlled) setUncontrolledValue(next);
9
+ onChange?.(next);
10
+ }, [isControlled, onChange])];
11
+ };
12
+
13
+ //#endregion
14
+ export { useControllableState as default };
15
+ //# sourceMappingURL=use-controllable-state.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-controllable-state.mjs","names":[],"sources":["../../src/hooks/use-controllable-state.ts"],"sourcesContent":["import { useCallback, useState } from 'react';\n\ninterface UseControllableStateProps<T> {\n value?: T;\n defaultValue?: T;\n onChange?: (value: T) => void;\n}\n\n// Backs a controlled/uncontrolled prop pair (`value`/`defaultValue`/\n// `onChange`) with one hook: falls back to internal state whenever `value`\n// is `undefined`, and always calls `onChange` regardless of which mode is\n// active, so callers don't have to branch on it themselves.\nconst useControllableState = <T>({\n value,\n defaultValue,\n onChange,\n}: UseControllableStateProps<T>): [T, (next: T) => void] => {\n const [uncontrolledValue, setUncontrolledValue] = useState<T | undefined>(\n defaultValue,\n );\n\n const isControlled = value !== undefined;\n const currentValue = (isControlled ? value : uncontrolledValue) as T;\n\n const setValue = useCallback(\n (next: T) => {\n if (!isControlled) {\n setUncontrolledValue(next);\n }\n onChange?.(next);\n },\n [isControlled, onChange],\n );\n\n return [currentValue, setValue];\n};\n\nexport default useControllableState;\n"],"mappings":";;;AAYA,MAAM,wBAA2B,EAC/B,OACA,cACA,eAC0D;CAC1D,MAAM,CAAC,mBAAmB,wBAAwB,SAChD,aACD;CAED,MAAM,eAAe,UAAU;AAa/B,QAAO,CAZe,eAAe,QAAQ,mBAE5B,aACd,SAAY;AACX,MAAI,CAAC,aACH,sBAAqB,KAAK;AAE5B,aAAW,KAAK;IAElB,CAAC,cAAc,SAAS,CACzB,CAE8B"}
@@ -2,11 +2,13 @@
2
2
  interface Options {
3
3
  delay?: number;
4
4
  autoInvoke?: boolean;
5
+ leading?: boolean;
5
6
  }
6
- declare const useDebounce: <T extends (...args: unknown[]) => unknown>(callback: T, {
7
+ declare const useDebounce: (callback: () => unknown, {
7
8
  delay,
8
- autoInvoke
9
- }: Options, deps?: React.DependencyList) => T;
9
+ autoInvoke,
10
+ leading
11
+ }: Options, deps?: React.DependencyList) => () => void;
10
12
  //#endregion
11
13
  export { useDebounce };
12
14
  //# sourceMappingURL=use-debounce.d.mts.map
@@ -1,27 +1,27 @@
1
1
  import { useEffect, useRef } from "react";
2
2
 
3
3
  //#region src/hooks/use-debounce.ts
4
- const useDebounce = (callback, { delay = 100, autoInvoke = true }, deps = []) => {
4
+ const useDebounce = (callback, { delay = 100, autoInvoke = true, leading = true }, deps = []) => {
5
5
  const timeoutRef = useRef(null);
6
6
  const callbackRef = useRef(callback);
7
+ const delayRef = useRef(delay);
7
8
  const prevDeps = useRef(void 0);
8
9
  useEffect(() => {
9
10
  callbackRef.current = callback;
11
+ delayRef.current = delay;
10
12
  });
11
13
  const stableDebouncedCallback = useRef(null);
12
- if (!stableDebouncedCallback.current) stableDebouncedCallback.current = ((...args) => {
14
+ if (!stableDebouncedCallback.current) stableDebouncedCallback.current = () => {
13
15
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
14
16
  timeoutRef.current = setTimeout(() => {
15
- callbackRef.current(...args);
16
- }, delay);
17
- });
17
+ callbackRef.current();
18
+ }, delayRef.current);
19
+ };
18
20
  useEffect(() => {
19
21
  const depsChanged = prevDeps.current === void 0 || prevDeps.current.length !== deps.length || prevDeps.current.some((dep, i) => dep !== deps[i]);
20
22
  if (depsChanged && timeoutRef.current) clearTimeout(timeoutRef.current);
21
- if (autoInvoke && depsChanged) {
22
- if (prevDeps.current === void 0) callbackRef.current();
23
- else if (stableDebouncedCallback.current) stableDebouncedCallback.current();
24
- }
23
+ if (autoInvoke && depsChanged) if (prevDeps.current === void 0 && leading) callbackRef.current();
24
+ else stableDebouncedCallback.current?.();
25
25
  prevDeps.current = deps;
26
26
  });
27
27
  useEffect(() => {
@@ -1 +1 @@
1
- {"version":3,"file":"use-debounce.mjs","names":[],"sources":["../../src/hooks/use-debounce.ts"],"sourcesContent":["import { useEffect, useRef } from 'react';\n\ninterface Options {\n delay?: number;\n autoInvoke?: boolean;\n}\n\nconst useDebounce = <T extends (...args: unknown[]) => unknown>(\n callback: T,\n { delay = 100, autoInvoke = true }: Options,\n deps: React.DependencyList = [],\n): T => {\n const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const callbackRef = useRef(callback);\n const prevDeps = useRef<React.DependencyList | undefined>(undefined);\n\n useEffect(() => {\n callbackRef.current = callback;\n });\n\n const stableDebouncedCallback = useRef<T | null>(null);\n\n if (!stableDebouncedCallback.current) {\n stableDebouncedCallback.current = ((...args: Parameters<T>) => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n }\n\n timeoutRef.current = setTimeout(() => {\n callbackRef.current(...args);\n }, delay);\n }) as T;\n }\n\n useEffect(() => {\n const depsChanged =\n prevDeps.current === undefined ||\n prevDeps.current.length !== deps.length ||\n prevDeps.current.some((dep, i) => dep !== deps[i]);\n\n if (depsChanged && timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n }\n\n if (autoInvoke && depsChanged) {\n const isFirstRender = prevDeps.current === undefined;\n if (isFirstRender) {\n callbackRef.current();\n } else if (stableDebouncedCallback.current) {\n stableDebouncedCallback.current();\n }\n }\n\n prevDeps.current = deps;\n });\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n }\n };\n }, []);\n\n return stableDebouncedCallback.current;\n};\n\nexport default useDebounce;\n"],"mappings":";;;AAOA,MAAM,eACJ,UACA,EAAE,QAAQ,KAAK,aAAa,QAC5B,OAA6B,EAAE,KACzB;CACN,MAAM,aAAa,OAA6C,KAAK;CACrE,MAAM,cAAc,OAAO,SAAS;CACpC,MAAM,WAAW,OAAyC,OAAU;AAEpE,iBAAgB;AACd,cAAY,UAAU;GACtB;CAEF,MAAM,0BAA0B,OAAiB,KAAK;AAEtD,KAAI,CAAC,wBAAwB,QAC3B,yBAAwB,YAAY,GAAG,SAAwB;AAC7D,MAAI,WAAW,QACb,cAAa,WAAW,QAAQ;AAGlC,aAAW,UAAU,iBAAiB;AACpC,eAAY,QAAQ,GAAG,KAAK;KAC3B,MAAM;;AAIb,iBAAgB;EACd,MAAM,cACJ,SAAS,YAAY,UACrB,SAAS,QAAQ,WAAW,KAAK,UACjC,SAAS,QAAQ,MAAM,KAAK,MAAM,QAAQ,KAAK,GAAG;AAEpD,MAAI,eAAe,WAAW,QAC5B,cAAa,WAAW,QAAQ;AAGlC,MAAI,cAAc,aAEhB;OADsB,SAAS,YAAY,OAEzC,aAAY,SAAS;YACZ,wBAAwB,QACjC,yBAAwB,SAAS;;AAIrC,WAAS,UAAU;GACnB;AAEF,iBAAgB;AACd,eAAa;AACX,OAAI,WAAW,QACb,cAAa,WAAW,QAAQ;;IAGnC,EAAE,CAAC;AAEN,QAAO,wBAAwB"}
1
+ {"version":3,"file":"use-debounce.mjs","names":[],"sources":["../../src/hooks/use-debounce.ts"],"sourcesContent":["import { useEffect, useRef } from 'react';\n\ninterface Options {\n delay?: number;\n autoInvoke?: boolean;\n leading?: boolean;\n}\n\n// Auto-invokes `callback` (debounced by `delay`ms) whenever `deps` change,\n// and also returns a stable debounced version of `callback` for manual use.\n// `callback` intentionally takes no arguments — the deps-triggered\n// auto-invoke has no natural argument to supply, so an arg-taking callback\n// here was a latent type hole (it was always invoked with zero args\n// regardless of what the callback's own signature claimed).\nconst useDebounce = (\n callback: () => unknown,\n { delay = 100, autoInvoke = true, leading = true }: Options,\n deps: React.DependencyList = [],\n) => {\n const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const callbackRef = useRef(callback);\n // Read the latest `delay` when the timeout actually fires, instead of\n // baking in whatever `delay` was on the render that created the stable\n // debounced function (which otherwise never updates again).\n const delayRef = useRef(delay);\n const prevDeps = useRef<React.DependencyList | undefined>(undefined);\n\n useEffect(() => {\n callbackRef.current = callback;\n delayRef.current = delay;\n });\n\n const stableDebouncedCallback = useRef<(() => void) | null>(null);\n\n if (!stableDebouncedCallback.current) {\n stableDebouncedCallback.current = () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n }\n\n timeoutRef.current = setTimeout(() => {\n callbackRef.current();\n }, delayRef.current);\n };\n }\n\n useEffect(() => {\n const depsChanged =\n prevDeps.current === undefined ||\n prevDeps.current.length !== deps.length ||\n prevDeps.current.some((dep, i) => dep !== deps[i]);\n\n if (depsChanged && timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n }\n\n if (autoInvoke && depsChanged) {\n const isFirstRender = prevDeps.current === undefined;\n\n // `leading` makes the \"first invocation fires immediately, without\n // waiting `delay`ms\" behavior explicit and opt-out-able, instead of\n // an unconditional special case baked into the first render.\n if (isFirstRender && leading) {\n callbackRef.current();\n } else {\n stableDebouncedCallback.current?.();\n }\n }\n\n prevDeps.current = deps;\n });\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n }\n };\n }, []);\n\n return stableDebouncedCallback.current;\n};\n\nexport default useDebounce;\n"],"mappings":";;;AAcA,MAAM,eACJ,UACA,EAAE,QAAQ,KAAK,aAAa,MAAM,UAAU,QAC5C,OAA6B,EAAE,KAC5B;CACH,MAAM,aAAa,OAA6C,KAAK;CACrE,MAAM,cAAc,OAAO,SAAS;CAIpC,MAAM,WAAW,OAAO,MAAM;CAC9B,MAAM,WAAW,OAAyC,OAAU;AAEpE,iBAAgB;AACd,cAAY,UAAU;AACtB,WAAS,UAAU;GACnB;CAEF,MAAM,0BAA0B,OAA4B,KAAK;AAEjE,KAAI,CAAC,wBAAwB,QAC3B,yBAAwB,gBAAgB;AACtC,MAAI,WAAW,QACb,cAAa,WAAW,QAAQ;AAGlC,aAAW,UAAU,iBAAiB;AACpC,eAAY,SAAS;KACpB,SAAS,QAAQ;;AAIxB,iBAAgB;EACd,MAAM,cACJ,SAAS,YAAY,UACrB,SAAS,QAAQ,WAAW,KAAK,UACjC,SAAS,QAAQ,MAAM,KAAK,MAAM,QAAQ,KAAK,GAAG;AAEpD,MAAI,eAAe,WAAW,QAC5B,cAAa,WAAW,QAAQ;AAGlC,MAAI,cAAc,YAMhB,KALsB,SAAS,YAAY,UAKtB,QACnB,aAAY,SAAS;MAErB,yBAAwB,WAAW;AAIvC,WAAS,UAAU;GACnB;AAEF,iBAAgB;AACd,eAAa;AACX,OAAI,WAAW,QACb,cAAa,WAAW,QAAQ;;IAGnC,EAAE,CAAC;AAEN,QAAO,wBAAwB"}
@@ -1,50 +1,79 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
2
2
 
3
3
  //#region src/hooks/use-local-storage.ts
4
+ const cache = /* @__PURE__ */ new Map();
5
+ const listeners = /* @__PURE__ */ new Map();
6
+ function getListeners(key) {
7
+ let set = listeners.get(key);
8
+ if (!set) {
9
+ set = /* @__PURE__ */ new Set();
10
+ listeners.set(key, set);
11
+ }
12
+ return set;
13
+ }
14
+ function notify(key) {
15
+ getListeners(key).forEach((callback) => callback());
16
+ }
17
+ function getSnapshot(key, initialValue) {
18
+ const raw = typeof window === "undefined" ? null : localStorage.getItem(key);
19
+ const cached = cache.get(key);
20
+ if (cached && cached.raw === raw) return cached.value;
21
+ let value;
22
+ try {
23
+ value = raw === null ? initialValue : JSON.parse(raw);
24
+ } catch {
25
+ value = initialValue;
26
+ }
27
+ cache.set(key, {
28
+ raw,
29
+ value
30
+ });
31
+ return value;
32
+ }
33
+ function subscribe(key, callback) {
34
+ const set = getListeners(key);
35
+ set.add(callback);
36
+ const onStorage = (event) => {
37
+ if (event.key === key) callback();
38
+ };
39
+ if (typeof window !== "undefined") window.addEventListener("storage", onStorage);
40
+ return () => {
41
+ set.delete(callback);
42
+ if (typeof window !== "undefined") window.removeEventListener("storage", onStorage);
43
+ };
44
+ }
4
45
  const useLocalStorage = (key, initialValue) => {
5
46
  const initialValueRef = useRef(initialValue);
6
47
  useEffect(() => {
7
48
  initialValueRef.current = initialValue;
8
49
  });
9
- const [storedValue, setStoredValue] = useState(() => {
10
- if (typeof window === "undefined") return initialValue;
11
- try {
12
- const item = window.localStorage.getItem(key);
13
- return item ? JSON.parse(item) : initialValue;
14
- } catch {
15
- return initialValue;
16
- }
17
- });
18
50
  useEffect(() => {
19
51
  if (typeof window === "undefined") return;
20
52
  try {
21
- const item = window.localStorage.getItem(key);
22
- if (item) setStoredValue(JSON.parse(item));
23
- else window.localStorage.setItem(key, JSON.stringify(initialValueRef.current));
53
+ if (localStorage.getItem(key) === null) {
54
+ const raw = JSON.stringify(initialValueRef.current);
55
+ localStorage.setItem(key, raw);
56
+ cache.set(key, {
57
+ raw,
58
+ value: initialValueRef.current
59
+ });
60
+ notify(key);
61
+ }
24
62
  } catch (e) {
25
- console.error(`Error reading localStorage key "${key}":`, e);
63
+ console.error(`Error seeding localStorage key "${key}":`, e);
26
64
  }
27
65
  }, [key]);
28
- useEffect(() => {
29
- if (typeof window === "undefined") return;
30
- const onStorage = (event) => {
31
- if (event.key !== key) return;
32
- try {
33
- setStoredValue(event.newValue ? JSON.parse(event.newValue) : initialValueRef.current);
34
- } catch (e) {
35
- console.error(`Error parsing localStorage key "${key}" from storage event:`, e);
36
- }
37
- };
38
- window.addEventListener("storage", onStorage);
39
- return () => window.removeEventListener("storage", onStorage);
40
- }, [key]);
41
- return [storedValue, useCallback((value) => {
66
+ return [useSyncExternalStore(useCallback((callback) => subscribe(key, callback), [key]), useCallback(() => getSnapshot(key, initialValueRef.current), [key]), useCallback(() => initialValueRef.current, [])), useCallback((value) => {
42
67
  try {
43
- setStoredValue((prev) => {
44
- const valueToStore = value instanceof Function ? value(prev) : value;
45
- localStorage.setItem(key, JSON.stringify(valueToStore));
46
- return valueToStore;
68
+ const prev = getSnapshot(key, initialValueRef.current);
69
+ const valueToStore = value instanceof Function ? value(prev) : value;
70
+ const raw = JSON.stringify(valueToStore);
71
+ localStorage.setItem(key, raw);
72
+ cache.set(key, {
73
+ raw,
74
+ value: valueToStore
47
75
  });
76
+ notify(key);
48
77
  } catch (e) {
49
78
  console.error(`Error setting localStorage key "${key}":`, e);
50
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"use-local-storage.mjs","names":[],"sources":["../../src/hooks/use-local-storage.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\nconst useLocalStorage = <T>(key: string, initialValue: T) => {\n const initialValueRef = useRef(initialValue);\n\n useEffect(() => {\n initialValueRef.current = initialValue;\n });\n\n const [storedValue, setStoredValue] = useState<T>(() => {\n if (typeof window === 'undefined') {\n return initialValue;\n }\n try {\n const item = window.localStorage.getItem(key);\n return item ? (JSON.parse(item) as T) : initialValue;\n } catch {\n return initialValue;\n }\n });\n\n useEffect(() => {\n if (typeof window === 'undefined') {\n return;\n }\n\n try {\n const item = window.localStorage.getItem(key);\n if (item) {\n setStoredValue(JSON.parse(item) as T);\n } else {\n window.localStorage.setItem(\n key,\n JSON.stringify(initialValueRef.current),\n );\n }\n } catch (e) {\n console.error(`Error reading localStorage key \"${key}\":`, e);\n }\n }, [key]);\n\n useEffect(() => {\n if (typeof window === 'undefined') {\n return;\n }\n\n const onStorage = (event: StorageEvent) => {\n if (event.key !== key) {\n return;\n }\n\n try {\n setStoredValue(\n event.newValue\n ? (JSON.parse(event.newValue) as T)\n : initialValueRef.current,\n );\n } catch (e) {\n console.error(\n `Error parsing localStorage key \"${key}\" from storage event:`,\n e,\n );\n }\n };\n\n window.addEventListener('storage', onStorage);\n return () => window.removeEventListener('storage', onStorage);\n }, [key]);\n\n const setValue = useCallback(\n (value: T | ((val: T) => T)) => {\n try {\n setStoredValue(prev => {\n const valueToStore = value instanceof Function ? value(prev) : value;\n localStorage.setItem(key, JSON.stringify(valueToStore));\n return valueToStore;\n });\n } catch (e) {\n console.error(`Error setting localStorage key \"${key}\":`, e);\n }\n },\n [key],\n );\n\n return [storedValue, setValue] as const;\n};\n\nexport default useLocalStorage;\n"],"mappings":";;;AAEA,MAAM,mBAAsB,KAAa,iBAAoB;CAC3D,MAAM,kBAAkB,OAAO,aAAa;AAE5C,iBAAgB;AACd,kBAAgB,UAAU;GAC1B;CAEF,MAAM,CAAC,aAAa,kBAAkB,eAAkB;AACtD,MAAI,OAAO,WAAW,YACpB,QAAO;AAET,MAAI;GACF,MAAM,OAAO,OAAO,aAAa,QAAQ,IAAI;AAC7C,UAAO,OAAQ,KAAK,MAAM,KAAK,GAAS;UAClC;AACN,UAAO;;GAET;AAEF,iBAAgB;AACd,MAAI,OAAO,WAAW,YACpB;AAGF,MAAI;GACF,MAAM,OAAO,OAAO,aAAa,QAAQ,IAAI;AAC7C,OAAI,KACF,gBAAe,KAAK,MAAM,KAAK,CAAM;OAErC,QAAO,aAAa,QAClB,KACA,KAAK,UAAU,gBAAgB,QAAQ,CACxC;WAEI,GAAG;AACV,WAAQ,MAAM,mCAAmC,IAAI,KAAK,EAAE;;IAE7D,CAAC,IAAI,CAAC;AAET,iBAAgB;AACd,MAAI,OAAO,WAAW,YACpB;EAGF,MAAM,aAAa,UAAwB;AACzC,OAAI,MAAM,QAAQ,IAChB;AAGF,OAAI;AACF,mBACE,MAAM,WACD,KAAK,MAAM,MAAM,SAAS,GAC3B,gBAAgB,QACrB;YACM,GAAG;AACV,YAAQ,MACN,mCAAmC,IAAI,wBACvC,EACD;;;AAIL,SAAO,iBAAiB,WAAW,UAAU;AAC7C,eAAa,OAAO,oBAAoB,WAAW,UAAU;IAC5D,CAAC,IAAI,CAAC;AAiBT,QAAO,CAAC,aAfS,aACd,UAA+B;AAC9B,MAAI;AACF,mBAAe,SAAQ;IACrB,MAAM,eAAe,iBAAiB,WAAW,MAAM,KAAK,GAAG;AAC/D,iBAAa,QAAQ,KAAK,KAAK,UAAU,aAAa,CAAC;AACvD,WAAO;KACP;WACK,GAAG;AACV,WAAQ,MAAM,mCAAmC,IAAI,KAAK,EAAE;;IAGhE,CAAC,IAAI,CACN,CAE6B"}
1
+ {"version":3,"file":"use-local-storage.mjs","names":[],"sources":["../../src/hooks/use-local-storage.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';\n\n// Module-level so every `useLocalStorage(key)` instance for the same key —\n// even across unrelated components in the same tab — shares one cache and\n// subscriber list. That's what makes same-tab sync possible: the native\n// `storage` event only fires in *other* tabs, so same-tab updates have to\n// be broadcast by us via `notify()`.\ninterface CacheEntry<T> {\n raw: string | null;\n value: T;\n}\n\nconst cache = new Map<string, CacheEntry<unknown>>();\nconst listeners = new Map<string, Set<() => void>>();\n\nfunction getListeners(key: string) {\n let set = listeners.get(key);\n if (!set) {\n set = new Set();\n listeners.set(key, set);\n }\n return set;\n}\n\nfunction notify(key: string) {\n getListeners(key).forEach(callback => callback());\n}\n\n// Returns the same cached value reference as long as the raw localStorage\n// string hasn't changed, so `useSyncExternalStore` doesn't see a \"new\"\n// snapshot (and re-render) on every call — only when the value actually\n// changed.\nfunction getSnapshot<T>(key: string, initialValue: T): T {\n const raw = typeof window === 'undefined' ? null : localStorage.getItem(key);\n const cached = cache.get(key) as CacheEntry<T> | undefined;\n\n if (cached && cached.raw === raw) {\n return cached.value;\n }\n\n let value: T;\n try {\n value = raw === null ? initialValue : (JSON.parse(raw) as T);\n } catch {\n value = initialValue;\n }\n\n cache.set(key, { raw, value });\n return value;\n}\n\nfunction subscribe(key: string, callback: () => void) {\n const set = getListeners(key);\n set.add(callback);\n\n const onStorage = (event: StorageEvent) => {\n if (event.key === key) {\n callback();\n }\n };\n\n if (typeof window !== 'undefined') {\n window.addEventListener('storage', onStorage);\n }\n\n return () => {\n set.delete(callback);\n if (typeof window !== 'undefined') {\n window.removeEventListener('storage', onStorage);\n }\n };\n}\n\nconst useLocalStorage = <T>(key: string, initialValue: T) => {\n const initialValueRef = useRef(initialValue);\n\n useEffect(() => {\n initialValueRef.current = initialValue;\n });\n\n // Seed the key with the initial value if it isn't set yet — done in an\n // effect (not inside the state updater below) so it's a one-shot side\n // effect rather than something React could invoke twice under\n // StrictMode/concurrent rendering.\n useEffect(() => {\n if (typeof window === 'undefined') {\n return;\n }\n\n try {\n if (localStorage.getItem(key) === null) {\n const raw = JSON.stringify(initialValueRef.current);\n localStorage.setItem(key, raw);\n cache.set(key, { raw, value: initialValueRef.current });\n notify(key);\n }\n } catch (e) {\n console.error(`Error seeding localStorage key \"${key}\":`, e);\n }\n }, [key]);\n\n const subscribeForKey = useCallback(\n (callback: () => void) => subscribe(key, callback),\n [key],\n );\n\n const getSnapshotForKey = useCallback(\n () => getSnapshot(key, initialValueRef.current),\n [key],\n );\n\n const getServerSnapshot = useCallback(() => initialValueRef.current, []);\n\n const storedValue = useSyncExternalStore(\n subscribeForKey,\n getSnapshotForKey,\n getServerSnapshot,\n );\n\n const setValue = useCallback(\n (value: T | ((val: T) => T)) => {\n try {\n const prev = getSnapshot(key, initialValueRef.current);\n const valueToStore = value instanceof Function ? value(prev) : value;\n const raw = JSON.stringify(valueToStore);\n\n localStorage.setItem(key, raw);\n cache.set(key, { raw, value: valueToStore });\n notify(key);\n } catch (e) {\n console.error(`Error setting localStorage key \"${key}\":`, e);\n }\n },\n [key],\n );\n\n return [storedValue, setValue] as const;\n};\n\nexport default useLocalStorage;\n"],"mappings":";;;AAYA,MAAM,wBAAQ,IAAI,KAAkC;AACpD,MAAM,4BAAY,IAAI,KAA8B;AAEpD,SAAS,aAAa,KAAa;CACjC,IAAI,MAAM,UAAU,IAAI,IAAI;AAC5B,KAAI,CAAC,KAAK;AACR,wBAAM,IAAI,KAAK;AACf,YAAU,IAAI,KAAK,IAAI;;AAEzB,QAAO;;AAGT,SAAS,OAAO,KAAa;AAC3B,cAAa,IAAI,CAAC,SAAQ,aAAY,UAAU,CAAC;;AAOnD,SAAS,YAAe,KAAa,cAAoB;CACvD,MAAM,MAAM,OAAO,WAAW,cAAc,OAAO,aAAa,QAAQ,IAAI;CAC5E,MAAM,SAAS,MAAM,IAAI,IAAI;AAE7B,KAAI,UAAU,OAAO,QAAQ,IAC3B,QAAO,OAAO;CAGhB,IAAI;AACJ,KAAI;AACF,UAAQ,QAAQ,OAAO,eAAgB,KAAK,MAAM,IAAI;SAChD;AACN,UAAQ;;AAGV,OAAM,IAAI,KAAK;EAAE;EAAK;EAAO,CAAC;AAC9B,QAAO;;AAGT,SAAS,UAAU,KAAa,UAAsB;CACpD,MAAM,MAAM,aAAa,IAAI;AAC7B,KAAI,IAAI,SAAS;CAEjB,MAAM,aAAa,UAAwB;AACzC,MAAI,MAAM,QAAQ,IAChB,WAAU;;AAId,KAAI,OAAO,WAAW,YACpB,QAAO,iBAAiB,WAAW,UAAU;AAG/C,cAAa;AACX,MAAI,OAAO,SAAS;AACpB,MAAI,OAAO,WAAW,YACpB,QAAO,oBAAoB,WAAW,UAAU;;;AAKtD,MAAM,mBAAsB,KAAa,iBAAoB;CAC3D,MAAM,kBAAkB,OAAO,aAAa;AAE5C,iBAAgB;AACd,kBAAgB,UAAU;GAC1B;AAMF,iBAAgB;AACd,MAAI,OAAO,WAAW,YACpB;AAGF,MAAI;AACF,OAAI,aAAa,QAAQ,IAAI,KAAK,MAAM;IACtC,MAAM,MAAM,KAAK,UAAU,gBAAgB,QAAQ;AACnD,iBAAa,QAAQ,KAAK,IAAI;AAC9B,UAAM,IAAI,KAAK;KAAE;KAAK,OAAO,gBAAgB;KAAS,CAAC;AACvD,WAAO,IAAI;;WAEN,GAAG;AACV,WAAQ,MAAM,mCAAmC,IAAI,KAAK,EAAE;;IAE7D,CAAC,IAAI,CAAC;AAqCT,QAAO,CAvBa,qBAZI,aACrB,aAAyB,UAAU,KAAK,SAAS,EAClD,CAAC,IAAI,CACN,EAEyB,kBAClB,YAAY,KAAK,gBAAgB,QAAQ,EAC/C,CAAC,IAAI,CACN,EAEyB,kBAAkB,gBAAgB,SAAS,EAAE,CAAC,CAMvE,EAEgB,aACd,UAA+B;AAC9B,MAAI;GACF,MAAM,OAAO,YAAY,KAAK,gBAAgB,QAAQ;GACtD,MAAM,eAAe,iBAAiB,WAAW,MAAM,KAAK,GAAG;GAC/D,MAAM,MAAM,KAAK,UAAU,aAAa;AAExC,gBAAa,QAAQ,KAAK,IAAI;AAC9B,SAAM,IAAI,KAAK;IAAE;IAAK,OAAO;IAAc,CAAC;AAC5C,UAAO,IAAI;WACJ,GAAG;AACV,WAAQ,MAAM,mCAAmC,IAAI,KAAK,EAAE;;IAGhE,CAAC,IAAI,CACN,CAE6B"}
@@ -1,5 +1,8 @@
1
1
  //#region src/hooks/use-recursive-timeout.d.ts
2
- declare const useRecursiveTimeout: <T>(callback: () => Promise<T> | (() => void), delay: number | null) => void;
2
+ interface Options {
3
+ stopOnError?: boolean;
4
+ }
5
+ declare const useRecursiveTimeout: <T>(callback: () => Promise<T> | void, delay: number | null, options?: Options) => void;
3
6
  //#endregion
4
7
  export { useRecursiveTimeout };
5
8
  //# sourceMappingURL=use-recursive-timeout.d.mts.map
@@ -1,26 +1,33 @@
1
1
  import { useEffect, useRef } from "react";
2
2
 
3
3
  //#region src/hooks/use-recursive-timeout.ts
4
- const useRecursiveTimeout = (callback, delay) => {
4
+ const useRecursiveTimeout = (callback, delay, options = {}) => {
5
5
  const savedCallback = useRef(callback);
6
+ const savedStopOnError = useRef(options.stopOnError ?? false);
6
7
  useEffect(() => {
7
8
  savedCallback.current = callback;
8
- }, [callback]);
9
+ savedStopOnError.current = options.stopOnError ?? false;
10
+ });
9
11
  useEffect(() => {
12
+ if (delay === null) return;
10
13
  let id;
14
+ let cancelled = false;
15
+ const scheduleNext = () => {
16
+ if (!cancelled) id = setTimeout(tick, delay);
17
+ };
11
18
  function tick() {
12
19
  const ret = savedCallback.current();
13
- if (ret instanceof Promise) ret.then(() => {
14
- if (delay) id = setTimeout(tick, delay);
20
+ if (ret instanceof Promise) ret.then(scheduleNext, (error) => {
21
+ console.error("useRecursiveTimeout callback rejected:", error);
22
+ if (!savedStopOnError.current) scheduleNext();
15
23
  });
16
- else if (delay) id = setTimeout(tick, delay);
17
- }
18
- if (delay) {
19
- id = setTimeout(tick, delay);
20
- return () => {
21
- clearTimeout(id);
22
- };
24
+ else scheduleNext();
23
25
  }
26
+ id = setTimeout(tick, delay);
27
+ return () => {
28
+ cancelled = true;
29
+ clearTimeout(id);
30
+ };
24
31
  }, [delay]);
25
32
  };
26
33
 
@@ -1 +1 @@
1
- {"version":3,"file":"use-recursive-timeout.mjs","names":[],"sources":["../../src/hooks/use-recursive-timeout.ts"],"sourcesContent":["import { useEffect, useRef } from 'react';\n\nconst useRecursiveTimeout = <T>(\n callback: () => Promise<T> | (() => void),\n delay: number | null,\n) => {\n const savedCallback = useRef(callback);\n\n useEffect(() => {\n savedCallback.current = callback;\n }, [callback]);\n\n useEffect(() => {\n let id: ReturnType<typeof setTimeout>;\n\n function tick() {\n const ret = savedCallback.current();\n\n if (ret instanceof Promise) {\n ret.then(() => {\n if (delay) {\n id = setTimeout(tick, delay);\n }\n });\n } else {\n if (delay) {\n id = setTimeout(tick, delay);\n }\n }\n }\n\n if (delay) {\n id = setTimeout(tick, delay);\n return () => {\n clearTimeout(id);\n };\n }\n }, [delay]);\n};\n\nexport default useRecursiveTimeout;\n"],"mappings":";;;AAEA,MAAM,uBACJ,UACA,UACG;CACH,MAAM,gBAAgB,OAAO,SAAS;AAEtC,iBAAgB;AACd,gBAAc,UAAU;IACvB,CAAC,SAAS,CAAC;AAEd,iBAAgB;EACd,IAAI;EAEJ,SAAS,OAAO;GACd,MAAM,MAAM,cAAc,SAAS;AAEnC,OAAI,eAAe,QACjB,KAAI,WAAW;AACb,QAAI,MACF,MAAK,WAAW,MAAM,MAAM;KAE9B;YAEE,MACF,MAAK,WAAW,MAAM,MAAM;;AAKlC,MAAI,OAAO;AACT,QAAK,WAAW,MAAM,MAAM;AAC5B,gBAAa;AACX,iBAAa,GAAG;;;IAGnB,CAAC,MAAM,CAAC"}
1
+ {"version":3,"file":"use-recursive-timeout.mjs","names":[],"sources":["../../src/hooks/use-recursive-timeout.ts"],"sourcesContent":["import { useEffect, useRef } from 'react';\n\ninterface Options {\n // Whether a rejected callback should stop the loop instead of scheduling\n // the next tick. Defaults to false — one failed tick shouldn't kill a\n // polling loop, but the rejection is still logged either way.\n stopOnError?: boolean;\n}\n\nconst useRecursiveTimeout = <T>(\n callback: () => Promise<T> | void,\n delay: number | null,\n options: Options = {},\n) => {\n const savedCallback = useRef(callback);\n const savedStopOnError = useRef(options.stopOnError ?? false);\n\n useEffect(() => {\n savedCallback.current = callback;\n savedStopOnError.current = options.stopOnError ?? false;\n });\n\n useEffect(() => {\n if (delay === null) {\n return;\n }\n\n let id: ReturnType<typeof setTimeout>;\n let cancelled = false;\n\n const scheduleNext = () => {\n if (!cancelled) {\n id = setTimeout(tick, delay);\n }\n };\n\n function tick() {\n const ret = savedCallback.current();\n\n if (ret instanceof Promise) {\n ret.then(scheduleNext, (error: unknown) => {\n console.error('useRecursiveTimeout callback rejected:', error);\n if (!savedStopOnError.current) {\n scheduleNext();\n }\n });\n } else {\n scheduleNext();\n }\n }\n\n id = setTimeout(tick, delay);\n\n return () => {\n cancelled = true;\n clearTimeout(id);\n };\n }, [delay]);\n};\n\nexport default useRecursiveTimeout;\n"],"mappings":";;;AASA,MAAM,uBACJ,UACA,OACA,UAAmB,EAAE,KAClB;CACH,MAAM,gBAAgB,OAAO,SAAS;CACtC,MAAM,mBAAmB,OAAO,QAAQ,eAAe,MAAM;AAE7D,iBAAgB;AACd,gBAAc,UAAU;AACxB,mBAAiB,UAAU,QAAQ,eAAe;GAClD;AAEF,iBAAgB;AACd,MAAI,UAAU,KACZ;EAGF,IAAI;EACJ,IAAI,YAAY;EAEhB,MAAM,qBAAqB;AACzB,OAAI,CAAC,UACH,MAAK,WAAW,MAAM,MAAM;;EAIhC,SAAS,OAAO;GACd,MAAM,MAAM,cAAc,SAAS;AAEnC,OAAI,eAAe,QACjB,KAAI,KAAK,eAAe,UAAmB;AACzC,YAAQ,MAAM,0CAA0C,MAAM;AAC9D,QAAI,CAAC,iBAAiB,QACpB,eAAc;KAEhB;OAEF,eAAc;;AAIlB,OAAK,WAAW,MAAM,MAAM;AAE5B,eAAa;AACX,eAAY;AACZ,gBAAa,GAAG;;IAEjB,CAAC,MAAM,CAAC"}
@@ -1,5 +1,5 @@
1
1
  //#region src/hooks/use-viewport.d.ts
2
- type ViewportInfo = VisualViewport | {
2
+ interface ViewportInfo {
3
3
  width: number;
4
4
  height: number;
5
5
  offsetLeft: number;
@@ -7,7 +7,7 @@ type ViewportInfo = VisualViewport | {
7
7
  pageLeft: number;
8
8
  pageTop: number;
9
9
  scale: number;
10
- };
10
+ }
11
11
  interface Options {
12
12
  isInApp?: boolean;
13
13
  debounce?: number;
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
 
3
3
  //#region src/hooks/use-viewport.ts
4
+ const viewportEqual = (a, b) => a.width === b.width && a.height === b.height && a.offsetLeft === b.offsetLeft && a.offsetTop === b.offsetTop && a.pageLeft === b.pageLeft && a.pageTop === b.pageTop && a.scale === b.scale;
4
5
  const useViewport = (options = {}) => {
5
6
  const { isInApp = false, debounce = 100 } = options;
6
7
  const [viewport, setViewport] = useState({
@@ -21,7 +22,6 @@ const useViewport = (options = {}) => {
21
22
  return Math.max(windowHeight, documentHeight, bodyHeight);
22
23
  }, []);
23
24
  const readViewport = useCallback(() => {
24
- if (window.visualViewport && !isInApp) return window.visualViewport;
25
25
  return {
26
26
  width: window.visualViewport?.width || window.innerWidth,
27
27
  height: isInApp ? getAppViewHeight() : window.visualViewport?.height || window.innerHeight,
@@ -34,13 +34,16 @@ const useViewport = (options = {}) => {
34
34
  }, [isInApp, getAppViewHeight]);
35
35
  useEffect(() => {
36
36
  let timeoutId;
37
+ const commitViewport = (next) => {
38
+ setViewport((prev) => viewportEqual(prev, next) ? prev : next);
39
+ };
37
40
  const debouncedUpdate = () => {
38
41
  clearTimeout(timeoutId);
39
42
  timeoutId = setTimeout(() => {
40
- setViewport(readViewport());
43
+ commitViewport(readViewport());
41
44
  }, debounce);
42
45
  };
43
- const immediateUpdate = () => setViewport(readViewport());
46
+ const immediateUpdate = () => commitViewport(readViewport());
44
47
  immediateUpdate();
45
48
  const events = ["resize", "orientationchange"];
46
49
  if (isInApp) events.push("focus", "blur", "touchstart", "touchend");
@@ -1 +1 @@
1
- {"version":3,"file":"use-viewport.mjs","names":[],"sources":["../../src/hooks/use-viewport.ts"],"sourcesContent":["import { useCallback, useEffect, useState } from 'react';\n\ntype ViewportInfo =\n | VisualViewport\n | {\n width: number;\n height: number;\n offsetLeft: number;\n offsetTop: number;\n pageLeft: number;\n pageTop: number;\n scale: number;\n };\n\ninterface Options {\n isInApp?: boolean;\n debounce?: number;\n}\n\nconst useViewport = (options: Options = {}) => {\n const { isInApp = false, debounce = 100 } = options;\n\n const [viewport, setViewport] = useState<ViewportInfo>({\n width: 0,\n height: 0,\n offsetLeft: 0,\n offsetTop: 0,\n pageLeft: 0,\n pageTop: 0,\n scale: 1,\n });\n\n const getAppViewHeight = useCallback(() => {\n const windowHeight = window.innerHeight;\n const visualHeight = window.visualViewport?.height || windowHeight;\n const documentHeight = document.documentElement.clientHeight;\n const bodyHeight = document.body.clientHeight;\n\n if (window.visualViewport && Math.abs(visualHeight - windowHeight) > 100) {\n return visualHeight;\n }\n\n return Math.max(windowHeight, documentHeight, bodyHeight);\n }, []);\n\n const readViewport = useCallback((): ViewportInfo => {\n if (window.visualViewport && !isInApp) {\n return window.visualViewport;\n }\n\n const width = window.visualViewport?.width || window.innerWidth;\n const height = isInApp\n ? getAppViewHeight()\n : window.visualViewport?.height || window.innerHeight;\n\n return {\n width,\n height,\n offsetLeft: window.visualViewport?.offsetLeft || 0,\n offsetTop: window.visualViewport?.offsetTop || 0,\n pageLeft: window.scrollX ?? window.pageXOffset ?? 0,\n pageTop: window.scrollY ?? window.pageYOffset ?? 0,\n scale: window.visualViewport?.scale || 1,\n };\n }, [isInApp, getAppViewHeight]);\n\n useEffect(() => {\n let timeoutId: ReturnType<typeof setTimeout>;\n\n const debouncedUpdate = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(() => {\n setViewport(readViewport());\n }, debounce);\n };\n\n const immediateUpdate = () => setViewport(readViewport());\n\n immediateUpdate();\n\n const events = ['resize', 'orientationchange'];\n\n if (isInApp) {\n events.push('focus', 'blur', 'touchstart', 'touchend');\n }\n\n events.forEach(event => {\n if (event === 'resize' || event === 'orientationchange') {\n window.addEventListener(event, debouncedUpdate);\n } else {\n window.addEventListener(event, immediateUpdate, { passive: true });\n }\n });\n\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', immediateUpdate);\n window.visualViewport.addEventListener('scroll', immediateUpdate);\n }\n\n let intervalId: ReturnType<typeof setInterval>;\n if (isInApp) {\n let lastHeight = readViewport().height;\n intervalId = setInterval(() => {\n const currentHeight = readViewport().height;\n if (Math.abs(currentHeight - lastHeight) > 50) {\n lastHeight = currentHeight;\n immediateUpdate();\n }\n }, 500);\n }\n\n return () => {\n clearTimeout(timeoutId);\n if (intervalId) clearInterval(intervalId);\n\n events.forEach(event => {\n window.removeEventListener(\n event,\n event === 'resize' || event === 'orientationchange'\n ? debouncedUpdate\n : immediateUpdate,\n );\n });\n\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', immediateUpdate);\n window.visualViewport.removeEventListener('scroll', immediateUpdate);\n }\n };\n }, [readViewport, isInApp, debounce]);\n\n return viewport;\n};\n\nexport default useViewport;\n"],"mappings":";;;AAmBA,MAAM,eAAe,UAAmB,EAAE,KAAK;CAC7C,MAAM,EAAE,UAAU,OAAO,WAAW,QAAQ;CAE5C,MAAM,CAAC,UAAU,eAAe,SAAuB;EACrD,OAAO;EACP,QAAQ;EACR,YAAY;EACZ,WAAW;EACX,UAAU;EACV,SAAS;EACT,OAAO;EACR,CAAC;CAEF,MAAM,mBAAmB,kBAAkB;EACzC,MAAM,eAAe,OAAO;EAC5B,MAAM,eAAe,OAAO,gBAAgB,UAAU;EACtD,MAAM,iBAAiB,SAAS,gBAAgB;EAChD,MAAM,aAAa,SAAS,KAAK;AAEjC,MAAI,OAAO,kBAAkB,KAAK,IAAI,eAAe,aAAa,GAAG,IACnE,QAAO;AAGT,SAAO,KAAK,IAAI,cAAc,gBAAgB,WAAW;IACxD,EAAE,CAAC;CAEN,MAAM,eAAe,kBAAgC;AACnD,MAAI,OAAO,kBAAkB,CAAC,QAC5B,QAAO,OAAO;AAQhB,SAAO;GACL,OANY,OAAO,gBAAgB,SAAS,OAAO;GAOnD,QANa,UACX,kBAAkB,GAClB,OAAO,gBAAgB,UAAU,OAAO;GAK1C,YAAY,OAAO,gBAAgB,cAAc;GACjD,WAAW,OAAO,gBAAgB,aAAa;GAC/C,UAAU,OAAO,WAAW,OAAO,eAAe;GAClD,SAAS,OAAO,WAAW,OAAO,eAAe;GACjD,OAAO,OAAO,gBAAgB,SAAS;GACxC;IACA,CAAC,SAAS,iBAAiB,CAAC;AAE/B,iBAAgB;EACd,IAAI;EAEJ,MAAM,wBAAwB;AAC5B,gBAAa,UAAU;AACvB,eAAY,iBAAiB;AAC3B,gBAAY,cAAc,CAAC;MAC1B,SAAS;;EAGd,MAAM,wBAAwB,YAAY,cAAc,CAAC;AAEzD,mBAAiB;EAEjB,MAAM,SAAS,CAAC,UAAU,oBAAoB;AAE9C,MAAI,QACF,QAAO,KAAK,SAAS,QAAQ,cAAc,WAAW;AAGxD,SAAO,SAAQ,UAAS;AACtB,OAAI,UAAU,YAAY,UAAU,oBAClC,QAAO,iBAAiB,OAAO,gBAAgB;OAE/C,QAAO,iBAAiB,OAAO,iBAAiB,EAAE,SAAS,MAAM,CAAC;IAEpE;AAEF,MAAI,OAAO,gBAAgB;AACzB,UAAO,eAAe,iBAAiB,UAAU,gBAAgB;AACjE,UAAO,eAAe,iBAAiB,UAAU,gBAAgB;;EAGnE,IAAI;AACJ,MAAI,SAAS;GACX,IAAI,aAAa,cAAc,CAAC;AAChC,gBAAa,kBAAkB;IAC7B,MAAM,gBAAgB,cAAc,CAAC;AACrC,QAAI,KAAK,IAAI,gBAAgB,WAAW,GAAG,IAAI;AAC7C,kBAAa;AACb,sBAAiB;;MAElB,IAAI;;AAGT,eAAa;AACX,gBAAa,UAAU;AACvB,OAAI,WAAY,eAAc,WAAW;AAEzC,UAAO,SAAQ,UAAS;AACtB,WAAO,oBACL,OACA,UAAU,YAAY,UAAU,sBAC5B,kBACA,gBACL;KACD;AAEF,OAAI,OAAO,gBAAgB;AACzB,WAAO,eAAe,oBAAoB,UAAU,gBAAgB;AACpE,WAAO,eAAe,oBAAoB,UAAU,gBAAgB;;;IAGvE;EAAC;EAAc;EAAS;EAAS,CAAC;AAErC,QAAO"}
1
+ {"version":3,"file":"use-viewport.mjs","names":[],"sources":["../../src/hooks/use-viewport.ts"],"sourcesContent":["import { useCallback, useEffect, useState } from 'react';\n\ninterface ViewportInfo {\n width: number;\n height: number;\n offsetLeft: number;\n offsetTop: number;\n pageLeft: number;\n pageTop: number;\n scale: number;\n}\n\nconst viewportEqual = (a: ViewportInfo, b: ViewportInfo) =>\n a.width === b.width &&\n a.height === b.height &&\n a.offsetLeft === b.offsetLeft &&\n a.offsetTop === b.offsetTop &&\n a.pageLeft === b.pageLeft &&\n a.pageTop === b.pageTop &&\n a.scale === b.scale;\n\ninterface Options {\n isInApp?: boolean;\n debounce?: number;\n}\n\nconst useViewport = (options: Options = {}) => {\n const { isInApp = false, debounce = 100 } = options;\n\n const [viewport, setViewport] = useState<ViewportInfo>({\n width: 0,\n height: 0,\n offsetLeft: 0,\n offsetTop: 0,\n pageLeft: 0,\n pageTop: 0,\n scale: 1,\n });\n\n const getAppViewHeight = useCallback(() => {\n const windowHeight = window.innerHeight;\n const visualHeight = window.visualViewport?.height || windowHeight;\n const documentHeight = document.documentElement.clientHeight;\n const bodyHeight = document.body.clientHeight;\n\n if (window.visualViewport && Math.abs(visualHeight - windowHeight) > 100) {\n return visualHeight;\n }\n\n return Math.max(windowHeight, documentHeight, bodyHeight);\n }, []);\n\n // Always builds a fresh plain-object snapshot rather than returning\n // `window.visualViewport` itself — that instance is a single mutable\n // object the browser updates in place, so returning it directly meant\n // every read produced the exact same reference and `setViewport` never\n // saw a change (no re-render, ever).\n const readViewport = useCallback((): ViewportInfo => {\n const width = window.visualViewport?.width || window.innerWidth;\n const height = isInApp\n ? getAppViewHeight()\n : window.visualViewport?.height || window.innerHeight;\n\n return {\n width,\n height,\n offsetLeft: window.visualViewport?.offsetLeft || 0,\n offsetTop: window.visualViewport?.offsetTop || 0,\n pageLeft: window.scrollX ?? window.pageXOffset ?? 0,\n pageTop: window.scrollY ?? window.pageYOffset ?? 0,\n scale: window.visualViewport?.scale || 1,\n };\n }, [isInApp, getAppViewHeight]);\n\n useEffect(() => {\n let timeoutId: ReturnType<typeof setTimeout>;\n\n // Snapshots are freshly-allocated objects on every read, so gate the\n // state update on an actual value change instead of always replacing\n // the reference — otherwise every resize/scroll tick would re-render\n // consumers even when nothing visibly moved.\n const commitViewport = (next: ViewportInfo) => {\n setViewport(prev => (viewportEqual(prev, next) ? prev : next));\n };\n\n const debouncedUpdate = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(() => {\n commitViewport(readViewport());\n }, debounce);\n };\n\n const immediateUpdate = () => commitViewport(readViewport());\n\n immediateUpdate();\n\n const events = ['resize', 'orientationchange'];\n\n if (isInApp) {\n events.push('focus', 'blur', 'touchstart', 'touchend');\n }\n\n events.forEach(event => {\n if (event === 'resize' || event === 'orientationchange') {\n window.addEventListener(event, debouncedUpdate);\n } else {\n window.addEventListener(event, immediateUpdate, { passive: true });\n }\n });\n\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', immediateUpdate);\n window.visualViewport.addEventListener('scroll', immediateUpdate);\n }\n\n let intervalId: ReturnType<typeof setInterval>;\n if (isInApp) {\n let lastHeight = readViewport().height;\n intervalId = setInterval(() => {\n const currentHeight = readViewport().height;\n if (Math.abs(currentHeight - lastHeight) > 50) {\n lastHeight = currentHeight;\n immediateUpdate();\n }\n }, 500);\n }\n\n return () => {\n clearTimeout(timeoutId);\n if (intervalId) clearInterval(intervalId);\n\n events.forEach(event => {\n window.removeEventListener(\n event,\n event === 'resize' || event === 'orientationchange'\n ? debouncedUpdate\n : immediateUpdate,\n );\n });\n\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', immediateUpdate);\n window.visualViewport.removeEventListener('scroll', immediateUpdate);\n }\n };\n }, [readViewport, isInApp, debounce]);\n\n return viewport;\n};\n\nexport default useViewport;\n"],"mappings":";;;AAYA,MAAM,iBAAiB,GAAiB,MACtC,EAAE,UAAU,EAAE,SACd,EAAE,WAAW,EAAE,UACf,EAAE,eAAe,EAAE,cACnB,EAAE,cAAc,EAAE,aAClB,EAAE,aAAa,EAAE,YACjB,EAAE,YAAY,EAAE,WAChB,EAAE,UAAU,EAAE;AAOhB,MAAM,eAAe,UAAmB,EAAE,KAAK;CAC7C,MAAM,EAAE,UAAU,OAAO,WAAW,QAAQ;CAE5C,MAAM,CAAC,UAAU,eAAe,SAAuB;EACrD,OAAO;EACP,QAAQ;EACR,YAAY;EACZ,WAAW;EACX,UAAU;EACV,SAAS;EACT,OAAO;EACR,CAAC;CAEF,MAAM,mBAAmB,kBAAkB;EACzC,MAAM,eAAe,OAAO;EAC5B,MAAM,eAAe,OAAO,gBAAgB,UAAU;EACtD,MAAM,iBAAiB,SAAS,gBAAgB;EAChD,MAAM,aAAa,SAAS,KAAK;AAEjC,MAAI,OAAO,kBAAkB,KAAK,IAAI,eAAe,aAAa,GAAG,IACnE,QAAO;AAGT,SAAO,KAAK,IAAI,cAAc,gBAAgB,WAAW;IACxD,EAAE,CAAC;CAON,MAAM,eAAe,kBAAgC;AAMnD,SAAO;GACL,OANY,OAAO,gBAAgB,SAAS,OAAO;GAOnD,QANa,UACX,kBAAkB,GAClB,OAAO,gBAAgB,UAAU,OAAO;GAK1C,YAAY,OAAO,gBAAgB,cAAc;GACjD,WAAW,OAAO,gBAAgB,aAAa;GAC/C,UAAU,OAAO,WAAW,OAAO,eAAe;GAClD,SAAS,OAAO,WAAW,OAAO,eAAe;GACjD,OAAO,OAAO,gBAAgB,SAAS;GACxC;IACA,CAAC,SAAS,iBAAiB,CAAC;AAE/B,iBAAgB;EACd,IAAI;EAMJ,MAAM,kBAAkB,SAAuB;AAC7C,gBAAY,SAAS,cAAc,MAAM,KAAK,GAAG,OAAO,KAAM;;EAGhE,MAAM,wBAAwB;AAC5B,gBAAa,UAAU;AACvB,eAAY,iBAAiB;AAC3B,mBAAe,cAAc,CAAC;MAC7B,SAAS;;EAGd,MAAM,wBAAwB,eAAe,cAAc,CAAC;AAE5D,mBAAiB;EAEjB,MAAM,SAAS,CAAC,UAAU,oBAAoB;AAE9C,MAAI,QACF,QAAO,KAAK,SAAS,QAAQ,cAAc,WAAW;AAGxD,SAAO,SAAQ,UAAS;AACtB,OAAI,UAAU,YAAY,UAAU,oBAClC,QAAO,iBAAiB,OAAO,gBAAgB;OAE/C,QAAO,iBAAiB,OAAO,iBAAiB,EAAE,SAAS,MAAM,CAAC;IAEpE;AAEF,MAAI,OAAO,gBAAgB;AACzB,UAAO,eAAe,iBAAiB,UAAU,gBAAgB;AACjE,UAAO,eAAe,iBAAiB,UAAU,gBAAgB;;EAGnE,IAAI;AACJ,MAAI,SAAS;GACX,IAAI,aAAa,cAAc,CAAC;AAChC,gBAAa,kBAAkB;IAC7B,MAAM,gBAAgB,cAAc,CAAC;AACrC,QAAI,KAAK,IAAI,gBAAgB,WAAW,GAAG,IAAI;AAC7C,kBAAa;AACb,sBAAiB;;MAElB,IAAI;;AAGT,eAAa;AACX,gBAAa,UAAU;AACvB,OAAI,WAAY,eAAc,WAAW;AAEzC,UAAO,SAAQ,UAAS;AACtB,WAAO,oBACL,OACA,UAAU,YAAY,UAAU,sBAC5B,kBACA,gBACL;KACD;AAEF,OAAI,OAAO,gBAAgB;AACzB,WAAO,eAAe,oBAAoB,UAAU,gBAAgB;AACpE,WAAO,eAAe,oBAAoB,UAAU,gBAAgB;;;IAGvE;EAAC;EAAc;EAAS;EAAS,CAAC;AAErC,QAAO"}
@@ -1,5 +1,7 @@
1
+ import { RefObject } from "react";
2
+
1
3
  //#region src/hooks/use-window-scroll.d.ts
2
- declare const useWindowScroll: () => {
4
+ declare const useWindowScroll: (targetRef?: RefObject<Element | null>) => {
3
5
  x: number;
4
6
  y: number;
5
7
  percent: {
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
 
3
3
  //#region src/hooks/use-window-scroll.ts
4
- const useWindowScroll = () => {
4
+ const useWindowScroll = (targetRef) => {
5
5
  const [state, setState] = useState({
6
6
  x: 0,
7
7
  y: 0,
@@ -11,15 +11,17 @@ const useWindowScroll = () => {
11
11
  }
12
12
  });
13
13
  useEffect(() => {
14
+ const target = targetRef?.current?.ownerDocument.defaultView ?? window;
15
+ const doc = target.document;
14
16
  const calculate = () => {
15
- const x = window.scrollX || 0;
16
- const y = window.scrollY || 0;
17
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
18
- const visualViewport = window.visualViewport;
19
- const viewportWidth = isIOS && visualViewport ? visualViewport.width : window.innerWidth;
20
- const viewportHeight = isIOS && visualViewport ? visualViewport.height : window.innerHeight;
21
- const maxScrollX = Math.max(0, document.documentElement.scrollWidth - viewportWidth);
22
- const maxScrollY = Math.max(0, document.documentElement.scrollHeight - viewportHeight);
17
+ const x = target.scrollX || 0;
18
+ const y = target.scrollY || 0;
19
+ const isIOS = /iPad|iPhone|iPod/.test(target.navigator.userAgent);
20
+ const visualViewport = target.visualViewport;
21
+ const viewportWidth = isIOS && visualViewport ? visualViewport.width : target.innerWidth;
22
+ const viewportHeight = isIOS && visualViewport ? visualViewport.height : target.innerHeight;
23
+ const maxScrollX = Math.max(0, doc.documentElement.scrollWidth - viewportWidth);
24
+ const maxScrollY = Math.max(0, doc.documentElement.scrollHeight - viewportHeight);
23
25
  const percentX = maxScrollX === 0 ? 0 : Math.min(100, x / maxScrollX * 100);
24
26
  const percentY = maxScrollY === 0 ? 0 : Math.min(100, y / maxScrollY * 100);
25
27
  setState({
@@ -41,17 +43,17 @@ const useWindowScroll = () => {
41
43
  const onVisualViewportChange = () => {
42
44
  setTimeout(calculate, 50);
43
45
  };
44
- window.addEventListener("scroll", onScroll, { passive: true });
45
- window.addEventListener("resize", onResize);
46
- window.addEventListener("orientationchange", onResize);
47
- if (window.visualViewport) window.visualViewport.addEventListener("resize", onVisualViewportChange);
46
+ target.addEventListener("scroll", onScroll, { passive: true });
47
+ target.addEventListener("resize", onResize);
48
+ target.addEventListener("orientationchange", onResize);
49
+ if (target.visualViewport) target.visualViewport.addEventListener("resize", onVisualViewportChange);
48
50
  return () => {
49
- window.removeEventListener("scroll", onScroll);
50
- window.removeEventListener("resize", onResize);
51
- window.removeEventListener("orientationchange", onResize);
52
- if (window.visualViewport) window.visualViewport.removeEventListener("resize", onVisualViewportChange);
51
+ target.removeEventListener("scroll", onScroll);
52
+ target.removeEventListener("resize", onResize);
53
+ target.removeEventListener("orientationchange", onResize);
54
+ if (target.visualViewport) target.visualViewport.removeEventListener("resize", onVisualViewportChange);
53
55
  };
54
- }, []);
56
+ }, [targetRef]);
55
57
  return state;
56
58
  };
57
59
 
@@ -1 +1 @@
1
- {"version":3,"file":"use-window-scroll.mjs","names":[],"sources":["../../src/hooks/use-window-scroll.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nconst useWindowScroll = () => {\n const [state, setState] = useState({\n x: 0,\n y: 0,\n percent: {\n x: 0,\n y: 0,\n },\n });\n\n useEffect(() => {\n const calculate = () => {\n const x = window.scrollX || 0;\n const y = window.scrollY || 0;\n\n const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);\n const visualViewport = window.visualViewport;\n\n const viewportWidth =\n isIOS && visualViewport ? visualViewport.width : window.innerWidth;\n\n const viewportHeight =\n isIOS && visualViewport ? visualViewport.height : window.innerHeight;\n\n const maxScrollX = Math.max(\n 0,\n document.documentElement.scrollWidth - viewportWidth,\n );\n const maxScrollY = Math.max(\n 0,\n document.documentElement.scrollHeight - viewportHeight,\n );\n\n const percentX =\n maxScrollX === 0 ? 0 : Math.min(100, (x / maxScrollX) * 100);\n const percentY =\n maxScrollY === 0 ? 0 : Math.min(100, (y / maxScrollY) * 100);\n\n setState({\n x,\n y,\n percent: {\n x: Math.floor(Math.max(0, percentX)),\n y: Math.floor(Math.max(0, percentY)),\n },\n });\n };\n\n calculate();\n\n const onScroll = () => {\n calculate();\n };\n\n const onResize = () => {\n setTimeout(calculate, 100);\n };\n\n const onVisualViewportChange = () => {\n setTimeout(calculate, 50);\n };\n\n window.addEventListener('scroll', onScroll, { passive: true });\n window.addEventListener('resize', onResize);\n window.addEventListener('orientationchange', onResize);\n\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', onVisualViewportChange);\n }\n\n return () => {\n window.removeEventListener('scroll', onScroll);\n window.removeEventListener('resize', onResize);\n window.removeEventListener('orientationchange', onResize);\n\n if (window.visualViewport) {\n window.visualViewport.removeEventListener(\n 'resize',\n onVisualViewportChange,\n );\n }\n };\n }, []);\n\n return state;\n};\n\nexport default useWindowScroll;\n"],"mappings":";;;AAEA,MAAM,wBAAwB;CAC5B,MAAM,CAAC,OAAO,YAAY,SAAS;EACjC,GAAG;EACH,GAAG;EACH,SAAS;GACP,GAAG;GACH,GAAG;GACJ;EACF,CAAC;AAEF,iBAAgB;EACd,MAAM,kBAAkB;GACtB,MAAM,IAAI,OAAO,WAAW;GAC5B,MAAM,IAAI,OAAO,WAAW;GAE5B,MAAM,QAAQ,mBAAmB,KAAK,UAAU,UAAU;GAC1D,MAAM,iBAAiB,OAAO;GAE9B,MAAM,gBACJ,SAAS,iBAAiB,eAAe,QAAQ,OAAO;GAE1D,MAAM,iBACJ,SAAS,iBAAiB,eAAe,SAAS,OAAO;GAE3D,MAAM,aAAa,KAAK,IACtB,GACA,SAAS,gBAAgB,cAAc,cACxC;GACD,MAAM,aAAa,KAAK,IACtB,GACA,SAAS,gBAAgB,eAAe,eACzC;GAED,MAAM,WACJ,eAAe,IAAI,IAAI,KAAK,IAAI,KAAM,IAAI,aAAc,IAAI;GAC9D,MAAM,WACJ,eAAe,IAAI,IAAI,KAAK,IAAI,KAAM,IAAI,aAAc,IAAI;AAE9D,YAAS;IACP;IACA;IACA,SAAS;KACP,GAAG,KAAK,MAAM,KAAK,IAAI,GAAG,SAAS,CAAC;KACpC,GAAG,KAAK,MAAM,KAAK,IAAI,GAAG,SAAS,CAAC;KACrC;IACF,CAAC;;AAGJ,aAAW;EAEX,MAAM,iBAAiB;AACrB,cAAW;;EAGb,MAAM,iBAAiB;AACrB,cAAW,WAAW,IAAI;;EAG5B,MAAM,+BAA+B;AACnC,cAAW,WAAW,GAAG;;AAG3B,SAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;AAC9D,SAAO,iBAAiB,UAAU,SAAS;AAC3C,SAAO,iBAAiB,qBAAqB,SAAS;AAEtD,MAAI,OAAO,eACT,QAAO,eAAe,iBAAiB,UAAU,uBAAuB;AAG1E,eAAa;AACX,UAAO,oBAAoB,UAAU,SAAS;AAC9C,UAAO,oBAAoB,UAAU,SAAS;AAC9C,UAAO,oBAAoB,qBAAqB,SAAS;AAEzD,OAAI,OAAO,eACT,QAAO,eAAe,oBACpB,UACA,uBACD;;IAGJ,EAAE,CAAC;AAEN,QAAO"}
1
+ {"version":3,"file":"use-window-scroll.mjs","names":[],"sources":["../../src/hooks/use-window-scroll.ts"],"sourcesContent":["import { type RefObject, useEffect, useState } from 'react';\n\n// Pass a ref to an element rendered inside the window you actually want to\n// track (e.g. a node portaled into an iframe). Without one, this defaults\n// to the host `window`, which is wrong whenever the caller doesn't live in\n// the top-level document. Resolved once per mount from `targetRef.current`.\nconst useWindowScroll = (targetRef?: RefObject<Element | null>) => {\n const [state, setState] = useState({\n x: 0,\n y: 0,\n percent: {\n x: 0,\n y: 0,\n },\n });\n\n useEffect(() => {\n const target = targetRef?.current?.ownerDocument.defaultView ?? window;\n const doc = target.document;\n\n const calculate = () => {\n const x = target.scrollX || 0;\n const y = target.scrollY || 0;\n\n const isIOS = /iPad|iPhone|iPod/.test(target.navigator.userAgent);\n const visualViewport = target.visualViewport;\n\n const viewportWidth =\n isIOS && visualViewport ? visualViewport.width : target.innerWidth;\n\n const viewportHeight =\n isIOS && visualViewport ? visualViewport.height : target.innerHeight;\n\n const maxScrollX = Math.max(\n 0,\n doc.documentElement.scrollWidth - viewportWidth,\n );\n const maxScrollY = Math.max(\n 0,\n doc.documentElement.scrollHeight - viewportHeight,\n );\n\n const percentX =\n maxScrollX === 0 ? 0 : Math.min(100, (x / maxScrollX) * 100);\n const percentY =\n maxScrollY === 0 ? 0 : Math.min(100, (y / maxScrollY) * 100);\n\n setState({\n x,\n y,\n percent: {\n x: Math.floor(Math.max(0, percentX)),\n y: Math.floor(Math.max(0, percentY)),\n },\n });\n };\n\n calculate();\n\n const onScroll = () => {\n calculate();\n };\n\n const onResize = () => {\n setTimeout(calculate, 100);\n };\n\n const onVisualViewportChange = () => {\n setTimeout(calculate, 50);\n };\n\n target.addEventListener('scroll', onScroll, { passive: true });\n target.addEventListener('resize', onResize);\n target.addEventListener('orientationchange', onResize);\n\n if (target.visualViewport) {\n target.visualViewport.addEventListener('resize', onVisualViewportChange);\n }\n\n return () => {\n target.removeEventListener('scroll', onScroll);\n target.removeEventListener('resize', onResize);\n target.removeEventListener('orientationchange', onResize);\n\n if (target.visualViewport) {\n target.visualViewport.removeEventListener(\n 'resize',\n onVisualViewportChange,\n );\n }\n };\n }, [targetRef]);\n\n return state;\n};\n\nexport default useWindowScroll;\n"],"mappings":";;;AAMA,MAAM,mBAAmB,cAA0C;CACjE,MAAM,CAAC,OAAO,YAAY,SAAS;EACjC,GAAG;EACH,GAAG;EACH,SAAS;GACP,GAAG;GACH,GAAG;GACJ;EACF,CAAC;AAEF,iBAAgB;EACd,MAAM,SAAS,WAAW,SAAS,cAAc,eAAe;EAChE,MAAM,MAAM,OAAO;EAEnB,MAAM,kBAAkB;GACtB,MAAM,IAAI,OAAO,WAAW;GAC5B,MAAM,IAAI,OAAO,WAAW;GAE5B,MAAM,QAAQ,mBAAmB,KAAK,OAAO,UAAU,UAAU;GACjE,MAAM,iBAAiB,OAAO;GAE9B,MAAM,gBACJ,SAAS,iBAAiB,eAAe,QAAQ,OAAO;GAE1D,MAAM,iBACJ,SAAS,iBAAiB,eAAe,SAAS,OAAO;GAE3D,MAAM,aAAa,KAAK,IACtB,GACA,IAAI,gBAAgB,cAAc,cACnC;GACD,MAAM,aAAa,KAAK,IACtB,GACA,IAAI,gBAAgB,eAAe,eACpC;GAED,MAAM,WACJ,eAAe,IAAI,IAAI,KAAK,IAAI,KAAM,IAAI,aAAc,IAAI;GAC9D,MAAM,WACJ,eAAe,IAAI,IAAI,KAAK,IAAI,KAAM,IAAI,aAAc,IAAI;AAE9D,YAAS;IACP;IACA;IACA,SAAS;KACP,GAAG,KAAK,MAAM,KAAK,IAAI,GAAG,SAAS,CAAC;KACpC,GAAG,KAAK,MAAM,KAAK,IAAI,GAAG,SAAS,CAAC;KACrC;IACF,CAAC;;AAGJ,aAAW;EAEX,MAAM,iBAAiB;AACrB,cAAW;;EAGb,MAAM,iBAAiB;AACrB,cAAW,WAAW,IAAI;;EAG5B,MAAM,+BAA+B;AACnC,cAAW,WAAW,GAAG;;AAG3B,SAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;AAC9D,SAAO,iBAAiB,UAAU,SAAS;AAC3C,SAAO,iBAAiB,qBAAqB,SAAS;AAEtD,MAAI,OAAO,eACT,QAAO,eAAe,iBAAiB,UAAU,uBAAuB;AAG1E,eAAa;AACX,UAAO,oBAAoB,UAAU,SAAS;AAC9C,UAAO,oBAAoB,UAAU,SAAS;AAC9C,UAAO,oBAAoB,qBAAqB,SAAS;AAEzD,OAAI,OAAO,eACT,QAAO,eAAe,oBACpB,UACA,uBACD;;IAGJ,CAAC,UAAU,CAAC;AAEf,QAAO"}
package/dist/index.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useDebounce } from "./hooks/use-debounce.mjs";
2
2
  import { useBodyScrollLock } from "./hooks/use-body-scroll-lock.mjs";
3
3
  import { useClickOutside } from "./hooks/use-click-outside.mjs";
4
+ import { useControllableState } from "./hooks/use-controllable-state.mjs";
4
5
  import { useElementPosition } from "./hooks/use-element-position.mjs";
5
6
  import { useElementScroll } from "./hooks/use-element-scroll.mjs";
6
7
  import { useFileToDataUrl } from "./hooks/use-file-to-data-url.mjs";
@@ -17,4 +18,4 @@ import { useTimeline } from "./hooks/use-timeline.mjs";
17
18
  import { useWindowScroll } from "./hooks/use-window-scroll.mjs";
18
19
  import { useViewport } from "./hooks/use-viewport.mjs";
19
20
  import "./hooks/index.mjs";
20
- export { useBodyScrollLock, useClickOutside, useDebounce, useElementPosition, useElementScroll, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useLocalStorage, useMultiSelect, useRecursiveTimeout, useResponsiveSize, useScrollToElements, useThrottle, useTimeline, useViewport, useWindowScroll };
21
+ export { useBodyScrollLock, useClickOutside, useControllableState, useDebounce, useElementPosition, useElementScroll, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useLocalStorage, useMultiSelect, useRecursiveTimeout, useResponsiveSize, useScrollToElements, useThrottle, useTimeline, useViewport, useWindowScroll };
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import useDebounce from "./hooks/use-debounce.mjs";
2
2
  import useBodyScrollLock from "./hooks/use-body-scroll-lock.mjs";
3
3
  import useClickOutside from "./hooks/use-click-outside.mjs";
4
+ import useControllableState from "./hooks/use-controllable-state.mjs";
4
5
  import useElementPosition from "./hooks/use-element-position.mjs";
5
6
  import useElementScroll from "./hooks/use-element-scroll.mjs";
6
7
  import useFileToDataUrl from "./hooks/use-file-to-data-url.mjs";
@@ -18,4 +19,4 @@ import useWindowScroll from "./hooks/use-window-scroll.mjs";
18
19
  import useViewport from "./hooks/use-viewport.mjs";
19
20
  import "./hooks/index.mjs";
20
21
 
21
- export { useBodyScrollLock, useClickOutside, useDebounce, useElementPosition, useElementScroll, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useLocalStorage, useMultiSelect, useRecursiveTimeout, useResponsiveSize, useScrollToElements, useThrottle, useTimeline, useViewport, useWindowScroll };
22
+ export { useBodyScrollLock, useClickOutside, useControllableState, useDebounce, useElementPosition, useElementScroll, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useLocalStorage, useMultiSelect, useRecursiveTimeout, useResponsiveSize, useScrollToElements, useThrottle, useTimeline, useViewport, useWindowScroll };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbpark/use-hooks",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "A collection of reusable React 19 hooks for common UI and interaction patterns",
5
5
  "keywords": [
6
6
  "react",