@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,54 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { useSessionStorage } from "./useSessionStorage";
5
+
6
+ const keyPrefix = "zt-test-sessionstorage";
7
+
8
+ async function flushEffects() {
9
+ await act(async () => {
10
+ await Promise.resolve();
11
+ });
12
+ }
13
+
14
+ describe("useSessionStorage", () => {
15
+ afterEach(() => {
16
+ sessionStorage.clear();
17
+ });
18
+
19
+ it("should apply stored value from sessionStorage after mount", async () => {
20
+ const key = `${keyPrefix}-hydrate`;
21
+ sessionStorage.setItem(key, JSON.stringify("stored"));
22
+ const { result } = renderHook(() =>
23
+ useSessionStorage(key, "initial"),
24
+ );
25
+ await flushEffects();
26
+ expect(result.current[0]).toBe("stored");
27
+ });
28
+
29
+ it("should persist setValue to sessionStorage", async () => {
30
+ const key = `${keyPrefix}-persist`;
31
+ const { result } = renderHook(() =>
32
+ useSessionStorage(key, { n: 0 }),
33
+ );
34
+ await flushEffects();
35
+ act(() => {
36
+ result.current[1]({ n: 3 });
37
+ });
38
+ expect(sessionStorage.getItem(key)).toBe(JSON.stringify({ n: 3 }));
39
+ expect(result.current[0]).toEqual({ n: 3 });
40
+ });
41
+
42
+ it("should remove and reset to initialValue", async () => {
43
+ const key = `${keyPrefix}-remove`;
44
+ sessionStorage.setItem(key, JSON.stringify(42));
45
+ const { result } = renderHook(() => useSessionStorage(key, 0));
46
+ await flushEffects();
47
+ expect(result.current[0]).toBe(42);
48
+ act(() => {
49
+ result.current[2]();
50
+ });
51
+ expect(sessionStorage.getItem(key)).toBeNull();
52
+ expect(result.current[0]).toBe(0);
53
+ });
54
+ });
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ function readValue<T>(key: string, fallback: T): T {
6
+ if (typeof window === "undefined") {
7
+ return fallback;
8
+ }
9
+ try {
10
+ const raw = window.sessionStorage.getItem(key);
11
+ if (raw == null) {
12
+ return fallback;
13
+ }
14
+ return JSON.parse(raw) as T;
15
+ } catch {
16
+ return fallback;
17
+ }
18
+ }
19
+
20
+ export type UseSessionStorageResult<T> = [
21
+ T,
22
+ (value: T | ((previous: T) => T)) => void,
23
+ () => void,
24
+ ];
25
+
26
+ /**
27
+ * Syncs JSON-serialized state with `window.sessionStorage` for the lifetime of the browser tab.
28
+ *
29
+ * Same persistence semantics as {@link useLocalStorage} but scoped to the session: data clears when the tab closes.
30
+ * Unlike `useLocalStorage`, this hook does **not** subscribe to `storage` events (session storage is not shared across tabs).
31
+ *
32
+ * State is initialized from `initialValue` only so the first client render can match SSR output; the stored value is
33
+ * applied after mount in an effect to avoid hydration mismatches.
34
+ *
35
+ * @typeParam T - Stored value type; must round-trip through `JSON.stringify` / `parse`.
36
+ * @param key - `sessionStorage` key.
37
+ * @param initialValue - Fallback when missing, invalid JSON, or during SSR; also used for the first render before hydrate.
38
+ * @returns `[stored, setValue, remove]` tuple.
39
+ */
40
+ export function useSessionStorage<T>(
41
+ key: string,
42
+ initialValue: T,
43
+ ): UseSessionStorageResult<T> {
44
+ const [stored, setStored] = useState<T>(initialValue);
45
+
46
+ const setValue = useCallback(
47
+ (value: T | ((previous: T) => T)) => {
48
+ setStored((previous) => {
49
+ const next =
50
+ typeof value === "function" ? (value as (p: T) => T)(previous) : value;
51
+ try {
52
+ if (typeof window !== "undefined") {
53
+ window.sessionStorage.setItem(key, JSON.stringify(next));
54
+ }
55
+ } catch {
56
+ /* quota or private mode */
57
+ }
58
+ return next;
59
+ });
60
+ },
61
+ [key],
62
+ );
63
+
64
+ const remove = useCallback(() => {
65
+ try {
66
+ if (typeof window !== "undefined") {
67
+ window.sessionStorage.removeItem(key);
68
+ }
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+ setStored(initialValue);
73
+ }, [initialValue, key]);
74
+
75
+ useEffect(() => {
76
+ const next = readValue(key, initialValue);
77
+
78
+ setStored((prev) => {
79
+ return JSON.stringify(prev) === JSON.stringify(next) ? prev : next;
80
+ });
81
+ }, [key]);
82
+
83
+ return [stored, setValue, remove];
84
+ }
@@ -0,0 +1 @@
1
+ export { useThrottledCallback } from "./useThrottledCallback";
@@ -0,0 +1,75 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { useThrottledCallback } from "./useThrottledCallback";
5
+
6
+ describe("useThrottledCallback", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ vi.setSystemTime(1_000_000);
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ it("should invoke callback immediately when outside cooldown", () => {
17
+ const callback = vi.fn();
18
+ const { result } = renderHook(() => useThrottledCallback(callback, 100));
19
+ act(() => {
20
+ result.current("a");
21
+ });
22
+ expect(callback).toHaveBeenCalledTimes(1);
23
+ expect(callback).toHaveBeenCalledWith("a");
24
+ });
25
+
26
+ it("should drop calls inside cooldown window", () => {
27
+ const callback = vi.fn();
28
+ const { result } = renderHook(() => useThrottledCallback(callback, 1000));
29
+ act(() => {
30
+ result.current();
31
+ result.current();
32
+ result.current();
33
+ });
34
+ expect(callback).toHaveBeenCalledTimes(1);
35
+ });
36
+
37
+ it("should allow another call after interval elapses", () => {
38
+ const callback = vi.fn();
39
+ const { result } = renderHook(() => useThrottledCallback(callback, 500));
40
+ act(() => {
41
+ result.current(1);
42
+ });
43
+ act(() => {
44
+ vi.setSystemTime(1_000_400);
45
+ result.current(2);
46
+ });
47
+ expect(callback).toHaveBeenCalledTimes(1);
48
+ act(() => {
49
+ vi.setSystemTime(1_000_500);
50
+ result.current(3);
51
+ });
52
+ expect(callback).toHaveBeenCalledTimes(2);
53
+ expect(callback).toHaveBeenLastCalledWith(3);
54
+ });
55
+
56
+ it("should always use latest callback reference without resetting clock", () => {
57
+ const first = vi.fn();
58
+ const second = vi.fn();
59
+ const { result, rerender } = renderHook(
60
+ ({ fn }: { fn: () => void }) => useThrottledCallback(fn, 200),
61
+ { initialProps: { fn: first } },
62
+ );
63
+ act(() => {
64
+ result.current();
65
+ });
66
+ expect(first).toHaveBeenCalledTimes(1);
67
+ rerender({ fn: second });
68
+ act(() => {
69
+ vi.setSystemTime(1_000_200);
70
+ result.current();
71
+ });
72
+ expect(second).toHaveBeenCalledTimes(1);
73
+ expect(first).toHaveBeenCalledTimes(1);
74
+ });
75
+ });
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+
5
+ /**
6
+ * Returns a stable, throttled version of `callback` that runs at most once per `intervalMs`.
7
+ *
8
+ * If invoked again inside the cooldown window, the extra calls are **dropped** (no trailing flush). The latest
9
+ * `callback` reference is always used via a ref, so changing the handler does not reset the throttle clock.
10
+ *
11
+ * @typeParam T - Void function type (`(...args) => void`).
12
+ * @param callback - Work to run when the throttle gate opens.
13
+ * @param intervalMs - Minimum milliseconds between invocations.
14
+ * @returns Throttled function with the same call signature as `callback`.
15
+ */
16
+ export function useThrottledCallback<
17
+ T extends (...args: any[]) => void,
18
+ >(callback: T, intervalMs: number): T {
19
+ const callbackRef = useRef(callback);
20
+ const lastRunRef = useRef(0);
21
+
22
+ useEffect(() => {
23
+ callbackRef.current = callback;
24
+ }, [callback]);
25
+
26
+ return useCallback(
27
+ (...args: Parameters<T>) => {
28
+ const now = Date.now();
29
+ if (now - lastRunRef.current >= intervalMs) {
30
+ lastRunRef.current = now;
31
+ callbackRef.current(...args);
32
+ }
33
+ },
34
+ [intervalMs],
35
+ ) as T;
36
+ }
@@ -0,0 +1 @@
1
+ export { useToggle } from "./useToggle";
@@ -0,0 +1,40 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { useToggle } from "./useToggle";
5
+
6
+ describe("useToggle", () => {
7
+ it("should default to false", () => {
8
+ const { result } = renderHook(() => useToggle());
9
+ expect(result.current[0]).toBe(false);
10
+ });
11
+
12
+ it("should respect initial true", () => {
13
+ const { result } = renderHook(() => useToggle(true));
14
+ expect(result.current[0]).toBe(true);
15
+ });
16
+
17
+ it("should flip via toggle", () => {
18
+ const { result } = renderHook(() => useToggle(false));
19
+ act(() => {
20
+ result.current[1]();
21
+ });
22
+ expect(result.current[0]).toBe(true);
23
+ act(() => {
24
+ result.current[1]();
25
+ });
26
+ expect(result.current[0]).toBe(false);
27
+ });
28
+
29
+ it("should set absolute value via set", () => {
30
+ const { result } = renderHook(() => useToggle(false));
31
+ act(() => {
32
+ result.current[2](true);
33
+ });
34
+ expect(result.current[0]).toBe(true);
35
+ act(() => {
36
+ result.current[2](false);
37
+ });
38
+ expect(result.current[0]).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+
5
+ /**
6
+ * Simple boolean state with `toggle()` and an explicit `set(next)` setter.
7
+ *
8
+ * @param initialValue - Starting boolean (default `false`).
9
+ * @returns `[on, toggle, set]` where `on` is the flag, `toggle` flips it, and `set` assigns an absolute value.
10
+ */
11
+ export function useToggle(
12
+ initialValue = false,
13
+ ): [boolean, () => void, (next: boolean) => void] {
14
+ const [on, setOn] = useState(initialValue);
15
+ const toggle = useCallback(() => {
16
+ setOn((previous) => !previous);
17
+ }, []);
18
+ const set = useCallback((next: boolean) => {
19
+ setOn(next);
20
+ }, []);
21
+ return [on, toggle, set];
22
+ }
@@ -0,0 +1 @@
1
+ export { useWindowSize, type WindowSize } from "./useWindowSize";
@@ -0,0 +1,23 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { useWindowSize } from "./useWindowSize";
5
+
6
+ describe("useWindowSize", () => {
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ it("should read inner dimensions and update on resize", () => {
12
+ vi.spyOn(window, "innerWidth", "get").mockReturnValue(800);
13
+ vi.spyOn(window, "innerHeight", "get").mockReturnValue(600);
14
+ const { result } = renderHook(() => useWindowSize());
15
+ expect(result.current).toEqual({ width: 800, height: 600 });
16
+ vi.spyOn(window, "innerWidth", "get").mockReturnValue(1024);
17
+ vi.spyOn(window, "innerHeight", "get").mockReturnValue(768);
18
+ act(() => {
19
+ window.dispatchEvent(new Event("resize"));
20
+ });
21
+ expect(result.current).toEqual({ width: 1024, height: 768 });
22
+ });
23
+ });
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export type WindowSize = {
6
+ width: number;
7
+ height: number;
8
+ };
9
+
10
+ const defaultSize: WindowSize = { width: 0, height: 0 };
11
+
12
+ /**
13
+ * Tracks the viewport size using `window.innerWidth` / `innerHeight` and a `resize` listener.
14
+ *
15
+ * Initializes to `{ width: 0, height: 0 }` on the server; after mount, size updates to the real viewport and on every resize.
16
+ *
17
+ * @returns Latest `{ width, height }` in CSS pixels.
18
+ */
19
+ export function useWindowSize(): WindowSize {
20
+ const [size, setSize] = useState<WindowSize>(() => {
21
+ if (typeof window === "undefined") {
22
+ return defaultSize;
23
+ }
24
+ return { width: window.innerWidth || 0, height: window.innerHeight || 0 };
25
+ });
26
+
27
+ useEffect(() => {
28
+ const onResize = () => {
29
+ setSize({ width: window.innerWidth || 0, height: window.innerHeight || 0 });
30
+ };
31
+ onResize();
32
+ window.addEventListener("resize", onResize);
33
+ return () => {
34
+ window.removeEventListener("resize", onResize);
35
+ };
36
+ }, []);
37
+
38
+ return size;
39
+ }
@@ -0,0 +1,25 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export const clampPage = (page: number, pageCount: number): number => {
9
+ if (pageCount <= 0) {
10
+ return 1;
11
+ }
12
+ return Math.min(pageCount, Math.max(1, Math.floor(page)));
13
+ }
14
+
15
+ export const range = (from: number, to: number): number[] => {
16
+ const out: number[] = [];
17
+ for (let i = from; i <= to; i += 1) {
18
+ out.push(i);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ export const clamp = (value: number, min: number, max: number) => {
24
+ return Math.min(max, Math.max(min, value));
25
+ }
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useId,
8
+ useMemo,
9
+ useState,
10
+ } from "react";
11
+
12
+ import { cn } from "../../lib/utils";
13
+
14
+ import type {
15
+ AccordionBaseProps,
16
+ AccordionContentProps,
17
+ AccordionCtx,
18
+ AccordionItemProps,
19
+ AccordionTriggerProps,
20
+ } from "./types";
21
+ import {
22
+ accordionContentVariants,
23
+ accordionItemVariants,
24
+ accordionTriggerVariants,
25
+ accordionVariants,
26
+ } from "./variants";
27
+
28
+ const AccordionContext = createContext<AccordionCtx | null>(null);
29
+
30
+ const AccordionItemValueContext = createContext<string | null>(null);
31
+
32
+ export function useAccordionContext(component: string): AccordionCtx {
33
+ const ctx = useContext(AccordionContext);
34
+ if (!ctx) {
35
+ throw new Error(`${component} must be used within <Accordion>`);
36
+ }
37
+ return ctx;
38
+ }
39
+
40
+ export function useAccordionItemValue(component: string): string {
41
+ const value = useContext(AccordionItemValueContext);
42
+ if (!value) {
43
+ throw new Error(`${component} must be used within <AccordionItem>`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ export function AccordionBase({
49
+ type = "single",
50
+ value,
51
+ values,
52
+ defaultValue,
53
+ defaultValues,
54
+ onValueChange,
55
+ onValuesChange,
56
+ appearance = "default",
57
+ size = "md",
58
+ className,
59
+ children,
60
+ }: AccordionBaseProps) {
61
+ const isSingleControlled = value !== undefined;
62
+ const isMultipleControlled = values !== undefined;
63
+ const [singleUncontrolled, setSingleUncontrolled] = useState<
64
+ string | undefined
65
+ >(defaultValue);
66
+ const [multipleUncontrolled, setMultipleUncontrolled] = useState<string[]>(
67
+ defaultValues ?? [],
68
+ );
69
+
70
+ const singleValue = isSingleControlled ? value : singleUncontrolled;
71
+ const multipleValues = useMemo(
72
+ () => (isMultipleControlled ? (values ?? []) : multipleUncontrolled),
73
+ [isMultipleControlled, values, multipleUncontrolled],
74
+ );
75
+
76
+ const isOpen = useCallback(
77
+ (itemValue: string) => {
78
+ if (type === "single") {
79
+ return singleValue === itemValue;
80
+ }
81
+ return multipleValues.includes(itemValue);
82
+ },
83
+ [multipleValues, singleValue, type],
84
+ );
85
+
86
+ const toggle = useCallback(
87
+ (itemValue: string) => {
88
+ if (type === "single") {
89
+ const next = singleValue === itemValue ? undefined : itemValue;
90
+ if (!isSingleControlled) {
91
+ setSingleUncontrolled(next);
92
+ }
93
+ onValueChange?.(next);
94
+ return;
95
+ }
96
+ const exists = multipleValues.includes(itemValue);
97
+ const next = exists
98
+ ? multipleValues.filter((entry) => entry !== itemValue)
99
+ : [...multipleValues, itemValue];
100
+ if (!isMultipleControlled) {
101
+ setMultipleUncontrolled(next);
102
+ }
103
+ onValuesChange?.(next);
104
+ },
105
+ [
106
+ isMultipleControlled,
107
+ isSingleControlled,
108
+ multipleValues,
109
+ onValueChange,
110
+ onValuesChange,
111
+ singleValue,
112
+ type,
113
+ ],
114
+ );
115
+
116
+ const ctx = useMemo(
117
+ () => ({
118
+ type,
119
+ appearance: appearance ?? "default",
120
+ size: size ?? "md",
121
+ isOpen,
122
+ toggle,
123
+ }),
124
+ [appearance, isOpen, size, toggle, type],
125
+ );
126
+
127
+ return (
128
+ <AccordionContext.Provider value={ctx}>
129
+ <div
130
+ data-slot="accordion"
131
+ className={cn(accordionVariants({ appearance, size }), className)}
132
+ >
133
+ {children}
134
+ </div>
135
+ </AccordionContext.Provider>
136
+ );
137
+ }
138
+
139
+ AccordionBase.displayName = "Accordion";
140
+
141
+ export function AccordionItem({
142
+ className,
143
+ value,
144
+ children,
145
+ ref,
146
+ ...rest
147
+ }: AccordionItemProps) {
148
+ const { appearance } = useAccordionContext("AccordionItem");
149
+ return (
150
+ <AccordionItemValueContext.Provider value={value}>
151
+ <div
152
+ ref={ref}
153
+ data-slot="accordion-item"
154
+ data-value={value}
155
+ className={cn(accordionItemVariants({ appearance }), className)}
156
+ {...rest}
157
+ >
158
+ {children}
159
+ </div>
160
+ </AccordionItemValueContext.Provider>
161
+ );
162
+ }
163
+
164
+ AccordionItem.displayName = "AccordionItem";
165
+
166
+ export function AccordionTrigger({
167
+ className,
168
+ children,
169
+ ref,
170
+ ...rest
171
+ }: AccordionTriggerProps) {
172
+ const itemValue = useAccordionItemValue("AccordionTrigger");
173
+ const { isOpen, toggle, size } = useAccordionContext("AccordionTrigger");
174
+ const open = isOpen(itemValue);
175
+ const panelId = `${itemValue}-panel`;
176
+ const baseId = useId();
177
+
178
+ return (
179
+ <h3 className="m-0">
180
+ <button
181
+ ref={ref}
182
+ type="button"
183
+ data-slot="accordion-trigger"
184
+ id={`${baseId}-trigger-${itemValue}`}
185
+ aria-expanded={open}
186
+ aria-controls={panelId}
187
+ className={cn(accordionTriggerVariants({ size }), className)}
188
+ onClick={() => toggle(itemValue)}
189
+ {...rest}
190
+ >
191
+ {children}
192
+ </button>
193
+ </h3>
194
+ );
195
+ }
196
+
197
+ AccordionTrigger.displayName = "AccordionTrigger";
198
+
199
+ export function AccordionContent({
200
+ className,
201
+ children,
202
+ ref,
203
+ }: AccordionContentProps) {
204
+ const itemValue = useAccordionItemValue("AccordionContent");
205
+ const { isOpen, size } = useAccordionContext("AccordionContent");
206
+ const open = isOpen(itemValue);
207
+ const panelId = `${itemValue}-panel`;
208
+
209
+ return open ? (
210
+ <div
211
+ key={itemValue}
212
+ ref={ref}
213
+ id={panelId}
214
+ role="region"
215
+ data-slot="accordion-content"
216
+ className={cn(accordionContentVariants({ size }), className)}
217
+ >
218
+ {children}
219
+ </div>
220
+ ) : null;
221
+ }
222
+
223
+ AccordionContent.displayName = "AccordionContent";