@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,33 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /**
6
+ * Reflects browser online/offline connectivity using `navigator.onLine` and the `window` `"online"` / `"offline"` events.
7
+ *
8
+ * Defaults to `true` during SSR when `navigator` is undefined. Does not expose `connection` quality metrics—only reachability hints.
9
+ *
10
+ * @returns `true` when the browser believes the device is online, `false` when offline.
11
+ */
12
+ export function useNetworkStatus(): boolean {
13
+ const [online, setOnline] = useState(() =>
14
+ typeof navigator === "undefined" ? true : navigator.onLine,
15
+ );
16
+
17
+ useEffect(() => {
18
+ const onOnline = () => {
19
+ setOnline(true);
20
+ };
21
+ const onOffline = () => {
22
+ setOnline(false);
23
+ };
24
+ window.addEventListener("online", onOnline);
25
+ window.addEventListener("offline", onOffline);
26
+ return () => {
27
+ window.removeEventListener("online", onOnline);
28
+ window.removeEventListener("offline", onOffline);
29
+ };
30
+ }, []);
31
+
32
+ return online;
33
+ }
@@ -0,0 +1 @@
1
+ export { usePageVisibility } from "./usePageVisibility";
@@ -0,0 +1,21 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { usePageVisibility } from "./usePageVisibility";
5
+
6
+ describe("usePageVisibility", () => {
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ it("should track document.visibilityState via visibilitychange", () => {
12
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
13
+ const { result } = renderHook(() => usePageVisibility());
14
+ expect(result.current).toBe("visible");
15
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
16
+ act(() => {
17
+ document.dispatchEvent(new Event("visibilitychange"));
18
+ });
19
+ expect(result.current).toBe("hidden");
20
+ });
21
+ });
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /**
6
+ * Tracks `document.visibilityState` (`"visible"`, `"hidden"`, or `"prerender"`) across tab visibility changes.
7
+ *
8
+ * Subscribes to `"visibilitychange"` so backgrounding a tab or switching windows updates state. SSR defaults to `"visible"`.
9
+ *
10
+ * @returns Current `DocumentVisibilityState` from the Page Visibility API.
11
+ */
12
+ export function usePageVisibility(): DocumentVisibilityState {
13
+ const [state, setState] = useState<DocumentVisibilityState>(() =>
14
+ typeof document === "undefined" ? "visible" : document.visibilityState,
15
+ );
16
+
17
+ useEffect(() => {
18
+ if (typeof document === "undefined") {
19
+ return;
20
+ }
21
+ const onChange = () => {
22
+ setState(document.visibilityState);
23
+ };
24
+ document.addEventListener("visibilitychange", onChange);
25
+ return () => {
26
+ document.removeEventListener("visibilitychange", onChange);
27
+ };
28
+ }, []);
29
+
30
+ return state;
31
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ buildPaginationItems,
3
+ usePagination,
4
+ type BuildPaginationItemsParams,
5
+ type PaginationPageItem,
6
+ } from "./usePagination";
@@ -0,0 +1,139 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { buildPaginationItems, usePagination } from "./usePagination";
5
+
6
+ describe("buildPaginationItems", () => {
7
+ it("should return empty list when pageCount is zero", () => {
8
+ expect(
9
+ buildPaginationItems({
10
+ pageCount: 0,
11
+ currentPage: 1,
12
+ siblingCount: 1,
13
+ boundaryCount: 1,
14
+ }),
15
+ ).toEqual([]);
16
+ });
17
+
18
+ it("should return full page range when within totalNumbers window", () => {
19
+ const items = buildPaginationItems({
20
+ pageCount: 5,
21
+ currentPage: 3,
22
+ siblingCount: 1,
23
+ boundaryCount: 1,
24
+ });
25
+ expect(items).toEqual([
26
+ { type: "page", value: 1 },
27
+ { type: "page", value: 2 },
28
+ { type: "page", value: 3 },
29
+ { type: "page", value: 4 },
30
+ { type: "page", value: 5 },
31
+ ]);
32
+ });
33
+
34
+ it("should insert ellipsis when range is fragmented", () => {
35
+ const items = buildPaginationItems({
36
+ pageCount: 10,
37
+ currentPage: 5,
38
+ siblingCount: 1,
39
+ boundaryCount: 1,
40
+ });
41
+ const ellipsisKeys = items
42
+ .filter((item) => item.type === "ellipsis")
43
+ .map((item) => item.key);
44
+ expect(ellipsisKeys.length).toBeGreaterThan(0);
45
+ const pages = items
46
+ .filter((item) => item.type === "page")
47
+ .map((item) => item.value);
48
+ expect(pages).toContain(1);
49
+ expect(pages).toContain(10);
50
+ expect(pages).toContain(5);
51
+ });
52
+
53
+ it("should clamp current page into range", () => {
54
+ const items = buildPaginationItems({
55
+ pageCount: 3,
56
+ currentPage: 99,
57
+ siblingCount: 0,
58
+ boundaryCount: 1,
59
+ });
60
+ expect(items.every((i) => i.type === "page")).toBe(true);
61
+ expect(items.some((i) => i.type === "page" && i.value === 3)).toBe(true);
62
+ });
63
+ });
64
+
65
+ describe("usePagination", () => {
66
+ it("should use internal state when uncontrolled", () => {
67
+ const { result } = renderHook(() =>
68
+ usePagination({ pageCount: 5, defaultPage: 2 }),
69
+ );
70
+ expect(result.current.currentPage).toBe(2);
71
+ expect(result.current.canGoPrev).toBe(true);
72
+ expect(result.current.canGoNext).toBe(true);
73
+ act(() => {
74
+ result.current.setPage(4);
75
+ });
76
+ expect(result.current.currentPage).toBe(4);
77
+ });
78
+
79
+ it("should clamp setPage into 1 and pageCount", () => {
80
+ const { result } = renderHook(() =>
81
+ usePagination({ pageCount: 3, defaultPage: 2 }),
82
+ );
83
+ act(() => {
84
+ result.current.setPage(0);
85
+ });
86
+ expect(result.current.currentPage).toBe(1);
87
+ act(() => {
88
+ result.current.setPage(100);
89
+ });
90
+ expect(result.current.currentPage).toBe(3);
91
+ });
92
+
93
+ it("should follow controlled page prop", () => {
94
+ const onPageChange = vi.fn();
95
+ const { result, rerender } = renderHook(
96
+ ({ page }: { page: number }) =>
97
+ usePagination({ pageCount: 4, page, onPageChange }),
98
+ { initialProps: { page: 1 } },
99
+ );
100
+ expect(result.current.currentPage).toBe(1);
101
+ act(() => {
102
+ result.current.setPage(3);
103
+ });
104
+ expect(result.current.currentPage).toBe(1);
105
+ expect(onPageChange).toHaveBeenCalledWith(3);
106
+ rerender({ page: 3 });
107
+ expect(result.current.currentPage).toBe(3);
108
+ });
109
+
110
+ it("should not change page on goPrev when already first", () => {
111
+ const { result } = renderHook(() =>
112
+ usePagination({ pageCount: 3, defaultPage: 1 }),
113
+ );
114
+ act(() => {
115
+ result.current.goPrev();
116
+ });
117
+ expect(result.current.currentPage).toBe(1);
118
+ expect(result.current.canGoPrev).toBe(false);
119
+ });
120
+
121
+ it("should not change page on goNext when already last", () => {
122
+ const { result } = renderHook(() =>
123
+ usePagination({ pageCount: 3, defaultPage: 3 }),
124
+ );
125
+ act(() => {
126
+ result.current.goNext();
127
+ });
128
+ expect(result.current.currentPage).toBe(3);
129
+ expect(result.current.canGoNext).toBe(false);
130
+ });
131
+
132
+ it("should set canGoPrev and canGoNext false when pageCount is zero", () => {
133
+ const { result } = renderHook(() =>
134
+ usePagination({ pageCount: 0, defaultPage: 1 }),
135
+ );
136
+ expect(result.current.canGoPrev).toBe(false);
137
+ expect(result.current.canGoNext).toBe(false);
138
+ });
139
+ });
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+
5
+ import type {
6
+ PaginationPageItem,
7
+ UsePaginationParams,
8
+ UsePaginationResult,
9
+ } from "../../ui/pagination/types";
10
+ import { clampPage, range } from "../../lib/utils";
11
+
12
+ export type { PaginationPageItem } from "../../ui/pagination/types";
13
+
14
+ export type BuildPaginationItemsParams = {
15
+ pageCount: number;
16
+ currentPage: number;
17
+ siblingCount: number;
18
+ boundaryCount: number;
19
+ };
20
+
21
+ /**
22
+ * Headless pagination state: current page, derived page button items, and prev/next helpers.
23
+ *
24
+ * Supports controlled mode when `page` is passed from the parent, or internal state seeded by `defaultPage`.
25
+ * All page indices are **1-based** and clamped to `[1, pageCount]` via {@link clampPage}. `items` is memoized from
26
+ * {@link buildPaginationItems} for rendering numeric pages and ellipsis gaps.
27
+ *
28
+ * @param params - See `UsePaginationParams` in `../ui/pagination/types` for full fields (`pageCount`, `page`, `onPageChange`, etc.).
29
+ * @returns Current page, item list, `setPage`, navigation helpers, and `canGoPrev` / `canGoNext` flags.
30
+ */
31
+ export function usePagination({
32
+ pageCount,
33
+ page,
34
+ defaultPage = 1,
35
+ siblingCount = 1,
36
+ boundaryCount = 1,
37
+ onPageChange,
38
+ }: UsePaginationParams): UsePaginationResult {
39
+ const [internalPage, setInternalPage] = useState(() =>
40
+ clampPage(defaultPage, pageCount),
41
+ );
42
+
43
+ const isControlled = page !== undefined;
44
+ const currentPage = clampPage(isControlled ? page : internalPage, pageCount);
45
+
46
+ const setPage = useCallback(
47
+ (next: number) => {
48
+ const clamped = clampPage(next, pageCount);
49
+ if (!isControlled) {
50
+ setInternalPage(clamped);
51
+ }
52
+ onPageChange?.(clamped);
53
+ },
54
+ [isControlled, onPageChange, pageCount],
55
+ );
56
+
57
+ const items = useMemo(
58
+ () =>
59
+ buildPaginationItems({
60
+ pageCount,
61
+ currentPage,
62
+ siblingCount,
63
+ boundaryCount,
64
+ }),
65
+ [boundaryCount, currentPage, pageCount, siblingCount],
66
+ );
67
+
68
+ const canGoPrev = pageCount > 0 && currentPage > 1;
69
+ const canGoNext = pageCount > 0 && currentPage < pageCount;
70
+
71
+ const goPrev = useCallback(() => {
72
+ if (canGoPrev) {
73
+ setPage(currentPage - 1);
74
+ }
75
+ }, [canGoPrev, currentPage, setPage]);
76
+
77
+ const goNext = useCallback(() => {
78
+ if (canGoNext) {
79
+ setPage(currentPage + 1);
80
+ }
81
+ }, [canGoNext, currentPage, setPage]);
82
+
83
+ return {
84
+ currentPage,
85
+ pageCount,
86
+ items,
87
+ setPage,
88
+ goPrev,
89
+ goNext,
90
+ canGoPrev,
91
+ canGoNext,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Builds the ordered list of page numbers and ellipsis markers for a pagination control.
97
+ *
98
+ * @param params.pageCount - Total number of pages (must be >= 0; empty list when 0).
99
+ * @param params.currentPage - Active page index (1-based).
100
+ * @param params.siblingCount - How many page buttons to show on each side of the current page.
101
+ * @param params.boundaryCount - How many pages to pin at the start and end of the range.
102
+ * @returns Items suitable for rendering, e.g. page `1`, ellipsis gap, middle window, ellipsis, last page.
103
+ */
104
+ export function buildPaginationItems({
105
+ pageCount,
106
+ currentPage,
107
+ siblingCount,
108
+ boundaryCount,
109
+ }: BuildPaginationItemsParams): PaginationPageItem[] {
110
+ if (pageCount <= 0) {
111
+ return [];
112
+ }
113
+
114
+ const safeBoundary = Math.max(1, boundaryCount);
115
+ const safeSibling = Math.max(0, siblingCount);
116
+ const current = clampPage(currentPage, pageCount);
117
+
118
+ const totalNumbers = safeBoundary * 2 + safeSibling * 2 + 1;
119
+ if (pageCount <= totalNumbers) {
120
+ return range(1, pageCount).map((value) => ({ type: "page", value }));
121
+ }
122
+
123
+ const leftBoundaryEnd = safeBoundary;
124
+ const rightBoundaryStart = pageCount - safeBoundary + 1;
125
+
126
+ const siblingsStart = Math.max(current - safeSibling, leftBoundaryEnd + 1);
127
+ const siblingsEnd = Math.min(current + safeSibling, rightBoundaryStart - 1);
128
+
129
+ const pages = new Set<number>();
130
+ for (let p = 1; p <= leftBoundaryEnd; p += 1) {
131
+ pages.add(p);
132
+ }
133
+ for (let p = rightBoundaryStart; p <= pageCount; p += 1) {
134
+ pages.add(p);
135
+ }
136
+ for (let p = siblingsStart; p <= siblingsEnd; p += 1) {
137
+ pages.add(p);
138
+ }
139
+
140
+ const sorted = [...pages].sort((a, b) => a - b);
141
+ const items: PaginationPageItem[] = [];
142
+
143
+ for (let i = 0; i < sorted.length; i += 1) {
144
+ const value = sorted[i];
145
+ const prev = sorted[i - 1];
146
+ if (i > 0 && prev !== undefined && value && value - prev > 1) {
147
+ items.push({ type: "ellipsis", key: `gap-${prev}-${value}` });
148
+ }
149
+ items.push({ type: "page", value: value as number });
150
+ }
151
+
152
+ return items;
153
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ usePrefersColorScheme,
3
+ type ColorSchemePreference,
4
+ } from "./usePrefersColorScheme";
@@ -0,0 +1,53 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { usePrefersColorScheme } from "./usePrefersColorScheme";
5
+
6
+ function createDarkPreferenceMatchMedia(initialDark: boolean) {
7
+ let dark = initialDark;
8
+ const listeners = new Set<() => void>();
9
+ return {
10
+ setDark(next: boolean) {
11
+ dark = next;
12
+ listeners.forEach((listener) => listener());
13
+ },
14
+ implementation: (query: string) => {
15
+ const mql = {
16
+ media: query,
17
+ get matches() {
18
+ return query === "(prefers-color-scheme: dark)" && dark;
19
+ },
20
+ addEventListener: (_type: string, listener: () => void) => {
21
+ listeners.add(listener);
22
+ },
23
+ removeEventListener: (_type: string, listener: () => void) => {
24
+ listeners.delete(listener);
25
+ },
26
+ addListener: vi.fn(),
27
+ removeListener: vi.fn(),
28
+ dispatchEvent: vi.fn(),
29
+ };
30
+ return mql as unknown as MediaQueryList;
31
+ },
32
+ };
33
+ }
34
+
35
+ describe("usePrefersColorScheme", () => {
36
+ afterEach(() => {
37
+ vi.spyOn(window, "matchMedia").mockRestore();
38
+ });
39
+
40
+ it("should map dark media query to dark scheme", () => {
41
+ const { setDark, implementation } = createDarkPreferenceMatchMedia(false);
42
+ const matchMediaSpy = vi
43
+ .spyOn(window, "matchMedia")
44
+ .mockImplementation(implementation);
45
+ const { result } = renderHook(() => usePrefersColorScheme("light"));
46
+ expect(matchMediaSpy).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
47
+ expect(result.current).toBe("light");
48
+ act(() => {
49
+ setDark(true);
50
+ });
51
+ expect(result.current).toBe("dark");
52
+ });
53
+ });
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { useMediaQuery } from "../useMediaQuery";
4
+
5
+ export type ColorSchemePreference = "light" | "dark";
6
+
7
+ /**
8
+ * Resolves the user’s preferred color scheme from `prefers-color-scheme: dark` via {@link useMediaQuery}.
9
+ *
10
+ * @param defaultScheme - Hydration / SSR fallback when media queries are unavailable (`"light"` or `"dark"`).
11
+ * @returns `"dark"` when the dark media query matches, otherwise `"light"`.
12
+ */
13
+ export function usePrefersColorScheme(
14
+ defaultScheme: ColorSchemePreference = "light",
15
+ ): ColorSchemePreference {
16
+ const prefersDark = useMediaQuery(
17
+ "(prefers-color-scheme: dark)",
18
+ defaultScheme === "dark",
19
+ );
20
+ return prefersDark ? "dark" : "light";
21
+ }
@@ -0,0 +1 @@
1
+ export { usePrefersReducedMotion } from "./usePrefersReducedMotion";
@@ -0,0 +1,27 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { usePrefersReducedMotion } from "./usePrefersReducedMotion";
5
+
6
+ describe("usePrefersReducedMotion", () => {
7
+ afterEach(() => {
8
+ vi.spyOn(window, "matchMedia").mockRestore();
9
+ });
10
+
11
+ it("should subscribe to prefers-reduced-motion reduce query", () => {
12
+ const matchMediaSpy = vi.spyOn(window, "matchMedia").mockImplementation(
13
+ () =>
14
+ ({
15
+ matches: true,
16
+ addEventListener: vi.fn(),
17
+ removeEventListener: vi.fn(),
18
+ addListener: vi.fn(),
19
+ removeListener: vi.fn(),
20
+ dispatchEvent: vi.fn(),
21
+ }) as unknown as MediaQueryList,
22
+ );
23
+ const { result } = renderHook(() => usePrefersReducedMotion());
24
+ expect(matchMediaSpy).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
25
+ expect(result.current).toBe(true);
26
+ });
27
+ });
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { useMediaQuery } from "../useMediaQuery";
4
+
5
+ /**
6
+ * Returns whether the user prefers reduced motion (`prefers-reduced-motion: reduce`).
7
+ *
8
+ * Use to disable non-essential animations or switch to instant transitions for accessibility.
9
+ *
10
+ * @returns `true` when reduced motion is requested.
11
+ */
12
+ export function usePrefersReducedMotion(): boolean {
13
+ return useMediaQuery("(prefers-reduced-motion: reduce)");
14
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ useResizeObserver,
3
+ type ElementSize,
4
+ } from "./useResizeObserver";
@@ -0,0 +1,68 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { useResizeObserver } from "./useResizeObserver";
5
+
6
+ describe("useResizeObserver", () => {
7
+ const OriginalRO = globalThis.ResizeObserver;
8
+ let lastCallback: ResizeObserverCallback | undefined;
9
+
10
+ afterEach(() => {
11
+ globalThis.ResizeObserver = OriginalRO;
12
+ lastCallback = undefined;
13
+ });
14
+
15
+ it("should observe and set size from contentRect", () => {
16
+ const observeSpy = vi.fn();
17
+ class MockResizeObserver {
18
+ constructor(cb: ResizeObserverCallback) {
19
+ lastCallback = cb;
20
+ }
21
+
22
+ observe = observeSpy;
23
+
24
+ disconnect = vi.fn();
25
+ }
26
+
27
+ globalThis.ResizeObserver =
28
+ MockResizeObserver as unknown as typeof ResizeObserver;
29
+
30
+ const { result } = renderHook(() => useResizeObserver());
31
+ const node = document.createElement("div");
32
+ act(() => {
33
+ result.current[0](node);
34
+ });
35
+ expect(observeSpy).toHaveBeenCalledWith(node);
36
+ act(() => {
37
+ lastCallback?.(
38
+ [
39
+ {
40
+ contentRect: { width: 120, height: 80 },
41
+ } as ResizeObserverEntry,
42
+ ],
43
+ {} as ResizeObserver,
44
+ );
45
+ });
46
+ expect(result.current[1]).toEqual({ width: 120, height: 80 });
47
+ });
48
+
49
+ it("should not attach when enabled is false", () => {
50
+ const observeSpy = vi.fn();
51
+ class MockResizeObserver {
52
+ constructor(_cb: ResizeObserverCallback) {}
53
+
54
+ observe = observeSpy;
55
+
56
+ disconnect = vi.fn();
57
+ }
58
+
59
+ globalThis.ResizeObserver =
60
+ MockResizeObserver as unknown as typeof ResizeObserver;
61
+
62
+ const { result } = renderHook(() => useResizeObserver({ enabled: false }));
63
+ act(() => {
64
+ result.current[0](document.createElement("div"));
65
+ });
66
+ expect(observeSpy).not.toHaveBeenCalled();
67
+ });
68
+ });
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import type { RefCallback } from "react";
4
+ import { useCallback, useEffect, useState } from "react";
5
+
6
+ export type ElementSize = {
7
+ width: number;
8
+ height: number;
9
+ };
10
+
11
+ export type UseResizeObserverParams = {
12
+ /** When `false`, no `ResizeObserver` is attached. */
13
+ enabled?: boolean;
14
+ };
15
+
16
+ /**
17
+ * Observes an element’s `contentRect` size via `ResizeObserver` and exposes `{ width, height }`.
18
+ *
19
+ * When `ResizeObserver` is undefined or `enabled` is false, size may remain `undefined`. Uses the first
20
+ * entry from the observer callback (`entries[0]`) aligned with the single observed node.
21
+ *
22
+ * @typeParam T - Observed element type.
23
+ * @param params - Optional `{ enabled }` (default `true`).
24
+ * @returns `[setRef, size]` callback ref and latest measured size.
25
+ */
26
+ export function useResizeObserver<T extends Element>(
27
+ params: UseResizeObserverParams = {},
28
+ ): [RefCallback<T>, ElementSize | undefined] {
29
+ const { enabled = true } = params;
30
+ const [element, setElement] = useState<T | null>(null);
31
+ const [size, setSize] = useState<ElementSize | undefined>(undefined);
32
+
33
+ const setRef = useCallback((node: T | null) => {
34
+ setElement(node);
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ if (!enabled || element == null) {
39
+ return;
40
+ }
41
+ if (typeof ResizeObserver === "undefined") {
42
+ return;
43
+ }
44
+ const observer = new ResizeObserver((entries) => {
45
+ const { width, height } = entries[0]?.contentRect ?? {
46
+ width: 0,
47
+ height: 0,
48
+ };
49
+ setSize({ width, height });
50
+ });
51
+ observer.observe(element);
52
+ return () => {
53
+ observer.disconnect();
54
+ };
55
+ }, [element, enabled]);
56
+
57
+ return [setRef, size];
58
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ useSessionStorage,
3
+ type UseSessionStorageResult,
4
+ } from "./useSessionStorage";