@jbpark/use-hooks 2.10.0 → 2.11.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.
@@ -4,11 +4,10 @@ interface UseControllableStateProps<T> {
4
4
  defaultValue?: T;
5
5
  onChange?: (value: T) => void;
6
6
  }
7
- declare const useControllableState: <T>({
8
- value,
9
- defaultValue,
10
- onChange
11
- }: UseControllableStateProps<T>) => [T, (next: T) => void];
7
+ declare function useControllableState<T>(props: UseControllableStateProps<T> & {
8
+ defaultValue: T;
9
+ }): [T, (next: T) => void];
10
+ declare function useControllableState<T>(props: UseControllableStateProps<T>): [T | undefined, (next: T) => void];
12
11
  //#endregion
13
12
  export { useControllableState };
14
13
  //# sourceMappingURL=use-controllable-state.d.mts.map
@@ -1,14 +1,20 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  //#region src/hooks/use-controllable-state.ts
4
- const useControllableState = ({ value, defaultValue, onChange }) => {
4
+ function useControllableState({ value, defaultValue, onChange }) {
5
5
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
6
6
  const isControlled = value !== void 0;
7
- return [isControlled ? value : uncontrolledValue, useCallback((next) => {
7
+ const currentValue = isControlled ? value : uncontrolledValue;
8
+ const wasControlledRef = useRef(isControlled);
9
+ useEffect(() => {
10
+ if (!(globalThis.process?.env?.NODE_ENV === "production") && wasControlledRef.current !== isControlled) console.warn(`useControllableState is changing from ${wasControlledRef.current ? "controlled" : "uncontrolled"} to ${isControlled ? "controlled" : "uncontrolled"}. A component should not switch between controlled and uncontrolled — decide on one and stick with it (e.g. by always passing a non-undefined \`value\`, or never passing one).`);
11
+ wasControlledRef.current = isControlled;
12
+ }, [isControlled]);
13
+ return [currentValue, useCallback((next) => {
8
14
  if (!isControlled) setUncontrolledValue(next);
9
15
  onChange?.(next);
10
16
  }, [isControlled, onChange])];
11
- };
17
+ }
12
18
 
13
19
  //#endregion
14
20
  export { useControllableState as default };
@@ -1 +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"}
1
+ {"version":3,"file":"use-controllable-state.mjs","names":[],"sources":["../../src/hooks/use-controllable-state.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, 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.\n//\n// `value === undefined` is what marks a render as uncontrolled, so it can't\n// also be used as a real, controlled value of `undefined` — that's an\n// inherent limitation of this \"sentinel\" style of controlled-state\n// detection (shared by most hooks of this shape, including Radix's own),\n// not something fixable without a different API (e.g. an explicit\n// `controlled` boolean prop instead of inferring it from `value`).\nfunction useControllableState<T>(\n props: UseControllableStateProps<T> & { defaultValue: T },\n): [T, (next: T) => void];\nfunction useControllableState<T>(\n props: UseControllableStateProps<T>,\n): [T | undefined, (next: T) => void];\nfunction useControllableState<T>({\n value,\n defaultValue,\n onChange,\n}: UseControllableStateProps<T>) {\n const [uncontrolledValue, setUncontrolledValue] = useState<T | undefined>(\n defaultValue,\n );\n\n const isControlled = value !== undefined;\n const currentValue = isControlled ? value : uncontrolledValue;\n\n const wasControlledRef = useRef(isControlled);\n\n useEffect(() => {\n // Read `process.env.NODE_ENV` through `globalThis` rather than the\n // bare `process` global — this hook ships as plain ESM and can end up\n // somewhere with no bundler define for it (or no `process` at all, and\n // no @types/node in scope to even name it), where referencing it\n // directly would fail instead of just missing a warning.\n const nodeEnv = (\n globalThis as { process?: { env?: { NODE_ENV?: string } } }\n ).process?.env?.NODE_ENV;\n const isProduction = nodeEnv === 'production';\n\n if (!isProduction && wasControlledRef.current !== isControlled) {\n console.warn(\n `useControllableState is changing from ${\n wasControlledRef.current ? 'controlled' : 'uncontrolled'\n } to ${\n isControlled ? 'controlled' : 'uncontrolled'\n }. A component should not switch between controlled and uncontrolled — decide on one and stick with it (e.g. by always passing a non-undefined \\`value\\`, or never passing one).`,\n );\n }\n wasControlledRef.current = isControlled;\n }, [isControlled]);\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":";;;AAyBA,SAAS,qBAAwB,EAC/B,OACA,cACA,YAC+B;CAC/B,MAAM,CAAC,mBAAmB,wBAAwB,SAChD,aACD;CAED,MAAM,eAAe,UAAU;CAC/B,MAAM,eAAe,eAAe,QAAQ;CAE5C,MAAM,mBAAmB,OAAO,aAAa;AAE7C,iBAAgB;AAWd,MAAI,EAJF,WACA,SAAS,KAAK,aACiB,iBAEZ,iBAAiB,YAAY,aAChD,SAAQ,KACN,yCACE,iBAAiB,UAAU,eAAe,eAC3C,MACC,eAAe,eAAe,eAC/B,iLACF;AAEH,mBAAiB,UAAU;IAC1B,CAAC,aAAa,CAAC;AAYlB,QAAO,CAAC,cAVS,aACd,SAAY;AACX,MAAI,CAAC,aACH,sBAAqB,KAAK;AAE5B,aAAW,KAAK;IAElB,CAAC,cAAc,SAAS,CACzB,CAE8B"}
@@ -1,27 +1,56 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  //#region src/hooks/use-element-position.ts
4
+ const rectsEqual = (a, b) => {
5
+ if (a === b) return true;
6
+ if (!a || !b) return false;
7
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height && a.top === b.top && a.left === b.left;
8
+ };
4
9
  const useElementPosition = (elementRef) => {
5
10
  const [rect, setRect] = useState(null);
11
+ const rectRef = useRef(null);
6
12
  useEffect(() => {
7
13
  const getElement = (ref) => {
8
14
  if (typeof ref === "string") return document.querySelector(ref);
9
15
  return ref.current;
10
16
  };
11
- const updateRect = () => {
12
- const element = getElement(elementRef);
13
- if (element) setRect(element.getBoundingClientRect());
14
- else setRect(null);
17
+ let rafId;
18
+ let currentElement = null;
19
+ const commitRect = (next) => {
20
+ if (rectsEqual(rectRef.current, next)) return;
21
+ rectRef.current = next;
22
+ setRect(next);
15
23
  };
24
+ const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => updateRect()) : void 0;
25
+ function updateRect() {
26
+ const element = getElement(elementRef);
27
+ if (element !== currentElement) {
28
+ if (currentElement) resizeObserver?.unobserve(currentElement);
29
+ if (element) resizeObserver?.observe(element);
30
+ currentElement = element;
31
+ }
32
+ commitRect(element ? element.getBoundingClientRect() : null);
33
+ }
16
34
  const onUpdate = () => {
17
- requestAnimationFrame(updateRect);
35
+ rafId = requestAnimationFrame(updateRect);
18
36
  };
19
37
  updateRect();
20
- window.addEventListener("scroll", onUpdate, { passive: true });
38
+ window.addEventListener("scroll", onUpdate, {
39
+ passive: true,
40
+ capture: true
41
+ });
21
42
  window.addEventListener("resize", onUpdate, { passive: true });
43
+ const mutationObserver = typeof elementRef === "string" && typeof MutationObserver !== "undefined" ? new MutationObserver(onUpdate) : void 0;
44
+ mutationObserver?.observe(document.body, {
45
+ childList: true,
46
+ subtree: true
47
+ });
22
48
  return () => {
23
- window.removeEventListener("scroll", onUpdate);
49
+ if (rafId !== void 0) cancelAnimationFrame(rafId);
50
+ window.removeEventListener("scroll", onUpdate, { capture: true });
24
51
  window.removeEventListener("resize", onUpdate);
52
+ resizeObserver?.disconnect();
53
+ mutationObserver?.disconnect();
25
54
  };
26
55
  }, [elementRef]);
27
56
  return rect;
@@ -1 +1 @@
1
- {"version":3,"file":"use-element-position.mjs","names":[],"sources":["../../src/hooks/use-element-position.ts"],"sourcesContent":["import { type RefObject, useEffect, useState } from 'react';\n\ntype ElementReference<T> = string | RefObject<T>;\n\nconst useElementPosition = <T>(elementRef: ElementReference<T>) => {\n const [rect, setRect] = useState<DOMRect | null>(null);\n\n useEffect(() => {\n const getElement = (ref: ElementReference<T>): T | null => {\n if (typeof ref === 'string') {\n return document.querySelector(ref) as T;\n }\n return ref.current;\n };\n\n const updateRect = () => {\n const element = getElement(elementRef) as HTMLElement | null;\n if (element) {\n setRect(element.getBoundingClientRect());\n } else {\n setRect(null);\n }\n };\n\n const onUpdate = () => {\n requestAnimationFrame(updateRect);\n };\n\n updateRect();\n\n window.addEventListener('scroll', onUpdate, { passive: true });\n window.addEventListener('resize', onUpdate, { passive: true });\n\n return () => {\n window.removeEventListener('scroll', onUpdate);\n window.removeEventListener('resize', onUpdate);\n };\n }, [elementRef]);\n\n return rect;\n};\n\nexport default useElementPosition;\n"],"mappings":";;;AAIA,MAAM,sBAAyB,eAAoC;CACjE,MAAM,CAAC,MAAM,WAAW,SAAyB,KAAK;AAEtD,iBAAgB;EACd,MAAM,cAAc,QAAuC;AACzD,OAAI,OAAO,QAAQ,SACjB,QAAO,SAAS,cAAc,IAAI;AAEpC,UAAO,IAAI;;EAGb,MAAM,mBAAmB;GACvB,MAAM,UAAU,WAAW,WAAW;AACtC,OAAI,QACF,SAAQ,QAAQ,uBAAuB,CAAC;OAExC,SAAQ,KAAK;;EAIjB,MAAM,iBAAiB;AACrB,yBAAsB,WAAW;;AAGnC,cAAY;AAEZ,SAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;AAC9D,SAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;AAE9D,eAAa;AACX,UAAO,oBAAoB,UAAU,SAAS;AAC9C,UAAO,oBAAoB,UAAU,SAAS;;IAE/C,CAAC,WAAW,CAAC;AAEhB,QAAO"}
1
+ {"version":3,"file":"use-element-position.mjs","names":[],"sources":["../../src/hooks/use-element-position.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef, useState } from 'react';\n\ntype ElementReference<T> = string | RefObject<T>;\n\nconst rectsEqual = (a: DOMRect | null, b: DOMRect | null) => {\n if (a === b) {\n return true;\n }\n if (!a || !b) {\n return false;\n }\n return (\n a.x === b.x &&\n a.y === b.y &&\n a.width === b.width &&\n a.height === b.height &&\n a.top === b.top &&\n a.left === b.left\n );\n};\n\nconst useElementPosition = <T>(elementRef: ElementReference<T>) => {\n const [rect, setRect] = useState<DOMRect | null>(null);\n const rectRef = useRef<DOMRect | null>(null);\n\n useEffect(() => {\n const getElement = (ref: ElementReference<T>): T | null => {\n if (typeof ref === 'string') {\n return document.querySelector(ref) as T | null;\n }\n return ref.current;\n };\n\n let rafId: number | undefined;\n let currentElement: HTMLElement | null = null;\n\n const commitRect = (next: DOMRect | null) => {\n if (rectsEqual(rectRef.current, next)) {\n return;\n }\n rectRef.current = next;\n setRect(next);\n };\n\n // ResizeObserver only fires for the observed element's own size/layout\n // changes, so it's kept pointed at whichever element is actually\n // current — which can change under a string selector, or if the DOM\n // node behind a ref gets swapped out.\n const resizeObserver =\n typeof ResizeObserver !== 'undefined'\n ? new ResizeObserver(() => updateRect())\n : undefined;\n\n function updateRect() {\n const element = getElement(elementRef) as HTMLElement | null;\n\n if (element !== currentElement) {\n if (currentElement) {\n resizeObserver?.unobserve(currentElement);\n }\n if (element) {\n resizeObserver?.observe(element);\n }\n currentElement = element;\n }\n\n commitRect(element ? element.getBoundingClientRect() : null);\n }\n\n const onUpdate = () => {\n rafId = requestAnimationFrame(updateRect);\n };\n\n updateRect();\n\n // `capture: true` also catches scroll events from scrollable ancestor\n // containers, which don't bubble and so wouldn't otherwise reach a\n // listener on `window`.\n window.addEventListener('scroll', onUpdate, {\n passive: true,\n capture: true,\n });\n window.addEventListener('resize', onUpdate, { passive: true });\n\n // A string selector's target may not exist yet when this effect first\n // runs — watch the DOM for it instead of leaving `rect` permanently\n // null once it does mount.\n const mutationObserver =\n typeof elementRef === 'string' && typeof MutationObserver !== 'undefined'\n ? new MutationObserver(onUpdate)\n : undefined;\n mutationObserver?.observe(document.body, {\n childList: true,\n subtree: true,\n });\n\n return () => {\n if (rafId !== undefined) {\n cancelAnimationFrame(rafId);\n }\n window.removeEventListener('scroll', onUpdate, { capture: true });\n window.removeEventListener('resize', onUpdate);\n resizeObserver?.disconnect();\n mutationObserver?.disconnect();\n };\n }, [elementRef]);\n\n return rect;\n};\n\nexport default useElementPosition;\n"],"mappings":";;;AAIA,MAAM,cAAc,GAAmB,MAAsB;AAC3D,KAAI,MAAM,EACR,QAAO;AAET,KAAI,CAAC,KAAK,CAAC,EACT,QAAO;AAET,QACE,EAAE,MAAM,EAAE,KACV,EAAE,MAAM,EAAE,KACV,EAAE,UAAU,EAAE,SACd,EAAE,WAAW,EAAE,UACf,EAAE,QAAQ,EAAE,OACZ,EAAE,SAAS,EAAE;;AAIjB,MAAM,sBAAyB,eAAoC;CACjE,MAAM,CAAC,MAAM,WAAW,SAAyB,KAAK;CACtD,MAAM,UAAU,OAAuB,KAAK;AAE5C,iBAAgB;EACd,MAAM,cAAc,QAAuC;AACzD,OAAI,OAAO,QAAQ,SACjB,QAAO,SAAS,cAAc,IAAI;AAEpC,UAAO,IAAI;;EAGb,IAAI;EACJ,IAAI,iBAAqC;EAEzC,MAAM,cAAc,SAAyB;AAC3C,OAAI,WAAW,QAAQ,SAAS,KAAK,CACnC;AAEF,WAAQ,UAAU;AAClB,WAAQ,KAAK;;EAOf,MAAM,iBACJ,OAAO,mBAAmB,cACtB,IAAI,qBAAqB,YAAY,CAAC,GACtC;EAEN,SAAS,aAAa;GACpB,MAAM,UAAU,WAAW,WAAW;AAEtC,OAAI,YAAY,gBAAgB;AAC9B,QAAI,eACF,iBAAgB,UAAU,eAAe;AAE3C,QAAI,QACF,iBAAgB,QAAQ,QAAQ;AAElC,qBAAiB;;AAGnB,cAAW,UAAU,QAAQ,uBAAuB,GAAG,KAAK;;EAG9D,MAAM,iBAAiB;AACrB,WAAQ,sBAAsB,WAAW;;AAG3C,cAAY;AAKZ,SAAO,iBAAiB,UAAU,UAAU;GAC1C,SAAS;GACT,SAAS;GACV,CAAC;AACF,SAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;EAK9D,MAAM,mBACJ,OAAO,eAAe,YAAY,OAAO,qBAAqB,cAC1D,IAAI,iBAAiB,SAAS,GAC9B;AACN,oBAAkB,QAAQ,SAAS,MAAM;GACvC,WAAW;GACX,SAAS;GACV,CAAC;AAEF,eAAa;AACX,OAAI,UAAU,OACZ,sBAAqB,MAAM;AAE7B,UAAO,oBAAoB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;AACjE,UAAO,oBAAoB,UAAU,SAAS;AAC9C,mBAAgB,YAAY;AAC5B,qBAAkB,YAAY;;IAE/B,CAAC,WAAW,CAAC;AAEhB,QAAO"}
@@ -1,5 +1,10 @@
1
1
  //#region src/hooks/use-element-scroll.d.ts
2
- declare const useElementScroll: () => {
2
+ interface Options {
3
+ threshold?: number;
4
+ }
5
+ declare const useElementScroll: ({
6
+ threshold
7
+ }?: Options) => {
3
8
  element: HTMLElement | null;
4
9
  setRef: (el: HTMLElement | null) => void;
5
10
  scrollY: number;
@@ -1,7 +1,8 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  //#region src/hooks/use-element-scroll.ts
4
- const useElementScroll = () => {
4
+ const scrollPositionEqual = (a, b) => a.scrollY === b.scrollY && a.scrollPercentage === b.scrollPercentage && a.isAtTop === b.isAtTop && a.isAtBottom === b.isAtBottom && a.scrollableHeight === b.scrollableHeight && a.clientHeight === b.clientHeight && a.scrollHeight === b.scrollHeight;
5
+ const useElementScroll = ({ threshold = 1 } = {}) => {
5
6
  const [element, setElement] = useState(null);
6
7
  const [scrollPosition, setScrollPosition] = useState({
7
8
  scrollY: 0,
@@ -12,16 +13,22 @@ const useElementScroll = () => {
12
13
  clientHeight: 0,
13
14
  scrollHeight: 0
14
15
  });
16
+ const scrollPositionRef = useRef(scrollPosition);
15
17
  const setRef = useCallback((el) => {
16
18
  setElement(el);
17
19
  }, []);
18
20
  useEffect(() => {
19
21
  if (!element) return;
22
+ const commit = (next) => {
23
+ if (scrollPositionEqual(scrollPositionRef.current, next)) return;
24
+ scrollPositionRef.current = next;
25
+ setScrollPosition(next);
26
+ };
20
27
  const updateScrollPosition = () => {
21
28
  const { scrollTop, scrollHeight, clientHeight } = element;
22
29
  const scrollableHeight = scrollHeight - clientHeight;
23
30
  if (scrollableHeight <= 0) {
24
- setScrollPosition({
31
+ commit({
25
32
  scrollY: 0,
26
33
  scrollPercentage: 0,
27
34
  isAtTop: true,
@@ -32,11 +39,11 @@ const useElementScroll = () => {
32
39
  });
33
40
  return;
34
41
  }
35
- setScrollPosition({
42
+ commit({
36
43
  scrollY: scrollTop,
37
44
  scrollPercentage: Math.min(100, Math.max(0, scrollTop / scrollableHeight * 100)),
38
45
  isAtTop: scrollTop <= 0,
39
- isAtBottom: scrollTop >= scrollableHeight - 1,
46
+ isAtBottom: scrollTop >= scrollableHeight - threshold,
40
47
  scrollableHeight,
41
48
  clientHeight,
42
49
  scrollHeight
@@ -50,12 +57,27 @@ const useElementScroll = () => {
50
57
  const resizeObserver = new ResizeObserver(() => {
51
58
  updateScrollPosition();
52
59
  });
53
- resizeObserver.observe(element);
60
+ const observeChildren = () => {
61
+ resizeObserver.observe(element);
62
+ Array.from(element.children).forEach((child) => {
63
+ resizeObserver.observe(child);
64
+ });
65
+ };
66
+ observeChildren();
67
+ const mutationObserver = new MutationObserver(() => {
68
+ observeChildren();
69
+ updateScrollPosition();
70
+ });
71
+ mutationObserver.observe(element, {
72
+ childList: true,
73
+ subtree: true
74
+ });
54
75
  return () => {
55
76
  element.removeEventListener("scroll", onScroll);
56
- resizeObserver.unobserve(element);
77
+ resizeObserver.disconnect();
78
+ mutationObserver.disconnect();
57
79
  };
58
- }, [element]);
80
+ }, [element, threshold]);
59
81
  return {
60
82
  ...scrollPosition,
61
83
  element,
@@ -1 +1 @@
1
- {"version":3,"file":"use-element-scroll.mjs","names":[],"sources":["../../src/hooks/use-element-scroll.ts"],"sourcesContent":["import { useCallback, useEffect, useState } from 'react';\n\ninterface ScrollPosition {\n scrollY: number;\n scrollPercentage: number;\n isAtTop: boolean;\n isAtBottom: boolean;\n scrollableHeight: number;\n clientHeight: number;\n scrollHeight: number;\n}\n\nconst useElementScroll = () => {\n const [element, setElement] = useState<HTMLElement | null>(null);\n const [scrollPosition, setScrollPosition] = useState<ScrollPosition>({\n scrollY: 0,\n scrollPercentage: 0,\n isAtTop: true,\n isAtBottom: false,\n scrollableHeight: 0,\n clientHeight: 0,\n scrollHeight: 0,\n });\n\n const setRef = useCallback((el: HTMLElement | null) => {\n setElement(el);\n }, []);\n\n useEffect(() => {\n if (!element) {\n return;\n }\n\n const updateScrollPosition = () => {\n const { scrollTop, scrollHeight, clientHeight } = element;\n const scrollableHeight = scrollHeight - clientHeight;\n\n if (scrollableHeight <= 0) {\n setScrollPosition({\n scrollY: 0,\n scrollPercentage: 0,\n isAtTop: true,\n isAtBottom: true,\n scrollableHeight: 0,\n clientHeight,\n scrollHeight,\n });\n return;\n }\n\n const percentage = Math.min(\n 100,\n Math.max(0, (scrollTop / scrollableHeight) * 100),\n );\n\n setScrollPosition({\n scrollY: scrollTop,\n scrollPercentage: percentage,\n isAtTop: scrollTop <= 0,\n isAtBottom: scrollTop >= scrollableHeight - 1,\n scrollableHeight,\n clientHeight,\n scrollHeight,\n });\n };\n\n updateScrollPosition();\n\n const onScroll = () => {\n updateScrollPosition();\n };\n\n element.addEventListener('scroll', onScroll, { passive: true });\n\n const resizeObserver = new ResizeObserver(() => {\n updateScrollPosition();\n });\n\n resizeObserver.observe(element);\n\n return () => {\n element.removeEventListener('scroll', onScroll);\n resizeObserver.unobserve(element);\n };\n }, [element]);\n\n return { ...scrollPosition, element, setRef };\n};\n\nexport default useElementScroll;\n"],"mappings":";;;AAYA,MAAM,yBAAyB;CAC7B,MAAM,CAAC,SAAS,cAAc,SAA6B,KAAK;CAChE,MAAM,CAAC,gBAAgB,qBAAqB,SAAyB;EACnE,SAAS;EACT,kBAAkB;EAClB,SAAS;EACT,YAAY;EACZ,kBAAkB;EAClB,cAAc;EACd,cAAc;EACf,CAAC;CAEF,MAAM,SAAS,aAAa,OAA2B;AACrD,aAAW,GAAG;IACb,EAAE,CAAC;AAEN,iBAAgB;AACd,MAAI,CAAC,QACH;EAGF,MAAM,6BAA6B;GACjC,MAAM,EAAE,WAAW,cAAc,iBAAiB;GAClD,MAAM,mBAAmB,eAAe;AAExC,OAAI,oBAAoB,GAAG;AACzB,sBAAkB;KAChB,SAAS;KACT,kBAAkB;KAClB,SAAS;KACT,YAAY;KACZ,kBAAkB;KAClB;KACA;KACD,CAAC;AACF;;AAQF,qBAAkB;IAChB,SAAS;IACT,kBAPiB,KAAK,IACtB,KACA,KAAK,IAAI,GAAI,YAAY,mBAAoB,IAAI,CAClD;IAKC,SAAS,aAAa;IACtB,YAAY,aAAa,mBAAmB;IAC5C;IACA;IACA;IACD,CAAC;;AAGJ,wBAAsB;EAEtB,MAAM,iBAAiB;AACrB,yBAAsB;;AAGxB,UAAQ,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;EAE/D,MAAM,iBAAiB,IAAI,qBAAqB;AAC9C,yBAAsB;IACtB;AAEF,iBAAe,QAAQ,QAAQ;AAE/B,eAAa;AACX,WAAQ,oBAAoB,UAAU,SAAS;AAC/C,kBAAe,UAAU,QAAQ;;IAElC,CAAC,QAAQ,CAAC;AAEb,QAAO;EAAE,GAAG;EAAgB;EAAS;EAAQ"}
1
+ {"version":3,"file":"use-element-scroll.mjs","names":[],"sources":["../../src/hooks/use-element-scroll.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\ninterface ScrollPosition {\n scrollY: number;\n scrollPercentage: number;\n isAtTop: boolean;\n isAtBottom: boolean;\n scrollableHeight: number;\n clientHeight: number;\n scrollHeight: number;\n}\n\nconst scrollPositionEqual = (a: ScrollPosition, b: ScrollPosition) =>\n a.scrollY === b.scrollY &&\n a.scrollPercentage === b.scrollPercentage &&\n a.isAtTop === b.isAtTop &&\n a.isAtBottom === b.isAtBottom &&\n a.scrollableHeight === b.scrollableHeight &&\n a.clientHeight === b.clientHeight &&\n a.scrollHeight === b.scrollHeight;\n\ninterface Options {\n // How close to the bottom (in px) still counts as \"at bottom\". Matches\n // the previous hardcoded value by default.\n threshold?: number;\n}\n\nconst useElementScroll = ({ threshold = 1 }: Options = {}) => {\n const [element, setElement] = useState<HTMLElement | null>(null);\n const [scrollPosition, setScrollPosition] = useState<ScrollPosition>({\n scrollY: 0,\n scrollPercentage: 0,\n isAtTop: true,\n isAtBottom: false,\n scrollableHeight: 0,\n clientHeight: 0,\n scrollHeight: 0,\n });\n const scrollPositionRef = useRef(scrollPosition);\n\n const setRef = useCallback((el: HTMLElement | null) => {\n setElement(el);\n }, []);\n\n useEffect(() => {\n if (!element) {\n return;\n }\n\n const commit = (next: ScrollPosition) => {\n if (scrollPositionEqual(scrollPositionRef.current, next)) {\n return;\n }\n scrollPositionRef.current = next;\n setScrollPosition(next);\n };\n\n const updateScrollPosition = () => {\n const { scrollTop, scrollHeight, clientHeight } = element;\n const scrollableHeight = scrollHeight - clientHeight;\n\n if (scrollableHeight <= 0) {\n commit({\n scrollY: 0,\n scrollPercentage: 0,\n isAtTop: true,\n isAtBottom: true,\n scrollableHeight: 0,\n clientHeight,\n scrollHeight,\n });\n return;\n }\n\n const percentage = Math.min(\n 100,\n Math.max(0, (scrollTop / scrollableHeight) * 100),\n );\n\n commit({\n scrollY: scrollTop,\n scrollPercentage: percentage,\n isAtTop: scrollTop <= 0,\n isAtBottom: scrollTop >= scrollableHeight - threshold,\n scrollableHeight,\n clientHeight,\n scrollHeight,\n });\n };\n\n updateScrollPosition();\n\n const onScroll = () => {\n updateScrollPosition();\n };\n\n element.addEventListener('scroll', onScroll, { passive: true });\n\n const resizeObserver = new ResizeObserver(() => {\n updateScrollPosition();\n });\n\n // Observing only the container misses content that grows/shrinks\n // `scrollHeight` without resizing the container's own box (a list\n // gaining items, an image finishing its load) — also watch every\n // child so their own size changes are caught.\n const observeChildren = () => {\n resizeObserver.observe(element);\n Array.from(element.children).forEach(child => {\n resizeObserver.observe(child);\n });\n };\n\n observeChildren();\n\n // Catches children being added/removed (re-syncs which elements the\n // ResizeObserver above watches, and recalculates immediately) —\n // ResizeObserver alone doesn't see brand new children until this runs.\n const mutationObserver = new MutationObserver(() => {\n observeChildren();\n updateScrollPosition();\n });\n mutationObserver.observe(element, { childList: true, subtree: true });\n\n return () => {\n element.removeEventListener('scroll', onScroll);\n resizeObserver.disconnect();\n mutationObserver.disconnect();\n };\n }, [element, threshold]);\n\n return { ...scrollPosition, element, setRef };\n};\n\nexport default useElementScroll;\n"],"mappings":";;;AAYA,MAAM,uBAAuB,GAAmB,MAC9C,EAAE,YAAY,EAAE,WAChB,EAAE,qBAAqB,EAAE,oBACzB,EAAE,YAAY,EAAE,WAChB,EAAE,eAAe,EAAE,cACnB,EAAE,qBAAqB,EAAE,oBACzB,EAAE,iBAAiB,EAAE,gBACrB,EAAE,iBAAiB,EAAE;AAQvB,MAAM,oBAAoB,EAAE,YAAY,MAAe,EAAE,KAAK;CAC5D,MAAM,CAAC,SAAS,cAAc,SAA6B,KAAK;CAChE,MAAM,CAAC,gBAAgB,qBAAqB,SAAyB;EACnE,SAAS;EACT,kBAAkB;EAClB,SAAS;EACT,YAAY;EACZ,kBAAkB;EAClB,cAAc;EACd,cAAc;EACf,CAAC;CACF,MAAM,oBAAoB,OAAO,eAAe;CAEhD,MAAM,SAAS,aAAa,OAA2B;AACrD,aAAW,GAAG;IACb,EAAE,CAAC;AAEN,iBAAgB;AACd,MAAI,CAAC,QACH;EAGF,MAAM,UAAU,SAAyB;AACvC,OAAI,oBAAoB,kBAAkB,SAAS,KAAK,CACtD;AAEF,qBAAkB,UAAU;AAC5B,qBAAkB,KAAK;;EAGzB,MAAM,6BAA6B;GACjC,MAAM,EAAE,WAAW,cAAc,iBAAiB;GAClD,MAAM,mBAAmB,eAAe;AAExC,OAAI,oBAAoB,GAAG;AACzB,WAAO;KACL,SAAS;KACT,kBAAkB;KAClB,SAAS;KACT,YAAY;KACZ,kBAAkB;KAClB;KACA;KACD,CAAC;AACF;;AAQF,UAAO;IACL,SAAS;IACT,kBAPiB,KAAK,IACtB,KACA,KAAK,IAAI,GAAI,YAAY,mBAAoB,IAAI,CAClD;IAKC,SAAS,aAAa;IACtB,YAAY,aAAa,mBAAmB;IAC5C;IACA;IACA;IACD,CAAC;;AAGJ,wBAAsB;EAEtB,MAAM,iBAAiB;AACrB,yBAAsB;;AAGxB,UAAQ,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;EAE/D,MAAM,iBAAiB,IAAI,qBAAqB;AAC9C,yBAAsB;IACtB;EAMF,MAAM,wBAAwB;AAC5B,kBAAe,QAAQ,QAAQ;AAC/B,SAAM,KAAK,QAAQ,SAAS,CAAC,SAAQ,UAAS;AAC5C,mBAAe,QAAQ,MAAM;KAC7B;;AAGJ,mBAAiB;EAKjB,MAAM,mBAAmB,IAAI,uBAAuB;AAClD,oBAAiB;AACjB,yBAAsB;IACtB;AACF,mBAAiB,QAAQ,SAAS;GAAE,WAAW;GAAM,SAAS;GAAM,CAAC;AAErE,eAAa;AACX,WAAQ,oBAAoB,UAAU,SAAS;AAC/C,kBAAe,YAAY;AAC3B,oBAAiB,YAAY;;IAE9B,CAAC,SAAS,UAAU,CAAC;AAExB,QAAO;EAAE,GAAG;EAAgB;EAAS;EAAQ"}
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
 
3
3
  //#region src/hooks/use-multi-select.ts
4
4
  const useMultiSelect = (count) => {
@@ -8,27 +8,36 @@ const useMultiSelect = (count) => {
8
8
  const next = new Set([...rawSelected].filter((index) => index < count));
9
9
  return next.size === rawSelected.size ? rawSelected : next;
10
10
  }, [rawSelected, count]);
11
+ useEffect(() => {
12
+ setRawSelected((prev) => {
13
+ const next = new Set([...prev].filter((index) => index < count));
14
+ return next.size === prev.size ? prev : next;
15
+ });
16
+ setAnchor((prev) => prev !== null && prev >= count ? null : prev);
17
+ }, [count]);
11
18
  const toggle = useCallback((index, shiftKey = false) => {
19
+ if (index < 0 || index >= count) return;
12
20
  setRawSelected((prev) => {
13
- const next = new Set(prev);
14
21
  if (shiftKey && anchor !== null) {
15
22
  const [start, end] = anchor < index ? [anchor, index] : [index, anchor];
23
+ const next = /* @__PURE__ */ new Set();
16
24
  for (let i = start; i <= end; i++) next.add(i);
17
25
  return next;
18
26
  }
27
+ const next = new Set(prev);
19
28
  if (next.has(index)) next.delete(index);
20
29
  else next.add(index);
21
30
  return next;
22
31
  });
23
32
  setAnchor(index);
24
- }, [anchor]);
33
+ }, [anchor, count]);
25
34
  const clear = useCallback(() => {
26
35
  setRawSelected(/* @__PURE__ */ new Set());
27
36
  setAnchor(null);
28
37
  }, []);
29
38
  const replace = useCallback((indices) => {
30
- setRawSelected(indices);
31
- }, []);
39
+ setRawSelected(new Set([...indices].filter((index) => index >= 0 && index < count)));
40
+ }, [count]);
32
41
  return {
33
42
  selected,
34
43
  isSelected: useCallback((index) => selected.has(index), [selected]),
@@ -1 +1 @@
1
- {"version":3,"file":"use-multi-select.mjs","names":[],"sources":["../../src/hooks/use-multi-select.ts"],"sourcesContent":["import { useCallback, useMemo, useState } from 'react';\n\nconst useMultiSelect = (count: number) => {\n const [rawSelected, setRawSelected] = useState<Set<number>>(new Set());\n const [anchor, setAnchor] = useState<number | null>(null);\n\n // Indices can outlive the item they pointed to (e.g. after the backing\n // list shrinks) — clamp against the current count on every render\n // instead of syncing state back via an effect.\n const selected = useMemo(() => {\n const next = new Set([...rawSelected].filter(index => index < count));\n return next.size === rawSelected.size ? rawSelected : next;\n }, [rawSelected, count]);\n\n const toggle = useCallback(\n (index: number, shiftKey = false) => {\n setRawSelected(prev => {\n const next = new Set(prev);\n\n if (shiftKey && anchor !== null) {\n const [start, end] =\n anchor < index ? [anchor, index] : [index, anchor];\n\n for (let i = start; i <= end; i++) {\n next.add(i);\n }\n return next;\n }\n\n if (next.has(index)) {\n next.delete(index);\n } else {\n next.add(index);\n }\n return next;\n });\n setAnchor(index);\n },\n [anchor],\n );\n\n const clear = useCallback(() => {\n setRawSelected(new Set());\n setAnchor(null);\n }, []);\n\n const replace = useCallback((indices: Set<number>) => {\n setRawSelected(indices);\n }, []);\n\n const isSelected = useCallback(\n (index: number) => selected.has(index),\n [selected],\n );\n\n return {\n selected,\n isSelected,\n toggle,\n clear,\n replace,\n } as const;\n};\n\nexport default useMultiSelect;\n"],"mappings":";;;AAEA,MAAM,kBAAkB,UAAkB;CACxC,MAAM,CAAC,aAAa,kBAAkB,yBAAsB,IAAI,KAAK,CAAC;CACtE,MAAM,CAAC,QAAQ,aAAa,SAAwB,KAAK;CAKzD,MAAM,WAAW,cAAc;EAC7B,MAAM,OAAO,IAAI,IAAI,CAAC,GAAG,YAAY,CAAC,QAAO,UAAS,QAAQ,MAAM,CAAC;AACrE,SAAO,KAAK,SAAS,YAAY,OAAO,cAAc;IACrD,CAAC,aAAa,MAAM,CAAC;CAExB,MAAM,SAAS,aACZ,OAAe,WAAW,UAAU;AACnC,kBAAe,SAAQ;GACrB,MAAM,OAAO,IAAI,IAAI,KAAK;AAE1B,OAAI,YAAY,WAAW,MAAM;IAC/B,MAAM,CAAC,OAAO,OACZ,SAAS,QAAQ,CAAC,QAAQ,MAAM,GAAG,CAAC,OAAO,OAAO;AAEpD,SAAK,IAAI,IAAI,OAAO,KAAK,KAAK,IAC5B,MAAK,IAAI,EAAE;AAEb,WAAO;;AAGT,OAAI,KAAK,IAAI,MAAM,CACjB,MAAK,OAAO,MAAM;OAElB,MAAK,IAAI,MAAM;AAEjB,UAAO;IACP;AACF,YAAU,MAAM;IAElB,CAAC,OAAO,CACT;CAED,MAAM,QAAQ,kBAAkB;AAC9B,iCAAe,IAAI,KAAK,CAAC;AACzB,YAAU,KAAK;IACd,EAAE,CAAC;CAEN,MAAM,UAAU,aAAa,YAAyB;AACpD,iBAAe,QAAQ;IACtB,EAAE,CAAC;AAON,QAAO;EACL;EACA,YAPiB,aAChB,UAAkB,SAAS,IAAI,MAAM,EACtC,CAAC,SAAS,CACX;EAKC;EACA;EACA;EACD"}
1
+ {"version":3,"file":"use-multi-select.mjs","names":[],"sources":["../../src/hooks/use-multi-select.ts"],"sourcesContent":["import { useCallback, useEffect, useMemo, useState } from 'react';\n\nconst useMultiSelect = (count: number) => {\n const [rawSelected, setRawSelected] = useState<Set<number>>(new Set());\n const [anchor, setAnchor] = useState<number | null>(null);\n\n const selected = useMemo(() => {\n const next = new Set([...rawSelected].filter(index => index < count));\n return next.size === rawSelected.size ? rawSelected : next;\n }, [rawSelected, count]);\n\n // Clamp `rawSelected`/`anchor` themselves (not just the derived\n // `selected` view above) whenever `count` shrinks — otherwise\n // out-of-range indices and a stale anchor keep accumulating in state\n // indefinitely instead of actually being dropped.\n useEffect(() => {\n setRawSelected(prev => {\n const next = new Set([...prev].filter(index => index < count));\n return next.size === prev.size ? prev : next;\n });\n setAnchor(prev => (prev !== null && prev >= count ? null : prev));\n }, [count]);\n\n const toggle = useCallback(\n (index: number, shiftKey = false) => {\n if (index < 0 || index >= count) {\n return;\n }\n\n setRawSelected(prev => {\n if (shiftKey && anchor !== null) {\n const [start, end] =\n anchor < index ? [anchor, index] : [index, anchor];\n\n // Range-select replaces the previous selection, matching the\n // usual file-explorer/table convention, instead of adding to\n // it — otherwise repeated shift-clicks just accumulate every\n // range ever selected rather than re-anchoring to the new one.\n const next = new Set<number>();\n for (let i = start; i <= end; i++) {\n next.add(i);\n }\n return next;\n }\n\n const next = new Set(prev);\n if (next.has(index)) {\n next.delete(index);\n } else {\n next.add(index);\n }\n return next;\n });\n setAnchor(index);\n },\n [anchor, count],\n );\n\n const clear = useCallback(() => {\n setRawSelected(new Set());\n setAnchor(null);\n }, []);\n\n const replace = useCallback(\n (indices: Set<number>) => {\n setRawSelected(\n new Set([...indices].filter(index => index >= 0 && index < count)),\n );\n },\n [count],\n );\n\n const isSelected = useCallback(\n (index: number) => selected.has(index),\n [selected],\n );\n\n return {\n selected,\n isSelected,\n toggle,\n clear,\n replace,\n } as const;\n};\n\nexport default useMultiSelect;\n"],"mappings":";;;AAEA,MAAM,kBAAkB,UAAkB;CACxC,MAAM,CAAC,aAAa,kBAAkB,yBAAsB,IAAI,KAAK,CAAC;CACtE,MAAM,CAAC,QAAQ,aAAa,SAAwB,KAAK;CAEzD,MAAM,WAAW,cAAc;EAC7B,MAAM,OAAO,IAAI,IAAI,CAAC,GAAG,YAAY,CAAC,QAAO,UAAS,QAAQ,MAAM,CAAC;AACrE,SAAO,KAAK,SAAS,YAAY,OAAO,cAAc;IACrD,CAAC,aAAa,MAAM,CAAC;AAMxB,iBAAgB;AACd,kBAAe,SAAQ;GACrB,MAAM,OAAO,IAAI,IAAI,CAAC,GAAG,KAAK,CAAC,QAAO,UAAS,QAAQ,MAAM,CAAC;AAC9D,UAAO,KAAK,SAAS,KAAK,OAAO,OAAO;IACxC;AACF,aAAU,SAAS,SAAS,QAAQ,QAAQ,QAAQ,OAAO,KAAM;IAChE,CAAC,MAAM,CAAC;CAEX,MAAM,SAAS,aACZ,OAAe,WAAW,UAAU;AACnC,MAAI,QAAQ,KAAK,SAAS,MACxB;AAGF,kBAAe,SAAQ;AACrB,OAAI,YAAY,WAAW,MAAM;IAC/B,MAAM,CAAC,OAAO,OACZ,SAAS,QAAQ,CAAC,QAAQ,MAAM,GAAG,CAAC,OAAO,OAAO;IAMpD,MAAM,uBAAO,IAAI,KAAa;AAC9B,SAAK,IAAI,IAAI,OAAO,KAAK,KAAK,IAC5B,MAAK,IAAI,EAAE;AAEb,WAAO;;GAGT,MAAM,OAAO,IAAI,IAAI,KAAK;AAC1B,OAAI,KAAK,IAAI,MAAM,CACjB,MAAK,OAAO,MAAM;OAElB,MAAK,IAAI,MAAM;AAEjB,UAAO;IACP;AACF,YAAU,MAAM;IAElB,CAAC,QAAQ,MAAM,CAChB;CAED,MAAM,QAAQ,kBAAkB;AAC9B,iCAAe,IAAI,KAAK,CAAC;AACzB,YAAU,KAAK;IACd,EAAE,CAAC;CAEN,MAAM,UAAU,aACb,YAAyB;AACxB,iBACE,IAAI,IAAI,CAAC,GAAG,QAAQ,CAAC,QAAO,UAAS,SAAS,KAAK,QAAQ,MAAM,CAAC,CACnE;IAEH,CAAC,MAAM,CACR;AAOD,QAAO;EACL;EACA,YAPiB,aAChB,UAAkB,SAAS,IAAI,MAAM,EACtC,CAAC,SAAS,CACX;EAKC;EACA;EACA;EACD"}
@@ -12,6 +12,7 @@ interface BreakpointInfo {
12
12
  interface Options {
13
13
  delay?: number;
14
14
  container?: HTMLElement | null;
15
+ viewport?: boolean;
15
16
  }
16
17
  declare const useResponsiveSize: <T extends HTMLElement>(options?: Options) => {
17
18
  size: {
@@ -1,5 +1,5 @@
1
1
  import useDebounce from "./use-debounce.mjs";
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
 
4
4
  //#region src/hooks/use-responsive-size.ts
5
5
  const BREAKPOINTS = {
@@ -28,76 +28,92 @@ const getBreakpointInfo = (width) => {
28
28
  "2xl": width >= BREAKPOINTS["2xl"]
29
29
  };
30
30
  };
31
+ const breakpointEqual = (a, b) => a.current === b.current && a.xs === b.xs && a.sm === b.sm && a.md === b.md && a.lg === b.lg && a.xl === b.xl && a["2xl"] === b["2xl"];
32
+ const measureTarget = (target) => ({
33
+ width: target.offsetWidth,
34
+ height: target.offsetHeight
35
+ });
36
+ const measureViewport = () => ({
37
+ width: window.innerWidth,
38
+ height: window.innerHeight
39
+ });
31
40
  const useResponsiveSize = (options) => {
32
- const { delay = 100, container } = options || {};
41
+ const { delay = 100, container, viewport = false } = options || {};
42
+ const [element, setElement] = useState(null);
33
43
  const [size, setSize] = useState({
34
44
  width: 0,
35
45
  height: 0
36
46
  });
37
- const [breakpoint, setBreakpoint] = useState({
38
- current: "xs",
39
- xs: true,
40
- sm: false,
41
- md: false,
42
- lg: false,
43
- xl: false,
44
- "2xl": false
47
+ const [breakpoint, setBreakpoint] = useState(() => getBreakpointInfo(0));
48
+ const observerRef = useRef(null);
49
+ const latestRef = useRef({
50
+ width: 0,
51
+ height: 0
45
52
  });
46
- const [debouncedSize, setDebouncedSize] = useState({
53
+ const committedRef = useRef({
47
54
  width: 0,
48
55
  height: 0
49
56
  });
50
- const [element, setElement] = useState(null);
51
- const observerRef = useRef(null);
52
57
  const ref = useCallback((node) => {
53
58
  setElement(node);
54
59
  }, []);
55
- useDebounce(() => {
56
- setDebouncedSize(size);
57
- }, { delay }, [size]);
58
- useEffect(() => {
59
- const updateSize = () => {
60
- const target = container ?? element ?? document.body;
61
- if (!target) return;
62
- const { offsetWidth, offsetHeight } = target;
63
- setSize((prev) => {
64
- if (prev.width !== offsetWidth || prev.height !== offsetHeight) return {
65
- width: offsetWidth,
66
- height: offsetHeight
67
- };
68
- return prev;
69
- });
70
- setBreakpoint((prev) => {
71
- const next = getBreakpointInfo(offsetWidth);
72
- if (prev.current !== next.current) return next;
73
- return prev;
74
- });
75
- };
76
- const connect = () => {
60
+ const commit = useCallback(() => {
61
+ const next = latestRef.current;
62
+ if (committedRef.current.width === next.width && committedRef.current.height === next.height) return;
63
+ committedRef.current = next;
64
+ setSize(next);
65
+ const nextBreakpoint = getBreakpointInfo(next.width);
66
+ setBreakpoint((prev) => breakpointEqual(prev, nextBreakpoint) ? prev : nextBreakpoint);
67
+ }, []);
68
+ const debouncedCommit = useDebounce(commit, {
69
+ delay,
70
+ autoInvoke: false
71
+ });
72
+ useLayoutEffect(() => {
73
+ const measured = viewport ? measureViewport() : (() => {
77
74
  const target = container ?? element ?? document.body;
78
- if (!target) return;
79
- updateSize();
80
- if (observerRef.current) observerRef.current.disconnect();
81
- observerRef.current = new ResizeObserver(() => {
82
- requestAnimationFrame(() => {
83
- updateSize();
84
- });
75
+ return target ? measureTarget(target) : null;
76
+ })();
77
+ if (!measured) return;
78
+ latestRef.current = measured;
79
+ commit();
80
+ }, [
81
+ container,
82
+ element,
83
+ viewport,
84
+ commit
85
+ ]);
86
+ useEffect(() => {
87
+ if (viewport) {
88
+ const onResize = () => {
89
+ latestRef.current = measureViewport();
90
+ debouncedCommit();
91
+ };
92
+ window.addEventListener("resize", onResize);
93
+ return () => window.removeEventListener("resize", onResize);
94
+ }
95
+ const target = container ?? element ?? document.body;
96
+ if (!target) return;
97
+ if (observerRef.current) observerRef.current.disconnect();
98
+ observerRef.current = new ResizeObserver(() => {
99
+ requestAnimationFrame(() => {
100
+ latestRef.current = measureTarget(target);
101
+ debouncedCommit();
85
102
  });
86
- observerRef.current.observe(target);
87
- };
88
- const disconnect = () => {
89
- if (observerRef.current) {
90
- observerRef.current.disconnect();
91
- observerRef.current = null;
92
- }
93
- };
94
- connect();
103
+ });
104
+ observerRef.current.observe(target);
95
105
  return () => {
96
- disconnect();
106
+ observerRef.current?.disconnect();
107
+ observerRef.current = null;
97
108
  };
98
- }, [container, element]);
109
+ }, [
110
+ container,
111
+ element,
112
+ viewport,
113
+ debouncedCommit
114
+ ]);
99
115
  return {
100
- size: debouncedSize,
116
+ size,
101
117
  breakpoint,
102
118
  ref
103
119
  };
@@ -1 +1 @@
1
- {"version":3,"file":"use-responsive-size.mjs","names":[],"sources":["../../src/hooks/use-responsive-size.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\nimport useDebounce from './use-debounce';\n\ntype Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';\n\ninterface BreakpointInfo {\n current: Breakpoint;\n xs: boolean;\n sm: boolean;\n md: boolean;\n lg: boolean;\n xl: boolean;\n '2xl': boolean;\n}\n\ninterface Options {\n delay?: number;\n container?: HTMLElement | null;\n}\n\nconst BREAKPOINTS = {\n xs: 0, // < 640px\n sm: 640, // >= 640px\n md: 768, // >= 768px\n lg: 1024, // >= 1024px\n xl: 1280, // >= 1280px\n '2xl': 1536, // >= 1536px\n} as const;\n\nconst getBreakpointInfo = (width: number): BreakpointInfo => {\n let current: Breakpoint = 'xs';\n\n if (width >= BREAKPOINTS['2xl']) {\n current = '2xl';\n } else if (width >= BREAKPOINTS.xl) {\n current = 'xl';\n } else if (width >= BREAKPOINTS.lg) {\n current = 'lg';\n } else if (width >= BREAKPOINTS.md) {\n current = 'md';\n } else if (width >= BREAKPOINTS.sm) {\n current = 'sm';\n } else {\n current = 'xs';\n }\n\n return {\n current,\n xs: width < BREAKPOINTS.sm,\n sm: width >= BREAKPOINTS.sm && width < BREAKPOINTS.md,\n md: width >= BREAKPOINTS.md && width < BREAKPOINTS.lg,\n lg: width >= BREAKPOINTS.lg && width < BREAKPOINTS.xl,\n xl: width >= BREAKPOINTS.xl && width < BREAKPOINTS['2xl'],\n '2xl': width >= BREAKPOINTS['2xl'],\n };\n};\n\nconst useResponsiveSize = <T extends HTMLElement>(options?: Options) => {\n const { delay = 100, container } = options || {};\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [breakpoint, setBreakpoint] = useState<BreakpointInfo>({\n current: 'xs',\n xs: true,\n sm: false,\n md: false,\n lg: false,\n xl: false,\n '2xl': false,\n });\n\n const [debouncedSize, setDebouncedSize] = useState({ width: 0, height: 0 });\n\n const [element, setElement] = useState<T | null>(null);\n const observerRef = useRef<ResizeObserver | null>(null);\n\n const ref = useCallback((node: T | null) => {\n setElement(node);\n }, []);\n\n useDebounce(\n () => {\n setDebouncedSize(size);\n },\n { delay },\n [size],\n );\n\n useEffect(() => {\n const updateSize = () => {\n const target = container ?? element ?? document.body;\n\n if (!target) {\n return;\n }\n\n const { offsetWidth, offsetHeight } = target;\n\n setSize(prev => {\n if (prev.width !== offsetWidth || prev.height !== offsetHeight) {\n return { width: offsetWidth, height: offsetHeight };\n }\n return prev;\n });\n\n setBreakpoint(prev => {\n const next = getBreakpointInfo(offsetWidth);\n if (prev.current !== next.current) {\n return next;\n }\n return prev;\n });\n };\n\n const connect = () => {\n const target = container ?? element ?? document.body;\n\n if (!target) {\n return;\n }\n\n updateSize();\n\n if (observerRef.current) {\n observerRef.current.disconnect();\n }\n\n observerRef.current = new ResizeObserver(() => {\n requestAnimationFrame(() => {\n updateSize();\n });\n });\n\n observerRef.current.observe(target);\n };\n\n const disconnect = () => {\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n };\n\n connect();\n\n return () => {\n disconnect();\n };\n }, [container, element]);\n\n return {\n size: debouncedSize,\n breakpoint,\n ref,\n };\n};\n\nexport default useResponsiveSize;\n"],"mappings":";;;;AAqBA,MAAM,cAAc;CAClB,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,IAAI;CACJ,OAAO;CACR;AAED,MAAM,qBAAqB,UAAkC;CAC3D,IAAI,UAAsB;AAE1B,KAAI,SAAS,YAAY,OACvB,WAAU;UACD,SAAS,YAAY,GAC9B,WAAU;UACD,SAAS,YAAY,GAC9B,WAAU;UACD,SAAS,YAAY,GAC9B,WAAU;UACD,SAAS,YAAY,GAC9B,WAAU;KAEV,WAAU;AAGZ,QAAO;EACL;EACA,IAAI,QAAQ,YAAY;EACxB,IAAI,SAAS,YAAY,MAAM,QAAQ,YAAY;EACnD,IAAI,SAAS,YAAY,MAAM,QAAQ,YAAY;EACnD,IAAI,SAAS,YAAY,MAAM,QAAQ,YAAY;EACnD,IAAI,SAAS,YAAY,MAAM,QAAQ,YAAY;EACnD,OAAO,SAAS,YAAY;EAC7B;;AAGH,MAAM,qBAA4C,YAAsB;CACtE,MAAM,EAAE,QAAQ,KAAK,cAAc,WAAW,EAAE;CAChD,MAAM,CAAC,MAAM,WAAW,SAAS;EAAE,OAAO;EAAG,QAAQ;EAAG,CAAC;CACzD,MAAM,CAAC,YAAY,iBAAiB,SAAyB;EAC3D,SAAS;EACT,IAAI;EACJ,IAAI;EACJ,IAAI;EACJ,IAAI;EACJ,IAAI;EACJ,OAAO;EACR,CAAC;CAEF,MAAM,CAAC,eAAe,oBAAoB,SAAS;EAAE,OAAO;EAAG,QAAQ;EAAG,CAAC;CAE3E,MAAM,CAAC,SAAS,cAAc,SAAmB,KAAK;CACtD,MAAM,cAAc,OAA8B,KAAK;CAEvD,MAAM,MAAM,aAAa,SAAmB;AAC1C,aAAW,KAAK;IACf,EAAE,CAAC;AAEN,mBACQ;AACJ,mBAAiB,KAAK;IAExB,EAAE,OAAO,EACT,CAAC,KAAK,CACP;AAED,iBAAgB;EACd,MAAM,mBAAmB;GACvB,MAAM,SAAS,aAAa,WAAW,SAAS;AAEhD,OAAI,CAAC,OACH;GAGF,MAAM,EAAE,aAAa,iBAAiB;AAEtC,YAAQ,SAAQ;AACd,QAAI,KAAK,UAAU,eAAe,KAAK,WAAW,aAChD,QAAO;KAAE,OAAO;KAAa,QAAQ;KAAc;AAErD,WAAO;KACP;AAEF,kBAAc,SAAQ;IACpB,MAAM,OAAO,kBAAkB,YAAY;AAC3C,QAAI,KAAK,YAAY,KAAK,QACxB,QAAO;AAET,WAAO;KACP;;EAGJ,MAAM,gBAAgB;GACpB,MAAM,SAAS,aAAa,WAAW,SAAS;AAEhD,OAAI,CAAC,OACH;AAGF,eAAY;AAEZ,OAAI,YAAY,QACd,aAAY,QAAQ,YAAY;AAGlC,eAAY,UAAU,IAAI,qBAAqB;AAC7C,gCAA4B;AAC1B,iBAAY;MACZ;KACF;AAEF,eAAY,QAAQ,QAAQ,OAAO;;EAGrC,MAAM,mBAAmB;AACvB,OAAI,YAAY,SAAS;AACvB,gBAAY,QAAQ,YAAY;AAChC,gBAAY,UAAU;;;AAI1B,WAAS;AAET,eAAa;AACX,eAAY;;IAEb,CAAC,WAAW,QAAQ,CAAC;AAExB,QAAO;EACL,MAAM;EACN;EACA;EACD"}
1
+ {"version":3,"file":"use-responsive-size.mjs","names":[],"sources":["../../src/hooks/use-responsive-size.ts"],"sourcesContent":["import {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n} from 'react';\n\nimport useDebounce from './use-debounce';\n\ntype Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';\n\ninterface BreakpointInfo {\n current: Breakpoint;\n xs: boolean;\n sm: boolean;\n md: boolean;\n lg: boolean;\n xl: boolean;\n '2xl': boolean;\n}\n\ninterface Options {\n delay?: number;\n container?: HTMLElement | null;\n // Measure the viewport (window.innerWidth/innerHeight) instead of an\n // element/document.body. Useful when no `container`/ref is attached and\n // the breakpoint should reflect the viewport rather than document.body's\n // box, which can diverge from it if body has margin/transform.\n viewport?: boolean;\n}\n\nconst BREAKPOINTS = {\n xs: 0, // < 640px\n sm: 640, // >= 640px\n md: 768, // >= 768px\n lg: 1024, // >= 1024px\n xl: 1280, // >= 1280px\n '2xl': 1536, // >= 1536px\n} as const;\n\nconst getBreakpointInfo = (width: number): BreakpointInfo => {\n let current: Breakpoint = 'xs';\n\n if (width >= BREAKPOINTS['2xl']) {\n current = '2xl';\n } else if (width >= BREAKPOINTS.xl) {\n current = 'xl';\n } else if (width >= BREAKPOINTS.lg) {\n current = 'lg';\n } else if (width >= BREAKPOINTS.md) {\n current = 'md';\n } else if (width >= BREAKPOINTS.sm) {\n current = 'sm';\n } else {\n current = 'xs';\n }\n\n return {\n current,\n xs: width < BREAKPOINTS.sm,\n sm: width >= BREAKPOINTS.sm && width < BREAKPOINTS.md,\n md: width >= BREAKPOINTS.md && width < BREAKPOINTS.lg,\n lg: width >= BREAKPOINTS.lg && width < BREAKPOINTS.xl,\n xl: width >= BREAKPOINTS.xl && width < BREAKPOINTS['2xl'],\n '2xl': width >= BREAKPOINTS['2xl'],\n };\n};\n\nconst breakpointEqual = (a: BreakpointInfo, b: BreakpointInfo) =>\n a.current === b.current &&\n a.xs === b.xs &&\n a.sm === b.sm &&\n a.md === b.md &&\n a.lg === b.lg &&\n a.xl === b.xl &&\n a['2xl'] === b['2xl'];\n\nconst measureTarget = (target: HTMLElement) => ({\n width: target.offsetWidth,\n height: target.offsetHeight,\n});\n\nconst measureViewport = () => ({\n width: window.innerWidth,\n height: window.innerHeight,\n});\n\nconst useResponsiveSize = <T extends HTMLElement>(options?: Options) => {\n const { delay = 100, container, viewport = false } = options || {};\n\n const [element, setElement] = useState<T | null>(null);\n const [size, setSize] = useState({ width: 0, height: 0 });\n const [breakpoint, setBreakpoint] = useState<BreakpointInfo>(() =>\n getBreakpointInfo(0),\n );\n\n const observerRef = useRef<ResizeObserver | null>(null);\n const latestRef = useRef({ width: 0, height: 0 });\n const committedRef = useRef({ width: 0, height: 0 });\n\n const ref = useCallback((node: T | null) => {\n setElement(node);\n }, []);\n\n // `size` and `breakpoint` are always committed together here, so\n // consumers never observe one reflecting a newer measurement than the\n // other.\n const commit = useCallback(() => {\n const next = latestRef.current;\n\n if (\n committedRef.current.width === next.width &&\n committedRef.current.height === next.height\n ) {\n return;\n }\n\n committedRef.current = next;\n setSize(next);\n\n const nextBreakpoint = getBreakpointInfo(next.width);\n setBreakpoint(prev =>\n breakpointEqual(prev, nextBreakpoint) ? prev : nextBreakpoint,\n );\n }, []);\n\n // Manual invocation only (`autoInvoke: false`) — reuses useDebounce's\n // debounced-callback machinery instead of duplicating it here.\n const debouncedCommit = useDebounce(commit, { delay, autoInvoke: false });\n\n // Measure synchronously before paint so the very first render reflects\n // the real size/breakpoint instead of the `{0,0}`/`xs` placeholder —\n // only later updates (from the observer/listener below) go through the\n // debounced path.\n useLayoutEffect(() => {\n const measured = viewport\n ? measureViewport()\n : (() => {\n const target = container ?? element ?? document.body;\n return target ? measureTarget(target) : null;\n })();\n\n if (!measured) {\n return;\n }\n\n latestRef.current = measured;\n commit();\n }, [container, element, viewport, commit]);\n\n useEffect(() => {\n if (viewport) {\n const onResize = () => {\n latestRef.current = measureViewport();\n debouncedCommit();\n };\n\n window.addEventListener('resize', onResize);\n return () => window.removeEventListener('resize', onResize);\n }\n\n const target = container ?? element ?? document.body;\n\n if (!target) {\n return;\n }\n\n if (observerRef.current) {\n observerRef.current.disconnect();\n }\n\n observerRef.current = new ResizeObserver(() => {\n requestAnimationFrame(() => {\n latestRef.current = measureTarget(target);\n debouncedCommit();\n });\n });\n\n observerRef.current.observe(target);\n\n return () => {\n observerRef.current?.disconnect();\n observerRef.current = null;\n };\n }, [container, element, viewport, debouncedCommit]);\n\n return {\n size,\n breakpoint,\n ref,\n };\n};\n\nexport default useResponsiveSize;\n"],"mappings":"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"}
@@ -16,7 +16,7 @@ const registerCSSProperty = (name, syntax = "<length>", initialValue = "0px") =>
16
16
  };
17
17
  const buildTimeline = (steps) => {
18
18
  const groups = [];
19
- for (const step of steps) if (step.transition.delay === 0 && groups.length > 0) groups[groups.length - 1]?.push(step);
19
+ for (const step of steps) if ((step.transition.delay ?? 0) === 0 && groups.length > 0) groups[groups.length - 1]?.push(step);
20
20
  else groups.push([step]);
21
21
  return groups;
22
22
  };
@@ -83,12 +83,41 @@ const applyStep = (container, step, skipTransition) => {
83
83
  });
84
84
  return timers;
85
85
  };
86
+ const resetAppliedStyles = (container, steps) => {
87
+ new Set(steps.map((step) => step.selector)).forEach((selector) => {
88
+ (container.matches(selector) ? [container] : Array.from(container.querySelectorAll(selector))).forEach((el) => {
89
+ el.style.transition = "";
90
+ el.style.backgroundColor = "";
91
+ el.style.opacity = "";
92
+ el.style.top = "";
93
+ el.style.left = "";
94
+ el.style.width = "";
95
+ el.style.height = "";
96
+ el.style.zIndex = "";
97
+ el.style.filter = "";
98
+ el.style.rotate = "";
99
+ el.style.transform = "";
100
+ el.style.removeProperty("--tx");
101
+ el.style.removeProperty("--ty");
102
+ el.style.removeProperty("--s");
103
+ });
104
+ });
105
+ };
86
106
  const useTimeline = ({ steps = [], loading, immediate, loop }) => {
87
107
  const [slotIndex, setSlotIndex] = useState(-1);
88
108
  const [completed, setCompleted] = useState(() => !!immediate);
89
109
  const ref = useRef(null);
90
- const timeline = useMemo(() => buildTimeline(steps), [steps]);
91
- const hasCurveSteps = useMemo(() => steps.some((s) => s.transition.easeX || s.transition.easeY), [steps]);
110
+ const stepsRef = useRef(steps);
111
+ useEffect(() => {
112
+ stepsRef.current = steps;
113
+ });
114
+ const stepsKey = useMemo(() => JSON.stringify(steps), [steps]);
115
+ const timeline = useMemo(() => buildTimeline(steps), [stepsKey]);
116
+ const hasCurveSteps = useMemo(() => steps.some((s) => s.transition.easeX || s.transition.easeY), [stepsKey]);
117
+ useEffect(() => {
118
+ setSlotIndex(-1);
119
+ setCompleted(!!immediate);
120
+ }, [stepsKey, loading]);
92
121
  useLayoutEffect(() => {
93
122
  if (hasCurveSteps) {
94
123
  registerCSSProperty("--tx");
@@ -100,7 +129,13 @@ const useTimeline = ({ steps = [], loading, immediate, loop }) => {
100
129
  if (!immediate || !ref.current || !steps.length) return;
101
130
  const container = ref.current;
102
131
  steps.forEach((step) => applyStep(container, step, true));
103
- }, [immediate, steps]);
132
+ }, [immediate, stepsKey]);
133
+ useEffect(() => {
134
+ const container = ref.current;
135
+ return () => {
136
+ if (container) resetAppliedStyles(container, stepsRef.current);
137
+ };
138
+ }, []);
104
139
  useEffect(() => {
105
140
  if (immediate || loading || !timeline.length) return;
106
141
  const firstDelay = timeline[0]?.[0]?.transition.delay ?? 0;
@@ -1 +1 @@
1
- {"version":3,"file":"use-timeline.mjs","names":[],"sources":["../../src/hooks/use-timeline.ts"],"sourcesContent":["import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\n\nconst registeredProperties = new Set<string>();\n\nconst registerCSSProperty = (\n name: string,\n syntax = '<length>',\n initialValue = '0px',\n) => {\n if (\n registeredProperties.has(name) ||\n typeof CSS === 'undefined' ||\n !CSS.registerProperty\n ) {\n return;\n }\n\n try {\n CSS.registerProperty({\n name,\n syntax,\n inherits: false,\n initialValue,\n });\n registeredProperties.add(name);\n } catch {\n // 이미 등록된 경우 무시\n }\n};\n\nexport interface TimelineStep {\n scale?: number;\n rotate?: { x?: number; y?: number; z?: number };\n position?: { x?: number; y?: number };\n top?: number;\n left?: number;\n width?: number;\n height?: number;\n zIndex?: number;\n filter?: string;\n backgroundColor?: string;\n opacity?: number;\n selector: string;\n transition: Transition;\n}\n\ninterface Transition {\n duration: number;\n ease?: string;\n delay?: number;\n easeX?: string;\n easeY?: string;\n}\n\ninterface Options {\n steps?: TimelineStep[];\n loading?: boolean;\n immediate?: boolean;\n loop?: boolean;\n}\n\nconst buildTimeline = (steps: TimelineStep[]): TimelineStep[][] => {\n const groups: TimelineStep[][] = [];\n\n for (const step of steps) {\n if (step.transition.delay === 0 && groups.length > 0) {\n groups[groups.length - 1]?.push(step);\n } else {\n groups.push([step]);\n }\n }\n\n return groups;\n};\n\nconst buildTransform = (step: TimelineStep) => {\n const parts: string[] = [];\n\n if (step.position?.x != null || step.position?.y != null) {\n parts.push(\n `translate(${step.position.x ?? 0}px, ${step.position.y ?? 0}px)`,\n );\n }\n\n if (step.scale != null) {\n parts.push(`scale(${step.scale})`);\n }\n\n if (step.rotate?.x != null) {\n parts.push(`rotateX(${step.rotate.x}deg)`);\n }\n\n if (step.rotate?.y != null) {\n parts.push(`rotateY(${step.rotate.y}deg)`);\n }\n\n if (step.rotate?.z != null) {\n parts.push(`rotateZ(${step.rotate.z}deg)`);\n }\n\n return parts.join(' ') || undefined;\n};\n\nconst applyStep = (\n container: HTMLElement,\n step: TimelineStep,\n skipTransition?: boolean,\n) => {\n const elements = container.matches(step.selector)\n ? [container]\n : Array.from(container.querySelectorAll<HTMLElement>(step.selector));\n\n if (!elements.length) {\n return;\n }\n\n const dur = `${step.transition.duration}ms`;\n const eas = step.transition.ease ?? 'ease-out';\n const easX = step.transition.easeX;\n const easY = step.transition.easeY;\n const hasCurve = !!(easX || easY);\n const timers: ReturnType<typeof setTimeout>[] = [];\n\n elements.forEach(el => {\n if (skipTransition) {\n el.style.transition = 'none';\n } else if (hasCurve) {\n const xEase = easX ?? eas;\n const yEase = easY ?? eas;\n el.style.transition = [\n `--tx ${dur} ${xEase}`,\n `--ty ${dur} ${yEase}`,\n `--s ${dur} ${eas}`,\n `rotate ${dur} ${eas}`,\n `width ${dur} ${eas}`,\n `height ${dur} ${eas}`,\n `filter ${dur} ${eas}`,\n `opacity ${dur} ${eas}`,\n ].join(', ');\n } else {\n el.style.transition = `all ${dur} ${eas}`;\n }\n\n if (step.backgroundColor) {\n el.style.backgroundColor = step.backgroundColor;\n }\n\n if (step.opacity !== undefined) {\n el.style.opacity = String(step.opacity);\n }\n\n if (step.top !== undefined) {\n el.style.top = `${step.top}px`;\n }\n\n if (step.left !== undefined) {\n el.style.left = `${step.left}px`;\n }\n\n if (step.width !== undefined) {\n el.style.width = `${step.width}px`;\n }\n\n if (step.height !== undefined) {\n el.style.height = `${step.height}px`;\n }\n\n if (step.zIndex !== undefined) {\n if (skipTransition) {\n el.style.zIndex = String(step.zIndex);\n } else {\n const timerId = setTimeout(() => {\n el.style.zIndex = String(step.zIndex);\n }, step.transition.duration / 2);\n timers.push(timerId);\n }\n }\n\n if (step.filter !== undefined) {\n el.style.filter = step.filter;\n }\n\n if (step.rotate?.z !== undefined) {\n el.style.rotate = `${step.rotate.z}deg`;\n }\n\n if (hasCurve) {\n if (step.position?.x != null || step.position?.y != null) {\n el.style.setProperty('--tx', `${step.position.x ?? 0}px`);\n el.style.setProperty('--ty', `${step.position.y ?? 0}px`);\n }\n if (step.scale != null) {\n el.style.setProperty('--s', String(step.scale));\n }\n el.style.transform = `translate(var(--tx), var(--ty)) scale(var(--s, 1))`;\n } else {\n const transform = buildTransform(step);\n\n if (transform) {\n el.style.transform = transform;\n }\n }\n });\n\n return timers;\n};\n\nconst useTimeline = ({ steps = [], loading, immediate, loop }: Options) => {\n const [slotIndex, setSlotIndex] = useState(-1);\n const [completed, setCompleted] = useState(() => !!immediate);\n\n const ref = useRef<HTMLDivElement>(null);\n const timeline = useMemo(() => buildTimeline(steps), [steps]);\n const hasCurveSteps = useMemo(\n () => steps.some(s => s.transition.easeX || s.transition.easeY),\n [steps],\n );\n\n useLayoutEffect(() => {\n if (hasCurveSteps) {\n registerCSSProperty('--tx');\n registerCSSProperty('--ty');\n registerCSSProperty('--s', '<number>', '1');\n }\n }, [hasCurveSteps]);\n\n useLayoutEffect(() => {\n if (!immediate || !ref.current || !steps.length) {\n return;\n }\n\n const container = ref.current;\n steps.forEach(step => applyStep(container, step, true));\n }, [immediate, steps]);\n\n useEffect(() => {\n if (immediate || loading || !timeline.length) {\n return;\n }\n\n const firstDelay = timeline[0]?.[0]?.transition.delay ?? 0;\n const timerId = window.setTimeout(() => {\n requestAnimationFrame(() => setSlotIndex(0));\n }, firstDelay);\n\n return () => clearTimeout(timerId);\n }, [loading, timeline, immediate]);\n\n useEffect(() => {\n if (immediate || loading || slotIndex < 0) {\n return;\n }\n\n const isLast = slotIndex >= timeline.length - 1;\n\n if (isLast && !loop) {\n return;\n }\n\n const currentSlot = timeline[slotIndex];\n const maxDuration = Math.max(\n ...(currentSlot?.map(s => s.transition.duration) ?? [0]),\n );\n\n const nextIndex = isLast ? 0 : slotIndex + 1;\n const nextDelay = timeline[nextIndex]?.[0]?.transition.delay ?? 0;\n\n const timerId = window.setTimeout(() => {\n setSlotIndex(nextIndex);\n }, maxDuration + nextDelay);\n\n return () => clearTimeout(timerId);\n }, [loading, slotIndex, timeline, immediate, loop]);\n\n useEffect(() => {\n if (immediate || slotIndex < 0 || !ref.current) {\n return;\n }\n\n const container = ref.current;\n const timers: ReturnType<typeof setTimeout>[] = [];\n\n const rafId = requestAnimationFrame(() => {\n timeline[slotIndex]?.forEach(step => {\n const stepTimers = applyStep(container, step);\n\n if (stepTimers) {\n timers.push(...stepTimers);\n }\n });\n });\n\n return () => {\n cancelAnimationFrame(rafId);\n timers.forEach(clearTimeout);\n };\n }, [immediate, slotIndex, timeline]);\n\n useEffect(() => {\n if (\n immediate ||\n loop ||\n slotIndex < 0 ||\n slotIndex < timeline.length - 1 ||\n !timeline.length\n ) {\n return;\n }\n\n const lastSlot = timeline[slotIndex];\n const maxDuration = Math.max(\n ...(lastSlot?.map(s => s.transition.duration) ?? [0]),\n );\n\n const timerId = window.setTimeout(() => {\n setCompleted(true);\n }, maxDuration);\n\n return () => clearTimeout(timerId);\n }, [slotIndex, timeline, immediate, loop]);\n\n return { ref, completed };\n};\n\nexport default useTimeline;\n"],"mappings":"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"}
1
+ {"version":3,"file":"use-timeline.mjs","names":[],"sources":["../../src/hooks/use-timeline.ts"],"sourcesContent":["import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\n\nconst registeredProperties = new Set<string>();\n\n// `CSS.registerProperty` is permanent for the page's lifetime — there is\n// no unregisterProperty API, so once a curved-easing step registers\n// `--tx`/`--ty`/`--s`, they stay registered globally even after every\n// `useTimeline` instance using them has unmounted.\nconst registerCSSProperty = (\n name: string,\n syntax = '<length>',\n initialValue = '0px',\n) => {\n if (\n registeredProperties.has(name) ||\n typeof CSS === 'undefined' ||\n !CSS.registerProperty\n ) {\n return;\n }\n\n try {\n CSS.registerProperty({\n name,\n syntax,\n inherits: false,\n initialValue,\n });\n registeredProperties.add(name);\n } catch {\n // 이미 등록된 경우 무시\n }\n};\n\nexport interface TimelineStep {\n scale?: number;\n rotate?: { x?: number; y?: number; z?: number };\n position?: { x?: number; y?: number };\n top?: number;\n left?: number;\n width?: number;\n height?: number;\n zIndex?: number;\n filter?: string;\n backgroundColor?: string;\n opacity?: number;\n selector: string;\n transition: Transition;\n}\n\ninterface Transition {\n duration: number;\n ease?: string;\n delay?: number;\n easeX?: string;\n easeY?: string;\n}\n\ninterface Options {\n steps?: TimelineStep[];\n loading?: boolean;\n immediate?: boolean;\n loop?: boolean;\n}\n\nconst buildTimeline = (steps: TimelineStep[]): TimelineStep[][] => {\n const groups: TimelineStep[][] = [];\n\n for (const step of steps) {\n // An unspecified delay means \"no delay\", same as an explicit 0 — both\n // merge into the previous group instead of only the explicit case.\n if ((step.transition.delay ?? 0) === 0 && groups.length > 0) {\n groups[groups.length - 1]?.push(step);\n } else {\n groups.push([step]);\n }\n }\n\n return groups;\n};\n\nconst buildTransform = (step: TimelineStep) => {\n const parts: string[] = [];\n\n if (step.position?.x != null || step.position?.y != null) {\n parts.push(\n `translate(${step.position.x ?? 0}px, ${step.position.y ?? 0}px)`,\n );\n }\n\n if (step.scale != null) {\n parts.push(`scale(${step.scale})`);\n }\n\n if (step.rotate?.x != null) {\n parts.push(`rotateX(${step.rotate.x}deg)`);\n }\n\n if (step.rotate?.y != null) {\n parts.push(`rotateY(${step.rotate.y}deg)`);\n }\n\n if (step.rotate?.z != null) {\n parts.push(`rotateZ(${step.rotate.z}deg)`);\n }\n\n return parts.join(' ') || undefined;\n};\n\nconst applyStep = (\n container: HTMLElement,\n step: TimelineStep,\n skipTransition?: boolean,\n) => {\n const elements = container.matches(step.selector)\n ? [container]\n : Array.from(container.querySelectorAll<HTMLElement>(step.selector));\n\n if (!elements.length) {\n return;\n }\n\n const dur = `${step.transition.duration}ms`;\n const eas = step.transition.ease ?? 'ease-out';\n const easX = step.transition.easeX;\n const easY = step.transition.easeY;\n const hasCurve = !!(easX || easY);\n const timers: ReturnType<typeof setTimeout>[] = [];\n\n elements.forEach(el => {\n if (skipTransition) {\n el.style.transition = 'none';\n } else if (hasCurve) {\n const xEase = easX ?? eas;\n const yEase = easY ?? eas;\n el.style.transition = [\n `--tx ${dur} ${xEase}`,\n `--ty ${dur} ${yEase}`,\n `--s ${dur} ${eas}`,\n `rotate ${dur} ${eas}`,\n `width ${dur} ${eas}`,\n `height ${dur} ${eas}`,\n `filter ${dur} ${eas}`,\n `opacity ${dur} ${eas}`,\n ].join(', ');\n } else {\n el.style.transition = `all ${dur} ${eas}`;\n }\n\n if (step.backgroundColor) {\n el.style.backgroundColor = step.backgroundColor;\n }\n\n if (step.opacity !== undefined) {\n el.style.opacity = String(step.opacity);\n }\n\n if (step.top !== undefined) {\n el.style.top = `${step.top}px`;\n }\n\n if (step.left !== undefined) {\n el.style.left = `${step.left}px`;\n }\n\n if (step.width !== undefined) {\n el.style.width = `${step.width}px`;\n }\n\n if (step.height !== undefined) {\n el.style.height = `${step.height}px`;\n }\n\n if (step.zIndex !== undefined) {\n if (skipTransition) {\n el.style.zIndex = String(step.zIndex);\n } else {\n const timerId = setTimeout(() => {\n el.style.zIndex = String(step.zIndex);\n }, step.transition.duration / 2);\n timers.push(timerId);\n }\n }\n\n if (step.filter !== undefined) {\n el.style.filter = step.filter;\n }\n\n if (step.rotate?.z !== undefined) {\n el.style.rotate = `${step.rotate.z}deg`;\n }\n\n if (hasCurve) {\n if (step.position?.x != null || step.position?.y != null) {\n el.style.setProperty('--tx', `${step.position.x ?? 0}px`);\n el.style.setProperty('--ty', `${step.position.y ?? 0}px`);\n }\n if (step.scale != null) {\n el.style.setProperty('--s', String(step.scale));\n }\n el.style.transform = `translate(var(--tx), var(--ty)) scale(var(--s, 1))`;\n } else {\n const transform = buildTransform(step);\n\n if (transform) {\n el.style.transform = transform;\n }\n }\n });\n\n return timers;\n};\n\n// Clears every inline style property `applyStep` can set, on every element\n// matching any of `steps`' selectors, so nothing this hook applied lingers\n// on elements that outlive it (e.g. kept mounted by a parent).\nconst resetAppliedStyles = (container: HTMLElement, steps: TimelineStep[]) => {\n const selectors = new Set(steps.map(step => step.selector));\n\n selectors.forEach(selector => {\n const elements = container.matches(selector)\n ? [container]\n : Array.from(container.querySelectorAll<HTMLElement>(selector));\n\n elements.forEach(el => {\n el.style.transition = '';\n el.style.backgroundColor = '';\n el.style.opacity = '';\n el.style.top = '';\n el.style.left = '';\n el.style.width = '';\n el.style.height = '';\n el.style.zIndex = '';\n el.style.filter = '';\n el.style.rotate = '';\n el.style.transform = '';\n el.style.removeProperty('--tx');\n el.style.removeProperty('--ty');\n el.style.removeProperty('--s');\n });\n });\n};\n\nconst useTimeline = ({ steps = [], loading, immediate, loop }: Options) => {\n const [slotIndex, setSlotIndex] = useState(-1);\n const [completed, setCompleted] = useState(() => !!immediate);\n\n const ref = useRef<HTMLDivElement>(null);\n const stepsRef = useRef(steps);\n\n useEffect(() => {\n stepsRef.current = steps;\n });\n\n // `steps` is naturally passed as an inline array literal at most call\n // sites, so a new reference every render would otherwise rebuild\n // `timeline` — clearing every running timer — on every unrelated\n // re-render. Key derived values off a serialized snapshot instead,\n // which only changes when the content actually does.\n const stepsKey = useMemo(() => JSON.stringify(steps), [steps]);\n const timeline = useMemo(\n () => buildTimeline(steps),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [stepsKey],\n );\n const hasCurveSteps = useMemo(\n () => steps.some(s => s.transition.easeX || s.transition.easeY),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [stepsKey],\n );\n\n // Restart playback from the top whenever the steps' content (not just\n // their reference) or `loading` changes — otherwise slotIndex/completed\n // stay wherever they were left on the previous timeline.\n useEffect(() => {\n setSlotIndex(-1);\n setCompleted(!!immediate);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [stepsKey, loading]);\n\n useLayoutEffect(() => {\n if (hasCurveSteps) {\n registerCSSProperty('--tx');\n registerCSSProperty('--ty');\n registerCSSProperty('--s', '<number>', '1');\n }\n }, [hasCurveSteps]);\n\n useLayoutEffect(() => {\n if (!immediate || !ref.current || !steps.length) {\n return;\n }\n\n const container = ref.current;\n steps.forEach(step => applyStep(container, step, true));\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [immediate, stepsKey]);\n\n useEffect(() => {\n const container = ref.current;\n\n return () => {\n if (container) {\n resetAppliedStyles(container, stepsRef.current);\n }\n };\n }, []);\n\n useEffect(() => {\n if (immediate || loading || !timeline.length) {\n return;\n }\n\n const firstDelay = timeline[0]?.[0]?.transition.delay ?? 0;\n const timerId = window.setTimeout(() => {\n requestAnimationFrame(() => setSlotIndex(0));\n }, firstDelay);\n\n return () => clearTimeout(timerId);\n }, [loading, timeline, immediate]);\n\n useEffect(() => {\n if (immediate || loading || slotIndex < 0) {\n return;\n }\n\n const isLast = slotIndex >= timeline.length - 1;\n\n if (isLast && !loop) {\n return;\n }\n\n const currentSlot = timeline[slotIndex];\n const maxDuration = Math.max(\n ...(currentSlot?.map(s => s.transition.duration) ?? [0]),\n );\n\n const nextIndex = isLast ? 0 : slotIndex + 1;\n const nextDelay = timeline[nextIndex]?.[0]?.transition.delay ?? 0;\n\n const timerId = window.setTimeout(() => {\n setSlotIndex(nextIndex);\n }, maxDuration + nextDelay);\n\n return () => clearTimeout(timerId);\n }, [loading, slotIndex, timeline, immediate, loop]);\n\n useEffect(() => {\n if (immediate || slotIndex < 0 || !ref.current) {\n return;\n }\n\n const container = ref.current;\n const timers: ReturnType<typeof setTimeout>[] = [];\n\n const rafId = requestAnimationFrame(() => {\n timeline[slotIndex]?.forEach(step => {\n const stepTimers = applyStep(container, step);\n\n if (stepTimers) {\n timers.push(...stepTimers);\n }\n });\n });\n\n return () => {\n cancelAnimationFrame(rafId);\n timers.forEach(clearTimeout);\n };\n }, [immediate, slotIndex, timeline]);\n\n useEffect(() => {\n if (\n immediate ||\n loop ||\n slotIndex < 0 ||\n slotIndex < timeline.length - 1 ||\n !timeline.length\n ) {\n return;\n }\n\n const lastSlot = timeline[slotIndex];\n const maxDuration = Math.max(\n ...(lastSlot?.map(s => s.transition.duration) ?? [0]),\n );\n\n const timerId = window.setTimeout(() => {\n setCompleted(true);\n }, maxDuration);\n\n return () => clearTimeout(timerId);\n }, [slotIndex, timeline, immediate, loop]);\n\n return { ref, completed };\n};\n\nexport default useTimeline;\n"],"mappings":"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"}
@@ -11,47 +11,69 @@ const useWindowScroll = (targetRef) => {
11
11
  }
12
12
  });
13
13
  useEffect(() => {
14
- const target = targetRef?.current?.ownerDocument.defaultView ?? window;
15
- const doc = target.document;
16
- const calculate = () => {
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);
25
- const percentX = maxScrollX === 0 ? 0 : Math.min(100, x / maxScrollX * 100);
26
- const percentY = maxScrollY === 0 ? 0 : Math.min(100, y / maxScrollY * 100);
27
- setState({
28
- x,
29
- y,
30
- percent: {
31
- x: Math.floor(Math.max(0, percentX)),
32
- y: Math.floor(Math.max(0, percentY))
33
- }
34
- });
35
- };
36
- calculate();
37
- const onScroll = () => {
14
+ let cancelled = false;
15
+ let rafId;
16
+ let cleanup;
17
+ const attach = () => {
18
+ if (cancelled) return;
19
+ if (targetRef && !targetRef.current) {
20
+ rafId = requestAnimationFrame(attach);
21
+ return;
22
+ }
23
+ const target = targetRef?.current?.ownerDocument.defaultView ?? window;
24
+ const doc = target.document;
25
+ const calculate = () => {
26
+ const x = target.scrollX || 0;
27
+ const y = target.scrollY || 0;
28
+ const isIOS = /iPad|iPhone|iPod/.test(target.navigator.userAgent);
29
+ const visualViewport = target.visualViewport;
30
+ const viewportWidth = isIOS && visualViewport ? visualViewport.width : target.innerWidth;
31
+ const viewportHeight = isIOS && visualViewport ? visualViewport.height : target.innerHeight;
32
+ const maxScrollX = Math.max(0, doc.documentElement.scrollWidth - viewportWidth);
33
+ const maxScrollY = Math.max(0, doc.documentElement.scrollHeight - viewportHeight);
34
+ const percentX = maxScrollX === 0 ? 0 : Math.min(100, x / maxScrollX * 100);
35
+ const percentY = maxScrollY === 0 ? 0 : Math.min(100, y / maxScrollY * 100);
36
+ setState({
37
+ x,
38
+ y,
39
+ percent: {
40
+ x: Math.round(Math.max(0, percentX)),
41
+ y: Math.round(Math.max(0, percentY))
42
+ }
43
+ });
44
+ };
38
45
  calculate();
46
+ const onScroll = () => {
47
+ calculate();
48
+ };
49
+ const onResize = () => {
50
+ setTimeout(calculate, 100);
51
+ };
52
+ const onVisualViewportChange = () => {
53
+ setTimeout(calculate, 50);
54
+ };
55
+ target.addEventListener("scroll", onScroll, { passive: true });
56
+ target.addEventListener("resize", onResize);
57
+ target.addEventListener("orientationchange", onResize);
58
+ if (target.visualViewport) target.visualViewport.addEventListener("resize", onVisualViewportChange);
59
+ let resizeObserver;
60
+ if (typeof ResizeObserver !== "undefined") {
61
+ resizeObserver = new ResizeObserver(() => calculate());
62
+ resizeObserver.observe(doc.documentElement);
63
+ }
64
+ cleanup = () => {
65
+ target.removeEventListener("scroll", onScroll);
66
+ target.removeEventListener("resize", onResize);
67
+ target.removeEventListener("orientationchange", onResize);
68
+ if (target.visualViewport) target.visualViewport.removeEventListener("resize", onVisualViewportChange);
69
+ resizeObserver?.disconnect();
70
+ };
39
71
  };
40
- const onResize = () => {
41
- setTimeout(calculate, 100);
42
- };
43
- const onVisualViewportChange = () => {
44
- setTimeout(calculate, 50);
45
- };
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);
72
+ attach();
50
73
  return () => {
51
- target.removeEventListener("scroll", onScroll);
52
- target.removeEventListener("resize", onResize);
53
- target.removeEventListener("orientationchange", onResize);
54
- if (target.visualViewport) target.visualViewport.removeEventListener("resize", onVisualViewportChange);
74
+ cancelled = true;
75
+ if (rafId !== void 0) cancelAnimationFrame(rafId);
76
+ cleanup?.();
55
77
  };
56
78
  }, [targetRef]);
57
79
  return state;
@@ -1 +1 @@
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"}
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.\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 let cancelled = false;\n let rafId: number | undefined;\n let cleanup: (() => void) | undefined;\n\n const attach = () => {\n if (cancelled) {\n return;\n }\n\n // `targetRef.current` can still be null right after mount (e.g. a\n // conditionally-rendered or portaled node) — retry every frame\n // until it's populated instead of permanently falling back to the\n // host `window`.\n if (targetRef && !targetRef.current) {\n rafId = requestAnimationFrame(attach);\n return;\n }\n\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 // Rounded (not floored) so reaching the true bottom/right edge\n // reads as 100 instead of falling just short of it.\n x: Math.round(Math.max(0, percentX)),\n y: Math.round(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(\n 'resize',\n onVisualViewportChange,\n );\n }\n\n // `resize`/`orientationchange` only fire for the viewport itself —\n // content that grows/shrinks the document height without resizing\n // the window (images loading, dynamic content) needs its own\n // observer to keep maxScrollY accurate.\n let resizeObserver: ResizeObserver | undefined;\n if (typeof ResizeObserver !== 'undefined') {\n resizeObserver = new ResizeObserver(() => calculate());\n resizeObserver.observe(doc.documentElement);\n }\n\n cleanup = () => {\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 resizeObserver?.disconnect();\n };\n };\n\n attach();\n\n return () => {\n cancelled = true;\n if (rafId !== undefined) {\n cancelAnimationFrame(rafId);\n }\n cleanup?.();\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,IAAI,YAAY;EAChB,IAAI;EACJ,IAAI;EAEJ,MAAM,eAAe;AACnB,OAAI,UACF;AAOF,OAAI,aAAa,CAAC,UAAU,SAAS;AACnC,YAAQ,sBAAsB,OAAO;AACrC;;GAGF,MAAM,SAAS,WAAW,SAAS,cAAc,eAAe;GAChE,MAAM,MAAM,OAAO;GAEnB,MAAM,kBAAkB;IACtB,MAAM,IAAI,OAAO,WAAW;IAC5B,MAAM,IAAI,OAAO,WAAW;IAE5B,MAAM,QAAQ,mBAAmB,KAAK,OAAO,UAAU,UAAU;IACjE,MAAM,iBAAiB,OAAO;IAE9B,MAAM,gBACJ,SAAS,iBAAiB,eAAe,QAAQ,OAAO;IAE1D,MAAM,iBACJ,SAAS,iBAAiB,eAAe,SAAS,OAAO;IAE3D,MAAM,aAAa,KAAK,IACtB,GACA,IAAI,gBAAgB,cAAc,cACnC;IACD,MAAM,aAAa,KAAK,IACtB,GACA,IAAI,gBAAgB,eAAe,eACpC;IAED,MAAM,WACJ,eAAe,IAAI,IAAI,KAAK,IAAI,KAAM,IAAI,aAAc,IAAI;IAC9D,MAAM,WACJ,eAAe,IAAI,IAAI,KAAK,IAAI,KAAM,IAAI,aAAc,IAAI;AAE9D,aAAS;KACP;KACA;KACA,SAAS;MAGP,GAAG,KAAK,MAAM,KAAK,IAAI,GAAG,SAAS,CAAC;MACpC,GAAG,KAAK,MAAM,KAAK,IAAI,GAAG,SAAS,CAAC;MACrC;KACF,CAAC;;AAGJ,cAAW;GAEX,MAAM,iBAAiB;AACrB,eAAW;;GAGb,MAAM,iBAAiB;AACrB,eAAW,WAAW,IAAI;;GAG5B,MAAM,+BAA+B;AACnC,eAAW,WAAW,GAAG;;AAG3B,UAAO,iBAAiB,UAAU,UAAU,EAAE,SAAS,MAAM,CAAC;AAC9D,UAAO,iBAAiB,UAAU,SAAS;AAC3C,UAAO,iBAAiB,qBAAqB,SAAS;AAEtD,OAAI,OAAO,eACT,QAAO,eAAe,iBACpB,UACA,uBACD;GAOH,IAAI;AACJ,OAAI,OAAO,mBAAmB,aAAa;AACzC,qBAAiB,IAAI,qBAAqB,WAAW,CAAC;AACtD,mBAAe,QAAQ,IAAI,gBAAgB;;AAG7C,mBAAgB;AACd,WAAO,oBAAoB,UAAU,SAAS;AAC9C,WAAO,oBAAoB,UAAU,SAAS;AAC9C,WAAO,oBAAoB,qBAAqB,SAAS;AAEzD,QAAI,OAAO,eACT,QAAO,eAAe,oBACpB,UACA,uBACD;AAGH,oBAAgB,YAAY;;;AAIhC,UAAQ;AAER,eAAa;AACX,eAAY;AACZ,OAAI,UAAU,OACZ,sBAAqB,MAAM;AAE7B,cAAW;;IAEZ,CAAC,UAAU,CAAC;AAEf,QAAO"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbpark/use-hooks",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "A collection of reusable React 19 hooks for common UI and interaction patterns",
5
5
  "keywords": [
6
6
  "react",