@musecat/functionkit 1.0.0 → 1.0.2

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 (157) hide show
  1. package/.agents/references/components/ScrolltoTop.md +36 -0
  2. package/.agents/references/components/SwitchCase.md +47 -0
  3. package/.agents/references/components/ViewportPortal.md +34 -0
  4. package/.agents/references/cookie/cookie.md +47 -0
  5. package/.agents/references/datetime/dateTime.client.md +42 -0
  6. package/.agents/references/datetime/dateTime.server.md +42 -0
  7. package/.agents/references/datetime/dateTime.shared.md +86 -0
  8. package/.agents/references/hooks/useAvoidKeyboard.md +29 -0
  9. package/.agents/references/hooks/useCheckInvisible.md +36 -0
  10. package/.agents/references/hooks/useCheckScroll.md +36 -0
  11. package/.agents/references/hooks/useClientDateTime.md +39 -0
  12. package/.agents/references/hooks/useDebounce.md +46 -0
  13. package/.agents/references/hooks/useDebouncedCallback.md +34 -0
  14. package/.agents/references/hooks/useDoubleClick.md +40 -0
  15. package/.agents/references/hooks/useGeolocation.md +37 -0
  16. package/.agents/references/hooks/useHasMounted.md +27 -0
  17. package/.agents/references/hooks/useIntersectionObserver.md +37 -0
  18. package/.agents/references/hooks/useInterval.md +40 -0
  19. package/.agents/references/hooks/useKeyboardHeight.md +33 -0
  20. package/.agents/references/hooks/useKeyboardListNavigation.md +58 -0
  21. package/.agents/references/hooks/useLongPress.md +48 -0
  22. package/.agents/references/hooks/usePreservedCallback.md +33 -0
  23. package/.agents/references/hooks/usePreservedReference.md +34 -0
  24. package/.agents/references/hooks/useRefEffect.md +35 -0
  25. package/.agents/references/hooks/useRelativeDateTime.md +35 -0
  26. package/.agents/references/hooks/useTimeout.md +42 -0
  27. package/.agents/references/hooks/useToggleState.md +39 -0
  28. package/.agents/references/hooks/useViewportHeight.md +26 -0
  29. package/.agents/references/hooks/useViewportMatch.md +32 -0
  30. package/.agents/references/utils/browserStorage.md +41 -0
  31. package/.agents/references/utils/buildContext.md +47 -0
  32. package/.agents/references/utils/clipboardShare.md +59 -0
  33. package/.agents/references/utils/floatingMotion.md +67 -0
  34. package/.agents/references/utils/getDeviceInfo.md +43 -0
  35. package/.agents/references/utils/isEditableKeyboardTarget.md +35 -0
  36. package/.agents/references/utils/mergeRefs.md +29 -0
  37. package/.agents/references/utils/seen.md +37 -0
  38. package/.agents/references/utils/subscribeKeyboardHeight.md +35 -0
  39. package/AGENTS.md +103 -396
  40. package/LICENSE +9 -0
  41. package/README.md +38 -0
  42. package/index.ts +97 -102
  43. package/package.json +27 -13
  44. package/{components → packages/components}/ScrolltoTop.tsx +3 -3
  45. package/{components → packages/components}/SwitchCase.tsx +7 -7
  46. package/packages/components/ViewportPortal.tsx +40 -0
  47. package/packages/cookie/cookie.shared.ts +138 -0
  48. package/packages/datetime/dateTime.client.ts +108 -0
  49. package/packages/datetime/dateTime.server.ts +108 -0
  50. package/packages/datetime/dateTime.shared.ts +358 -0
  51. package/packages/hooks/useAvoidKeyboard.ts +28 -0
  52. package/packages/hooks/useCheckInvisible.ts +25 -0
  53. package/packages/hooks/useCheckScroll.ts +19 -0
  54. package/packages/hooks/useClientDateTime.ts +49 -0
  55. package/packages/hooks/useDebounce.ts +118 -0
  56. package/packages/hooks/useDebouncedCallback.ts +69 -0
  57. package/packages/hooks/useDoubleClick.ts +53 -0
  58. package/packages/hooks/useGeolocation.ts +146 -0
  59. package/packages/hooks/useHasMounted.ts +13 -0
  60. package/packages/hooks/useIntersectionObserver.ts +34 -0
  61. package/packages/hooks/useInterval.ts +56 -0
  62. package/packages/hooks/useKeyboardHeight.ts +18 -0
  63. package/packages/hooks/useKeyboardListNavigation.ts +170 -0
  64. package/packages/hooks/useLongPress.ts +120 -0
  65. package/packages/hooks/usePreservedCallback.ts +21 -0
  66. package/packages/hooks/usePreservedReference.ts +21 -0
  67. package/packages/hooks/useRefEffect.ts +35 -0
  68. package/packages/hooks/useRelativeDateTime.ts +54 -0
  69. package/packages/hooks/useTimeout.ts +56 -0
  70. package/packages/hooks/useToggleState.ts +13 -0
  71. package/packages/hooks/useViewportHeight.ts +23 -0
  72. package/packages/hooks/useViewportMatch.ts +27 -0
  73. package/packages/utils/browserStorage.ts +59 -0
  74. package/packages/utils/buildContext.tsx +19 -0
  75. package/packages/utils/checkDevice.ts +74 -0
  76. package/packages/utils/clipboardShare.tsx +47 -0
  77. package/{utils → packages/utils}/clipboardShare.types.ts +7 -7
  78. package/packages/utils/floatingMotion.ts +99 -0
  79. package/packages/utils/keyboardTarget.ts +13 -0
  80. package/packages/utils/mergeRefs.ts +15 -0
  81. package/packages/utils/seen.ts +29 -0
  82. package/packages/utils/subscribeKeyboardHeight.ts +54 -0
  83. package/tests/components/ScrolltoTop.test.tsx +15 -0
  84. package/tests/components/SwitchCase.test.tsx +34 -0
  85. package/tests/components/ViewportPortal.test.tsx +42 -0
  86. package/tests/cookie/cookie.test.ts +156 -0
  87. package/tests/datetime/datetime.test.ts +250 -0
  88. package/tests/hooks/useAvoidKeyboard.test.ts +12 -0
  89. package/tests/hooks/useCheckInvisible.test.ts +28 -0
  90. package/tests/hooks/useCheckScroll.test.ts +23 -0
  91. package/tests/hooks/useClientDateTime.test.ts +14 -0
  92. package/tests/hooks/useDebounce.test.ts +71 -0
  93. package/tests/hooks/useDebouncedCallback.test.ts +34 -0
  94. package/tests/hooks/useDoubleClick.test.ts +31 -0
  95. package/tests/hooks/useGeolocation.test.ts +50 -0
  96. package/tests/hooks/useHasMounted.test.ts +10 -0
  97. package/tests/hooks/useIntersectionObserver.test.ts +13 -0
  98. package/tests/hooks/useInterval.test.ts +31 -0
  99. package/tests/hooks/useKeyboardHeight.test.ts +10 -0
  100. package/tests/hooks/useKeyboardListNavigation.test.ts +82 -0
  101. package/tests/hooks/useLongPress.test.ts +52 -0
  102. package/tests/hooks/usePreservedCallback.test.ts +35 -0
  103. package/tests/hooks/usePreservedReference.test.ts +35 -0
  104. package/tests/hooks/useRefEffect.test.ts +30 -0
  105. package/tests/hooks/useRelativeDateTime.test.ts +19 -0
  106. package/tests/hooks/useTimeout.test.ts +34 -0
  107. package/tests/hooks/useToggleState.test.ts +35 -0
  108. package/tests/hooks/useViewportHeight.test.ts +10 -0
  109. package/tests/hooks/useViewportMatch.test.ts +35 -0
  110. package/tests/setup.ts +9 -0
  111. package/tests/utils/browserStorage.test.ts +54 -0
  112. package/tests/utils/buildContext.test.tsx +27 -0
  113. package/tests/utils/checkDevice.test.ts +84 -0
  114. package/tests/utils/clipboardShare.test.ts +78 -0
  115. package/tests/utils/floatingMotion.test.ts +84 -0
  116. package/tests/utils/keyboardTarget.test.ts +44 -0
  117. package/tests/utils/mergeRefs.test.ts +18 -0
  118. package/tests/utils/seen.test.ts +31 -0
  119. package/tests/utils/subscribeKeyboardHeight.test.ts +50 -0
  120. package/tsconfig.json +16 -16
  121. package/vitest.config.ts +16 -0
  122. package/components/ViewportPortal.tsx +0 -40
  123. package/cookie/cookie.shared.ts +0 -142
  124. package/datetime/dateTime.client.ts +0 -108
  125. package/datetime/dateTime.server.ts +0 -108
  126. package/datetime/dateTime.shared.ts +0 -358
  127. package/hooks/useAvoidKeyboard.ts +0 -28
  128. package/hooks/useCheckInvisible.ts +0 -25
  129. package/hooks/useCheckScroll.ts +0 -19
  130. package/hooks/useClientDateTime.ts +0 -49
  131. package/hooks/useDebounce.ts +0 -118
  132. package/hooks/useDebouncedCallback.ts +0 -69
  133. package/hooks/useDoubleClick.ts +0 -53
  134. package/hooks/useGeolocation.ts +0 -146
  135. package/hooks/useHasMounted.ts +0 -13
  136. package/hooks/useIntersectionObserver.ts +0 -34
  137. package/hooks/useInterval.ts +0 -55
  138. package/hooks/useKeyboardHeight.ts +0 -18
  139. package/hooks/useKeyboardListNavigation.ts +0 -170
  140. package/hooks/useLongPress.ts +0 -120
  141. package/hooks/usePreservedCallback.ts +0 -21
  142. package/hooks/usePreservedReference.ts +0 -21
  143. package/hooks/useRefEffect.ts +0 -35
  144. package/hooks/useRelativeDateTime.ts +0 -54
  145. package/hooks/useTimeout.ts +0 -56
  146. package/hooks/useToggleState.ts +0 -13
  147. package/hooks/useViewportHeight.ts +0 -23
  148. package/hooks/useViewportMatch.ts +0 -33
  149. package/utils/browserStorage.ts +0 -59
  150. package/utils/buildContext.tsx +0 -19
  151. package/utils/checkDevice.ts +0 -74
  152. package/utils/clipboardShare.tsx +0 -47
  153. package/utils/floatingMotion.ts +0 -99
  154. package/utils/keyboardTarget.ts +0 -13
  155. package/utils/mergeRefs.ts +0 -15
  156. package/utils/seen.ts +0 -29
  157. package/utils/subscribeKeyboardHeight.ts +0 -54
@@ -0,0 +1,358 @@
1
+ export type AppLocale = "kr" | "en" | "jp";
2
+ export type DateInput = string | number | Date;
3
+ export type DatePreset = "long" | "dot";
4
+ export type TimePreset = "ko" | "12h" | "24h-minute" | "24h-second";
5
+ const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
6
+
7
+ type DateParts = {
8
+ year: string;
9
+ month: string;
10
+ day: string;
11
+ };
12
+
13
+ type TimeParts = {
14
+ hour: string;
15
+ minute: string;
16
+ second: string;
17
+ };
18
+
19
+ export function normalizeAppLocale(locale?: string): AppLocale {
20
+ const normalized =
21
+ typeof locale === "string" ? locale.trim().toLowerCase() : "";
22
+ if (normalized === "kr" || normalized === "ko") return "kr";
23
+ if (normalized === "jp" || normalized === "ja") return "jp";
24
+ return "en";
25
+ }
26
+
27
+ export function toIntlLocale(locale?: string): string {
28
+ switch (normalizeAppLocale(locale)) {
29
+ case "kr":
30
+ return "ko-KR";
31
+ case "jp":
32
+ return "ja-JP";
33
+ default:
34
+ return "en-US";
35
+ }
36
+ }
37
+
38
+ export function toDate(value: DateInput): Date | null {
39
+ if (value instanceof Date) {
40
+ return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
41
+ }
42
+
43
+ const date = new Date(value);
44
+ return Number.isNaN(date.getTime()) ? null : date;
45
+ }
46
+
47
+ export function toUtcMidnight(date: Date): Date {
48
+ return new Date(
49
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
50
+ );
51
+ }
52
+
53
+ export function parseUtcDateInput(
54
+ value?: DateInput | null,
55
+ fallback?: DateInput,
56
+ ): Date | null {
57
+ if (typeof value === "string") {
58
+ const trimmed = value.trim();
59
+ const match = DATE_ONLY_PATTERN.exec(trimmed);
60
+
61
+ if (match) {
62
+ const [, year, month, day] = match;
63
+ return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
64
+ }
65
+ }
66
+
67
+ const parsed = value !== undefined && value !== null ? toDate(value) : null;
68
+ if (parsed) {
69
+ return toUtcMidnight(parsed);
70
+ }
71
+
72
+ if (fallback !== undefined) {
73
+ const fallbackDate = toDate(fallback);
74
+ if (fallbackDate) {
75
+ return toUtcMidnight(fallbackDate);
76
+ }
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ export function formatUtcDateKey(date: Date): string {
83
+ return [
84
+ date.getUTCFullYear(),
85
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
86
+ String(date.getUTCDate()).padStart(2, "0"),
87
+ ].join("-");
88
+ }
89
+
90
+ export function getUtcWeekdayIndex(date: Date): number {
91
+ return date.getUTCDay();
92
+ }
93
+
94
+ export function addUtcDays(date: Date, days: number): Date {
95
+ const next = new Date(date.getTime());
96
+ next.setUTCDate(next.getUTCDate() + days);
97
+ return next;
98
+ }
99
+
100
+ function getDateParts(date: Date, timeZone?: string): DateParts {
101
+ const parts = new Intl.DateTimeFormat("en-US", {
102
+ timeZone,
103
+ year: "numeric",
104
+ month: "2-digit",
105
+ day: "2-digit",
106
+ }).formatToParts(date);
107
+
108
+ return {
109
+ year: parts.find((part) => part.type === "year")?.value ?? "0000",
110
+ month: parts.find((part) => part.type === "month")?.value ?? "00",
111
+ day: parts.find((part) => part.type === "day")?.value ?? "00",
112
+ };
113
+ }
114
+
115
+ function getTimeParts(date: Date, timeZone?: string): TimeParts {
116
+ const parts = new Intl.DateTimeFormat("en-US", {
117
+ timeZone,
118
+ hour: "2-digit",
119
+ minute: "2-digit",
120
+ second: "2-digit",
121
+ hourCycle: "h23",
122
+ }).formatToParts(date);
123
+
124
+ return {
125
+ hour: parts.find((part) => part.type === "hour")?.value ?? "00",
126
+ minute: parts.find((part) => part.type === "minute")?.value ?? "00",
127
+ second: parts.find((part) => part.type === "second")?.value ?? "00",
128
+ };
129
+ }
130
+
131
+ export function formatLongDate(
132
+ date: Date,
133
+ locale?: string,
134
+ timeZone?: string,
135
+ ): string {
136
+ const localeKey = normalizeAppLocale(locale);
137
+ const { year, month, day } = getDateParts(date, timeZone);
138
+
139
+ if (localeKey === "kr") {
140
+ return `${year}년 ${Number(month)}월 ${Number(day)}일`;
141
+ }
142
+
143
+ if (localeKey === "jp") {
144
+ return `${year}年${Number(month)}月${Number(day)}日`;
145
+ }
146
+
147
+ return new Intl.DateTimeFormat(toIntlLocale(localeKey), {
148
+ timeZone,
149
+ year: "numeric",
150
+ month: "long",
151
+ day: "numeric",
152
+ }).format(date);
153
+ }
154
+
155
+ export function formatDotDate(date: Date, timeZone?: string): string {
156
+ const { year, month, day } = getDateParts(date, timeZone);
157
+ return `${year}. ${Number(month)}. ${Number(day)}.`;
158
+ }
159
+
160
+ export function format24HourTime(
161
+ date: Date,
162
+ options?: {
163
+ includeSeconds?: boolean;
164
+ timeZone?: string;
165
+ },
166
+ ): string {
167
+ const { includeSeconds = false, timeZone } = options ?? {};
168
+ const { hour, minute, second } = getTimeParts(date, timeZone);
169
+ return includeSeconds ? `${hour}:${minute}:${second}` : `${hour}:${minute}`;
170
+ }
171
+
172
+ export function formatKoreanTime(date: Date, timeZone?: string): string {
173
+ const { hour, minute } = getTimeParts(date, timeZone);
174
+ const hourNumber = Number(hour);
175
+ const meridiem = hourNumber < 12 ? "오전" : "오후";
176
+ const hour12 = hourNumber % 12 === 0 ? 12 : hourNumber % 12;
177
+ return `${meridiem} ${hour12}시 ${minute}분`;
178
+ }
179
+
180
+ export function formatTwelveHourTime(
181
+ date: Date,
182
+ locale?: string,
183
+ timeZone?: string,
184
+ ): string {
185
+ const localeKey = normalizeAppLocale(locale);
186
+ if (localeKey === "kr") {
187
+ const { hour, minute } = getTimeParts(date, timeZone);
188
+ const hourNumber = Number(hour);
189
+ const meridiem = hourNumber < 12 ? "오전" : "오후";
190
+ const hour12 = hourNumber % 12 === 0 ? 12 : hourNumber % 12;
191
+ return `${meridiem} ${hour12}:${minute}`;
192
+ }
193
+
194
+ return new Intl.DateTimeFormat(toIntlLocale(localeKey), {
195
+ timeZone,
196
+ hour: "numeric",
197
+ minute: "2-digit",
198
+ hour12: true,
199
+ }).format(date);
200
+ }
201
+
202
+ export function formatRelativeText(
203
+ diffMs: number,
204
+ locale?: string,
205
+ ): { text: string; isRelative: boolean } {
206
+ const localeKey = normalizeAppLocale(locale);
207
+ const safeDiffMs = Math.max(0, diffMs);
208
+ const seconds = Math.max(1, Math.floor(safeDiffMs / 1000));
209
+ const minutes = Math.max(1, Math.floor(safeDiffMs / (60 * 1000)));
210
+ const hours = Math.max(1, Math.floor(safeDiffMs / (60 * 60 * 1000)));
211
+ const days = Math.max(1, Math.floor(safeDiffMs / (24 * 60 * 60 * 1000)));
212
+
213
+ if (safeDiffMs < 60 * 1000) {
214
+ return {
215
+ text: formatRelativeUnit(seconds, "second", localeKey),
216
+ isRelative: true,
217
+ };
218
+ }
219
+
220
+ if (safeDiffMs < 60 * 60 * 1000) {
221
+ return {
222
+ text: formatRelativeUnit(minutes, "minute", localeKey),
223
+ isRelative: true,
224
+ };
225
+ }
226
+
227
+ if (safeDiffMs < 24 * 60 * 60 * 1000) {
228
+ return {
229
+ text: formatRelativeUnit(hours, "hour", localeKey),
230
+ isRelative: true,
231
+ };
232
+ }
233
+
234
+ return { text: formatRelativeUnit(days, "day", localeKey), isRelative: true };
235
+ }
236
+
237
+ export function formatRemainingText(
238
+ diffMs: number,
239
+ locale?: string,
240
+ options?: {
241
+ includeSuffix?: boolean;
242
+ },
243
+ ): string {
244
+ const localeKey = normalizeAppLocale(locale);
245
+ const includeSuffix = options?.includeSuffix ?? true;
246
+ const safeDiffMs = Math.max(0, diffMs);
247
+
248
+ if (safeDiffMs === 0) {
249
+ if (localeKey === "kr") return "마감됨";
250
+ if (localeKey === "jp") return "締切終了";
251
+ return "Closed";
252
+ }
253
+
254
+ const units = [
255
+ { label: "day", value: 24 * 60 * 60 * 1000 },
256
+ { label: "hour", value: 60 * 60 * 1000 },
257
+ { label: "minute", value: 60 * 1000 },
258
+ { label: "second", value: 1000 },
259
+ ] as const;
260
+
261
+ const parts: string[] = [];
262
+ let remaining = safeDiffMs;
263
+
264
+ for (const unit of units) {
265
+ const count = Math.floor(remaining / unit.value);
266
+ remaining -= count * unit.value;
267
+
268
+ if (count > 0) {
269
+ parts.push(formatRemainingUnit(count, unit.label, localeKey));
270
+ }
271
+
272
+ if (parts.length >= 2) break;
273
+ }
274
+
275
+ if (parts.length === 0) {
276
+ parts.push(formatRemainingUnit(1, "second", localeKey));
277
+ }
278
+
279
+ if (!includeSuffix) {
280
+ return parts.join(localeKey === "jp" ? "" : " ");
281
+ }
282
+
283
+ if (localeKey === "kr") {
284
+ return `${parts.join(" ")} 남음`;
285
+ }
286
+
287
+ if (localeKey === "jp") {
288
+ return `あと${parts.join("")}`;
289
+ }
290
+
291
+ return `${parts.join(" ")} left`;
292
+ }
293
+
294
+ function formatRelativeUnit(
295
+ count: number,
296
+ unit: "second" | "minute" | "hour" | "day",
297
+ locale: AppLocale,
298
+ ): string {
299
+ if (locale === "kr") {
300
+ const suffix =
301
+ unit === "second"
302
+ ? "초"
303
+ : unit === "minute"
304
+ ? "분"
305
+ : unit === "hour"
306
+ ? "시간"
307
+ : "일";
308
+ return `${count}${suffix} 전`;
309
+ }
310
+
311
+ if (locale === "jp") {
312
+ const suffix =
313
+ unit === "second"
314
+ ? "秒"
315
+ : unit === "minute"
316
+ ? "分"
317
+ : unit === "hour"
318
+ ? "時間"
319
+ : "日";
320
+ return `${count}${suffix}前`;
321
+ }
322
+
323
+ const englishUnit = count === 1 ? unit : `${unit}s`;
324
+ return `${count} ${englishUnit} ago`;
325
+ }
326
+
327
+ function formatRemainingUnit(
328
+ count: number,
329
+ unit: "second" | "minute" | "hour" | "day",
330
+ locale: AppLocale,
331
+ ): string {
332
+ if (locale === "kr") {
333
+ const suffix =
334
+ unit === "second"
335
+ ? "초"
336
+ : unit === "minute"
337
+ ? "분"
338
+ : unit === "hour"
339
+ ? "시간"
340
+ : "일";
341
+ return `${count}${suffix}`;
342
+ }
343
+
344
+ if (locale === "jp") {
345
+ const suffix =
346
+ unit === "second"
347
+ ? "秒"
348
+ : unit === "minute"
349
+ ? "分"
350
+ : unit === "hour"
351
+ ? "時間"
352
+ : "日";
353
+ return `${count}${suffix}`;
354
+ }
355
+
356
+ const englishUnit = count === 1 ? unit : `${unit}s`;
357
+ return `${count} ${englishUnit}`;
358
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { useKeyboardHeight } from "./useKeyboardHeight";
4
+
5
+ type UseAvoidKeyboardOptions = {
6
+ safeAreaBottom?: number;
7
+ transitionDuration?: number;
8
+ transitionTimingFunction?: string;
9
+ };
10
+
11
+ export function useAvoidKeyboard(options?: UseAvoidKeyboardOptions) {
12
+ const {
13
+ safeAreaBottom = 0,
14
+ transitionDuration = 200,
15
+ transitionTimingFunction = "ease-out",
16
+ } = options ?? {};
17
+
18
+ const { keyboardHeight } = useKeyboardHeight();
19
+
20
+ const offset = keyboardHeight > 0 ? keyboardHeight : safeAreaBottom;
21
+
22
+ const style: React.CSSProperties = {
23
+ transform: offset > 0 ? `translateY(-${offset}px)` : undefined,
24
+ transition: `transform ${transitionDuration}ms ${transitionTimingFunction}`,
25
+ };
26
+
27
+ return { style };
28
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useCheckInvisible(className: string) {
6
+ const [isInvisible, setIsInvisible] = useState(false);
7
+
8
+ useEffect(() => {
9
+ const handleScroll = () => {
10
+ const el = document.querySelector(`.${className}`);
11
+ if (el) {
12
+ const { top } = el.getBoundingClientRect();
13
+ setIsInvisible(top < 0);
14
+ } else {
15
+ setIsInvisible(false);
16
+ }
17
+ };
18
+
19
+ handleScroll();
20
+ window.addEventListener("scroll", handleScroll);
21
+ return () => window.removeEventListener("scroll", handleScroll);
22
+ }, [className]);
23
+
24
+ return { isInvisible };
25
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useCheckScroll() {
6
+ const [isScrolled, setIsScrolled] = useState(false);
7
+
8
+ useEffect(() => {
9
+ const handleScroll = () => {
10
+ setIsScrolled(window.scrollY > 0);
11
+ };
12
+
13
+ handleScroll();
14
+ window.addEventListener("scroll", handleScroll);
15
+ return () => window.removeEventListener("scroll", handleScroll);
16
+ }, []);
17
+
18
+ return { isScrolled };
19
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { formatClientDateTime } from "../datetime/dateTime.client";
5
+ import type {
6
+ DateInput,
7
+ DatePreset,
8
+ TimePreset,
9
+ } from "../datetime/dateTime.shared";
10
+ import { toDate } from "../datetime/dateTime.shared";
11
+
12
+ type UseClientDateTimeOptions = {
13
+ locale?: string;
14
+ timeZone?: string;
15
+ datePreset?: DatePreset;
16
+ timePreset?: TimePreset;
17
+ };
18
+
19
+ export function useClientDateTime(
20
+ value: DateInput,
21
+ options?: UseClientDateTimeOptions,
22
+ ) {
23
+ const [ready, setReady] = useState(false);
24
+ const [text, setText] = useState("");
25
+
26
+ useEffect(() => {
27
+ setText(
28
+ formatClientDateTime(value, {
29
+ locale: options?.locale,
30
+ timeZone: options?.timeZone,
31
+ datePreset: options?.datePreset,
32
+ timePreset: options?.timePreset,
33
+ }),
34
+ );
35
+ setReady(true);
36
+ }, [
37
+ options?.datePreset,
38
+ options?.locale,
39
+ options?.timePreset,
40
+ options?.timeZone,
41
+ value,
42
+ ]);
43
+
44
+ return {
45
+ ready,
46
+ text,
47
+ date: toDate(value),
48
+ };
49
+ }
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo } from "react";
4
+
5
+ import { usePreservedCallback } from "./usePreservedCallback";
6
+
7
+ export function debounce<F extends (...args: unknown[]) => void>(
8
+ func: F,
9
+ debounceMs: number,
10
+ {
11
+ edges = ["leading", "trailing"],
12
+ }: { edges?: Array<"leading" | "trailing"> } = {},
13
+ ): {
14
+ (...args: Parameters<F>): void;
15
+ cancel: () => void;
16
+ } {
17
+ let pendingThis: ThisParameterType<F> | undefined;
18
+ let pendingArgs: Parameters<F> | null = null;
19
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
20
+
21
+ const leading = edges.includes("leading");
22
+ const trailing = edges.includes("trailing");
23
+
24
+ const invoke = () => {
25
+ if (pendingArgs !== null) {
26
+ func.apply(pendingThis, pendingArgs);
27
+ pendingThis = undefined;
28
+ pendingArgs = null;
29
+ }
30
+ };
31
+
32
+ const cancelTimer = () => {
33
+ if (timeoutId !== null) {
34
+ clearTimeout(timeoutId);
35
+ timeoutId = null;
36
+ }
37
+ };
38
+
39
+ const cancel = () => {
40
+ cancelTimer();
41
+ pendingThis = undefined;
42
+ pendingArgs = null;
43
+ };
44
+
45
+ const schedule = () => {
46
+ cancelTimer();
47
+ timeoutId = setTimeout(() => {
48
+ timeoutId = null;
49
+ if (trailing) invoke();
50
+ cancel();
51
+ }, debounceMs);
52
+ };
53
+
54
+ const debounced = function (
55
+ this: ThisParameterType<F>,
56
+ ...args: Parameters<F>
57
+ ) {
58
+ pendingThis = this;
59
+ pendingArgs = args;
60
+
61
+ const isFirstCall = timeoutId == null;
62
+
63
+ schedule();
64
+
65
+ if (leading && isFirstCall) {
66
+ invoke();
67
+ }
68
+ } as {
69
+ (...args: Parameters<F>): void;
70
+ cancel: () => void;
71
+ };
72
+
73
+ debounced.cancel = cancel;
74
+ return debounced;
75
+ }
76
+
77
+ type DebounceOptions = {
78
+ leading?: boolean;
79
+ trailing?: boolean;
80
+ };
81
+
82
+ export function useDebounce<F extends (...args: unknown[]) => void>(
83
+ callback: F,
84
+ wait: number,
85
+ options: DebounceOptions = {},
86
+ ) {
87
+ const preservedCallback = usePreservedCallback(callback) as F;
88
+
89
+ const { leading = false, trailing = true } = options;
90
+
91
+ const edges = useMemo(() => {
92
+ const _edges: Array<"leading" | "trailing"> = [];
93
+ if (leading) {
94
+ _edges.push("leading");
95
+ }
96
+
97
+ if (trailing) {
98
+ _edges.push("trailing");
99
+ }
100
+
101
+ return _edges;
102
+ }, [leading, trailing]);
103
+
104
+ const debounced = useMemo(() => {
105
+ return debounce(preservedCallback, wait, { edges });
106
+ }, [preservedCallback, wait, edges]);
107
+
108
+ useEffect(
109
+ function cancelDebouncedOnUnmount() {
110
+ return () => {
111
+ debounced.cancel();
112
+ };
113
+ },
114
+ [debounced],
115
+ );
116
+
117
+ return debounced;
118
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef } from "react";
4
+
5
+ import { debounce } from "./useDebounce";
6
+ import { usePreservedCallback } from "./usePreservedCallback";
7
+
8
+ type DebounceOptions = {
9
+ leading?: boolean;
10
+ trailing?: boolean;
11
+ };
12
+
13
+ export function useDebouncedCallback({
14
+ onChange,
15
+ timeThreshold,
16
+ leading = false,
17
+ trailing = true,
18
+ }: DebounceOptions & {
19
+ onChange: (newValue: boolean) => void;
20
+ timeThreshold: number;
21
+ }) {
22
+ const handleChange = usePreservedCallback(onChange);
23
+ const ref = useRef({ value: false, clearPreviousDebounce: () => {} });
24
+
25
+ useEffect(function clearDebouncedOnUnmount() {
26
+ const current = ref.current;
27
+ return () => {
28
+ current.clearPreviousDebounce();
29
+ };
30
+ }, []);
31
+
32
+ const edges = useMemo(() => {
33
+ const _edges: Array<"leading" | "trailing"> = [];
34
+ if (leading) {
35
+ _edges.push("leading");
36
+ }
37
+
38
+ if (trailing) {
39
+ _edges.push("trailing");
40
+ }
41
+
42
+ return _edges;
43
+ }, [leading, trailing]);
44
+
45
+ return useCallback(
46
+ (nextValue: boolean) => {
47
+ if (nextValue === ref.current.value) {
48
+ return;
49
+ }
50
+
51
+ const debounced = debounce(
52
+ () => {
53
+ handleChange(nextValue);
54
+
55
+ ref.current.value = nextValue;
56
+ },
57
+ timeThreshold,
58
+ { edges },
59
+ );
60
+
61
+ ref.current.clearPreviousDebounce();
62
+
63
+ debounced();
64
+
65
+ ref.current.clearPreviousDebounce = debounced.cancel;
66
+ },
67
+ [handleChange, timeThreshold, edges],
68
+ );
69
+ }