@jbpark/use-hooks 3.0.0 → 4.0.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.
Files changed (32) hide show
  1. package/README.ko.md +40 -22
  2. package/README.md +40 -22
  3. package/dist/hooks/index.d.mts +2 -2
  4. package/dist/hooks/index.mjs +2 -2
  5. package/dist/hooks/use-click-outside.mjs +1 -1
  6. package/dist/hooks/use-click-outside.mjs.map +1 -1
  7. package/dist/hooks/use-debounced-callback.d.mts +14 -0
  8. package/dist/hooks/{use-debounce.mjs → use-debounced-callback.mjs} +4 -4
  9. package/dist/hooks/use-debounced-callback.mjs.map +1 -0
  10. package/dist/hooks/use-event-listener.mjs +21 -8
  11. package/dist/hooks/use-event-listener.mjs.map +1 -1
  12. package/dist/hooks/use-key-press.mjs +29 -7
  13. package/dist/hooks/use-key-press.mjs.map +1 -1
  14. package/dist/hooks/use-merged-ref.d.mts +1 -1
  15. package/dist/hooks/use-merged-ref.mjs +12 -6
  16. package/dist/hooks/use-merged-ref.mjs.map +1 -1
  17. package/dist/hooks/use-mutation-observer.mjs +21 -7
  18. package/dist/hooks/use-mutation-observer.mjs.map +1 -1
  19. package/dist/hooks/use-resize-observer.mjs +33 -9
  20. package/dist/hooks/use-resize-observer.mjs.map +1 -1
  21. package/dist/hooks/use-responsive-size.mjs +2 -2
  22. package/dist/hooks/use-responsive-size.mjs.map +1 -1
  23. package/dist/hooks/use-throttled-value.d.mts +9 -0
  24. package/dist/hooks/{use-throttle.mjs → use-throttled-value.mjs} +4 -4
  25. package/dist/hooks/use-throttled-value.mjs.map +1 -0
  26. package/dist/index.d.mts +3 -3
  27. package/dist/index.mjs +3 -3
  28. package/package.json +1 -1
  29. package/dist/hooks/use-debounce.d.mts +0 -14
  30. package/dist/hooks/use-debounce.mjs.map +0 -1
  31. package/dist/hooks/use-throttle.d.mts +0 -9
  32. package/dist/hooks/use-throttle.mjs.map +0 -1
package/README.ko.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  ## 기능
13
13
 
14
- - 📦 **15개 프로덕션 레디 훅** - 스크롤, 뷰포트, 스토리지 등 다양한 유틸리티
14
+ - 📦 **30개 프로덕션 레디 훅** - 스크롤, 뷰포트, 스토리지, 옵저버, 이벤트, 타이밍 등 다양한 유틸리티
15
15
  - 🎯 **TypeScript 지원** - 완전한 타입 지원으로 더 나은 개발 경험
16
16
  - ⚡ **트리 셰이킹 지원** - 필요한 것만 임포트하세요
17
17
  - 🔒 **SSR 안전** - window/document 전역 변수에 대한 보호
@@ -36,7 +36,7 @@ pnpm add @jbpark/use-hooks
36
36
  import {
37
37
  useLocalStorage,
38
38
  useResponsiveSize,
39
- useThrottle,
39
+ useThrottledValue,
40
40
  useWindowScroll,
41
41
  } from '@jbpark/use-hooks';
42
42
 
@@ -51,7 +51,7 @@ function MyComponent() {
51
51
  const { size, breakpoint, ref } = useResponsiveSize();
52
52
 
53
53
  // 너비 업데이트를 스로틀링
54
- const throttledWidth = useThrottle(size.width, 200);
54
+ const throttledWidth = useThrottledValue(size.width, 200);
55
55
 
56
56
  return (
57
57
  <div ref={ref}>
@@ -67,25 +67,43 @@ 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-클릭 범위 선택을 지원하는 체크박스 스타일 다중 선택 |
88
- | `useControllableState` | value/defaultValue/onChange prop 조합을 훅 하나로 제어/비제어 상태 관리 |
70
+ | 훅 | 설명 |
71
+ | ------------------------- | ------------------------------------------------------------------------------------------- |
72
+ | `useLocalStorage` | 에러 핸들링이 포함된 JSON 기반 영속 상태 (SSR 안전) |
73
+ | `useHistoryState` | 히스토리 개수 제한을 설정할 수 있는 실행 취소/다시 실행 상태 관리 |
74
+ | `useControllableState` | value/defaultValue/onChange prop 조합을 훅 하나로 제어/비제어 상태 관리 |
75
+ | `usePrevious` | 이전 렌더 시점의 값을 반환 |
76
+ | `useToggle` | 토글 함수와 명시적 setter를 제공하는 불리언 상태 |
77
+ | `useMultiSelect` | shift-클릭 범위 선택을 지원하는 체크박스 스타일 다중 선택 |
78
+ | `useWindowScroll` | 윈도우 스크롤 위치 및 백분율 추적 (iOS visualViewport 대응) |
79
+ | `useElementScroll` | ResizeObserver를 사용한 특정 요소의 스크롤 상태 추적 |
80
+ | `useElementPosition` | 스크롤/리사이즈 시 요소의 바운딩 렉트 모니터링 (요소 참조 지원) |
81
+ | `useResponsiveSize` | Tailwind 유사 브레이크포인트를 포함한 요소 크기 추적 (debounce) |
82
+ | `useViewport` | visualViewport 지원, 인앱 모드 옵션, debounce 포함 |
83
+ | `useScrollToElements` | 키로 요소를 등록하고 키로 스크롤 (오프셋 조절, 컨테이너 지정 가능) |
84
+ | `useBodyScrollLock` | 스타일 보존을 포함한 바디 스크롤 잠금/해제 (iOS 특별 처리) |
85
+ | `useIntersectionObserver` | 뷰포트 교차 추적, `[ref, { entry, isIntersecting }]` 반환, `freezeOnceVisible` 옵션 |
86
+ | `useResizeObserver` | 콜백 ref로 요소 자체의 너비/높이 추적 (content-box/border-box) |
87
+ | `useMutationObserver` | ref 또는 raw 노드(예: `document.head`)의 DOM 변경 관측 |
88
+ | `useEventListener` | `window`, ref, raw 타겟에 이벤트 리스너 등록/해제 |
89
+ | `useClickOutside` | 참조한 요소(들) 바깥 클릭/터치(또는 opt-in `escape`로 `Escape`) 시 콜백 실행, 다중 ref 지원 |
90
+ | `useKeyPress` | `mod`/`ctrl`/`meta`/`shift`/`alt` 조합과 `space`/`esc` 별칭으로 키 콜백 실행 |
91
+ | `useFileDrop` | `accept`/`multiple` 필터링을 갖춘 드래그앤드롭 존, `{ dropRef, isDragging }` 반환 |
92
+ | `useFileToDataUrl` | `File`/`Blob`을 data URL로 변환 |
93
+ | `useDebouncedCallback` | deps 변경 시 debounce된 콜백 자동 실행 (`leading`/`autoInvoke` 옵션); 별칭: `useDebounce` |
94
+ | `useDebouncedValue` | 변하는 값을 일정 지연으로 debounce |
95
+ | `useThrottledValue` | 값 업데이트를 일정 간격으로 제한; 별칭: `useThrottle` |
96
+ | `useThrottledCallback` | 콜백을 일정 간격으로 제한 |
97
+ | `useTimeout` | 지연 후 콜백 1회 실행, `{ reset, clear }` 반환 (`null`이면 일시정지) |
98
+ | `useInterval` | 일정 간격으로 콜백 실행 (`null`이면 일시정지) |
99
+ | `useRecursiveTimeout` | 비동기/동기 콜백을 재귀적으로 스케줄링 |
100
+ | `useMergedRef` | 여러 객체/콜백 ref를 하나의 콜백 ref로 병합 |
101
+ | `useImage` | 이미지 사전로드 및 `loading`/`error`(`Error`)/`loaded`/`attemptCount`/`retry` 노출 |
102
+
103
+ ## v2에서 마이그레이션
104
+
105
+ v3.0.0은 breaking change가 여럿 포함된 메이저 릴리스입니다. 전후 예제는
106
+ [마이그레이션 가이드](./MIGRATION.ko.md)를 참고하세요.
89
107
 
90
108
  ## 개발
91
109
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ A collection of reusable React 19 hooks for common UI and interaction patterns.
11
11
 
12
12
  ## Features
13
13
 
14
- - 📦 **15 Production-Ready Hooks** - Utilities for scrolling, viewport, storage, and more
14
+ - 📦 **30 Production-Ready Hooks** - Utilities for scrolling, viewport, storage, observers, events, timing, and more
15
15
  - 🎯 **Full TypeScript Support** - Complete type definitions for better development experience
16
16
  - ⚡ **Tree-Shakeable** - Import only what you need
17
17
  - 🔒 **SSR-Safe** - Built-in protection for window/document globals
@@ -36,7 +36,7 @@ pnpm add @jbpark/use-hooks
36
36
  import {
37
37
  useLocalStorage,
38
38
  useResponsiveSize,
39
- useThrottle,
39
+ useThrottledValue,
40
40
  useWindowScroll,
41
41
  } from '@jbpark/use-hooks';
42
42
 
@@ -51,7 +51,7 @@ function MyComponent() {
51
51
  const { size, breakpoint, ref } = useResponsiveSize();
52
52
 
53
53
  // Throttled width update
54
- const throttledWidth = useThrottle(size.width, 200);
54
+ const throttledWidth = useThrottledValue(size.width, 200);
55
55
 
56
56
  return (
57
57
  <div ref={ref}>
@@ -67,25 +67,43 @@ 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 |
88
- | `useControllableState` | Back a value/defaultValue/onChange prop pair with one controlled/uncontrolled state hook |
70
+ | Hook | Description |
71
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `useLocalStorage` | JSON-based persistent state with error handling (SSR-safe) |
73
+ | `useHistoryState` | Undo/redo state management with a configurable history limit |
74
+ | `useControllableState` | Back a value/defaultValue/onChange prop pair with one controlled/uncontrolled state hook |
75
+ | `usePrevious` | Return a value as it was on the previous render |
76
+ | `useToggle` | Boolean state with a toggle and an explicit setter |
77
+ | `useMultiSelect` | Checkbox-style multi-select for a list, with shift-click range selection |
78
+ | `useWindowScroll` | Track window scroll position and percentage (iOS visualViewport compatible) |
79
+ | `useElementScroll` | Monitor scroll state of specific elements using ResizeObserver |
80
+ | `useElementPosition` | Monitor element bounding rect on scroll/resize (element ref support) |
81
+ | `useResponsiveSize` | Track element size with Tailwind-like breakpoints (debounced) |
82
+ | `useViewport` | visualViewport support with in-app mode option and debounce |
83
+ | `useScrollToElements` | Register elements by key and scroll to them by key (adjustable offset, optional container) |
84
+ | `useBodyScrollLock` | Lock/unlock body scroll with style preservation (iOS-specific handling) |
85
+ | `useIntersectionObserver` | Track viewport intersection; returns `[ref, { entry, isIntersecting }]` with optional `freezeOnceVisible` |
86
+ | `useResizeObserver` | Track an element's own width/height via a callback ref (content-box or border-box) |
87
+ | `useMutationObserver` | Observe DOM mutations on a ref or a raw node (e.g. `document.head`) |
88
+ | `useEventListener` | Add/remove an event listener on `window`, a ref, or a raw target |
89
+ | `useClickOutside` | Run a callback when a click/touch (or `Escape`, opt-in via `escape`) happens outside the referenced element(s); accepts multiple refs |
90
+ | `useKeyPress` | Run a callback on key combos with `mod`/`ctrl`/`meta`/`shift`/`alt` and aliases like `space`/`esc` |
91
+ | `useFileDrop` | Drag-and-drop file zone with `accept`/`multiple` filtering; returns `{ dropRef, isDragging }` |
92
+ | `useFileToDataUrl` | Read a `File`/`Blob` into a data URL |
93
+ | `useDebouncedCallback` | Auto-invoke a debounced callback when deps change (`leading`/`autoInvoke` options); alias: `useDebounce` |
94
+ | `useDebouncedValue` | Debounce a changing value to a fixed delay |
95
+ | `useThrottledValue` | Throttle value updates to a fixed interval; alias: `useThrottle` |
96
+ | `useThrottledCallback` | Throttle a callback to a fixed interval |
97
+ | `useTimeout` | Run a callback once after a delay; returns `{ reset, clear }` (`null` pauses) |
98
+ | `useInterval` | Run a callback on an interval (`null` pauses) |
99
+ | `useRecursiveTimeout` | Recursively schedule async/sync callbacks |
100
+ | `useMergedRef` | Merge multiple object/callback refs into one callback ref |
101
+ | `useImage` | Preload an image and expose `loading`/`error` (an `Error`)/`loaded`/`attemptCount`/`retry` |
102
+
103
+ ## Migrating from v2
104
+
105
+ v3.0.0 is a major release with several breaking changes. See the
106
+ [Migration Guide](./MIGRATION.md) for before/after examples.
89
107
 
90
108
  ## Development
91
109
 
@@ -1,4 +1,4 @@
1
- import { useDebounce } from "./use-debounce.mjs";
1
+ import { useDebouncedCallback } from "./use-debounced-callback.mjs";
2
2
  import { useDebouncedValue } from "./use-debounced-value.mjs";
3
3
  import { useBodyScrollLock } from "./use-body-scroll-lock.mjs";
4
4
  import { useClickOutside } from "./use-click-outside.mjs";
@@ -22,7 +22,7 @@ import { usePrevious } from "./use-previous.mjs";
22
22
  import { useRecursiveTimeout } from "./use-recursive-timeout.mjs";
23
23
  import { useResizeObserver } from "./use-resize-observer.mjs";
24
24
  import { useScrollToElements } from "./use-scroll-to-elements.mjs";
25
- import { useThrottle } from "./use-throttle.mjs";
25
+ import { useThrottledValue } from "./use-throttled-value.mjs";
26
26
  import { useThrottledCallback } from "./use-throttled-callback.mjs";
27
27
  import { useTimeout } from "./use-timeout.mjs";
28
28
  import { useToggle } from "./use-toggle.mjs";
@@ -1,4 +1,4 @@
1
- import useDebounce from "./use-debounce.mjs";
1
+ import useDebouncedCallback from "./use-debounced-callback.mjs";
2
2
  import useDebouncedValue from "./use-debounced-value.mjs";
3
3
  import useBodyScrollLock from "./use-body-scroll-lock.mjs";
4
4
  import useClickOutside from "./use-click-outside.mjs";
@@ -23,7 +23,7 @@ import useRecursiveTimeout from "./use-recursive-timeout.mjs";
23
23
  import useResizeObserver from "./use-resize-observer.mjs";
24
24
  import useScrollToElements from "./use-scroll-to-elements.mjs";
25
25
  import useThrottledCallback from "./use-throttled-callback.mjs";
26
- import useThrottle from "./use-throttle.mjs";
26
+ import useThrottledValue from "./use-throttled-value.mjs";
27
27
  import useTimeout from "./use-timeout.mjs";
28
28
  import useToggle from "./use-toggle.mjs";
29
29
  import useWindowScroll from "./use-window-scroll.mjs";
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
2
2
 
3
3
  //#region src/hooks/use-click-outside.ts
4
4
  const useClickOutside = (refs, handler, options = {}) => {
5
- const { enabled = true, events = ["pointerdown"], escape = true } = options;
5
+ const { enabled = true, events = ["pointerdown"], escape = false } = options;
6
6
  const handlerRef = useRef(handler);
7
7
  useEffect(() => {
8
8
  handlerRef.current = handler;
@@ -1 +1 @@
1
- {"version":3,"file":"use-click-outside.mjs","names":[],"sources":["../../src/hooks/use-click-outside.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ntype ClickOutsideRef = RefObject<HTMLElement | null> | null | undefined;\n\ntype ClickOutsideEvent = PointerEvent | MouseEvent | TouchEvent | KeyboardEvent;\n\ninterface Options {\n enabled?: boolean;\n events?: ('pointerdown' | 'mousedown' | 'touchstart')[];\n escape?: boolean;\n}\n\n// Takes the \"inside\" ref(s) as an argument instead of creating and\n// returning one — lets a caller pass multiple exclusion targets (e.g. a\n// dropdown's trigger button *and* its portaled panel, which live in\n// different DOM subtrees) instead of only ever watching one element.\nconst useClickOutside = (\n refs: ClickOutsideRef | ClickOutsideRef[],\n handler: (event: ClickOutsideEvent) => void,\n options: Options = {},\n) => {\n const { enabled = true, events = ['pointerdown'], escape = true } = options;\n\n const handlerRef = useRef(handler);\n\n useEffect(() => {\n handlerRef.current = handler;\n });\n\n const refList = Array.isArray(refs) ? refs : [refs];\n // `refs` is almost always an inline array literal at the call site, so\n // depending on it directly would tear down/rebuild the listeners on\n // every render — read the latest value from a ref instead.\n const refListRef = useRef(refList);\n\n useEffect(() => {\n refListRef.current = refList;\n });\n\n const eventsKey = events.join(',');\n\n useEffect(() => {\n if (!enabled || typeof document === 'undefined') {\n return;\n }\n\n const isInside = (target: Node) =>\n refListRef.current.some(ref => ref?.current?.contains(target));\n\n const onPointerEvent = (event: Event) => {\n const target = event.target as Node | null;\n\n if (!target || isInside(target)) {\n return;\n }\n\n handlerRef.current(event as ClickOutsideEvent);\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n handlerRef.current(event);\n }\n };\n\n events.forEach(eventName => {\n document.addEventListener(eventName, onPointerEvent);\n });\n\n if (escape) {\n document.addEventListener('keydown', onKeyDown);\n }\n\n return () => {\n events.forEach(eventName => {\n document.removeEventListener(eventName, onPointerEvent);\n });\n\n if (escape) {\n document.removeEventListener('keydown', onKeyDown);\n }\n };\n // `events` is covered by `eventsKey` below instead of the array\n // itself, which (like `refs`) is usually a fresh literal every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, escape, eventsKey]);\n};\n\nexport default useClickOutside;\n"],"mappings":";;;AAgBA,MAAM,mBACJ,MACA,SACA,UAAmB,EAAE,KAClB;CACH,MAAM,EAAE,UAAU,MAAM,SAAS,CAAC,cAAc,EAAE,SAAS,SAAS;CAEpE,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;CAEF,MAAM,UAAU,MAAM,QAAQ,KAAK,GAAG,OAAO,CAAC,KAAK;CAInD,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;AAIF,iBAAgB;AACd,MAAI,CAAC,WAAW,OAAO,aAAa,YAClC;EAGF,MAAM,YAAY,WAChB,WAAW,QAAQ,MAAK,QAAO,KAAK,SAAS,SAAS,OAAO,CAAC;EAEhE,MAAM,kBAAkB,UAAiB;GACvC,MAAM,SAAS,MAAM;AAErB,OAAI,CAAC,UAAU,SAAS,OAAO,CAC7B;AAGF,cAAW,QAAQ,MAA2B;;EAGhD,MAAM,aAAa,UAAyB;AAC1C,OAAI,MAAM,QAAQ,SAChB,YAAW,QAAQ,MAAM;;AAI7B,SAAO,SAAQ,cAAa;AAC1B,YAAS,iBAAiB,WAAW,eAAe;IACpD;AAEF,MAAI,OACF,UAAS,iBAAiB,WAAW,UAAU;AAGjD,eAAa;AACX,UAAO,SAAQ,cAAa;AAC1B,aAAS,oBAAoB,WAAW,eAAe;KACvD;AAEF,OAAI,OACF,UAAS,oBAAoB,WAAW,UAAU;;IAMrD;EAAC;EAAS;EA9CK,OAAO,KAAK,IAAI;EA8CH,CAAC"}
1
+ {"version":3,"file":"use-click-outside.mjs","names":[],"sources":["../../src/hooks/use-click-outside.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ntype ClickOutsideRef = RefObject<HTMLElement | null> | null | undefined;\n\ntype ClickOutsideEvent = PointerEvent | MouseEvent | TouchEvent | KeyboardEvent;\n\ninterface Options {\n enabled?: boolean;\n events?: ('pointerdown' | 'mousedown' | 'touchstart')[];\n escape?: boolean;\n}\n\n// Takes the \"inside\" ref(s) as an argument instead of creating and\n// returning one — lets a caller pass multiple exclusion targets (e.g. a\n// dropdown's trigger button *and* its portaled panel, which live in\n// different DOM subtrees) instead of only ever watching one element.\nconst useClickOutside = (\n refs: ClickOutsideRef | ClickOutsideRef[],\n handler: (event: ClickOutsideEvent) => void,\n options: Options = {},\n) => {\n // `escape` is opt-in (default `false`): the hook is named for clicks, and\n // an always-on Escape handler both surprises callers who already handle\n // Escape themselves (double-fire) and closes every layer at once in\n // nested UI (a dropdown inside a modal). Turn it on where you want it.\n const { enabled = true, events = ['pointerdown'], escape = false } = options;\n\n const handlerRef = useRef(handler);\n\n useEffect(() => {\n handlerRef.current = handler;\n });\n\n const refList = Array.isArray(refs) ? refs : [refs];\n // `refs` is almost always an inline array literal at the call site, so\n // depending on it directly would tear down/rebuild the listeners on\n // every render — read the latest value from a ref instead.\n const refListRef = useRef(refList);\n\n useEffect(() => {\n refListRef.current = refList;\n });\n\n const eventsKey = events.join(',');\n\n useEffect(() => {\n if (!enabled || typeof document === 'undefined') {\n return;\n }\n\n const isInside = (target: Node) =>\n refListRef.current.some(ref => ref?.current?.contains(target));\n\n const onPointerEvent = (event: Event) => {\n const target = event.target as Node | null;\n\n if (!target || isInside(target)) {\n return;\n }\n\n handlerRef.current(event as ClickOutsideEvent);\n };\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n handlerRef.current(event);\n }\n };\n\n events.forEach(eventName => {\n document.addEventListener(eventName, onPointerEvent);\n });\n\n if (escape) {\n document.addEventListener('keydown', onKeyDown);\n }\n\n return () => {\n events.forEach(eventName => {\n document.removeEventListener(eventName, onPointerEvent);\n });\n\n if (escape) {\n document.removeEventListener('keydown', onKeyDown);\n }\n };\n // `events` is covered by `eventsKey` below instead of the array\n // itself, which (like `refs`) is usually a fresh literal every render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, escape, eventsKey]);\n};\n\nexport default useClickOutside;\n"],"mappings":";;;AAgBA,MAAM,mBACJ,MACA,SACA,UAAmB,EAAE,KAClB;CAKH,MAAM,EAAE,UAAU,MAAM,SAAS,CAAC,cAAc,EAAE,SAAS,UAAU;CAErE,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;CAEF,MAAM,UAAU,MAAM,QAAQ,KAAK,GAAG,OAAO,CAAC,KAAK;CAInD,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;AAIF,iBAAgB;AACd,MAAI,CAAC,WAAW,OAAO,aAAa,YAClC;EAGF,MAAM,YAAY,WAChB,WAAW,QAAQ,MAAK,QAAO,KAAK,SAAS,SAAS,OAAO,CAAC;EAEhE,MAAM,kBAAkB,UAAiB;GACvC,MAAM,SAAS,MAAM;AAErB,OAAI,CAAC,UAAU,SAAS,OAAO,CAC7B;AAGF,cAAW,QAAQ,MAA2B;;EAGhD,MAAM,aAAa,UAAyB;AAC1C,OAAI,MAAM,QAAQ,SAChB,YAAW,QAAQ,MAAM;;AAI7B,SAAO,SAAQ,cAAa;AAC1B,YAAS,iBAAiB,WAAW,eAAe;IACpD;AAEF,MAAI,OACF,UAAS,iBAAiB,WAAW,UAAU;AAGjD,eAAa;AACX,UAAO,SAAQ,cAAa;AAC1B,aAAS,oBAAoB,WAAW,eAAe;KACvD;AAEF,OAAI,OACF,UAAS,oBAAoB,WAAW,UAAU;;IAMrD;EAAC;EAAS;EA9CK,OAAO,KAAK,IAAI;EA8CH,CAAC"}
@@ -0,0 +1,14 @@
1
+ //#region src/hooks/use-debounced-callback.d.ts
2
+ interface Options {
3
+ delay?: number;
4
+ autoInvoke?: boolean;
5
+ leading?: boolean;
6
+ }
7
+ declare const useDebouncedCallback: (callback: () => unknown, {
8
+ delay,
9
+ autoInvoke,
10
+ leading
11
+ }: Options, deps?: React.DependencyList) => () => void;
12
+ //#endregion
13
+ export { useDebouncedCallback };
14
+ //# sourceMappingURL=use-debounced-callback.d.mts.map
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef } from "react";
2
2
 
3
- //#region src/hooks/use-debounce.ts
4
- const useDebounce = (callback, { delay = 100, autoInvoke = true, leading = true }, deps = []) => {
3
+ //#region src/hooks/use-debounced-callback.ts
4
+ const useDebouncedCallback = (callback, { delay = 100, autoInvoke = true, leading = true }, deps = []) => {
5
5
  const timeoutRef = useRef(null);
6
6
  const callbackRef = useRef(callback);
7
7
  const delayRef = useRef(delay);
@@ -33,5 +33,5 @@ const useDebounce = (callback, { delay = 100, autoInvoke = true, leading = true
33
33
  };
34
34
 
35
35
  //#endregion
36
- export { useDebounce as default };
37
- //# sourceMappingURL=use-debounce.mjs.map
36
+ export { useDebouncedCallback as default };
37
+ //# sourceMappingURL=use-debounced-callback.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-debounced-callback.mjs","names":[],"sources":["../../src/hooks/use-debounced-callback.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 useDebouncedCallback = (\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 useDebouncedCallback;\n"],"mappings":";;;AAcA,MAAM,wBACJ,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"}
@@ -9,18 +9,31 @@ function useEventListener(type, handler, options = {}) {
9
9
  });
10
10
  useEffect(() => {
11
11
  if (!enabled) return;
12
- const resolvedTarget = target && "current" in target ? target.current : target ?? (typeof window === "undefined" ? null : window);
13
- if (!resolvedTarget) return;
12
+ let cancelled = false;
13
+ let rafId;
14
+ let resolvedTarget = null;
14
15
  const listener = (event) => {
15
16
  handlerRef.current(event);
16
17
  };
17
- resolvedTarget.addEventListener(type, listener, {
18
- capture,
19
- passive,
20
- once
21
- });
18
+ const attach = () => {
19
+ if (cancelled) return;
20
+ if (target && "current" in target && !target.current) {
21
+ rafId = requestAnimationFrame(attach);
22
+ return;
23
+ }
24
+ resolvedTarget = target && "current" in target ? target.current : target ?? (typeof window === "undefined" ? null : window);
25
+ if (!resolvedTarget) return;
26
+ resolvedTarget.addEventListener(type, listener, {
27
+ capture,
28
+ passive,
29
+ once
30
+ });
31
+ };
32
+ attach();
22
33
  return () => {
23
- resolvedTarget.removeEventListener(type, listener, { capture });
34
+ cancelled = true;
35
+ if (rafId !== void 0) cancelAnimationFrame(rafId);
36
+ resolvedTarget?.removeEventListener(type, listener, { capture });
24
37
  };
25
38
  }, [
26
39
  type,
@@ -1 +1 @@
1
- {"version":3,"file":"use-event-listener.mjs","names":[],"sources":["../../src/hooks/use-event-listener.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ninterface Options {\n capture?: boolean;\n passive?: boolean;\n once?: boolean;\n enabled?: boolean;\n}\n\ntype EventListenerTarget = EventTarget | RefObject<EventTarget | null> | null;\n\nfunction useEventListener<K extends keyof WindowEventMap>(\n type: K,\n handler: (event: WindowEventMap[K]) => void,\n options?: Options & { target?: Window | null },\n): void;\nfunction useEventListener<K extends keyof DocumentEventMap>(\n type: K,\n handler: (event: DocumentEventMap[K]) => void,\n options: Options & { target: Document | RefObject<Document | null> },\n): void;\nfunction useEventListener<\n T extends HTMLElement,\n K extends keyof HTMLElementEventMap,\n>(\n type: K,\n handler: (event: HTMLElementEventMap[K]) => void,\n options: Options & { target: RefObject<T | null> },\n): void;\n\n// Resolves `target` (default `window`, or a raw EventTarget, or a\n// RefObject pointing at one) and (de)registers `type` on it — the pair of\n// addEventListener/removeEventListener calls duplicated across ui-kit\n// (marquee item) and live-editor (error handlers). `handler` is read\n// through a ref so a fresh function every render doesn't tear down and\n// re-add the listener.\nfunction useEventListener(\n type: string,\n handler: (event: Event) => void,\n options: Options & { target?: EventListenerTarget } = {},\n) {\n const { target, capture, passive, once, enabled = true } = options;\n\n const handlerRef = useRef(handler);\n\n useEffect(() => {\n handlerRef.current = handler;\n });\n\n useEffect(() => {\n if (!enabled) {\n return;\n }\n\n const resolvedTarget =\n target && 'current' in target\n ? target.current\n : (target ?? (typeof window === 'undefined' ? null : window));\n\n if (!resolvedTarget) {\n return;\n }\n\n const listener = (event: Event) => {\n handlerRef.current(event);\n };\n\n resolvedTarget.addEventListener(type, listener, { capture, passive, once });\n\n return () => {\n resolvedTarget.removeEventListener(type, listener, { capture });\n };\n }, [type, target, capture, passive, once, enabled]);\n}\n\nexport default useEventListener;\n"],"mappings":";;;AAoCA,SAAS,iBACP,MACA,SACA,UAAsD,EAAE,EACxD;CACA,MAAM,EAAE,QAAQ,SAAS,SAAS,MAAM,UAAU,SAAS;CAE3D,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;AAEF,iBAAgB;AACd,MAAI,CAAC,QACH;EAGF,MAAM,iBACJ,UAAU,aAAa,SACnB,OAAO,UACN,WAAW,OAAO,WAAW,cAAc,OAAO;AAEzD,MAAI,CAAC,eACH;EAGF,MAAM,YAAY,UAAiB;AACjC,cAAW,QAAQ,MAAM;;AAG3B,iBAAe,iBAAiB,MAAM,UAAU;GAAE;GAAS;GAAS;GAAM,CAAC;AAE3E,eAAa;AACX,kBAAe,oBAAoB,MAAM,UAAU,EAAE,SAAS,CAAC;;IAEhE;EAAC;EAAM;EAAQ;EAAS;EAAS;EAAM;EAAQ,CAAC"}
1
+ {"version":3,"file":"use-event-listener.mjs","names":[],"sources":["../../src/hooks/use-event-listener.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ninterface Options {\n capture?: boolean;\n passive?: boolean;\n once?: boolean;\n enabled?: boolean;\n}\n\ntype EventListenerTarget = EventTarget | RefObject<EventTarget | null> | null;\n\nfunction useEventListener<K extends keyof WindowEventMap>(\n type: K,\n handler: (event: WindowEventMap[K]) => void,\n options?: Options & { target?: Window | null },\n): void;\nfunction useEventListener<K extends keyof DocumentEventMap>(\n type: K,\n handler: (event: DocumentEventMap[K]) => void,\n options: Options & { target: Document | RefObject<Document | null> },\n): void;\nfunction useEventListener<\n T extends HTMLElement,\n K extends keyof HTMLElementEventMap,\n>(\n type: K,\n handler: (event: HTMLElementEventMap[K]) => void,\n options: Options & { target: RefObject<T | null> },\n): void;\n\n// Resolves `target` (default `window`, or a raw EventTarget, or a\n// RefObject pointing at one) and (de)registers `type` on it — the pair of\n// addEventListener/removeEventListener calls duplicated across ui-kit\n// (marquee item) and live-editor (error handlers). `handler` is read\n// through a ref so a fresh function every render doesn't tear down and\n// re-add the listener.\nfunction useEventListener(\n type: string,\n handler: (event: Event) => void,\n options: Options & { target?: EventListenerTarget } = {},\n) {\n const { target, capture, passive, once, enabled = true } = options;\n\n const handlerRef = useRef(handler);\n\n useEffect(() => {\n handlerRef.current = handler;\n });\n\n useEffect(() => {\n if (!enabled) {\n return;\n }\n\n let cancelled = false;\n let rafId: number | undefined;\n let resolvedTarget: EventTarget | null = null;\n\n const listener = (event: Event) => {\n handlerRef.current(event);\n };\n\n const attach = () => {\n if (cancelled) {\n return;\n }\n\n // A RefObject target can still be null right after mount (conditional\n // render, portal, lazy mount) — retry every frame until it's\n // populated instead of giving up on the first effect run. A raw\n // target or the default window has nothing to wait for.\n if (target && 'current' in target && !target.current) {\n rafId = requestAnimationFrame(attach);\n return;\n }\n\n resolvedTarget =\n target && 'current' in target\n ? target.current\n : (target ?? (typeof window === 'undefined' ? null : window));\n\n if (!resolvedTarget) {\n return;\n }\n\n resolvedTarget.addEventListener(type, listener, {\n capture,\n passive,\n once,\n });\n };\n\n attach();\n\n return () => {\n cancelled = true;\n if (rafId !== undefined) {\n cancelAnimationFrame(rafId);\n }\n resolvedTarget?.removeEventListener(type, listener, { capture });\n };\n }, [type, target, capture, passive, once, enabled]);\n}\n\nexport default useEventListener;\n"],"mappings":";;;AAoCA,SAAS,iBACP,MACA,SACA,UAAsD,EAAE,EACxD;CACA,MAAM,EAAE,QAAQ,SAAS,SAAS,MAAM,UAAU,SAAS;CAE3D,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;AAEF,iBAAgB;AACd,MAAI,CAAC,QACH;EAGF,IAAI,YAAY;EAChB,IAAI;EACJ,IAAI,iBAAqC;EAEzC,MAAM,YAAY,UAAiB;AACjC,cAAW,QAAQ,MAAM;;EAG3B,MAAM,eAAe;AACnB,OAAI,UACF;AAOF,OAAI,UAAU,aAAa,UAAU,CAAC,OAAO,SAAS;AACpD,YAAQ,sBAAsB,OAAO;AACrC;;AAGF,oBACE,UAAU,aAAa,SACnB,OAAO,UACN,WAAW,OAAO,WAAW,cAAc,OAAO;AAEzD,OAAI,CAAC,eACH;AAGF,kBAAe,iBAAiB,MAAM,UAAU;IAC9C;IACA;IACA;IACD,CAAC;;AAGJ,UAAQ;AAER,eAAa;AACX,eAAY;AACZ,OAAI,UAAU,OACZ,sBAAqB,MAAM;AAE7B,mBAAgB,oBAAoB,MAAM,UAAU,EAAE,SAAS,CAAC;;IAEjE;EAAC;EAAM;EAAQ;EAAS;EAAS;EAAM;EAAQ,CAAC"}
@@ -2,10 +2,19 @@ import { useEffect, useRef } from "react";
2
2
 
3
3
  //#region src/hooks/use-key-press.ts
4
4
  const isMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
5
+ const KEY_ALIASES = {
6
+ space: " ",
7
+ spacebar: " ",
8
+ esc: "escape"
9
+ };
5
10
  const parseCombo = (combo) => {
6
- const parts = combo.split("+").map((part) => part.trim().toLowerCase());
7
- const key = parts[parts.length - 1] ?? "";
8
- const modifiers = parts.slice(0, -1);
11
+ const parts = combo.split("+");
12
+ const modifiers = parts.slice(0, -1).map((part) => part.trim().toLowerCase());
13
+ const rawKey = parts[parts.length - 1] ?? "";
14
+ const trimmedKey = rawKey.trim();
15
+ let key = (trimmedKey === "" && rawKey !== "" ? rawKey : trimmedKey).toLowerCase();
16
+ if (key === "" && parts.length > 1) key = "+";
17
+ key = KEY_ALIASES[key] ?? key;
9
18
  let ctrl = modifiers.includes("ctrl");
10
19
  let meta = modifiers.includes("meta") || modifiers.includes("cmd");
11
20
  const shift = modifiers.includes("shift");
@@ -35,8 +44,9 @@ const useKeyPress = (combo, handler, options = {}) => {
35
44
  const parsedCombos = combos.map(parseCombo);
36
45
  useEffect(() => {
37
46
  if (!enabled) return;
38
- const resolvedTarget = resolveTarget(target);
39
- if (!resolvedTarget) return;
47
+ let cancelled = false;
48
+ let rafId;
49
+ let resolvedTarget = null;
40
50
  const onKeyDown = (event) => {
41
51
  const keyboardEvent = event;
42
52
  const eventTarget = keyboardEvent.target;
@@ -45,9 +55,21 @@ const useKeyPress = (combo, handler, options = {}) => {
45
55
  if (preventDefault) keyboardEvent.preventDefault();
46
56
  handlerRef.current(keyboardEvent);
47
57
  };
48
- resolvedTarget.addEventListener("keydown", onKeyDown);
58
+ const attach = () => {
59
+ if (cancelled) return;
60
+ if (target && "current" in target && !target.current) {
61
+ rafId = requestAnimationFrame(attach);
62
+ return;
63
+ }
64
+ resolvedTarget = resolveTarget(target);
65
+ if (!resolvedTarget) return;
66
+ resolvedTarget.addEventListener("keydown", onKeyDown);
67
+ };
68
+ attach();
49
69
  return () => {
50
- resolvedTarget.removeEventListener("keydown", onKeyDown);
70
+ cancelled = true;
71
+ if (rafId !== void 0) cancelAnimationFrame(rafId);
72
+ resolvedTarget?.removeEventListener("keydown", onKeyDown);
51
73
  };
52
74
  }, [
53
75
  enabled,
@@ -1 +1 @@
1
- {"version":3,"file":"use-key-press.mjs","names":[],"sources":["../../src/hooks/use-key-press.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ntype KeyPressTarget =\n | RefObject<HTMLElement | null>\n | Window\n | Document\n | HTMLElement\n | null\n | undefined;\n\ninterface Options {\n target?: KeyPressTarget;\n enabled?: boolean;\n preventDefault?: boolean;\n // CSS selector — a keydown whose target is inside a matching element is\n // ignored (e.g. don't hijack Cmd+Z while the user is typing in a code\n // editor that has its own undo).\n ignore?: string;\n}\n\ninterface ParsedCombo {\n key: string;\n ctrl: boolean;\n meta: boolean;\n shift: boolean;\n alt: boolean;\n}\n\nconst isMac = () =>\n typeof navigator !== 'undefined' &&\n /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);\n\n// `mod` normalizes to the platform's usual \"primary\" modifier (Cmd on\n// macOS, Ctrl elsewhere) so a single combo string covers both without the\n// caller branching on platform themselves.\nconst parseCombo = (combo: string): ParsedCombo => {\n const parts = combo.split('+').map(part => part.trim().toLowerCase());\n const key = parts[parts.length - 1] ?? '';\n const modifiers = parts.slice(0, -1);\n\n let ctrl = modifiers.includes('ctrl');\n let meta = modifiers.includes('meta') || modifiers.includes('cmd');\n const shift = modifiers.includes('shift');\n const alt = modifiers.includes('alt') || modifiers.includes('option');\n\n if (modifiers.includes('mod')) {\n if (isMac()) {\n meta = true;\n } else {\n ctrl = true;\n }\n }\n\n return { key, ctrl, meta, shift, alt };\n};\n\n// Modifiers not named in the combo are required to be *absent*, not just\n// ignored — otherwise 'mod+z' and 'mod+shift+z' registered as separate\n// bindings (the exact motivating case for this hook) would both fire on\n// the same Cmd+Shift+Z keypress instead of only the latter.\nconst matchesCombo = (event: KeyboardEvent, combo: ParsedCombo) =>\n event.key.toLowerCase() === combo.key &&\n event.ctrlKey === combo.ctrl &&\n event.metaKey === combo.meta &&\n event.shiftKey === combo.shift &&\n event.altKey === combo.alt;\n\nconst resolveTarget = (target: KeyPressTarget): EventTarget | null => {\n if (!target) {\n return typeof window === 'undefined' ? null : window;\n }\n return 'current' in target ? target.current : target;\n};\n\nconst useKeyPress = (\n combo: string | string[],\n handler: (event: KeyboardEvent) => void,\n options: Options = {},\n) => {\n const { target, enabled = true, preventDefault = false, ignore } = options;\n\n const handlerRef = useRef(handler);\n\n useEffect(() => {\n handlerRef.current = handler;\n });\n\n const combos = Array.isArray(combo) ? combo : [combo];\n const parsedCombos = combos.map(parseCombo);\n // `combos` is typically a fresh array literal at the call site — depend\n // on a stable string proxy instead so this doesn't tear down and\n // re-register the listener on every render. `target` itself (a\n // RefObject, or window/document/an element variable) is normally\n // already stable across renders, so it's used directly below.\n const comboKey = combos.join(',');\n\n useEffect(() => {\n if (!enabled) {\n return;\n }\n\n const resolvedTarget = resolveTarget(target);\n\n if (!resolvedTarget) {\n return;\n }\n\n const onKeyDown = (event: Event) => {\n const keyboardEvent = event as KeyboardEvent;\n const eventTarget = keyboardEvent.target as HTMLElement | null;\n\n if (ignore && eventTarget?.closest(ignore)) {\n return;\n }\n\n if (!parsedCombos.some(parsed => matchesCombo(keyboardEvent, parsed))) {\n return;\n }\n\n if (preventDefault) {\n keyboardEvent.preventDefault();\n }\n\n handlerRef.current(keyboardEvent);\n };\n\n resolvedTarget.addEventListener('keydown', onKeyDown);\n\n return () => {\n resolvedTarget.removeEventListener('keydown', onKeyDown);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, comboKey, target, preventDefault, ignore]);\n};\n\nexport default useKeyPress;\n"],"mappings":";;;AA4BA,MAAM,cACJ,OAAO,cAAc,eACrB,uBAAuB,KAAK,UAAU,UAAU;AAKlD,MAAM,cAAc,UAA+B;CACjD,MAAM,QAAQ,MAAM,MAAM,IAAI,CAAC,KAAI,SAAQ,KAAK,MAAM,CAAC,aAAa,CAAC;CACrE,MAAM,MAAM,MAAM,MAAM,SAAS,MAAM;CACvC,MAAM,YAAY,MAAM,MAAM,GAAG,GAAG;CAEpC,IAAI,OAAO,UAAU,SAAS,OAAO;CACrC,IAAI,OAAO,UAAU,SAAS,OAAO,IAAI,UAAU,SAAS,MAAM;CAClE,MAAM,QAAQ,UAAU,SAAS,QAAQ;CACzC,MAAM,MAAM,UAAU,SAAS,MAAM,IAAI,UAAU,SAAS,SAAS;AAErE,KAAI,UAAU,SAAS,MAAM,CAC3B,KAAI,OAAO,CACT,QAAO;KAEP,QAAO;AAIX,QAAO;EAAE;EAAK;EAAM;EAAM;EAAO;EAAK;;AAOxC,MAAM,gBAAgB,OAAsB,UAC1C,MAAM,IAAI,aAAa,KAAK,MAAM,OAClC,MAAM,YAAY,MAAM,QACxB,MAAM,YAAY,MAAM,QACxB,MAAM,aAAa,MAAM,SACzB,MAAM,WAAW,MAAM;AAEzB,MAAM,iBAAiB,WAA+C;AACpE,KAAI,CAAC,OACH,QAAO,OAAO,WAAW,cAAc,OAAO;AAEhD,QAAO,aAAa,SAAS,OAAO,UAAU;;AAGhD,MAAM,eACJ,OACA,SACA,UAAmB,EAAE,KAClB;CACH,MAAM,EAAE,QAAQ,UAAU,MAAM,iBAAiB,OAAO,WAAW;CAEnE,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;CAEF,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,QAAQ,CAAC,MAAM;CACrD,MAAM,eAAe,OAAO,IAAI,WAAW;AAQ3C,iBAAgB;AACd,MAAI,CAAC,QACH;EAGF,MAAM,iBAAiB,cAAc,OAAO;AAE5C,MAAI,CAAC,eACH;EAGF,MAAM,aAAa,UAAiB;GAClC,MAAM,gBAAgB;GACtB,MAAM,cAAc,cAAc;AAElC,OAAI,UAAU,aAAa,QAAQ,OAAO,CACxC;AAGF,OAAI,CAAC,aAAa,MAAK,WAAU,aAAa,eAAe,OAAO,CAAC,CACnE;AAGF,OAAI,eACF,eAAc,gBAAgB;AAGhC,cAAW,QAAQ,cAAc;;AAGnC,iBAAe,iBAAiB,WAAW,UAAU;AAErD,eAAa;AACX,kBAAe,oBAAoB,WAAW,UAAU;;IAGzD;EAAC;EAtCa,OAAO,KAAK,IAAI;EAsCV;EAAQ;EAAgB;EAAO,CAAC"}
1
+ {"version":3,"file":"use-key-press.mjs","names":[],"sources":["../../src/hooks/use-key-press.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ntype KeyPressTarget =\n | RefObject<HTMLElement | null>\n | Window\n | Document\n | HTMLElement\n | null\n | undefined;\n\ninterface Options {\n target?: KeyPressTarget;\n enabled?: boolean;\n preventDefault?: boolean;\n // CSS selector — a keydown whose target is inside a matching element is\n // ignored (e.g. don't hijack Cmd+Z while the user is typing in a code\n // editor that has its own undo).\n ignore?: string;\n}\n\ninterface ParsedCombo {\n key: string;\n ctrl: boolean;\n meta: boolean;\n shift: boolean;\n alt: boolean;\n}\n\nconst isMac = () =>\n typeof navigator !== 'undefined' &&\n /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);\n\n// Human-friendly spellings mapped to the actual `event.key` value, so\n// callers can write `'space'` instead of a literal ' ' (whose trimmed form\n// is empty and easy to lose) or `'esc'` for Escape.\nconst KEY_ALIASES: Record<string, string> = {\n space: ' ',\n spacebar: ' ',\n esc: 'escape',\n};\n\n// `mod` normalizes to the platform's usual \"primary\" modifier (Cmd on\n// macOS, Ctrl elsewhere) so a single combo string covers both without the\n// caller branching on platform themselves.\nconst parseCombo = (combo: string): ParsedCombo => {\n const parts = combo.split('+');\n const modifiers = parts.slice(0, -1).map(part => part.trim().toLowerCase());\n\n // The last segment is the key. Trim it like the modifiers so 'ctrl + z'\n // still resolves to 'z' — but if trimming would wipe a non-empty segment\n // entirely, the key *is* whitespace: the space key, whose event.key is ' '.\n const rawKey = parts[parts.length - 1] ?? '';\n const trimmedKey = rawKey.trim();\n let key = (\n trimmedKey === '' && rawKey !== '' ? rawKey : trimmedKey\n ).toLowerCase();\n\n // A bare '+' or a combo like 'ctrl++' splits to an empty final segment —\n // the '+' separator itself is the intended key.\n if (key === '' && parts.length > 1) {\n key = '+';\n }\n\n key = KEY_ALIASES[key] ?? key;\n\n let ctrl = modifiers.includes('ctrl');\n let meta = modifiers.includes('meta') || modifiers.includes('cmd');\n const shift = modifiers.includes('shift');\n const alt = modifiers.includes('alt') || modifiers.includes('option');\n\n if (modifiers.includes('mod')) {\n if (isMac()) {\n meta = true;\n } else {\n ctrl = true;\n }\n }\n\n return { key, ctrl, meta, shift, alt };\n};\n\n// Modifiers not named in the combo are required to be *absent*, not just\n// ignored — otherwise 'mod+z' and 'mod+shift+z' registered as separate\n// bindings (the exact motivating case for this hook) would both fire on\n// the same Cmd+Shift+Z keypress instead of only the latter.\nconst matchesCombo = (event: KeyboardEvent, combo: ParsedCombo) =>\n event.key.toLowerCase() === combo.key &&\n event.ctrlKey === combo.ctrl &&\n event.metaKey === combo.meta &&\n event.shiftKey === combo.shift &&\n event.altKey === combo.alt;\n\nconst resolveTarget = (target: KeyPressTarget): EventTarget | null => {\n if (!target) {\n return typeof window === 'undefined' ? null : window;\n }\n return 'current' in target ? target.current : target;\n};\n\nconst useKeyPress = (\n combo: string | string[],\n handler: (event: KeyboardEvent) => void,\n options: Options = {},\n) => {\n const { target, enabled = true, preventDefault = false, ignore } = options;\n\n const handlerRef = useRef(handler);\n\n useEffect(() => {\n handlerRef.current = handler;\n });\n\n const combos = Array.isArray(combo) ? combo : [combo];\n const parsedCombos = combos.map(parseCombo);\n // `combos` is typically a fresh array literal at the call site — depend\n // on a stable string proxy instead so this doesn't tear down and\n // re-register the listener on every render. `target` itself (a\n // RefObject, or window/document/an element variable) is normally\n // already stable across renders, so it's used directly below.\n const comboKey = combos.join(',');\n\n useEffect(() => {\n if (!enabled) {\n return;\n }\n\n let cancelled = false;\n let rafId: number | undefined;\n let resolvedTarget: EventTarget | null = null;\n\n const onKeyDown = (event: Event) => {\n const keyboardEvent = event as KeyboardEvent;\n const eventTarget = keyboardEvent.target as HTMLElement | null;\n\n if (ignore && eventTarget?.closest(ignore)) {\n return;\n }\n\n if (!parsedCombos.some(parsed => matchesCombo(keyboardEvent, parsed))) {\n return;\n }\n\n if (preventDefault) {\n keyboardEvent.preventDefault();\n }\n\n handlerRef.current(keyboardEvent);\n };\n\n const attach = () => {\n if (cancelled) {\n return;\n }\n\n // A RefObject target can still be null right after mount (conditional\n // render, portal, lazy mount) — retry every frame until it's\n // populated instead of giving up on the first effect run. The default\n // window and raw element/document targets have nothing to wait for.\n if (target && 'current' in target && !target.current) {\n rafId = requestAnimationFrame(attach);\n return;\n }\n\n resolvedTarget = resolveTarget(target);\n\n if (!resolvedTarget) {\n return;\n }\n\n resolvedTarget.addEventListener('keydown', onKeyDown);\n };\n\n attach();\n\n return () => {\n cancelled = true;\n if (rafId !== undefined) {\n cancelAnimationFrame(rafId);\n }\n resolvedTarget?.removeEventListener('keydown', onKeyDown);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, comboKey, target, preventDefault, ignore]);\n};\n\nexport default useKeyPress;\n"],"mappings":";;;AA4BA,MAAM,cACJ,OAAO,cAAc,eACrB,uBAAuB,KAAK,UAAU,UAAU;AAKlD,MAAM,cAAsC;CAC1C,OAAO;CACP,UAAU;CACV,KAAK;CACN;AAKD,MAAM,cAAc,UAA+B;CACjD,MAAM,QAAQ,MAAM,MAAM,IAAI;CAC9B,MAAM,YAAY,MAAM,MAAM,GAAG,GAAG,CAAC,KAAI,SAAQ,KAAK,MAAM,CAAC,aAAa,CAAC;CAK3E,MAAM,SAAS,MAAM,MAAM,SAAS,MAAM;CAC1C,MAAM,aAAa,OAAO,MAAM;CAChC,IAAI,OACF,eAAe,MAAM,WAAW,KAAK,SAAS,YAC9C,aAAa;AAIf,KAAI,QAAQ,MAAM,MAAM,SAAS,EAC/B,OAAM;AAGR,OAAM,YAAY,QAAQ;CAE1B,IAAI,OAAO,UAAU,SAAS,OAAO;CACrC,IAAI,OAAO,UAAU,SAAS,OAAO,IAAI,UAAU,SAAS,MAAM;CAClE,MAAM,QAAQ,UAAU,SAAS,QAAQ;CACzC,MAAM,MAAM,UAAU,SAAS,MAAM,IAAI,UAAU,SAAS,SAAS;AAErE,KAAI,UAAU,SAAS,MAAM,CAC3B,KAAI,OAAO,CACT,QAAO;KAEP,QAAO;AAIX,QAAO;EAAE;EAAK;EAAM;EAAM;EAAO;EAAK;;AAOxC,MAAM,gBAAgB,OAAsB,UAC1C,MAAM,IAAI,aAAa,KAAK,MAAM,OAClC,MAAM,YAAY,MAAM,QACxB,MAAM,YAAY,MAAM,QACxB,MAAM,aAAa,MAAM,SACzB,MAAM,WAAW,MAAM;AAEzB,MAAM,iBAAiB,WAA+C;AACpE,KAAI,CAAC,OACH,QAAO,OAAO,WAAW,cAAc,OAAO;AAEhD,QAAO,aAAa,SAAS,OAAO,UAAU;;AAGhD,MAAM,eACJ,OACA,SACA,UAAmB,EAAE,KAClB;CACH,MAAM,EAAE,QAAQ,UAAU,MAAM,iBAAiB,OAAO,WAAW;CAEnE,MAAM,aAAa,OAAO,QAAQ;AAElC,iBAAgB;AACd,aAAW,UAAU;GACrB;CAEF,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,QAAQ,CAAC,MAAM;CACrD,MAAM,eAAe,OAAO,IAAI,WAAW;AAQ3C,iBAAgB;AACd,MAAI,CAAC,QACH;EAGF,IAAI,YAAY;EAChB,IAAI;EACJ,IAAI,iBAAqC;EAEzC,MAAM,aAAa,UAAiB;GAClC,MAAM,gBAAgB;GACtB,MAAM,cAAc,cAAc;AAElC,OAAI,UAAU,aAAa,QAAQ,OAAO,CACxC;AAGF,OAAI,CAAC,aAAa,MAAK,WAAU,aAAa,eAAe,OAAO,CAAC,CACnE;AAGF,OAAI,eACF,eAAc,gBAAgB;AAGhC,cAAW,QAAQ,cAAc;;EAGnC,MAAM,eAAe;AACnB,OAAI,UACF;AAOF,OAAI,UAAU,aAAa,UAAU,CAAC,OAAO,SAAS;AACpD,YAAQ,sBAAsB,OAAO;AACrC;;AAGF,oBAAiB,cAAc,OAAO;AAEtC,OAAI,CAAC,eACH;AAGF,kBAAe,iBAAiB,WAAW,UAAU;;AAGvD,UAAQ;AAER,eAAa;AACX,eAAY;AACZ,OAAI,UAAU,OACZ,sBAAqB,MAAM;AAE7B,mBAAgB,oBAAoB,WAAW,UAAU;;IAG1D;EAAC;EA/Da,OAAO,KAAK,IAAI;EA+DV;EAAQ;EAAgB;EAAO,CAAC"}
@@ -2,7 +2,7 @@ import { RefObject } from "react";
2
2
 
3
3
  //#region src/hooks/use-merged-ref.d.ts
4
4
  type MergeableRef<T> = ((node: T | null) => void | (() => void)) | RefObject<T | null> | null | undefined;
5
- declare const useMergedRef: <T>(...refs: MergeableRef<T>[]) => (node: T | null) => (() => void) | undefined;
5
+ declare const useMergedRef: <T>(...refs: MergeableRef<T>[]) => (node: T | null) => () => void;
6
6
  //#endregion
7
7
  export { useMergedRef };
8
8
  //# sourceMappingURL=use-merged-ref.d.mts.map
@@ -3,19 +3,25 @@ import { useCallback } from "react";
3
3
  //#region src/hooks/use-merged-ref.ts
4
4
  const useMergedRef = (...refs) => {
5
5
  return useCallback((node) => {
6
- const cleanups = [];
7
- refs.forEach((ref) => {
6
+ const cleanups = refs.map((ref) => {
8
7
  if (!ref) return;
9
8
  if (typeof ref === "function") {
10
9
  const cleanup = ref(node);
11
- if (typeof cleanup === "function") cleanups.push(cleanup);
12
- return;
10
+ return typeof cleanup === "function" ? cleanup : void 0;
13
11
  }
14
12
  ref.current = node;
15
13
  });
16
- if (cleanups.length === 0) return;
17
14
  return () => {
18
- cleanups.forEach((cleanup) => cleanup());
15
+ refs.forEach((ref, i) => {
16
+ if (!ref) return;
17
+ if (typeof ref === "function") {
18
+ const cleanup = cleanups[i];
19
+ if (cleanup) cleanup();
20
+ else ref(null);
21
+ return;
22
+ }
23
+ ref.current = null;
24
+ });
19
25
  };
20
26
  }, refs);
21
27
  };
@@ -1 +1 @@
1
- {"version":3,"file":"use-merged-ref.mjs","names":[],"sources":["../../src/hooks/use-merged-ref.ts"],"sourcesContent":["import { type RefObject, useCallback } from 'react';\n\ntype MergeableRef<T> =\n | ((node: T | null) => void | (() => void))\n | RefObject<T | null>\n | null\n | undefined;\n\n// Merges any number of refs (forwarded function refs, RefObjects, or\n// either left null/undefined) into one callback ref that updates all of\n// them. Collects React 19's optional per-ref cleanup return values and\n// runs them together when the node detaches.\nconst useMergedRef = <T>(...refs: MergeableRef<T>[]) => {\n return useCallback((node: T | null) => {\n const cleanups: (() => void)[] = [];\n\n refs.forEach(ref => {\n if (!ref) {\n return;\n }\n\n if (typeof ref === 'function') {\n const cleanup = ref(node);\n\n if (typeof cleanup === 'function') {\n cleanups.push(cleanup);\n }\n return;\n }\n\n ref.current = node;\n });\n\n if (cleanups.length === 0) {\n return;\n }\n\n return () => {\n cleanups.forEach(cleanup => cleanup());\n };\n // The number of refs passed at a given call site is stable across\n // renders even though this array literal isn't — same pattern every\n // ref-merging hook of this shape relies on.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, refs);\n};\n\nexport default useMergedRef;\n"],"mappings":";;;AAYA,MAAM,gBAAmB,GAAG,SAA4B;AACtD,QAAO,aAAa,SAAmB;EACrC,MAAM,WAA2B,EAAE;AAEnC,OAAK,SAAQ,QAAO;AAClB,OAAI,CAAC,IACH;AAGF,OAAI,OAAO,QAAQ,YAAY;IAC7B,MAAM,UAAU,IAAI,KAAK;AAEzB,QAAI,OAAO,YAAY,WACrB,UAAS,KAAK,QAAQ;AAExB;;AAGF,OAAI,UAAU;IACd;AAEF,MAAI,SAAS,WAAW,EACtB;AAGF,eAAa;AACX,YAAS,SAAQ,YAAW,SAAS,CAAC;;IAMvC,KAAK"}
1
+ {"version":3,"file":"use-merged-ref.mjs","names":[],"sources":["../../src/hooks/use-merged-ref.ts"],"sourcesContent":["import { type RefObject, useCallback } from 'react';\n\ntype MergeableRef<T> =\n | ((node: T | null) => void | (() => void))\n | RefObject<T | null>\n | null\n | undefined;\n\n// Merges any number of refs (forwarded function refs, RefObjects, or\n// either left null/undefined) into one callback ref that updates all of\n// them. Collects React 19's optional per-ref cleanup return values and\n// runs them together when the node detaches.\nconst useMergedRef = <T>(...refs: MergeableRef<T>[]) => {\n return useCallback((node: T | null) => {\n // Per-ref cleanups, aligned to `refs` by index: a function ref that\n // returns its own cleanup keeps it here, everything else is undefined.\n const cleanups = refs.map(ref => {\n if (!ref) {\n return undefined;\n }\n\n if (typeof ref === 'function') {\n const cleanup = ref(node);\n return typeof cleanup === 'function' ? cleanup : undefined;\n }\n\n ref.current = node;\n return undefined;\n });\n\n // Always return a cleanup and detach *every* ref here. When any ref\n // returns a cleanup, React 19 runs this instead of re-invoking the\n // callback with null on detach — so if we only ran the collected\n // cleanups, the object refs and cleanup-less function refs merged\n // alongside would never be released and would pin a stale node.\n return () => {\n refs.forEach((ref, i) => {\n if (!ref) {\n return;\n }\n\n if (typeof ref === 'function') {\n const cleanup = cleanups[i];\n if (cleanup) {\n cleanup();\n } else {\n ref(null);\n }\n return;\n }\n\n ref.current = null;\n });\n };\n // The number of refs passed at a given call site is stable across\n // renders even though this array literal isn't — same pattern every\n // ref-merging hook of this shape relies on.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, refs);\n};\n\nexport default useMergedRef;\n"],"mappings":";;;AAYA,MAAM,gBAAmB,GAAG,SAA4B;AACtD,QAAO,aAAa,SAAmB;EAGrC,MAAM,WAAW,KAAK,KAAI,QAAO;AAC/B,OAAI,CAAC,IACH;AAGF,OAAI,OAAO,QAAQ,YAAY;IAC7B,MAAM,UAAU,IAAI,KAAK;AACzB,WAAO,OAAO,YAAY,aAAa,UAAU;;AAGnD,OAAI,UAAU;IAEd;AAOF,eAAa;AACX,QAAK,SAAS,KAAK,MAAM;AACvB,QAAI,CAAC,IACH;AAGF,QAAI,OAAO,QAAQ,YAAY;KAC7B,MAAM,UAAU,SAAS;AACzB,SAAI,QACF,UAAS;SAET,KAAI,KAAK;AAEX;;AAGF,QAAI,UAAU;KACd;;IAMH,KAAK"}
@@ -12,14 +12,28 @@ const useMutationObserver = (target, callback, options = {}) => {
12
12
  callbackRef.current = callback;
13
13
  });
14
14
  useEffect(() => {
15
- const node = resolveTarget(target);
16
- if (!enabled || !node || typeof MutationObserver === "undefined") return;
17
- const observer = new MutationObserver((mutations, obs) => {
18
- callbackRef.current(mutations, obs);
19
- });
20
- observer.observe(node, mutationOptions);
15
+ if (!enabled || typeof MutationObserver === "undefined") return;
16
+ let cancelled = false;
17
+ let rafId;
18
+ let observer;
19
+ const attach = () => {
20
+ if (cancelled) return;
21
+ if (target && "current" in target && !target.current) {
22
+ rafId = requestAnimationFrame(attach);
23
+ return;
24
+ }
25
+ const node = resolveTarget(target);
26
+ if (!node) return;
27
+ observer = new MutationObserver((mutations, obs) => {
28
+ callbackRef.current(mutations, obs);
29
+ });
30
+ observer.observe(node, mutationOptions);
31
+ };
32
+ attach();
21
33
  return () => {
22
- observer.disconnect();
34
+ cancelled = true;
35
+ if (rafId !== void 0) cancelAnimationFrame(rafId);
36
+ observer?.disconnect();
23
37
  };
24
38
  }, [
25
39
  target,
@@ -1 +1 @@
1
- {"version":3,"file":"use-mutation-observer.mjs","names":[],"sources":["../../src/hooks/use-mutation-observer.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ntype Target<T extends Node> = RefObject<T | null> | T | null | undefined;\n\ninterface Options extends MutationObserverInit {\n enabled?: boolean;\n}\n\nconst resolveTarget = <T extends Node>(target: Target<T>): T | null => {\n if (!target) {\n return null;\n }\n return 'current' in target ? target.current : target;\n};\n\n// Takes the target directly (a RefObject, or a plain Node like\n// document.head that isn't behind any React ref at all) rather than\n// producing its own — this and useResizeObserver together replace the\n// ResizeObserver+MutationObserver pair live-editor's iframe hand-rolls\n// for auto-sizing its preview content.\nconst useMutationObserver = <T extends Node>(\n target: Target<T>,\n callback: MutationCallback,\n options: Options = {},\n) => {\n const { enabled = true, ...mutationOptions } = options;\n\n const callbackRef = useRef(callback);\n\n useEffect(() => {\n callbackRef.current = callback;\n });\n\n // `mutationOptions` is typically a fresh object literal at the call\n // site — serialize it into a stable key instead of depending on the\n // object itself, which would tear down and recreate the observer on\n // every render.\n const optionsKey = JSON.stringify(mutationOptions);\n\n useEffect(() => {\n const node = resolveTarget(target);\n\n if (!enabled || !node || typeof MutationObserver === 'undefined') {\n return;\n }\n\n const observer = new MutationObserver((mutations, obs) => {\n callbackRef.current(mutations, obs);\n });\n\n observer.observe(node, mutationOptions);\n\n return () => {\n observer.disconnect();\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [target, enabled, optionsKey]);\n};\n\nexport default useMutationObserver;\n"],"mappings":";;;AAQA,MAAM,iBAAiC,WAAgC;AACrE,KAAI,CAAC,OACH,QAAO;AAET,QAAO,aAAa,SAAS,OAAO,UAAU;;AAQhD,MAAM,uBACJ,QACA,UACA,UAAmB,EAAE,KAClB;CACH,MAAM,EAAE,UAAU,MAAM,GAAG,oBAAoB;CAE/C,MAAM,cAAc,OAAO,SAAS;AAEpC,iBAAgB;AACd,cAAY,UAAU;GACtB;AAQF,iBAAgB;EACd,MAAM,OAAO,cAAc,OAAO;AAElC,MAAI,CAAC,WAAW,CAAC,QAAQ,OAAO,qBAAqB,YACnD;EAGF,MAAM,WAAW,IAAI,kBAAkB,WAAW,QAAQ;AACxD,eAAY,QAAQ,WAAW,IAAI;IACnC;AAEF,WAAS,QAAQ,MAAM,gBAAgB;AAEvC,eAAa;AACX,YAAS,YAAY;;IAGtB;EAAC;EAAQ;EAnBO,KAAK,UAAU,gBAAgB;EAmBlB,CAAC"}
1
+ {"version":3,"file":"use-mutation-observer.mjs","names":[],"sources":["../../src/hooks/use-mutation-observer.ts"],"sourcesContent":["import { type RefObject, useEffect, useRef } from 'react';\n\ntype Target<T extends Node> = RefObject<T | null> | T | null | undefined;\n\ninterface Options extends MutationObserverInit {\n enabled?: boolean;\n}\n\nconst resolveTarget = <T extends Node>(target: Target<T>): T | null => {\n if (!target) {\n return null;\n }\n return 'current' in target ? target.current : target;\n};\n\n// Takes the target directly (a RefObject, or a plain Node like\n// document.head that isn't behind any React ref at all) rather than\n// producing its own — this and useResizeObserver together replace the\n// ResizeObserver+MutationObserver pair live-editor's iframe hand-rolls\n// for auto-sizing its preview content.\nconst useMutationObserver = <T extends Node>(\n target: Target<T>,\n callback: MutationCallback,\n options: Options = {},\n) => {\n const { enabled = true, ...mutationOptions } = options;\n\n const callbackRef = useRef(callback);\n\n useEffect(() => {\n callbackRef.current = callback;\n });\n\n // `mutationOptions` is typically a fresh object literal at the call\n // site — serialize it into a stable key instead of depending on the\n // object itself, which would tear down and recreate the observer on\n // every render.\n const optionsKey = JSON.stringify(mutationOptions);\n\n useEffect(() => {\n if (!enabled || typeof MutationObserver === 'undefined') {\n return;\n }\n\n let cancelled = false;\n let rafId: number | undefined;\n let observer: MutationObserver | undefined;\n\n const attach = () => {\n if (cancelled) {\n return;\n }\n\n // A RefObject target can still be null right after mount (conditional\n // render, portal, lazy mount) — retry every frame until it's\n // populated instead of giving up on the first effect run, since the\n // ref object's identity doesn't change to re-run this effect. A raw\n // node has nothing to wait for.\n if (target && 'current' in target && !target.current) {\n rafId = requestAnimationFrame(attach);\n return;\n }\n\n const node = resolveTarget(target);\n\n if (!node) {\n return;\n }\n\n observer = new MutationObserver((mutations, obs) => {\n callbackRef.current(mutations, obs);\n });\n\n observer.observe(node, mutationOptions);\n };\n\n attach();\n\n return () => {\n cancelled = true;\n if (rafId !== undefined) {\n cancelAnimationFrame(rafId);\n }\n observer?.disconnect();\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [target, enabled, optionsKey]);\n};\n\nexport default useMutationObserver;\n"],"mappings":";;;AAQA,MAAM,iBAAiC,WAAgC;AACrE,KAAI,CAAC,OACH,QAAO;AAET,QAAO,aAAa,SAAS,OAAO,UAAU;;AAQhD,MAAM,uBACJ,QACA,UACA,UAAmB,EAAE,KAClB;CACH,MAAM,EAAE,UAAU,MAAM,GAAG,oBAAoB;CAE/C,MAAM,cAAc,OAAO,SAAS;AAEpC,iBAAgB;AACd,cAAY,UAAU;GACtB;AAQF,iBAAgB;AACd,MAAI,CAAC,WAAW,OAAO,qBAAqB,YAC1C;EAGF,IAAI,YAAY;EAChB,IAAI;EACJ,IAAI;EAEJ,MAAM,eAAe;AACnB,OAAI,UACF;AAQF,OAAI,UAAU,aAAa,UAAU,CAAC,OAAO,SAAS;AACpD,YAAQ,sBAAsB,OAAO;AACrC;;GAGF,MAAM,OAAO,cAAc,OAAO;AAElC,OAAI,CAAC,KACH;AAGF,cAAW,IAAI,kBAAkB,WAAW,QAAQ;AAClD,gBAAY,QAAQ,WAAW,IAAI;KACnC;AAEF,YAAS,QAAQ,MAAM,gBAAgB;;AAGzC,UAAQ;AAER,eAAa;AACX,eAAY;AACZ,OAAI,UAAU,OACZ,sBAAqB,MAAM;AAE7B,aAAU,YAAY;;IAGvB;EAAC;EAAQ;EAjDO,KAAK,UAAU,gBAAgB;EAiDlB,CAAC"}
@@ -1,27 +1,51 @@
1
- import { useCallback, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  //#region src/hooks/use-resize-observer.ts
4
4
  const useResizeObserver = (options) => {
5
+ const box = options?.box ?? "content-box";
5
6
  const [size, setSize] = useState(null);
6
7
  const observerRef = useRef(null);
7
- return [useCallback((node) => {
8
+ const nodeRef = useRef(null);
9
+ const boxRef = useRef(box);
10
+ useEffect(() => {
11
+ boxRef.current = box;
12
+ });
13
+ const connect = useCallback(() => {
8
14
  if (observerRef.current) {
9
15
  observerRef.current.disconnect();
10
16
  observerRef.current = null;
11
17
  }
18
+ const node = nodeRef.current;
12
19
  if (!node || typeof ResizeObserver === "undefined") return;
13
- const box = options?.box ?? "content-box";
20
+ const currentBox = boxRef.current;
14
21
  const observer = new ResizeObserver(([entry]) => {
15
22
  if (!entry) return;
16
- const [{ inlineSize, blockSize }] = box === "border-box" ? entry.borderBoxSize : entry.contentBoxSize;
17
- setSize({
18
- width: inlineSize,
19
- height: blockSize
23
+ const measurement = (currentBox === "border-box" ? entry.borderBoxSize : entry.contentBoxSize)?.[0];
24
+ if (measurement) setSize({
25
+ width: measurement.inlineSize,
26
+ height: measurement.blockSize
27
+ });
28
+ else setSize({
29
+ width: entry.contentRect.width,
30
+ height: entry.contentRect.height
20
31
  });
21
32
  });
22
- observer.observe(node, { box });
33
+ observer.observe(node, { box: currentBox });
23
34
  observerRef.current = observer;
24
- }, []), size];
35
+ }, []);
36
+ const ref = useCallback((node) => {
37
+ nodeRef.current = node;
38
+ connect();
39
+ }, []);
40
+ useEffect(() => {
41
+ connect();
42
+ }, [box]);
43
+ useEffect(() => {
44
+ return () => {
45
+ observerRef.current?.disconnect();
46
+ };
47
+ }, []);
48
+ return [ref, size];
25
49
  };
26
50
 
27
51
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"use-resize-observer.mjs","names":[],"sources":["../../src/hooks/use-resize-observer.ts"],"sourcesContent":["import { useCallback, useRef, useState } from 'react';\n\ninterface Size {\n width: number;\n height: number;\n}\n\ninterface Options {\n box?: ResizeObserverBoxOptions;\n}\n\n// The unprocessed version of useResponsiveSize/useElementScroll/\n// useElementPosition — those each return values shaped for their own\n// purpose (breakpoints, scroll position, a viewport-relative DOMRect).\n// This one just reports an element's own width/height.\nconst useResizeObserver = <T extends Element = Element>(\n options?: Options,\n): [(node: T | null) => void, Size | null] => {\n const [size, setSize] = useState<Size | null>(null);\n const observerRef = useRef<ResizeObserver | null>(null);\n\n // Same convention as useIntersectionObserver: `options` is typically a\n // fresh object literal at the call site, so it isn't a dependency here\n // — that would tear down and recreate the observer on every render. The\n // ref callback only re-runs when the observed node itself changes,\n // capturing whatever `options` was current at that time.\n const ref = useCallback((node: T | null) => {\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n\n if (!node || typeof ResizeObserver === 'undefined') {\n return;\n }\n\n const box = options?.box ?? 'content-box';\n\n const observer = new ResizeObserver(([entry]) => {\n if (!entry) {\n return;\n }\n\n const [{ inlineSize, blockSize }] =\n box === 'border-box' ? entry.borderBoxSize : entry.contentBoxSize;\n\n setSize({ width: inlineSize, height: blockSize });\n });\n\n observer.observe(node, { box });\n observerRef.current = observer;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n return [ref, size];\n};\n\nexport default useResizeObserver;\n"],"mappings":";;;AAeA,MAAM,qBACJ,YAC4C;CAC5C,MAAM,CAAC,MAAM,WAAW,SAAsB,KAAK;CACnD,MAAM,cAAc,OAA8B,KAAK;AAmCvD,QAAO,CA5BK,aAAa,SAAmB;AAC1C,MAAI,YAAY,SAAS;AACvB,eAAY,QAAQ,YAAY;AAChC,eAAY,UAAU;;AAGxB,MAAI,CAAC,QAAQ,OAAO,mBAAmB,YACrC;EAGF,MAAM,MAAM,SAAS,OAAO;EAE5B,MAAM,WAAW,IAAI,gBAAgB,CAAC,WAAW;AAC/C,OAAI,CAAC,MACH;GAGF,MAAM,CAAC,EAAE,YAAY,eACnB,QAAQ,eAAe,MAAM,gBAAgB,MAAM;AAErD,WAAQ;IAAE,OAAO;IAAY,QAAQ;IAAW,CAAC;IACjD;AAEF,WAAS,QAAQ,MAAM,EAAE,KAAK,CAAC;AAC/B,cAAY,UAAU;IAErB,EAAE,CAAC,EAEO,KAAK"}
1
+ {"version":3,"file":"use-resize-observer.mjs","names":[],"sources":["../../src/hooks/use-resize-observer.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\ninterface Size {\n width: number;\n height: number;\n}\n\ninterface Options {\n box?: ResizeObserverBoxOptions;\n}\n\n// The unprocessed version of useResponsiveSize/useElementScroll/\n// useElementPosition — those each return values shaped for their own\n// purpose (breakpoints, scroll position, a viewport-relative DOMRect).\n// This one just reports an element's own width/height.\nconst useResizeObserver = <T extends Element = Element>(\n options?: Options,\n): [(node: T | null) => void, Size | null] => {\n const box = options?.box ?? 'content-box';\n\n const [size, setSize] = useState<Size | null>(null);\n const observerRef = useRef<ResizeObserver | null>(null);\n const nodeRef = useRef<T | null>(null);\n\n // `options` is typically a fresh object literal at the call site — track\n // the current box in a ref (read at connect time) and reconnect only when\n // the value actually changes, so `box` can be flipped at runtime without\n // tearing the observer down on every render. The previous version pinned\n // whatever `box` was current when the node first attached and never\n // reacted to changes — the same bug #118 fixed in useIntersectionObserver.\n const boxRef = useRef(box);\n\n useEffect(() => {\n boxRef.current = box;\n });\n\n const connect = useCallback(() => {\n if (observerRef.current) {\n observerRef.current.disconnect();\n observerRef.current = null;\n }\n\n const node = nodeRef.current;\n\n if (!node || typeof ResizeObserver === 'undefined') {\n return;\n }\n\n const currentBox = boxRef.current;\n\n const observer = new ResizeObserver(([entry]) => {\n if (!entry) {\n return;\n }\n\n const boxSize =\n currentBox === 'border-box'\n ? entry.borderBoxSize\n : entry.contentBoxSize;\n const measurement = boxSize?.[0];\n\n if (measurement) {\n // inlineSize/blockSize are writing-mode relative; for the default\n // horizontal-tb writing mode they map to width/height as expected.\n setSize({\n width: measurement.inlineSize,\n height: measurement.blockSize,\n });\n } else {\n // contentBoxSize/borderBoxSize come back as empty arrays for a\n // `display: none` target, so destructuring the first element would\n // throw. contentRect still reports (0×0 there) and is already in\n // physical width/height, so it's also unaffected by writing-mode.\n setSize({\n width: entry.contentRect.width,\n height: entry.contentRect.height,\n });\n }\n });\n\n observer.observe(node, { box: currentBox });\n observerRef.current = observer;\n }, []);\n\n const ref = useCallback((node: T | null) => {\n nodeRef.current = node;\n connect();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n connect();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [box]);\n\n useEffect(() => {\n return () => {\n observerRef.current?.disconnect();\n };\n }, []);\n\n return [ref, size];\n};\n\nexport default useResizeObserver;\n"],"mappings":";;;AAeA,MAAM,qBACJ,YAC4C;CAC5C,MAAM,MAAM,SAAS,OAAO;CAE5B,MAAM,CAAC,MAAM,WAAW,SAAsB,KAAK;CACnD,MAAM,cAAc,OAA8B,KAAK;CACvD,MAAM,UAAU,OAAiB,KAAK;CAQtC,MAAM,SAAS,OAAO,IAAI;AAE1B,iBAAgB;AACd,SAAO,UAAU;GACjB;CAEF,MAAM,UAAU,kBAAkB;AAChC,MAAI,YAAY,SAAS;AACvB,eAAY,QAAQ,YAAY;AAChC,eAAY,UAAU;;EAGxB,MAAM,OAAO,QAAQ;AAErB,MAAI,CAAC,QAAQ,OAAO,mBAAmB,YACrC;EAGF,MAAM,aAAa,OAAO;EAE1B,MAAM,WAAW,IAAI,gBAAgB,CAAC,WAAW;AAC/C,OAAI,CAAC,MACH;GAOF,MAAM,eAHJ,eAAe,eACX,MAAM,gBACN,MAAM,kBACkB;AAE9B,OAAI,YAGF,SAAQ;IACN,OAAO,YAAY;IACnB,QAAQ,YAAY;IACrB,CAAC;OAMF,SAAQ;IACN,OAAO,MAAM,YAAY;IACzB,QAAQ,MAAM,YAAY;IAC3B,CAAC;IAEJ;AAEF,WAAS,QAAQ,MAAM,EAAE,KAAK,YAAY,CAAC;AAC3C,cAAY,UAAU;IACrB,EAAE,CAAC;CAEN,MAAM,MAAM,aAAa,SAAmB;AAC1C,UAAQ,UAAU;AAClB,WAAS;IAER,EAAE,CAAC;AAEN,iBAAgB;AACd,WAAS;IAER,CAAC,IAAI,CAAC;AAET,iBAAgB;AACd,eAAa;AACX,eAAY,SAAS,YAAY;;IAElC,EAAE,CAAC;AAEN,QAAO,CAAC,KAAK,KAAK"}
@@ -1,4 +1,4 @@
1
- import useDebounce from "./use-debounce.mjs";
1
+ import useDebouncedCallback from "./use-debounced-callback.mjs";
2
2
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
 
4
4
  //#region src/hooks/use-responsive-size.ts
@@ -65,7 +65,7 @@ const useResponsiveSize = (options) => {
65
65
  const nextBreakpoint = getBreakpointInfo(next.width);
66
66
  setBreakpoint((prev) => breakpointEqual(prev, nextBreakpoint) ? prev : nextBreakpoint);
67
67
  }, []);
68
- const debouncedCommit = useDebounce(commit, {
68
+ const debouncedCommit = useDebouncedCallback(commit, {
69
69
  delay,
70
70
  autoInvoke: false
71
71
  });
@@ -1 +1 @@
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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"}
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 useDebouncedCallback from './use-debounced-callback';\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\n // useDebouncedCallback's machinery instead of duplicating it here.\n const debouncedCommit = useDebouncedCallback(commit, {\n delay,\n autoInvoke: false,\n });\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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"}
@@ -0,0 +1,9 @@
1
+ //#region src/hooks/use-throttled-value.d.ts
2
+ interface Options {
3
+ leading?: boolean;
4
+ trailing?: boolean;
5
+ }
6
+ declare const useThrottledValue: <T>(value: T, delay?: number, options?: Options) => T;
7
+ //#endregion
8
+ export { useThrottledValue };
9
+ //# sourceMappingURL=use-throttled-value.d.mts.map
@@ -1,8 +1,8 @@
1
1
  import useThrottledCallback from "./use-throttled-callback.mjs";
2
2
  import { useEffect, useState } from "react";
3
3
 
4
- //#region src/hooks/use-throttle.ts
5
- const useThrottle = (value, delay = 100, options = {}) => {
4
+ //#region src/hooks/use-throttled-value.ts
5
+ const useThrottledValue = (value, delay = 100, options = {}) => {
6
6
  const [throttledValue, setThrottledValue] = useState(value);
7
7
  const throttledSetValue = useThrottledCallback(setThrottledValue, delay, options);
8
8
  useEffect(() => {
@@ -12,5 +12,5 @@ const useThrottle = (value, delay = 100, options = {}) => {
12
12
  };
13
13
 
14
14
  //#endregion
15
- export { useThrottle as default };
16
- //# sourceMappingURL=use-throttle.mjs.map
15
+ export { useThrottledValue as default };
16
+ //# sourceMappingURL=use-throttled-value.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-throttled-value.mjs","names":[],"sources":["../../src/hooks/use-throttled-value.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nimport useThrottledCallback from './use-throttled-callback';\n\ninterface Options {\n leading?: boolean;\n trailing?: boolean;\n}\n\nconst useThrottledValue = <T>(\n value: T,\n delay = 100,\n options: Options = {},\n): T => {\n const [throttledValue, setThrottledValue] = useState(value);\n\n const throttledSetValue = useThrottledCallback(\n setThrottledValue,\n delay,\n options,\n );\n\n useEffect(() => {\n throttledSetValue(value);\n // `throttledSetValue`'s identity is stable for the lifetime of the\n // component (see useThrottledCallback), so listing it here doesn't\n // cause any extra re-runs beyond `value` actually changing.\n }, [value, throttledSetValue]);\n\n return throttledValue;\n};\n\nexport default useThrottledValue;\n"],"mappings":";;;;AASA,MAAM,qBACJ,OACA,QAAQ,KACR,UAAmB,EAAE,KACf;CACN,MAAM,CAAC,gBAAgB,qBAAqB,SAAS,MAAM;CAE3D,MAAM,oBAAoB,qBACxB,mBACA,OACA,QACD;AAED,iBAAgB;AACd,oBAAkB,MAAM;IAIvB,CAAC,OAAO,kBAAkB,CAAC;AAE9B,QAAO"}
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { useDebounce } from "./hooks/use-debounce.mjs";
1
+ import { useDebouncedCallback } from "./hooks/use-debounced-callback.mjs";
2
2
  import { useDebouncedValue } from "./hooks/use-debounced-value.mjs";
3
3
  import { useBodyScrollLock } from "./hooks/use-body-scroll-lock.mjs";
4
4
  import { useClickOutside } from "./hooks/use-click-outside.mjs";
@@ -22,11 +22,11 @@ import { usePrevious } from "./hooks/use-previous.mjs";
22
22
  import { useRecursiveTimeout } from "./hooks/use-recursive-timeout.mjs";
23
23
  import { useResizeObserver } from "./hooks/use-resize-observer.mjs";
24
24
  import { useScrollToElements } from "./hooks/use-scroll-to-elements.mjs";
25
- import { useThrottle } from "./hooks/use-throttle.mjs";
25
+ import { useThrottledValue } from "./hooks/use-throttled-value.mjs";
26
26
  import { useThrottledCallback } from "./hooks/use-throttled-callback.mjs";
27
27
  import { useTimeout } from "./hooks/use-timeout.mjs";
28
28
  import { useToggle } from "./hooks/use-toggle.mjs";
29
29
  import { useWindowScroll } from "./hooks/use-window-scroll.mjs";
30
30
  import { useViewport } from "./hooks/use-viewport.mjs";
31
31
  import "./hooks/index.mjs";
32
- export { useBodyScrollLock, useClickOutside, useControllableState, useDebounce, useDebouncedValue, useElementPosition, useElementScroll, useEventListener, useFileDrop, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useInterval, useKeyPress, useLocalStorage, useMergedRef, useMultiSelect, useMutationObserver, usePrevious, useRecursiveTimeout, useResizeObserver, useResponsiveSize, useScrollToElements, useThrottle, useThrottledCallback, useTimeout, useToggle, useViewport, useWindowScroll };
32
+ export { useBodyScrollLock, useClickOutside, useControllableState, useDebouncedCallback as useDebounce, useDebouncedCallback, useDebouncedValue, useElementPosition, useElementScroll, useEventListener, useFileDrop, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useInterval, useKeyPress, useLocalStorage, useMergedRef, useMultiSelect, useMutationObserver, usePrevious, useRecursiveTimeout, useResizeObserver, useResponsiveSize, useScrollToElements, useThrottledValue as useThrottle, useThrottledCallback, useThrottledValue, useTimeout, useToggle, useViewport, useWindowScroll };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import useDebounce from "./hooks/use-debounce.mjs";
1
+ import useDebouncedCallback from "./hooks/use-debounced-callback.mjs";
2
2
  import useDebouncedValue from "./hooks/use-debounced-value.mjs";
3
3
  import useBodyScrollLock from "./hooks/use-body-scroll-lock.mjs";
4
4
  import useClickOutside from "./hooks/use-click-outside.mjs";
@@ -23,11 +23,11 @@ import useRecursiveTimeout from "./hooks/use-recursive-timeout.mjs";
23
23
  import useResizeObserver from "./hooks/use-resize-observer.mjs";
24
24
  import useScrollToElements from "./hooks/use-scroll-to-elements.mjs";
25
25
  import useThrottledCallback from "./hooks/use-throttled-callback.mjs";
26
- import useThrottle from "./hooks/use-throttle.mjs";
26
+ import useThrottledValue from "./hooks/use-throttled-value.mjs";
27
27
  import useTimeout from "./hooks/use-timeout.mjs";
28
28
  import useToggle from "./hooks/use-toggle.mjs";
29
29
  import useWindowScroll from "./hooks/use-window-scroll.mjs";
30
30
  import useViewport from "./hooks/use-viewport.mjs";
31
31
  import "./hooks/index.mjs";
32
32
 
33
- export { useBodyScrollLock, useClickOutside, useControllableState, useDebounce, useDebouncedValue, useElementPosition, useElementScroll, useEventListener, useFileDrop, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useInterval, useKeyPress, useLocalStorage, useMergedRef, useMultiSelect, useMutationObserver, usePrevious, useRecursiveTimeout, useResizeObserver, useResponsiveSize, useScrollToElements, useThrottle, useThrottledCallback, useTimeout, useToggle, useViewport, useWindowScroll };
33
+ export { useBodyScrollLock, useClickOutside, useControllableState, useDebouncedCallback as useDebounce, useDebouncedCallback, useDebouncedValue, useElementPosition, useElementScroll, useEventListener, useFileDrop, useFileToDataUrl, useHistoryState, useImage, useIntersectionObserver, useInterval, useKeyPress, useLocalStorage, useMergedRef, useMultiSelect, useMutationObserver, usePrevious, useRecursiveTimeout, useResizeObserver, useResponsiveSize, useScrollToElements, useThrottledValue as useThrottle, useThrottledCallback, useThrottledValue, useTimeout, useToggle, useViewport, useWindowScroll };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbpark/use-hooks",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "A collection of reusable React 19 hooks for common UI and interaction patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -1,14 +0,0 @@
1
- //#region src/hooks/use-debounce.d.ts
2
- interface Options {
3
- delay?: number;
4
- autoInvoke?: boolean;
5
- leading?: boolean;
6
- }
7
- declare const useDebounce: (callback: () => unknown, {
8
- delay,
9
- autoInvoke,
10
- leading
11
- }: Options, deps?: React.DependencyList) => () => void;
12
- //#endregion
13
- export { useDebounce };
14
- //# sourceMappingURL=use-debounce.d.mts.map
@@ -1 +0,0 @@
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,9 +0,0 @@
1
- //#region src/hooks/use-throttle.d.ts
2
- interface Options {
3
- leading?: boolean;
4
- trailing?: boolean;
5
- }
6
- declare const useThrottle: <T>(value: T, delay?: number, options?: Options) => T;
7
- //#endregion
8
- export { useThrottle };
9
- //# sourceMappingURL=use-throttle.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"use-throttle.mjs","names":[],"sources":["../../src/hooks/use-throttle.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nimport useThrottledCallback from './use-throttled-callback';\n\ninterface Options {\n leading?: boolean;\n trailing?: boolean;\n}\n\nconst useThrottle = <T>(value: T, delay = 100, options: Options = {}): T => {\n const [throttledValue, setThrottledValue] = useState(value);\n\n const throttledSetValue = useThrottledCallback(\n setThrottledValue,\n delay,\n options,\n );\n\n useEffect(() => {\n throttledSetValue(value);\n // `throttledSetValue`'s identity is stable for the lifetime of the\n // component (see useThrottledCallback), so listing it here doesn't\n // cause any extra re-runs beyond `value` actually changing.\n }, [value, throttledSetValue]);\n\n return throttledValue;\n};\n\nexport default useThrottle;\n"],"mappings":";;;;AASA,MAAM,eAAkB,OAAU,QAAQ,KAAK,UAAmB,EAAE,KAAQ;CAC1E,MAAM,CAAC,gBAAgB,qBAAqB,SAAS,MAAM;CAE3D,MAAM,oBAAoB,qBACxB,mBACA,OACA,QACD;AAED,iBAAgB;AACd,oBAAkB,MAAM;IAIvB,CAAC,OAAO,kBAAkB,CAAC;AAE9B,QAAO"}