@zentauri-ui/zentauri-components 1.3.1 → 1.4.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 (307) hide show
  1. package/README.md +78 -0
  2. package/cli/cli.integration.test.ts +51 -0
  3. package/cli/index.mjs +664 -0
  4. package/cli/registry.json +36 -0
  5. package/cli/rewrite-imports.mjs +57 -0
  6. package/cli/rewrite-imports.test.ts +71 -0
  7. package/dist/ui/slider/slider.d.ts +18 -0
  8. package/dist/ui/slider/slider.d.ts.map +1 -1
  9. package/dist/ui/slider.js +21 -25
  10. package/dist/ui/slider.js.map +1 -1
  11. package/dist/ui/slider.mjs +21 -25
  12. package/dist/ui/slider.mjs.map +1 -1
  13. package/package.json +8 -2
  14. package/src/hooks/index.ts +48 -0
  15. package/src/hooks/useBodyScrollLock/index.ts +1 -0
  16. package/src/hooks/useBodyScrollLock/useBodyScrollLock.test.ts +51 -0
  17. package/src/hooks/useBodyScrollLock/useBodyScrollLock.ts +48 -0
  18. package/src/hooks/useClickOutside/index.ts +5 -0
  19. package/src/hooks/useClickOutside/useClickOutside.test.tsx +60 -0
  20. package/src/hooks/useClickOutside/useClickOutside.ts +52 -0
  21. package/src/hooks/useClipboard/index.ts +1 -0
  22. package/src/hooks/useClipboard/useClipboard.test.ts +101 -0
  23. package/src/hooks/useClipboard/useClipboard.ts +69 -0
  24. package/src/hooks/useControllableState/index.ts +4 -0
  25. package/src/hooks/useControllableState/useControllableState.test.ts +59 -0
  26. package/src/hooks/useControllableState/useControllableState.ts +49 -0
  27. package/src/hooks/useDebouncedValue/index.ts +1 -0
  28. package/src/hooks/useDebouncedValue/useDebouncedValue.test.ts +74 -0
  29. package/src/hooks/useDebouncedValue/useDebouncedValue.ts +29 -0
  30. package/src/hooks/useDisclosure/index.ts +5 -0
  31. package/src/hooks/useDisclosure/useDisclosure.test.ts +64 -0
  32. package/src/hooks/useDisclosure/useDisclosure.ts +62 -0
  33. package/src/hooks/useDocumentTitle/index.ts +4 -0
  34. package/src/hooks/useDocumentTitle/useDocumentTitle.test.ts +40 -0
  35. package/src/hooks/useDocumentTitle/useDocumentTitle.ts +58 -0
  36. package/src/hooks/useFocusManagement/index.ts +1 -0
  37. package/src/hooks/useFocusManagement/useFocusManagement.test.tsx +45 -0
  38. package/src/hooks/useFocusManagement/useFocusManagement.ts +77 -0
  39. package/src/hooks/useHover/index.ts +1 -0
  40. package/src/hooks/useHover/useHover.test.ts +45 -0
  41. package/src/hooks/useHover/useHover.ts +45 -0
  42. package/src/hooks/useInView/index.ts +1 -0
  43. package/src/hooks/useInView/useInView.test.ts +43 -0
  44. package/src/hooks/useInView/useInView.ts +28 -0
  45. package/src/hooks/useIntersectionObserver/index.ts +4 -0
  46. package/src/hooks/useIntersectionObserver/useIntersectionObserver.test.ts +75 -0
  47. package/src/hooks/useIntersectionObserver/useIntersectionObserver.ts +54 -0
  48. package/src/hooks/useIsMounted/index.ts +1 -0
  49. package/src/hooks/useIsMounted/useIsMounted.test.ts +25 -0
  50. package/src/hooks/useIsMounted/useIsMounted.ts +22 -0
  51. package/src/hooks/useIsomorphicLayoutEffect/index.ts +1 -0
  52. package/src/hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.test.ts +19 -0
  53. package/src/hooks/useIsomorphicLayoutEffect/useIsomorphicLayoutEffect.ts +12 -0
  54. package/src/hooks/useLocalStorage/index.ts +4 -0
  55. package/src/hooks/useLocalStorage/useLocalStorage.test.ts +99 -0
  56. package/src/hooks/useLocalStorage/useLocalStorage.ts +109 -0
  57. package/src/hooks/useMediaQuery/index.ts +1 -0
  58. package/src/hooks/useMediaQuery/useMediaQuery.test.ts +63 -0
  59. package/src/hooks/useMediaQuery/useMediaQuery.ts +37 -0
  60. package/src/hooks/useNetworkStatus/index.ts +1 -0
  61. package/src/hooks/useNetworkStatus/useNetworkStatus.test.ts +53 -0
  62. package/src/hooks/useNetworkStatus/useNetworkStatus.ts +33 -0
  63. package/src/hooks/usePageVisibility/index.ts +1 -0
  64. package/src/hooks/usePageVisibility/usePageVisibility.test.ts +21 -0
  65. package/src/hooks/usePageVisibility/usePageVisibility.ts +31 -0
  66. package/src/hooks/usePagination/index.ts +6 -0
  67. package/src/hooks/usePagination/usePagination.test.ts +139 -0
  68. package/src/hooks/usePagination/usePagination.ts +153 -0
  69. package/src/hooks/usePrefersColorScheme/index.ts +4 -0
  70. package/src/hooks/usePrefersColorScheme/usePrefersColorScheme.test.ts +53 -0
  71. package/src/hooks/usePrefersColorScheme/usePrefersColorScheme.ts +21 -0
  72. package/src/hooks/usePrefersReducedMotion/index.ts +1 -0
  73. package/src/hooks/usePrefersReducedMotion/usePrefersReducedMotion.test.ts +27 -0
  74. package/src/hooks/usePrefersReducedMotion/usePrefersReducedMotion.ts +14 -0
  75. package/src/hooks/useResizeObserver/index.ts +4 -0
  76. package/src/hooks/useResizeObserver/useResizeObserver.test.ts +68 -0
  77. package/src/hooks/useResizeObserver/useResizeObserver.ts +58 -0
  78. package/src/hooks/useSessionStorage/index.ts +4 -0
  79. package/src/hooks/useSessionStorage/useSessionStorage.test.ts +54 -0
  80. package/src/hooks/useSessionStorage/useSessionStorage.ts +84 -0
  81. package/src/hooks/useThrottledCallback/index.ts +1 -0
  82. package/src/hooks/useThrottledCallback/useThrottledCallback.test.ts +75 -0
  83. package/src/hooks/useThrottledCallback/useThrottledCallback.ts +36 -0
  84. package/src/hooks/useToggle/index.ts +1 -0
  85. package/src/hooks/useToggle/useToggle.test.ts +40 -0
  86. package/src/hooks/useToggle/useToggle.ts +22 -0
  87. package/src/hooks/useWindowSize/index.ts +1 -0
  88. package/src/hooks/useWindowSize/useWindowSize.test.ts +23 -0
  89. package/src/hooks/useWindowSize/useWindowSize.ts +39 -0
  90. package/src/lib/utils.ts +25 -0
  91. package/src/ui/accordion/accordion-base.tsx +223 -0
  92. package/src/ui/accordion/accordion.test.tsx +146 -0
  93. package/src/ui/accordion/accordion.tsx +11 -0
  94. package/src/ui/accordion/animated/accordion-content-animated.tsx +46 -0
  95. package/src/ui/accordion/animated/accordion-root-animated.tsx +10 -0
  96. package/src/ui/accordion/animated/animations.ts +16 -0
  97. package/src/ui/accordion/animated/index.ts +7 -0
  98. package/src/ui/accordion/animated/types.ts +7 -0
  99. package/src/ui/accordion/index.ts +23 -0
  100. package/src/ui/accordion/types.ts +48 -0
  101. package/src/ui/accordion/variants.ts +115 -0
  102. package/src/ui/alert/alert-base.tsx +157 -0
  103. package/src/ui/alert/alert.test.tsx +150 -0
  104. package/src/ui/alert/alert.tsx +9 -0
  105. package/src/ui/alert/animated/alert-animated.tsx +20 -0
  106. package/src/ui/alert/animated/animations.ts +20 -0
  107. package/src/ui/alert/animated/index.ts +3 -0
  108. package/src/ui/alert/animated/types.ts +16 -0
  109. package/src/ui/alert/index.ts +22 -0
  110. package/src/ui/alert/types.ts +28 -0
  111. package/src/ui/alert/variants.ts +74 -0
  112. package/src/ui/avatar/animated/animations.ts +11 -0
  113. package/src/ui/avatar/animated/avatar-animated.tsx +25 -0
  114. package/src/ui/avatar/animated/index.ts +6 -0
  115. package/src/ui/avatar/animated/types.ts +16 -0
  116. package/src/ui/avatar/avatar-base.tsx +184 -0
  117. package/src/ui/avatar/avatar.test.tsx +51 -0
  118. package/src/ui/avatar/avatar.tsx +11 -0
  119. package/src/ui/avatar/index.ts +16 -0
  120. package/src/ui/avatar/types.ts +36 -0
  121. package/src/ui/avatar/variants.ts +52 -0
  122. package/src/ui/badge/animated/animations.ts +20 -0
  123. package/src/ui/badge/animated/badge-animated.tsx +28 -0
  124. package/src/ui/badge/animated/index.ts +5 -0
  125. package/src/ui/badge/animated/types.ts +18 -0
  126. package/src/ui/badge/badge-base.tsx +53 -0
  127. package/src/ui/badge/badge.test.tsx +48 -0
  128. package/src/ui/badge/badge.tsx +9 -0
  129. package/src/ui/badge/index.ts +5 -0
  130. package/src/ui/badge/types.ts +25 -0
  131. package/src/ui/badge/variants.ts +85 -0
  132. package/src/ui/breadcrumb/breadcrumb.test.tsx +62 -0
  133. package/src/ui/breadcrumb/breadcrumb.tsx +135 -0
  134. package/src/ui/breadcrumb/index.ts +28 -0
  135. package/src/ui/breadcrumb/types.ts +29 -0
  136. package/src/ui/breadcrumb/variants.ts +53 -0
  137. package/src/ui/buttons/animated/animations.ts +34 -0
  138. package/src/ui/buttons/animated/button-animated.tsx +70 -0
  139. package/src/ui/buttons/animated/index.ts +5 -0
  140. package/src/ui/buttons/animated/types.ts +29 -0
  141. package/src/ui/buttons/button-base.tsx +59 -0
  142. package/src/ui/buttons/button.test.tsx +480 -0
  143. package/src/ui/buttons/button.tsx +9 -0
  144. package/src/ui/buttons/index.ts +5 -0
  145. package/src/ui/buttons/types.ts +14 -0
  146. package/src/ui/buttons/variants.ts +77 -0
  147. package/src/ui/card/animated/animations.ts +32 -0
  148. package/src/ui/card/animated/card-animated.tsx +28 -0
  149. package/src/ui/card/animated/index.ts +12 -0
  150. package/src/ui/card/animated/types.ts +8 -0
  151. package/src/ui/card/card-base.tsx +146 -0
  152. package/src/ui/card/card.test.tsx +79 -0
  153. package/src/ui/card/card.tsx +11 -0
  154. package/src/ui/card/index.ts +21 -0
  155. package/src/ui/card/types.ts +42 -0
  156. package/src/ui/card/variants.ts +122 -0
  157. package/src/ui/divider/animated/animations.ts +27 -0
  158. package/src/ui/divider/animated/divider-animated.tsx +24 -0
  159. package/src/ui/divider/animated/index.ts +4 -0
  160. package/src/ui/divider/animated/types.ts +18 -0
  161. package/src/ui/divider/divider-base.tsx +80 -0
  162. package/src/ui/divider/divider.tsx +9 -0
  163. package/src/ui/divider/index.ts +14 -0
  164. package/src/ui/divider/types.ts +18 -0
  165. package/src/ui/divider/variants.ts +98 -0
  166. package/src/ui/drawer/animated/animations.ts +39 -0
  167. package/src/ui/drawer/animated/drawer-content-animated.tsx +101 -0
  168. package/src/ui/drawer/animated/index.ts +14 -0
  169. package/src/ui/drawer/animated/types.ts +18 -0
  170. package/src/ui/drawer/drawer-base.tsx +259 -0
  171. package/src/ui/drawer/drawer.test.tsx +132 -0
  172. package/src/ui/drawer/drawer.tsx +11 -0
  173. package/src/ui/drawer/index.ts +21 -0
  174. package/src/ui/drawer/types.ts +39 -0
  175. package/src/ui/drawer/variants.ts +122 -0
  176. package/src/ui/dropdown/dropdown.test.tsx +114 -0
  177. package/src/ui/dropdown/dropdown.tsx +179 -0
  178. package/src/ui/dropdown/index.ts +15 -0
  179. package/src/ui/dropdown/types.ts +68 -0
  180. package/src/ui/dropdown/variants.ts +138 -0
  181. package/src/ui/empty-state/animated/animations.ts +19 -0
  182. package/src/ui/empty-state/animated/empty-state-animated.tsx +23 -0
  183. package/src/ui/empty-state/animated/index.ts +7 -0
  184. package/src/ui/empty-state/animated/types.ts +26 -0
  185. package/src/ui/empty-state/empty-state-base.tsx +114 -0
  186. package/src/ui/empty-state/empty-state.tsx +9 -0
  187. package/src/ui/empty-state/index.ts +10 -0
  188. package/src/ui/empty-state/types.ts +19 -0
  189. package/src/ui/empty-state/variants.ts +51 -0
  190. package/src/ui/file-upload/file-upload.test.tsx +36 -0
  191. package/src/ui/file-upload/file-upload.tsx +119 -0
  192. package/src/ui/file-upload/index.ts +5 -0
  193. package/src/ui/file-upload/types.ts +21 -0
  194. package/src/ui/file-upload/variants.ts +29 -0
  195. package/src/ui/inputs/animated/animations.ts +36 -0
  196. package/src/ui/inputs/animated/index.ts +5 -0
  197. package/src/ui/inputs/animated/input-animated.tsx +124 -0
  198. package/src/ui/inputs/animated/types.ts +40 -0
  199. package/src/ui/inputs/index.ts +5 -0
  200. package/src/ui/inputs/input-base.tsx +114 -0
  201. package/src/ui/inputs/input.test.tsx +414 -0
  202. package/src/ui/inputs/input.tsx +8 -0
  203. package/src/ui/inputs/types.ts +18 -0
  204. package/src/ui/inputs/variants.ts +316 -0
  205. package/src/ui/modal/animated/animations.ts +29 -0
  206. package/src/ui/modal/animated/index.ts +5 -0
  207. package/src/ui/modal/animated/modal-content-animated.tsx +96 -0
  208. package/src/ui/modal/animated/types.ts +23 -0
  209. package/src/ui/modal/index.ts +21 -0
  210. package/src/ui/modal/modal-base.tsx +279 -0
  211. package/src/ui/modal/modal.test.tsx +129 -0
  212. package/src/ui/modal/modal.tsx +8 -0
  213. package/src/ui/modal/types.ts +31 -0
  214. package/src/ui/modal/variants.ts +109 -0
  215. package/src/ui/pagination/index.ts +13 -0
  216. package/src/ui/pagination/pagination.test.tsx +165 -0
  217. package/src/ui/pagination/pagination.tsx +237 -0
  218. package/src/ui/pagination/types.ts +66 -0
  219. package/src/ui/pagination/variants.ts +97 -0
  220. package/src/ui/progress/animated/animations.ts +9 -0
  221. package/src/ui/progress/animated/index.ts +17 -0
  222. package/src/ui/progress/animated/progress-animated.tsx +133 -0
  223. package/src/ui/progress/animated/types.ts +35 -0
  224. package/src/ui/progress/index.ts +10 -0
  225. package/src/ui/progress/progress-base.tsx +151 -0
  226. package/src/ui/progress/progress.test.tsx +84 -0
  227. package/src/ui/progress/progress.tsx +12 -0
  228. package/src/ui/progress/types.ts +33 -0
  229. package/src/ui/progress/variants.ts +105 -0
  230. package/src/ui/select/index.ts +25 -0
  231. package/src/ui/select/select.test.tsx +128 -0
  232. package/src/ui/select/select.tsx +221 -0
  233. package/src/ui/select/types.ts +77 -0
  234. package/src/ui/select/variants.ts +163 -0
  235. package/src/ui/skeleton/animated/animations.ts +15 -0
  236. package/src/ui/skeleton/animated/index.ts +20 -0
  237. package/src/ui/skeleton/animated/skeleton-animated.tsx +119 -0
  238. package/src/ui/skeleton/animated/types.ts +49 -0
  239. package/src/ui/skeleton/index.ts +24 -0
  240. package/src/ui/skeleton/skeleton-base.tsx +288 -0
  241. package/src/ui/skeleton/skeleton.tsx +8 -0
  242. package/src/ui/skeleton/types.ts +31 -0
  243. package/src/ui/skeleton/variants.ts +254 -0
  244. package/src/ui/slider/index.ts +22 -0
  245. package/src/ui/slider/slider.test.tsx +94 -0
  246. package/src/ui/slider/slider.tsx +728 -0
  247. package/src/ui/slider/types.ts +66 -0
  248. package/src/ui/slider/variants.ts +81 -0
  249. package/src/ui/spinner/animated/index.ts +5 -0
  250. package/src/ui/spinner/animated/spinner.test.tsx +41 -0
  251. package/src/ui/spinner/animated/spinner.tsx +143 -0
  252. package/src/ui/spinner/animated/types.ts +11 -0
  253. package/src/ui/spinner/animated/variants.ts +50 -0
  254. package/src/ui/stepper/index.ts +22 -0
  255. package/src/ui/stepper/stepper.test.tsx +183 -0
  256. package/src/ui/stepper/stepper.tsx +172 -0
  257. package/src/ui/stepper/types.ts +32 -0
  258. package/src/ui/stepper/variants.ts +69 -0
  259. package/src/ui/table/animated/animations.ts +9 -0
  260. package/src/ui/table/animated/index.ts +15 -0
  261. package/src/ui/table/animated/table-animated.tsx +15 -0
  262. package/src/ui/table/animated/types.ts +16 -0
  263. package/src/ui/table/index.ts +22 -0
  264. package/src/ui/table/table-base.tsx +197 -0
  265. package/src/ui/table/table.tsx +13 -0
  266. package/src/ui/table/types.ts +47 -0
  267. package/src/ui/table/variants.ts +105 -0
  268. package/src/ui/tabs/animated/animations.ts +48 -0
  269. package/src/ui/tabs/animated/index.ts +8 -0
  270. package/src/ui/tabs/animated/tabs-content-animated.tsx +46 -0
  271. package/src/ui/tabs/animated/types.ts +24 -0
  272. package/src/ui/tabs/index.ts +10 -0
  273. package/src/ui/tabs/tabs-base.tsx +185 -0
  274. package/src/ui/tabs/tabs.test.tsx +53 -0
  275. package/src/ui/tabs/tabs.tsx +2 -0
  276. package/src/ui/tabs/types.ts +88 -0
  277. package/src/ui/tabs/variants.ts +70 -0
  278. package/src/ui/toast/animated/animations.ts +17 -0
  279. package/src/ui/toast/animated/index.ts +9 -0
  280. package/src/ui/toast/animated/toast-animated.tsx +96 -0
  281. package/src/ui/toast/animated/types.ts +13 -0
  282. package/src/ui/toast/index.ts +26 -0
  283. package/src/ui/toast/toast-base.tsx +231 -0
  284. package/src/ui/toast/toast.test.tsx +102 -0
  285. package/src/ui/toast/toast.tsx +13 -0
  286. package/src/ui/toast/types.ts +57 -0
  287. package/src/ui/toast/variants.ts +73 -0
  288. package/src/ui/toggle/animated/animations.ts +9 -0
  289. package/src/ui/toggle/animated/index.ts +7 -0
  290. package/src/ui/toggle/animated/toggle-animated.tsx +76 -0
  291. package/src/ui/toggle/animated/types.ts +13 -0
  292. package/src/ui/toggle/index.ts +5 -0
  293. package/src/ui/toggle/toggle-base.tsx +70 -0
  294. package/src/ui/toggle/toggle.test.tsx +44 -0
  295. package/src/ui/toggle/toggle.tsx +9 -0
  296. package/src/ui/toggle/types.ts +18 -0
  297. package/src/ui/toggle/variants.ts +84 -0
  298. package/src/ui/tooltip/animated/animations.ts +16 -0
  299. package/src/ui/tooltip/animated/index.ts +10 -0
  300. package/src/ui/tooltip/animated/tooltip-content-animated.tsx +47 -0
  301. package/src/ui/tooltip/animated/types.ts +19 -0
  302. package/src/ui/tooltip/index.ts +17 -0
  303. package/src/ui/tooltip/tooltip-base.tsx +152 -0
  304. package/src/ui/tooltip/tooltip.test.tsx +84 -0
  305. package/src/ui/tooltip/tooltip.tsx +8 -0
  306. package/src/ui/tooltip/types.ts +57 -0
  307. package/src/ui/tooltip/variants.ts +61 -0
@@ -0,0 +1,59 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { useControllableState } from "./useControllableState";
5
+
6
+ describe("useControllableState", () => {
7
+ it("should use internal state when uncontrolled", () => {
8
+ const { result } = renderHook(() =>
9
+ useControllableState({ defaultValue: 1 }),
10
+ );
11
+ expect(result.current[0]).toBe(1);
12
+ act(() => {
13
+ result.current[1](2);
14
+ });
15
+ expect(result.current[0]).toBe(2);
16
+ });
17
+
18
+ it("should follow value prop when controlled", () => {
19
+ const onChange = vi.fn();
20
+ const { result, rerender } = renderHook(
21
+ ({ value }: { value: number }) =>
22
+ useControllableState({ value, defaultValue: 0, onChange }),
23
+ { initialProps: { value: 5 } },
24
+ );
25
+ expect(result.current[0]).toBe(5);
26
+ act(() => {
27
+ result.current[1](9);
28
+ });
29
+ expect(result.current[0]).toBe(5);
30
+ expect(onChange).toHaveBeenCalledWith(9);
31
+ rerender({ value: 9 });
32
+ expect(result.current[0]).toBe(9);
33
+ });
34
+
35
+ it("should call onChange with functional updater using current controlled value", () => {
36
+ const onChange = vi.fn();
37
+ const { result } = renderHook(() =>
38
+ useControllableState({
39
+ value: 10,
40
+ defaultValue: 0,
41
+ onChange,
42
+ }),
43
+ );
44
+ act(() => {
45
+ result.current[1]((previous) => previous + 1);
46
+ });
47
+ expect(onChange).toHaveBeenCalledWith(11);
48
+ });
49
+
50
+ it("should apply functional updater in uncontrolled mode", () => {
51
+ const { result } = renderHook(() =>
52
+ useControllableState({ defaultValue: 3 }),
53
+ );
54
+ act(() => {
55
+ result.current[1]((previous) => previous * 2);
56
+ });
57
+ expect(result.current[0]).toBe(6);
58
+ });
59
+ });
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef, useState } from "react";
4
+
5
+ export type UseControllableStateParams<T> = {
6
+ /** When defined, the hook is controlled and this value is returned as state. */
7
+ value?: T;
8
+ /** Initial / fallback value when uncontrolled (`value` is `undefined`). */
9
+ defaultValue: T;
10
+ /** Notified on every `setValue` with the resolved next value (controlled and uncontrolled). */
11
+ onChange?: (next: T) => void;
12
+ };
13
+
14
+ /**
15
+ * Implements the React “controlled vs uncontrolled” pattern as a single state tuple.
16
+ *
17
+ * - If `value` is `undefined`, internal state mirrors `defaultValue` and updates on `setValue`.
18
+ * - If `value` is defined, returned state follows `value`; `setValue` still calls `onChange` so the parent can update.
19
+ * - `setValue` accepts either the next value or an updater `(prev) => next` (updater uses the current `value` in controlled mode).
20
+ *
21
+ * @typeParam T - State value type.
22
+ * @param params - `value`, `defaultValue`, and optional `onChange`.
23
+ * @returns `[value, setValue]` compatible with `useState`-style usage.
24
+ */
25
+ export function useControllableState<T>({
26
+ value: valueProp,
27
+ defaultValue,
28
+ onChange,
29
+ }: UseControllableStateParams<T>): [T, (next: T | ((prev: T) => T)) => void] {
30
+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
31
+ const isControlled = valueProp !== undefined;
32
+ const value = isControlled ? valueProp : uncontrolled;
33
+ const onChangeRef = useRef(onChange);
34
+ onChangeRef.current = onChange;
35
+
36
+ const setValue = useCallback(
37
+ (next: T | ((prev: T) => T)) => {
38
+ const resolved =
39
+ typeof next === "function" ? (next as (prev: T) => T)(value) : next;
40
+ if (!isControlled) {
41
+ setUncontrolled(resolved);
42
+ }
43
+ onChangeRef.current?.(resolved);
44
+ },
45
+ [isControlled, value],
46
+ );
47
+
48
+ return [value, setValue];
49
+ }
@@ -0,0 +1 @@
1
+ export { useDebouncedValue } from "./useDebouncedValue";
@@ -0,0 +1,74 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { useDebouncedValue } from "./useDebouncedValue";
5
+
6
+ describe("useDebouncedValue", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it("should return initial value immediately", () => {
16
+ const { result } = renderHook(() => useDebouncedValue("a", 300));
17
+ expect(result.current).toBe("a");
18
+ });
19
+
20
+ it("should update after delay when value stabilizes", () => {
21
+ const { result, rerender } = renderHook(
22
+ ({ value, delay }: { value: string; delay: number }) =>
23
+ useDebouncedValue(value, delay),
24
+ { initialProps: { value: "x", delay: 100 } },
25
+ );
26
+ rerender({ value: "y", delay: 100 });
27
+ expect(result.current).toBe("x");
28
+ act(() => {
29
+ vi.advanceTimersByTime(100);
30
+ });
31
+ expect(result.current).toBe("y");
32
+ });
33
+
34
+ it("should reset timer when value changes rapidly", () => {
35
+ const { result, rerender } = renderHook(
36
+ ({ value }: { value: number }) => useDebouncedValue(value, 50),
37
+ { initialProps: { value: 0 } },
38
+ );
39
+ rerender({ value: 1 });
40
+ act(() => {
41
+ vi.advanceTimersByTime(40);
42
+ });
43
+ rerender({ value: 2 });
44
+ act(() => {
45
+ vi.advanceTimersByTime(40);
46
+ });
47
+ expect(result.current).toBe(0);
48
+ act(() => {
49
+ vi.advanceTimersByTime(20);
50
+ });
51
+ expect(result.current).toBe(2);
52
+ });
53
+
54
+ it("should reset debounce when delayMs changes", () => {
55
+ const { result, rerender } = renderHook(
56
+ ({ value, delay }: { value: string; delay: number }) =>
57
+ useDebouncedValue(value, delay),
58
+ { initialProps: { value: "a", delay: 200 } },
59
+ );
60
+ rerender({ value: "b", delay: 200 });
61
+ act(() => {
62
+ vi.advanceTimersByTime(100);
63
+ });
64
+ rerender({ value: "b", delay: 500 });
65
+ act(() => {
66
+ vi.advanceTimersByTime(200);
67
+ });
68
+ expect(result.current).toBe("a");
69
+ act(() => {
70
+ vi.advanceTimersByTime(300);
71
+ });
72
+ expect(result.current).toBe("b");
73
+ });
74
+ });
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /**
6
+ * Returns a lagged copy of `value` that only updates after `value` has been stable for `delayMs`.
7
+ * Each change to `value` or `delayMs` resets the timer; rapid updates collapse into one committed update.
8
+ *
9
+ * Common uses: search inputs, resize-driven layout, or any expensive work that should not run on every keystroke.
10
+ *
11
+ * @typeParam T - Value type (any JSON-serializable or referential type).
12
+ * @param value - Live value from props or state.
13
+ * @param delayMs - Debounce interval in milliseconds.
14
+ * @returns The last value that survived the full delay without a newer `value` arriving.
15
+ */
16
+ export function useDebouncedValue<T>(value: T, delayMs: number): T {
17
+ const [debounced, setDebounced] = useState(value);
18
+
19
+ useEffect(() => {
20
+ const id = setTimeout(() => {
21
+ setDebounced(value);
22
+ }, delayMs);
23
+ return () => {
24
+ clearTimeout(id);
25
+ };
26
+ }, [delayMs, value]);
27
+
28
+ return debounced;
29
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ useDisclosure,
3
+ type UseDisclosureParams,
4
+ type UseDisclosureResult,
5
+ } from "./useDisclosure";
@@ -0,0 +1,64 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { useDisclosure } from "./useDisclosure";
5
+
6
+ describe("useDisclosure", () => {
7
+ it("should start closed by default", () => {
8
+ const { result } = renderHook(() => useDisclosure());
9
+ expect(result.current.isOpen).toBe(false);
10
+ });
11
+
12
+ it("should respect defaultOpen when uncontrolled", () => {
13
+ const { result } = renderHook(() => useDisclosure({ defaultOpen: true }));
14
+ expect(result.current.isOpen).toBe(true);
15
+ });
16
+
17
+ it("should open close and toggle", () => {
18
+ const { result } = renderHook(() => useDisclosure({ defaultOpen: false }));
19
+ act(() => {
20
+ result.current.open();
21
+ });
22
+ expect(result.current.isOpen).toBe(true);
23
+ act(() => {
24
+ result.current.close();
25
+ });
26
+ expect(result.current.isOpen).toBe(false);
27
+ act(() => {
28
+ result.current.toggle();
29
+ });
30
+ expect(result.current.isOpen).toBe(true);
31
+ act(() => {
32
+ result.current.toggle();
33
+ });
34
+ expect(result.current.isOpen).toBe(false);
35
+ });
36
+
37
+ it("should call onOpenChange when state changes", () => {
38
+ const onOpenChange = vi.fn();
39
+ const { result } = renderHook(() =>
40
+ useDisclosure({ defaultOpen: false, onOpenChange }),
41
+ );
42
+ act(() => {
43
+ result.current.setOpen(true);
44
+ });
45
+ expect(onOpenChange).toHaveBeenLastCalledWith(true);
46
+ });
47
+
48
+ it("should be controlled when open prop is defined", () => {
49
+ const onOpenChange = vi.fn();
50
+ const { result, rerender } = renderHook(
51
+ ({ open }: { open: boolean }) =>
52
+ useDisclosure({ open, onOpenChange }),
53
+ { initialProps: { open: false } },
54
+ );
55
+ expect(result.current.isOpen).toBe(false);
56
+ act(() => {
57
+ result.current.open();
58
+ });
59
+ expect(result.current.isOpen).toBe(false);
60
+ expect(onOpenChange).toHaveBeenCalledWith(true);
61
+ rerender({ open: true });
62
+ expect(result.current.isOpen).toBe(true);
63
+ });
64
+ });
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+
5
+ import { useControllableState } from "../useControllableState";
6
+
7
+ export type UseDisclosureParams = {
8
+ /** Controlled open flag; omit for uncontrolled usage with `defaultOpen`. */
9
+ open?: boolean;
10
+ /** Initial open state when uncontrolled. */
11
+ defaultOpen?: boolean;
12
+ /** Fired whenever open state changes from user-driven `setOpen` / `open` / `close` / `toggle`. */
13
+ onOpenChange?: (open: boolean) => void;
14
+ };
15
+
16
+ export type UseDisclosureResult = {
17
+ isOpen: boolean;
18
+ open: () => void;
19
+ close: () => void;
20
+ toggle: () => void;
21
+ setOpen: (open: boolean) => void;
22
+ };
23
+
24
+ /**
25
+ * Boolean open/close state for overlays (dialogs, menus, collapsible regions) with optional control from the parent.
26
+ * Built on {@link useControllableState}; semantics match common headless UI libraries.
27
+ *
28
+ * @param params - Optional `open`, `defaultOpen`, and `onOpenChange`.
29
+ * @returns Helpers `open`, `close`, `toggle`, `setOpen`, and the current `isOpen` flag.
30
+ */
31
+ export function useDisclosure({
32
+ open: openProp,
33
+ defaultOpen = false,
34
+ onOpenChange,
35
+ }: UseDisclosureParams = {}): UseDisclosureResult {
36
+ const [isOpen, setOpenState] = useControllableState({
37
+ value: openProp,
38
+ defaultValue: defaultOpen,
39
+ onChange: onOpenChange,
40
+ });
41
+
42
+ const setOpen = useCallback(
43
+ (open: boolean) => {
44
+ setOpenState(open);
45
+ },
46
+ [setOpenState],
47
+ );
48
+
49
+ const open = useCallback(() => {
50
+ setOpenState(true);
51
+ }, [setOpenState]);
52
+
53
+ const close = useCallback(() => {
54
+ setOpenState(false);
55
+ }, [setOpenState]);
56
+
57
+ const toggle = useCallback(() => {
58
+ setOpenState(!isOpen);
59
+ }, [isOpen, setOpenState]);
60
+
61
+ return { isOpen, open, close, toggle, setOpen };
62
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ useDocumentTitle,
3
+ type UseDocumentTitleParams,
4
+ } from "./useDocumentTitle";
@@ -0,0 +1,40 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { useDocumentTitle } from "./useDocumentTitle";
5
+
6
+ describe("useDocumentTitle", () => {
7
+ it("should set document title from title prop", () => {
8
+ document.title = "Original";
9
+ const { unmount } = renderHook(() =>
10
+ useDocumentTitle({ title: "Page A" }),
11
+ );
12
+ expect(document.title).toBe("Page A");
13
+ unmount();
14
+ expect(document.title).toBe("Original");
15
+ });
16
+
17
+ it("should update title when title prop changes", () => {
18
+ document.title = "Start";
19
+ const { rerender, unmount } = renderHook(
20
+ ({ title }: { title: string }) => useDocumentTitle({ title }),
21
+ { initialProps: { title: "One" } },
22
+ );
23
+ expect(document.title).toBe("One");
24
+ rerender({ title: "Two" });
25
+ expect(document.title).toBe("Two");
26
+ unmount();
27
+ expect(document.title).toBe("Start");
28
+ });
29
+
30
+ it("should not restore when restoreOnUnmount is false", () => {
31
+ document.title = "KeepMe";
32
+ const { unmount } = renderHook(() =>
33
+ useDocumentTitle({ title: "Temp", restoreOnUnmount: false }),
34
+ );
35
+ expect(document.title).toBe("Temp");
36
+ unmount();
37
+ expect(document.title).toBe("Temp");
38
+ document.title = "KeepMe";
39
+ });
40
+ });
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ export type UseDocumentTitleParams = {
6
+ title: string;
7
+ /** When true (default), restores the document title from before this hook mounted when the component unmounts */
8
+ restoreOnUnmount?: boolean;
9
+ };
10
+
11
+ /**
12
+ * Keeps `document.title` in sync with `title` for the lifetime of the component.
13
+ *
14
+ * On first mount in the browser, the current title is captured. When `title` changes, the document title updates.
15
+ * If `restoreOnUnmount` is true when the component unmounts, the captured title is restored so nested routes or
16
+ * modals do not leak titles. Changing `restoreOnUnmount` while mounted does not run restore; only unmount does.
17
+ *
18
+ * @param params.title - Desired `document.title` string.
19
+ * @param params.restoreOnUnmount - Whether to restore the pre-mount title on unmount (default `true`). The value
20
+ * read at unmount time determines behavior, not mid-mount prop changes.
21
+ */
22
+ export function useDocumentTitle({
23
+ title,
24
+ restoreOnUnmount = true,
25
+ }: UseDocumentTitleParams): void {
26
+ const originalTitle = useRef<string | undefined>(undefined);
27
+ const restoreOnUnmountRef = useRef(restoreOnUnmount);
28
+ restoreOnUnmountRef.current = restoreOnUnmount;
29
+
30
+ useEffect(() => {
31
+ if (typeof document === "undefined") {
32
+ return;
33
+ }
34
+ if (originalTitle.current === undefined) {
35
+ originalTitle.current = document.title;
36
+ }
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ if (typeof document === "undefined") {
41
+ return;
42
+ }
43
+ document.title = title;
44
+ }, [title]);
45
+
46
+ useEffect(() => {
47
+ return () => {
48
+ if (
49
+ !restoreOnUnmountRef.current ||
50
+ typeof document === "undefined" ||
51
+ originalTitle.current === undefined
52
+ ) {
53
+ return;
54
+ }
55
+ document.title = originalTitle.current;
56
+ };
57
+ }, []);
58
+ }
@@ -0,0 +1 @@
1
+ export { useFocusManagement } from "./useFocusManagement";
@@ -0,0 +1,45 @@
1
+ import { createRef, useState } from "react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { useFocusManagement } from "./useFocusManagement";
6
+
7
+ function Modal({ initialOpen }: { initialOpen: boolean }) {
8
+ const [open, setOpen] = useState(initialOpen);
9
+ const contentRef = createRef<HTMLDivElement>();
10
+ useFocusManagement({
11
+ open,
12
+ setOpen,
13
+ contentRef,
14
+ });
15
+ return (
16
+ <div>
17
+ <button type="button" data-testid="trigger">
18
+ trigger
19
+ </button>
20
+ {open ? (
21
+ <div ref={contentRef} tabIndex={-1} data-testid="dialog">
22
+ <button type="button">first</button>
23
+ <button type="button">second</button>
24
+ </div>
25
+ ) : null}
26
+ <button type="button" data-testid="after">
27
+ after
28
+ </button>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ describe("useFocusManagement", () => {
34
+ it("should call setOpen false when Escape is pressed while open", () => {
35
+ render(<Modal initialOpen />);
36
+ fireEvent.keyDown(window, { key: "Escape" });
37
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
38
+ });
39
+
40
+ it("should not react to Escape when closed", () => {
41
+ render(<Modal initialOpen={false} />);
42
+ fireEvent.keyDown(window, { key: "Escape" });
43
+ expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
44
+ });
45
+ });
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import type { RefObject } from "react";
4
+ import { useEffect } from "react";
5
+ import { useBodyScrollLock } from "../useBodyScrollLock";
6
+
7
+ const FOCUSABLE_SELECTOR =
8
+ 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
9
+
10
+ /**
11
+ * Composes modal-like behavior for an open overlay: body scroll lock, Escape to close, and focus trapping.
12
+ *
13
+ * - Delegates scroll locking to {@link useBodyScrollLock} while `open` is true.
14
+ * - Listens for `Escape` on `window` and calls `setOpen(false)`.
15
+ * - When `open` becomes true, focuses the first visible focusable inside `contentRef`, or the container itself.
16
+ * - Traps focus within `contentRef` via a capturing `focusin` listener on `document` that redirects focus back inside.
17
+ * - On close/unmount of the open effect, restores focus to the element that was focused before the trap ran.
18
+ *
19
+ * @param params.open - Whether the overlay is visible.
20
+ * @param params.setOpen - Setter used for Escape and cleanup paths.
21
+ * @param params.contentRef - Root of the dialog/drawer content (must point at a focusable container or include focusables).
22
+ * @param params.focusableSelector - Query selector for tabbable elements; defaults to a common interactive set.
23
+ */
24
+ export const useFocusManagement = ({
25
+ open,
26
+ setOpen,
27
+ contentRef,
28
+ focusableSelector = FOCUSABLE_SELECTOR,
29
+ }: {
30
+ open: boolean;
31
+ setOpen: (open: boolean) => void;
32
+ contentRef: RefObject<HTMLDivElement | null>;
33
+ focusableSelector?: string;
34
+ }) => {
35
+ useBodyScrollLock(open);
36
+
37
+ useEffect(() => {
38
+ if (!open) {
39
+ return;
40
+ }
41
+ const handleKeyDown = (event: KeyboardEvent) => {
42
+ if (event.key === "Escape") {
43
+ setOpen(false);
44
+ }
45
+ };
46
+ window.addEventListener("keydown", handleKeyDown);
47
+ return () => window.removeEventListener("keydown", handleKeyDown);
48
+ }, [open, setOpen]);
49
+
50
+ useEffect(() => {
51
+ if (!open) {
52
+ return;
53
+ }
54
+ const node = contentRef.current;
55
+ if (!node) {
56
+ return;
57
+ }
58
+ const focusables = Array.from(
59
+ node.querySelectorAll<HTMLElement>(focusableSelector),
60
+ ).filter((element) => element.offsetParent !== null || element === node);
61
+ const target = focusables[0] ?? node;
62
+ const previouslyFocused = document.activeElement as HTMLElement | null;
63
+ target.focus();
64
+
65
+ const handleFocusIn = (event: FocusEvent) => {
66
+ if (!node.contains(event.target as Node)) {
67
+ event.stopPropagation();
68
+ target.focus();
69
+ }
70
+ };
71
+ document.addEventListener("focusin", handleFocusIn);
72
+ return () => {
73
+ document.removeEventListener("focusin", handleFocusIn);
74
+ previouslyFocused?.focus?.();
75
+ };
76
+ }, [contentRef, open]);
77
+ };
@@ -0,0 +1 @@
1
+ export { useHover } from "./useHover";
@@ -0,0 +1,45 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { useHover } from "./useHover";
5
+
6
+ describe("useHover", () => {
7
+ afterEach(() => {
8
+ document.body.replaceChildren();
9
+ });
10
+
11
+ it("should report hovered true on pointerenter and false on pointerleave", () => {
12
+ const { result } = renderHook(() => useHover<HTMLDivElement>());
13
+ const node = document.createElement("div");
14
+ document.body.appendChild(node);
15
+ act(() => {
16
+ result.current[0](node);
17
+ });
18
+ expect(result.current[1]).toBe(false);
19
+ act(() => {
20
+ node.dispatchEvent(new Event("pointerenter", { bubbles: true }));
21
+ });
22
+ expect(result.current[1]).toBe(true);
23
+ act(() => {
24
+ node.dispatchEvent(new Event("pointerleave", { bubbles: true }));
25
+ });
26
+ expect(result.current[1]).toBe(false);
27
+ });
28
+
29
+ it("should clear hover when ref target is cleared", () => {
30
+ const { result } = renderHook(() => useHover<HTMLDivElement>());
31
+ const node = document.createElement("div");
32
+ document.body.appendChild(node);
33
+ act(() => {
34
+ result.current[0](node);
35
+ });
36
+ act(() => {
37
+ node.dispatchEvent(new Event("pointerenter", { bubbles: true }));
38
+ });
39
+ expect(result.current[1]).toBe(true);
40
+ act(() => {
41
+ result.current[0](null);
42
+ });
43
+ expect(result.current[1]).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import type { RefCallback } from "react";
4
+ import { useCallback, useEffect, useState } from "react";
5
+
6
+ /**
7
+ * Tracks pointer hover state for a single DOM node using `pointerenter` / `pointerleave`.
8
+ *
9
+ * When the ref target changes (or unmounts), hover is cleared so state does not stay `true` after
10
+ * retargeting without a `pointerleave` on the previous node.
11
+ *
12
+ * @typeParam T - HTMLElement subtype for the ref callback (e.g. `HTMLDivElement`).
13
+ * @returns A tuple `[setRef, hovered]` where `setRef` is a callback ref to attach to the target element
14
+ * and `hovered` is `true` while the pointer is over that element (primary button agnostic).
15
+ */
16
+ export function useHover<T extends HTMLElement>(): [RefCallback<T>, boolean] {
17
+ const [element, setElement] = useState<T | null>(null);
18
+ const [hovered, setHovered] = useState(false);
19
+
20
+ const setRef = useCallback((node: T | null) => {
21
+ setElement(node);
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ if (element == null) {
26
+ setHovered(false);
27
+ return;
28
+ }
29
+ const onEnter = () => {
30
+ setHovered(true);
31
+ };
32
+ const onLeave = () => {
33
+ setHovered(false);
34
+ };
35
+ element.addEventListener("pointerenter", onEnter);
36
+ element.addEventListener("pointerleave", onLeave);
37
+ return () => {
38
+ setHovered(false);
39
+ element.removeEventListener("pointerenter", onEnter);
40
+ element.removeEventListener("pointerleave", onLeave);
41
+ };
42
+ }, [element]);
43
+
44
+ return [setRef, hovered];
45
+ }
@@ -0,0 +1 @@
1
+ export { useInView, type UseInViewParams } from "./useInView";