@musecat/functionkit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +2 -0
- package/AGENTS.md +398 -0
- package/components/ScrolltoTop.tsx +9 -0
- package/components/SwitchCase.tsx +15 -0
- package/components/ViewportPortal.tsx +40 -0
- package/cookie/cookie.shared.ts +142 -0
- package/datetime/dateTime.client.ts +108 -0
- package/datetime/dateTime.server.ts +108 -0
- package/datetime/dateTime.shared.ts +358 -0
- package/hooks/useAvoidKeyboard.ts +28 -0
- package/hooks/useCheckInvisible.ts +25 -0
- package/hooks/useCheckScroll.ts +19 -0
- package/hooks/useClientDateTime.ts +49 -0
- package/hooks/useDebounce.ts +118 -0
- package/hooks/useDebouncedCallback.ts +69 -0
- package/hooks/useDoubleClick.ts +53 -0
- package/hooks/useGeolocation.ts +146 -0
- package/hooks/useHasMounted.ts +13 -0
- package/hooks/useIntersectionObserver.ts +34 -0
- package/hooks/useInterval.ts +55 -0
- package/hooks/useKeyboardHeight.ts +18 -0
- package/hooks/useKeyboardListNavigation.ts +170 -0
- package/hooks/useLongPress.ts +120 -0
- package/hooks/usePreservedCallback.ts +21 -0
- package/hooks/usePreservedReference.ts +21 -0
- package/hooks/useRefEffect.ts +35 -0
- package/hooks/useRelativeDateTime.ts +54 -0
- package/hooks/useTimeout.ts +56 -0
- package/hooks/useToggleState.ts +13 -0
- package/hooks/useViewportHeight.ts +23 -0
- package/hooks/useViewportMatch.ts +33 -0
- package/index.ts +111 -0
- package/package.json +15 -0
- package/tsconfig.json +18 -0
- package/utils/browserStorage.ts +59 -0
- package/utils/buildContext.tsx +19 -0
- package/utils/checkDevice.ts +74 -0
- package/utils/clipboardShare.tsx +47 -0
- package/utils/clipboardShare.types.ts +18 -0
- package/utils/floatingMotion.ts +99 -0
- package/utils/keyboardTarget.ts +13 -0
- package/utils/mergeRefs.ts +15 -0
- package/utils/seen.ts +29 -0
- package/utils/subscribeKeyboardHeight.ts +54 -0
package/.gitattributes
ADDED
package/AGENTS.md
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# FunctionKit (@musecat/functionkit)
|
|
2
|
+
|
|
3
|
+
React 19 기반 프론트엔드 유틸 라이브러리. 단일 패키지, ESM. 모든 훅/유틸/컴포넌트는 `index.ts` 배럴을 통해 `@musecat/functionkit`에서 import한다. 서브패스(`@musecat/functionkit/hooks/useX`)도 가능하나 배럴优先.
|
|
4
|
+
|
|
5
|
+
## AI 사용 규칙 (필수)
|
|
6
|
+
|
|
7
|
+
- **모든 구현에서 반드시 이 FunctionKit을 먼저 참조한다.** 훅·유틸·날짜포맷·쿠키·스토리지·컴포넌트가 필요하면 예외 없이 아래 목록을 먼저 확인하고 존재하면 그것을 사용한다.
|
|
8
|
+
- **직접 구현 금지.** 여기 있는 기능을 새로 짜거나 유사 기능을 중복 구현하는 것은 금지한다. 여기 없는 것만 새로 만든다.
|
|
9
|
+
- **import는 배럴 경로만 사용:** `import { useDebounce } from "@musecat/functionkit"`.
|
|
10
|
+
- SSR 주의: `"use client"` 없는 순수 함수(shared, seen, cookie.shared, debounce, mergeRefs, isEditableKeyboardTarget, floatingMotion, SwitchCase)만 서버 컴포넌트에서 사용 가능. 나머지는 반드시 클라이언트 컴포넌트.
|
|
11
|
+
- 타입 import도 배럴: `import type { AppLocale, DateInput } from "@musecat/functionkit"`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Hooks — 완전 분석
|
|
16
|
+
|
|
17
|
+
### useHasMounted
|
|
18
|
+
`useHasMounted(): boolean` — 마운트 전 `false`, 후 `true`. hydration guard로 사용. useEffect 한 번으로 state 전환.
|
|
19
|
+
```tsx
|
|
20
|
+
const mounted = useHasMounted()
|
|
21
|
+
if (!mounted) return null // SSR/SSG에서 클라이언트 전용 UI 숨기기
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### usePreservedCallback
|
|
25
|
+
`usePreservedCallback<Args extends unknown[], Return>(callback: (...args: Args) => Return): (...args: Args) => Return`
|
|
26
|
+
- ref에 callback을 저장해서 항상 최신 참조 유지. 반환된 함수는 절대 재생성되지 않음.
|
|
27
|
+
- 자식 컴포넌트에 callback prop으로 넘길 때 불필요한 리렌더 방지.
|
|
28
|
+
- 내부적으로 `useDebounce`, `useIntersectionObserver`, `useInterval`, `useTimeout` 등 많은 훅이 이 함수에 의존함.
|
|
29
|
+
|
|
30
|
+
### usePreservedReference
|
|
31
|
+
`usePreservedReference<T>(value: T, areValuesEqual?: (a: T, b: T) => boolean): T`
|
|
32
|
+
- 기본 비교는 `JSON.stringify(a) === JSON.stringify(b)`. 깊은 비교로 stable reference 반환.
|
|
33
|
+
- 객체/배열이 내용은 같은데 참조만 바뀌는 경우 의존성 배열에 쓸 때 유용.
|
|
34
|
+
- 예: `useEffect(fn, [usePreservedReference(options)])` — options 객체 내용이 같으면 effect 재실행 안 함.
|
|
35
|
+
- 주의: JSON.stringify의 한계 — undefined, Symbol, circular reference는 무시됨.
|
|
36
|
+
|
|
37
|
+
### useDebounce
|
|
38
|
+
`useDebounce(callback, wait: number, options?: { leading?, trailing? }): { (...args): void, cancel(): void }`
|
|
39
|
+
- `{ leading: false, trailing: true }`가 기본.
|
|
40
|
+
- 내부적으로 `usePreservedCallback`으로 callback 고정 + `useMemo`로 debounced 함수 생성.
|
|
41
|
+
- 반환된 함수의 `cancel()`로 pending 취소 가능. unmount 시 자동 cancel.
|
|
42
|
+
- pure 함수 `debounce(fn, debounceMs, { edges })`도 export됨 — edges는 `["leading", "trailing"]` 배열.
|
|
43
|
+
- **용례:** 검색어 입력(300ms), 저장 버튼 중복 방지, 스크롤 이벤트 제한.
|
|
44
|
+
```tsx
|
|
45
|
+
const handleSearch = useDebounce((term: string) => fetchResults(term), 300)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### useDebouncedCallback
|
|
49
|
+
`useDebouncedCallback({ onChange, timeThreshold, leading?, trailing? }): (nextValue: boolean) => void`
|
|
50
|
+
- boolean 값만 받음. 동일한 값 들어오면 조기 return (no-op).
|
|
51
|
+
- debounce 인스턴스를 매 호출마다 새로 생성 (이전 pending cancel 후 새로 등록).
|
|
52
|
+
- **용례:** 토글 스위치 변경 이벤트 디바운스, 체크박스 onChange 지연 처리.
|
|
53
|
+
|
|
54
|
+
### useDoubleClick
|
|
55
|
+
`useDoubleClick<E extends HTMLElement>({ delay?, click?, doubleClick }): (event: MouseEvent<E>) => void`
|
|
56
|
+
- 반환된 함수를 `onClick`에 바인딩. `event.detail`로 싱글/더블클릭 구분.
|
|
57
|
+
- delay(기본 250ms) 내 두 번째 클릭이 없으면 `click` 호출, 있으면 `doubleClick` 호출하고 pending 취소.
|
|
58
|
+
- `click` 없으면 싱글클릭 시 아무 일도 안 일어남 (그래도 timer는 돔).
|
|
59
|
+
- ref로 callback 관리 → 항상 최신 참조, 리렌더 영향 없음.
|
|
60
|
+
- **용례:** 리스트 아이템 싱글클릭 미리보기 / 더블클릭 상세보기, 파일명 더블클릭 수정.
|
|
61
|
+
|
|
62
|
+
### useLongPress
|
|
63
|
+
`useLongPress<E>(onLongPress, { delay?, moveThreshold?, onClick?, onLongPressEnd? }): { onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchEnd, onTouchMove?, onMouseMove? }`
|
|
64
|
+
- delay 기본 500ms. `moveThreshold` 설정 시 지정 픽셀 이상 움직이면 long press 취소.
|
|
65
|
+
- mouse/touch 이벤트 통합. `onMouseLeave`도 timeout 취소.
|
|
66
|
+
- long press 성공 시 `onLongPress` 즉시 호출, `onLongPressEnd`는 mouseup/touchend 시 호출.
|
|
67
|
+
- 실패 시 `onClick` 호출 (long press가 아니었다는 의미).
|
|
68
|
+
- **용례:** 채팅 메시지 길게 눌러 컨텍스트 메뉴, 아이콘 길게 눌러 드래그 시작.
|
|
69
|
+
|
|
70
|
+
### useGeolocation
|
|
71
|
+
`useGeolocation(options): { loading, error, data, getCurrentPosition, startTracking, stopTracking, isTracking }`
|
|
72
|
+
- `mountBehavior: "get"`(기본, 한 번 조회) | `"watch"`(계속 추적).
|
|
73
|
+
- SSR: `typeof window` 체크로 안전. 지원 안 하면 error 설정.
|
|
74
|
+
- `enableHighAccuracy: false`, `maximumAge: 0`, `timeout: Infinity` 기본.
|
|
75
|
+
- data 타입: `{ latitude, longitude, accuracy, altitude, altitudeAccuracy, heading, speed, timestamp }`.
|
|
76
|
+
- `startTracking()`/`stopTracking()`으로 동적으로 watch on/off.
|
|
77
|
+
- **용례:** 사용자 위치 기반 검색, 길찾기, 출퇴근 기록.
|
|
78
|
+
|
|
79
|
+
### useIntersectionObserver
|
|
80
|
+
`useIntersectionObserver<Element extends HTMLElement>(callback, options): (element: Element | null) => void`
|
|
81
|
+
- 반환값을 ref callback에 전달: `ref={observerRef}`. ref 콜백은 `useRefEffect` 기반.
|
|
82
|
+
- SSR: `IntersectionObserver` 없으면 observer 생성 안 함 (no-op).
|
|
83
|
+
- options 그대로 `IntersectionObserverInit` 전달 (threshold, rootMargin 등).
|
|
84
|
+
- **용례:** 무한 스크롤 트리거, 이미지 lazy loading, 광고 노출 추적.
|
|
85
|
+
```tsx
|
|
86
|
+
const ref = useIntersectionObserver((entry) => {
|
|
87
|
+
if (entry.isIntersecting) loadMore()
|
|
88
|
+
}, { threshold: 0.1 })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### useInterval
|
|
92
|
+
`useInterval(callback, options: number | { delay, immediate?, enabled? }): void`
|
|
93
|
+
- number = delay(ms). `{ immediate: true }`면 첫 tick 전에 한 번 실행. `enabled: false`면 interval 정지.
|
|
94
|
+
- StrictMode에서 immediate 중복 실행 방지 (ref로 guard).
|
|
95
|
+
- `enabled` 토글 시 interval 정지/재시작.
|
|
96
|
+
- **용례:** 폴링(5초마다 상태 갱신), 실시간 시계, 캐러셀 자동 재생.
|
|
97
|
+
|
|
98
|
+
### useTimeout
|
|
99
|
+
`useTimeout(callback, delay?, options?: { enabled? }): { start, reset, clear, isPending }`
|
|
100
|
+
- `enabled: true`(기본)면 mount 시 자동 시작, deps 변경 시 재시작.
|
|
101
|
+
- 명령형 제어: `start(overrideDelay?)`/`reset(overrideDelay?)` 직접 실행, `clear()` 취소, `isPending()` 실행 중 여부.
|
|
102
|
+
- `start`와 `reset`은 동일 함수 (alias). `enabled: false`면 pending timer도 clear.
|
|
103
|
+
- **용례:** 토스트 메시지 자동 사라짐(3초), 애니메이션 순차 실행, idle 상태 감지.
|
|
104
|
+
|
|
105
|
+
### useToggleState
|
|
106
|
+
`useToggleState(initialValue?: boolean): { value, setValue, setTrue, setFalse, toggle }`
|
|
107
|
+
- `initialValue` 기본 `false`. 모든 setter 함수는 stable (useCallback deps=[]).
|
|
108
|
+
- **용례:** 모달 열림/닫힘, 사이드바 토글, 접힘/펼침 UI.
|
|
109
|
+
|
|
110
|
+
### useKeyboardHeight / useAvoidKeyboard
|
|
111
|
+
`useKeyboardHeight(): { keyboardHeight: number }`
|
|
112
|
+
- `subscribeKeyboardHeight({ immediate: true })`로 visualViewport 변화 구독.
|
|
113
|
+
- 모바일 키보드 높이를 px 단위로 반환 (닫히면 0).
|
|
114
|
+
|
|
115
|
+
`useAvoidKeyboard({ safeAreaBottom?, transitionDuration?, transitionTimingFunction? }): { style: React.CSSProperties }`
|
|
116
|
+
- `useKeyboardHeight()`로 높이 얻어 transform: translateY로 회피. keyboardHeight=0이고 safeAreaBottom=0이면 transform 없음.
|
|
117
|
+
- transition: `200ms ease-out` 기본.
|
|
118
|
+
- **용례:** 모바일에서 입력 필드가 키보드에 가려지지 않도록 뷰포트 밀어올리기.
|
|
119
|
+
```tsx
|
|
120
|
+
const { style } = useAvoidKeyboard({ safeAreaBottom: 16 })
|
|
121
|
+
return <input style={style} />
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### useKeyboardListNavigation
|
|
125
|
+
`useKeyboardListNavigation({ itemCount }): { activeIndex, setActiveIndex, setItemRef, clickActiveItem, focusBoundaryItem, handleListKeyDown }`
|
|
126
|
+
- 방향키(ArrowDown/Up)로 리스트 탐색, Home/End로 처음/끝, Enter/Space로 활성 아이템 클릭, Escape로 선택 해제(activeIndex=-1).
|
|
127
|
+
- `itemCount=0`이면 아무 동작 안 함. `event.defaultPrevented`면 스킵. editable input에서는 동작 안 함.
|
|
128
|
+
- `setItemRef(index, element)`로 각 아이템 ref 등록. `focusBoundaryItem(key)`는 탭 네비게이션용 (첫 항목/마지막 항목 focus).
|
|
129
|
+
- `clickActiveItem()`은 현재 active 아이템의 click() 호출. 없으면 false 반환.
|
|
130
|
+
- **용례:** 드롭다운 메뉴 키보드 탐색, 검색 자동완성 리스트, 커맨드 팔레트.
|
|
131
|
+
|
|
132
|
+
### useViewportHeight
|
|
133
|
+
`useViewportHeight(): { height: number }`
|
|
134
|
+
- `window.visualViewport.height` 우선, 없으면 `window.innerHeight`. SSR에선 0.
|
|
135
|
+
- `visualViewport.resize` + `window.resize` 모두 구독.
|
|
136
|
+
- **용례:** 모바일 100vh 대체 (주소창 등으로 인한 실제 뷰포트 높이), 키보드 올라왔을 때 레이아웃 대응.
|
|
137
|
+
|
|
138
|
+
### useViewportMatch
|
|
139
|
+
`useViewportMatch(mediaQuery: string): boolean`
|
|
140
|
+
- `window.matchMedia(mediaQuery).matches` 반환. SSR에선 false.
|
|
141
|
+
- media query change 이벤트 구독 → 실시간 반영.
|
|
142
|
+
- **용례:** `const isMobile = useViewportMatch("(max-width: 768px)")` — 반응형 로직.
|
|
143
|
+
|
|
144
|
+
### useClientDateTime
|
|
145
|
+
`useClientDateTime(value: DateInput, { locale?, timeZone?, datePreset?, timePreset? }): { ready, text, date }`
|
|
146
|
+
- `ready`는 hydration guard (초기 false → effect에서 true).
|
|
147
|
+
- `formatClientDateTime()`으로 텍스트 생성. value/options 바뀔 때마다 재포맷.
|
|
148
|
+
- **용례:** 클라이언트에서만 날짜 표시 (SSR 불일치 방지).
|
|
149
|
+
|
|
150
|
+
### useRelativeDateTime
|
|
151
|
+
`useRelativeDateTime(value: DateInput, { locale?, maxRelativeDays?, fallbackDatePreset?, updateIntervalMs? }): { ready, text, isRelative, date }`
|
|
152
|
+
- `maxRelativeDays`(기본 7) 초과 시 절대 날짜(fallbackDatePreset 기본 "dot")로 표시.
|
|
153
|
+
- `updateIntervalMs`(기본 1000ms)마다 갱신. `"3분 전"`, `"2시간 전"` 등 실시간 업데이트.
|
|
154
|
+
- **용례:** 게시글 작성 시간, 댓글 시간, 채팅 메시지 timestamp.
|
|
155
|
+
|
|
156
|
+
### useRefEffect
|
|
157
|
+
`useRefEffect<RefElement>(callback, deps): (element: RefElement | null) => void`
|
|
158
|
+
- callback ref 생성. element 부착 시 callback 실행, cleanup 반환 가능. unmount 시 cleanup 실행.
|
|
159
|
+
- deps 바뀌면 old cleanup → new callback.
|
|
160
|
+
-**용례:** DOM에 직접 이벤트 리스너 부착, IntersectionObserver 연결, 외부 라이브러리 DOM 초기화.
|
|
161
|
+
```tsx
|
|
162
|
+
const ref = useRefEffect((el) => {
|
|
163
|
+
const handler = () => {}
|
|
164
|
+
el.addEventListener("scroll", handler)
|
|
165
|
+
return () => el.removeEventListener("scroll", handler)
|
|
166
|
+
}, [])
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Components — 완전 분석
|
|
172
|
+
|
|
173
|
+
### `<ScrolltoTop />`
|
|
174
|
+
`export function ScrolltoTop(): undefined`
|
|
175
|
+
- `"use client"`. useEffect로 모든 렌더 시 `window.scrollTo(0, 0)` 실행. 의존성 배열 없음 → 매 렌더마다 실행.
|
|
176
|
+
- **용례:** 페이지 이동 시 스크롤 최상단 리셋. `<ScrolltoTop />`를 layout에 한 번 배치.
|
|
177
|
+
- 주의: 모든 상태 변경 시 스크롤이 리셋되므로 조건부 삽입하거나 최상위 layout에만 배치할 것.
|
|
178
|
+
|
|
179
|
+
### `<SwitchCase />`
|
|
180
|
+
`SwitchCase<T extends string | number>({ value, cases, otherwise }): ReactNode`
|
|
181
|
+
- `"use client"` 없음 → 서버에서도 사용 가능.
|
|
182
|
+
- `cases[value] ?? otherwise`. value가 cases에 없으면 otherwise, otherwise도 없으면 null.
|
|
183
|
+
- typesafe: case key가 T로 제한됨. Partial이라 모든 경우를 다룰 필요 없음.
|
|
184
|
+
- **용례:** 조건부 렌더링을 삼항연산자 대신 선언적으로.
|
|
185
|
+
```tsx
|
|
186
|
+
<SwitchCase
|
|
187
|
+
value={status}
|
|
188
|
+
cases={{
|
|
189
|
+
loading: <Spinner />,
|
|
190
|
+
error: <ErrorMsg />,
|
|
191
|
+
success: <Content />,
|
|
192
|
+
}}
|
|
193
|
+
otherwise={<NotFound />}
|
|
194
|
+
/>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### `<ViewportPortal />` / `getViewportPortalRoot()`
|
|
198
|
+
- `getViewportPortalRoot()`: SSR safe. 최초 호출 시 `position:fixed;inset:0;pointer-events:none;z-index:9999` div 생성.
|
|
199
|
+
- `<ViewportPortal>`: `createPortal`로 자식들을 위 div에 렌더. useEffect로 root 연결 전까진 null 반환 (hydration safe).
|
|
200
|
+
- pointer-events:none이라 portal 내부 요소는 별도로 pointer-events:auto 필요.
|
|
201
|
+
- **용례:** 모달/툴팁/드롭다운을 DOM 트리 최상단에 렌더링 (z-index 충돌 방지, overflow:hidden 탈출).
|
|
202
|
+
```tsx
|
|
203
|
+
<ViewportPortal>
|
|
204
|
+
<div style={{ pointerEvents: "auto" }}>
|
|
205
|
+
<Modal />
|
|
206
|
+
</div>
|
|
207
|
+
</ViewportPortal>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Utils — 완전 분석
|
|
213
|
+
|
|
214
|
+
### browserStorage
|
|
215
|
+
`getLocalStorage(key): unknown` / `updateLocalStorage(key, value): void` / `removeLocalStorage(key): void`
|
|
216
|
+
- 동일한 API로 SessionStorage 버전도 있음: `getSessionStorage`, `updateSessionStorage`, `removeSessionStorage`.
|
|
217
|
+
- 내부적으로 SSR 체크 후 JSON.stringify/parse. 파싱 실패, 용량 초과 등 모든 에러 조용히 처리 (try-catch).
|
|
218
|
+
- 저장할 때 직접 `JSON.stringify`, 불러올 때 `JSON.parse`. 값이 없거나 파싱 실패 시 null.
|
|
219
|
+
- **용례:** 사용자 설정 유지 (테마, 언어), 최근 검색어 저장.
|
|
220
|
+
```tsx
|
|
221
|
+
updateLocalStorage("theme", "dark")
|
|
222
|
+
const theme = getLocalStorage("theme") // "dark"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### buildContext
|
|
226
|
+
`buildContext<T>(defaultValue): [Provider, useValue]`
|
|
227
|
+
- `Provider`: value를 `useMemo`로 감싸 불필요한 리렌더 방지.
|
|
228
|
+
- `useValue`: 단순 `useContext(context)`. Provider 없이 호출해도 에러 안 남 (defaultValue 반환).
|
|
229
|
+
- createContext를 직접 쓰는 것보다 안전하고 간결.
|
|
230
|
+
- **용례:** 전역 상태 관리가 필요할 때 Context Provider 패턴을 간단히.
|
|
231
|
+
```tsx
|
|
232
|
+
const [ThemeProvider, useTheme] = buildContext("light")
|
|
233
|
+
// <ThemeProvider value="dark">...</ThemeProvider>
|
|
234
|
+
// const theme = useTheme() // "dark"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### getDeviceInfo
|
|
238
|
+
`getDeviceInfo(): DeviceInfo`
|
|
239
|
+
- 반환: `{ isMobile, isIOS, isAndroid, isSafari, isIOSSafari, isMacSafari, isSamsungBrowser, isTouchDevice, browser }`
|
|
240
|
+
- SSR: window 없으면 모든 값 false. `isTouchDevice`는 `"ontouchstart" in window` 체크.
|
|
241
|
+
- browser: samsung > safari > chrome > firefox > edge > unknown 순서로 판별.
|
|
242
|
+
- UA로 판별하므로 User-Agent 스푸핑에 취약하지만 실제 환경에선 충분히 정확.
|
|
243
|
+
- **용례:** 모바일 웹에서 iOS/Android 분기 처리, Safari 전용 스타일/로직.
|
|
244
|
+
|
|
245
|
+
### NavigatorClipboard / NavigatorShare
|
|
246
|
+
`NavigatorClipboard({ text }): Promise<{ success: boolean }>` — `navigator.clipboard.writeText` 호출.
|
|
247
|
+
`NavigatorShare({ title, text, link }): Promise<{ success: boolean, method: "share" | "clipboard" | "unsupported" }>`
|
|
248
|
+
- `navigator.share()` 우선 시도. 사용자 취소(AbortError)면 `{ success: false, method: "share" }`.
|
|
249
|
+
- share 실패 시 clipboard fallback. clipboard도 없으면 `{ method: "unsupported" }`.
|
|
250
|
+
- **용례:** 공유 버튼 → 모바일 네이티브 Share Sheet, 데스크톱에선 링크 복사.
|
|
251
|
+
```tsx
|
|
252
|
+
if (result.success && result.method === "share") trackShared()
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### floatingMotion
|
|
256
|
+
`getFloatingMotionPreset(mode): { enterMs, exitMs, ease }`
|
|
257
|
+
|
|
258
|
+
| mode | enterMs | exitMs | ease |
|
|
259
|
+
|---|---|---|---|
|
|
260
|
+
| anchored | 230 | 170 | cubic-bezier(0.16, 1, 0.3, 1) |
|
|
261
|
+
| center-selected | 190 | 140 | cubic-bezier(0.2, 0, 0, 1) |
|
|
262
|
+
| modal-center | 340 | 260 | cubic-bezier(0.22, 1, 0.36, 1) |
|
|
263
|
+
| mobile-sheet | 360 | 280 | cubic-bezier(0.2, 0.8, 0.2, 1) |
|
|
264
|
+
|
|
265
|
+
`getFloatingTransformOrigin(placement?): string` — placement("bottom-left" 등)를 CSS transform-origin으로 변환. top→bottom, left→left, bottom→top, center→center. 기본값 "top center".
|
|
266
|
+
`getFloatingHiddenTransform({ mode, placement }): string` — hidden 상태의 transform 값.
|
|
267
|
+
- mobile-sheet: translateY(1.8rem) scale(1)
|
|
268
|
+
- modal-center: translateY(.8rem) scale(.94)
|
|
269
|
+
- center-selected: translateY(.45rem) scale(.99)
|
|
270
|
+
- anchored: placement의 위/아래/좌우에 따라 translateY 또는 translateX
|
|
271
|
+
|
|
272
|
+
**용례:** Floating UI(focus-trap, portal, auto-update)와 함께 사용하여 드롭다운/모달/시트의 enter/exit 애니메이션 구성. framer-motion의 initial/exit에 이 값들 전달.
|
|
273
|
+
|
|
274
|
+
### isEditableKeyboardTarget
|
|
275
|
+
`isEditableKeyboardTarget(target: HTMLElement): boolean`
|
|
276
|
+
- true 반환 조건: `contentEditable === "true"`, `TEXTAREA`, `SELECT`, `INPUT`(type이 hidden/file/submit/reset 등이 아닌 경우).
|
|
277
|
+
- 키보드 이벤트 핸들러에서 `isEditableKeyboardTarget(event.target)`로 체크 → 입력 중에는 단축키 동작 막기.
|
|
278
|
+
- **용례:** `onKeyDown`에서 방향키/Enter 이벤트를 입력 필드에선 무시.
|
|
279
|
+
|
|
280
|
+
### mergeRefs
|
|
281
|
+
`mergeRefs<T>(...refs: (Ref<T> | undefined | null)[]): RefCallback<T>`
|
|
282
|
+
- 함수 ref는 직접 호출, 객체 ref는 `.current` 할당. null/undefined는 안전하게 무시.
|
|
283
|
+
- **용례:** 외부 ref와 내부 ref를 동시에 한 요소에 연결.
|
|
284
|
+
```tsx
|
|
285
|
+
const internalRef = useRef(null)
|
|
286
|
+
const externalRef = useRef(null)
|
|
287
|
+
return <div ref={mergeRefs(internalRef, externalRef)} />
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### seen
|
|
291
|
+
`SEEN_STORAGE_KEY = "seen"`
|
|
292
|
+
`parseSeen(raw: string | null): Record<string, boolean>` — JSON 파싱, 실패 시 `{}`.
|
|
293
|
+
`hasSeenKey(raw: string | null, key: string): boolean` — 특정 키를 본 적 있는지.
|
|
294
|
+
`buildSeenValue(raw: string | null, key: string): string` — 키를 seen에 추가한 JSON 문자열 반환.
|
|
295
|
+
- 모두 순수 함수, SSR 안전. localStorage에 "seen" 키로 저장하는 패턴 전제.
|
|
296
|
+
- **용례:** 공지사항/업데이트 알림 "다시 보지 않음" 체크, onboarding 진행 상태, 읽은 글 목록 관리.
|
|
297
|
+
```tsx
|
|
298
|
+
const raw = getLocalStorage(SEEN_STORAGE_KEY)
|
|
299
|
+
if (!hasSeenKey(raw, noticeId)) {
|
|
300
|
+
updateLocalStorage(SEEN_STORAGE_KEY, buildSeenValue(raw, noticeId))
|
|
301
|
+
showNotice()
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### subscribeKeyboardHeight
|
|
306
|
+
`subscribeKeyboardHeight({ callback, immediate?, throttleMs? }): { unsubscribe }`
|
|
307
|
+
- `visualViewport.resize`/`scroll` 구독. 높이 계산: `window.innerHeight - visualViewport.height` (0 이상 clamp).
|
|
308
|
+
- throttleMs 기본 16ms(≈60fps). 0 이하로 설정하면 동기 실행.
|
|
309
|
+
- `immediate: true`면 구독 즉시 callback 실행.
|
|
310
|
+
- **용례:** `useKeyboardHeight()` 훅이 내부적으로 사용. 직접 구독이 필요한 경우 (React 외부에서 DOM 직접 조작).
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Cookie — 완전 분석
|
|
315
|
+
|
|
316
|
+
### cookie.shared.ts
|
|
317
|
+
모든 함수에 SSR guard 있음 → 서버에선 undefined/null 반환 또는 no-op.
|
|
318
|
+
|
|
319
|
+
`getClientCookie(name): string | undefined` — `document.cookie` 파싱하여 value 반환. 없으면 undefined.
|
|
320
|
+
|
|
321
|
+
`setClientCookie(name, value, days?): void` — `path=/`, URL 인코딩. days 생략 시 session cookie. `document.cookie = name=value; path=/; max-age=...`.
|
|
322
|
+
|
|
323
|
+
`clearClientCookie(name, { hostname?, path?, documentRef?, cookieStore? }): void` — 과거 만료일로 설정하여 삭제. 모든 서브도메인, 경로 조합을 순회하며 삭제. cookieStore가 있으면 우선 사용.
|
|
324
|
+
|
|
325
|
+
`clearAllClientCookies({ hostname?, path?, documentRef?, cookieStore?, includeRoot?, cookieString? }): string[]`
|
|
326
|
+
- hostname의 서브도메인 조합을 모두 생성하여 각각의 모든 경로(현재 path + includeRoot면 "/")에 대해 쿠키 삭제 시도.
|
|
327
|
+
- cookieStore가 있으면 `.delete()` 사용.
|
|
328
|
+
- 삭제된 쿠키 이름 배열 반환.
|
|
329
|
+
|
|
330
|
+
`parseClientCookieNames(cookieString: string): string[]` — "name=value; name2=value2" → ["name", "name2"].
|
|
331
|
+
**용례:** 로그인 토큰 관리, 사용자 설정 쿠키, A/B 테스트 배리언트 저장.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## DateTime — 완전 분석
|
|
336
|
+
|
|
337
|
+
### Shared (dateTime.shared.ts) — 순수 함수, 서버 안전
|
|
338
|
+
|
|
339
|
+
**타입:**
|
|
340
|
+
- `AppLocale = "kr" | "en" | "jp"`
|
|
341
|
+
- `DateInput = string | number | Date`
|
|
342
|
+
- `DatePreset = "long" | "dot"`
|
|
343
|
+
- `TimePreset = "ko" | "12h" | "24h-minute" | "24h-second"`
|
|
344
|
+
|
|
345
|
+
**유틸:**
|
|
346
|
+
- `toDate(value): Date | null` — Date 인스턴스면 복사 후 반환, 아니면 `new Date(value)`. 유효성 검사 후 null 가능.
|
|
347
|
+
- `toUtcMidnight(date): Date` — UTC 기준 해당일 00:00:00.000Z.
|
|
348
|
+
- `parseUtcDateInput(value?, fallback?): Date | null` — "YYYY-MM-DD" 문자열은 timezone 없이 UTC date로 파싱. 다른 형식은 toDate → toUtcMidnight.
|
|
349
|
+
- `addUtcDays(date, days): Date` — 새 Date 반환. `date.setUTCDate(date.getUTCDate() + days)`.
|
|
350
|
+
- `formatUtcDateKey(date): string` — "YYYY-MM-DD" UTC.
|
|
351
|
+
- `getUtcWeekdayIndex(date): number` — 0(일)~6(토) UTC.
|
|
352
|
+
- `normalizeAppLocale(locale?): AppLocale` — "ko"/"kr" → "kr", "ja"/"jp" → "jp", 나머지 → "en".
|
|
353
|
+
- `toIntlLocale(locale?): string` — "ko-KR" / "ja-JP" / "en-US".
|
|
354
|
+
|
|
355
|
+
**날짜 포맷:**
|
|
356
|
+
- `formatLongDate(date, locale?, timeZone?): string` — kr: "2024년 1월 1일", jp: "2024年1月1日", en: "January 1, 2024". Intl.DateTimeFormat.formatToParts로 커스텀 포맷.
|
|
357
|
+
- `formatDotDate(date, timeZone?): string` — "2024. 1. 1."
|
|
358
|
+
|
|
359
|
+
**시간 포맷:**
|
|
360
|
+
- `format24HourTime(date, { includeSeconds?, timeZone? }): string` — "14:30" / "14:30:00"
|
|
361
|
+
- `formatKoreanTime(date, timeZone?): string` — "오후 2시 30분"
|
|
362
|
+
- `formatTwelveHourTime(date, locale?, timeZone?): string` — kr: "오후 2:30", en/jp: Intl.DateTimeFormat hour12.
|
|
363
|
+
|
|
364
|
+
**상대 시간:**
|
|
365
|
+
- `formatRelativeText(diffMs, locale?): { text, isRelative }` — diffMs를 "N초 전", "N분 전", "N시간 전", "N일 전"(모든 locale)로 변환. diffMs는 0 이하로 clamp. 카운트는 최소 1.
|
|
366
|
+
- `formatRemainingText(diffMs, locale?, { includeSuffix? }): string` — 최대 2개 단위 조합. "2일 3시간 남음". diffMs=0이면 "마감됨" / "締切終了" / "Closed".
|
|
367
|
+
|
|
368
|
+
**용례 (공통 패턴):**
|
|
369
|
+
```tsx
|
|
370
|
+
// UTC date 키 생성
|
|
371
|
+
const dateKey = formatUtcDateKey(toUtcMidnight(new Date()))
|
|
372
|
+
// 상대 시간
|
|
373
|
+
const { text } = formatRelativeText(Date.now() - post.createdAt.getTime())
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Client (dateTime.client.ts) — "use client" 필요
|
|
377
|
+
|
|
378
|
+
`formatClientDate(value, { locale?, timeZone?, preset }): string` — preset으로 "long"/"dot" 선택. 유효하지 않은 날짜는 "" 반환.
|
|
379
|
+
`formatClientTime(value, { locale?, timeZone?, preset }): string` — "ko"/"12h"/"24h-minute"/"24h-second".
|
|
380
|
+
`formatClientDateTime(value, { locale?, timeZone?, datePreset?, timePreset? }): string` — date + " " + time. 기본 datePreset "long", timePreset "24h-minute". 둘 중 하나가 빈 문자열이면 다른 쪽만 반환.
|
|
381
|
+
`formatClientRelative(value, { locale?, now?, maxRelativeDays?, fallbackDatePreset? }): { text, isRelative }` — maxRelativeDays(기본 7) 초과 시 절대 날짜(fallbackDatePreset 기본 "dot"). 날짜 유효성 검사, 실패 시 `{ text: "", isRelative: false }`.
|
|
382
|
+
|
|
383
|
+
### Server (dateTime.server.ts) — 서버 렌더링용
|
|
384
|
+
`formatServerDate`, `formatServerTime`, `formatServerDateTime`, `formatServerRelative` — client와 동일한 로직. 차이점: `formatServerRelative`의 `now` 기본값이 `new Date()` (client는 `Date.now()`).
|
|
385
|
+
|
|
386
|
+
**datetime 선택 가이드:**
|
|
387
|
+
- 서버 컴포넌트에서 날짜 표시 → shared 함수 사용. timezone은 서버 timezone 기준.
|
|
388
|
+
- 클라이언트 hydration 후에도 계속 표시 → shared 함수. SSR/클라이언트 일관성 보장.
|
|
389
|
+
- 클라이언트 로케일/timezone 반영 → client 함수. "use client" 필요.
|
|
390
|
+
- 서버에서 렌더링된 HTML 그대로 → server 함수.
|
|
391
|
+
- 상대 시간(실시간 갱신) → `useRelativeDateTime` 훅.
|
|
392
|
+
|
|
393
|
+
**전체 datetime 함수 로직 요약:**
|
|
394
|
+
1. `toDate(value)`로 DateInput → Date | null 변환 (유효성 검사)
|
|
395
|
+
2. 선택적으로 `toUtcMidnight`로 UTC 자정 정규화
|
|
396
|
+
3. `normalizeAppLocale(locale)`로 AppLocale 결정
|
|
397
|
+
4. `Intl.DateTimeFormat.formatToParts` 또는 `Intl.DateTimeFormat.format`으로 포맷
|
|
398
|
+
5. 한국어/일본어는 커스텀 포맷팅 (toParts로 각 컴포넌트 추출 후 조립), 영어는 Intl 네이티브 포맷 사용
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type SwitchCaseProps<T extends string | number> = {
|
|
4
|
+
value: T;
|
|
5
|
+
cases: Partial<Record<T, ReactNode>>;
|
|
6
|
+
otherwise?: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function SwitchCase<T extends string | number>({
|
|
10
|
+
value,
|
|
11
|
+
cases,
|
|
12
|
+
otherwise = null,
|
|
13
|
+
}: SwitchCaseProps<T>) {
|
|
14
|
+
return (cases[value] ?? otherwise) as ReactNode;
|
|
15
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
|
|
6
|
+
const VIEWPORT_PORTAL_ROOT_ID = "viewport-portal-root";
|
|
7
|
+
|
|
8
|
+
export function getViewportPortalRoot() {
|
|
9
|
+
if (typeof document === "undefined") {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const existingRoot = document.getElementById(VIEWPORT_PORTAL_ROOT_ID);
|
|
14
|
+
if (existingRoot instanceof HTMLDivElement) {
|
|
15
|
+
return existingRoot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const root = document.createElement("div");
|
|
19
|
+
root.id = VIEWPORT_PORTAL_ROOT_ID;
|
|
20
|
+
root.setAttribute("data-viewport-portal-root", "true");
|
|
21
|
+
root.style.cssText =
|
|
22
|
+
"position:fixed;inset:0;pointer-events:none;z-index:9999;";
|
|
23
|
+
document.body.appendChild(root);
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ViewportPortalProps = {
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function ViewportPortal({ children }: ViewportPortalProps) {
|
|
32
|
+
const [root, setRoot] = useState<HTMLElement | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setRoot(getViewportPortalRoot());
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
if (!root) return null;
|
|
39
|
+
return createPortal(children, root);
|
|
40
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
const EXPIRED_COOKIE_DATE = "Thu, 01 Jan 1970 00:00:00 GMT";
|
|
4
|
+
|
|
5
|
+
type CookieStoreLike = {
|
|
6
|
+
delete: (name: string) => Promise<void>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type DocumentCookieRef = {
|
|
10
|
+
cookie: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ClearClientCookieOptions = {
|
|
14
|
+
hostname?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
documentRef?: DocumentCookieRef;
|
|
17
|
+
cookieStore?: CookieStoreLike;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ClearAllClientCookiesOptions = ClearClientCookieOptions & {
|
|
21
|
+
/**
|
|
22
|
+
* If true, cookies in the hostname's root path ("/") are also deleted.
|
|
23
|
+
* Use with caution.
|
|
24
|
+
*/
|
|
25
|
+
includeRoot?: boolean;
|
|
26
|
+
cookieString?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function findClientCookie(
|
|
30
|
+
name: string,
|
|
31
|
+
documentRef: DocumentCookieRef,
|
|
32
|
+
): string | undefined {
|
|
33
|
+
const parsed = parseClientCookie(documentRef.cookie);
|
|
34
|
+
return parsed[name];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseClientCookie(cookieString: string): Record<string, string> {
|
|
38
|
+
return cookieString
|
|
39
|
+
.split(";")
|
|
40
|
+
.map((pair) => pair.trim().split("=") as [string, string])
|
|
41
|
+
.reduce<Record<string, string>>((acc, [key, value]) => {
|
|
42
|
+
if (key) {
|
|
43
|
+
acc[key] = value;
|
|
44
|
+
}
|
|
45
|
+
return acc;
|
|
46
|
+
}, {});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseClientCookieNames(cookieString: string): string[] {
|
|
50
|
+
return cookieString
|
|
51
|
+
.split(";")
|
|
52
|
+
.map((entry) => {
|
|
53
|
+
const eqPos = entry.indexOf("=");
|
|
54
|
+
return eqPos > -1 ? entry.slice(0, eqPos).trim() : entry.trim();
|
|
55
|
+
})
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getClientCookie(name: string): string | undefined {
|
|
60
|
+
if (typeof document === "undefined") {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return findClientCookie(name, document);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setClientCookie(name: string, value: string, days?: number) {
|
|
68
|
+
if (typeof document === "undefined") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let expires = "";
|
|
73
|
+
if (days) {
|
|
74
|
+
const date = new Date();
|
|
75
|
+
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
76
|
+
expires = `; expires=${date.toUTCString()}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
document.cookie = `${name}=${encodeURIComponent(value)}${expires}; path=/`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function clearClientCookie(
|
|
83
|
+
name: string,
|
|
84
|
+
options: ClearClientCookieOptions = {},
|
|
85
|
+
): void {
|
|
86
|
+
const {
|
|
87
|
+
hostname = window.location.hostname,
|
|
88
|
+
path = "/",
|
|
89
|
+
documentRef = document,
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
documentRef.cookie = `${name}=; expires=${EXPIRED_COOKIE_DATE}; path=${path}; domain=${hostname};`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function clearAllClientCookies(
|
|
96
|
+
options: ClearAllClientCookiesOptions = {},
|
|
97
|
+
): string[] {
|
|
98
|
+
const {
|
|
99
|
+
documentRef = document,
|
|
100
|
+
cookieStore,
|
|
101
|
+
includeRoot = false,
|
|
102
|
+
cookieString,
|
|
103
|
+
} = options;
|
|
104
|
+
|
|
105
|
+
const entries = cookieString
|
|
106
|
+
? parseClientCookieNames(cookieString)
|
|
107
|
+
: documentRef.cookie
|
|
108
|
+
.split(/;\s*/)
|
|
109
|
+
.map((entry) => {
|
|
110
|
+
const eqPos = entry.indexOf("=");
|
|
111
|
+
return eqPos > -1 ? entry.slice(0, eqPos).trim() : entry.trim();
|
|
112
|
+
})
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
|
|
115
|
+
if (cookieStore) {
|
|
116
|
+
for (const name of entries) {
|
|
117
|
+
cookieStore.delete(name);
|
|
118
|
+
}
|
|
119
|
+
return entries;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const hostnameParts = window.location.hostname.split(".");
|
|
123
|
+
const { pathname } = window.location;
|
|
124
|
+
|
|
125
|
+
const paths = [pathname];
|
|
126
|
+
if (includeRoot) {
|
|
127
|
+
paths.unshift("/");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const name of entries) {
|
|
131
|
+
if (!name) continue;
|
|
132
|
+
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
for (let i = 0; i < hostnameParts.length; i++) {
|
|
135
|
+
const domain = hostnameParts.slice(i).join(".");
|
|
136
|
+
documentRef.cookie = `${name}=; expires=${EXPIRED_COOKIE_DATE}; path=${path}; domain=${domain};`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return entries;
|
|
142
|
+
}
|