@coldsurf/ocean-road 1.13.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 (195) hide show
  1. package/dist/css/global.css +30 -0
  2. package/dist/index.d.ts +641 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +733 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/native.cjs +94 -0
  7. package/dist/native.cjs.map +1 -0
  8. package/dist/native.d.cts +304 -0
  9. package/dist/native.d.cts.map +1 -0
  10. package/dist/native.d.ts +304 -0
  11. package/dist/native.d.ts.map +1 -0
  12. package/dist/native.js +94 -0
  13. package/dist/native.js.map +1 -0
  14. package/dist/next.cjs +949 -0
  15. package/dist/next.cjs.map +1 -0
  16. package/dist/next.d.cts +270 -0
  17. package/dist/next.d.cts.map +1 -0
  18. package/dist/next.d.ts +270 -0
  19. package/dist/next.d.ts.map +1 -0
  20. package/dist/next.js +949 -0
  21. package/dist/next.js.map +1 -0
  22. package/native/index.d.ts +7 -0
  23. package/next/index.d.ts +7 -0
  24. package/package.json +126 -0
  25. package/src/GlobalStyle.tsx +111 -0
  26. package/src/base/badge/badge.tsx +50 -0
  27. package/src/base/badge/index.ts +1 -0
  28. package/src/base/button/button.styled.tsx +123 -0
  29. package/src/base/button/button.tsx +60 -0
  30. package/src/base/button/button.types.ts +20 -0
  31. package/src/base/button/button.utils.ts +36 -0
  32. package/src/base/button/index.tsx +2 -0
  33. package/src/base/checkbox/checkbox.styled.ts +52 -0
  34. package/src/base/checkbox/checkbox.tsx +26 -0
  35. package/src/base/checkbox/index.ts +1 -0
  36. package/src/base/icon-button/icon-button.styled.ts +8 -0
  37. package/src/base/icon-button/icon-button.tsx +15 -0
  38. package/src/base/icon-button/icon-button.types.ts +3 -0
  39. package/src/base/icon-button/index.ts +2 -0
  40. package/src/base/index.ts +11 -0
  41. package/src/base/label/index.ts +1 -0
  42. package/src/base/label/label.styled.ts +7 -0
  43. package/src/base/label/label.tsx +27 -0
  44. package/src/base/modal/index.ts +1 -0
  45. package/src/base/modal/modal.tsx +59 -0
  46. package/src/base/spinner/index.ts +2 -0
  47. package/src/base/spinner/spinner.styled.ts +25 -0
  48. package/src/base/spinner/spinner.tsx +36 -0
  49. package/src/base/spinner/spinner.types.ts +1 -0
  50. package/src/base/switch/index.ts +1 -0
  51. package/src/base/switch/switch.styled.tsx +49 -0
  52. package/src/base/switch/switch.tsx +29 -0
  53. package/src/base/text/index.ts +1 -0
  54. package/src/base/text/text.styled.ts +17 -0
  55. package/src/base/text/text.tsx +37 -0
  56. package/src/base/text-area/index.ts +2 -0
  57. package/src/base/text-area/text-area.styled.ts +16 -0
  58. package/src/base/text-area/text-area.tsx +29 -0
  59. package/src/base/text-area/text-area.types.ts +11 -0
  60. package/src/base/text-input/index.ts +2 -0
  61. package/src/base/text-input/text-input.styled.ts +40 -0
  62. package/src/base/text-input/text-input.tsx +59 -0
  63. package/src/base/text-input/text-input.types.ts +15 -0
  64. package/src/base/toast/index.ts +2 -0
  65. package/src/base/toast/toast.tsx +60 -0
  66. package/src/base/toast/toast.types.ts +5 -0
  67. package/src/constants.ts +1 -0
  68. package/src/contexts/ColorSchemeProvider.tsx +154 -0
  69. package/src/css/global.css +30 -0
  70. package/src/extensions/accordion/accordion.hooks.ts +11 -0
  71. package/src/extensions/accordion/accordion.tsx +80 -0
  72. package/src/extensions/accordion/index.ts +1 -0
  73. package/src/extensions/app-header/app-header.hooks.ts +94 -0
  74. package/src/extensions/app-header/app-header.tsx +31 -0
  75. package/src/extensions/app-header/app-header.types.ts +1 -0
  76. package/src/extensions/app-header/index.ts +8 -0
  77. package/src/extensions/app-logo/app-logo.tsx +40 -0
  78. package/src/extensions/app-logo/index.ts +1 -0
  79. package/src/extensions/app-store-button/app-store-button.tsx +64 -0
  80. package/src/extensions/app-store-button/index.ts +1 -0
  81. package/src/extensions/brand-icon/brand-icon.android.tsx +11 -0
  82. package/src/extensions/brand-icon/brand-icon.apple.tsx +11 -0
  83. package/src/extensions/brand-icon/brand-icon.google.tsx +11 -0
  84. package/src/extensions/brand-icon/brand-icon.tsx +22 -0
  85. package/src/extensions/brand-icon/index.ts +1 -0
  86. package/src/extensions/color-scheme-toggle/color-scheme-toggle.tsx +76 -0
  87. package/src/extensions/color-scheme-toggle/index.ts +1 -0
  88. package/src/extensions/dropdown/dropdown.menu-item.tsx +237 -0
  89. package/src/extensions/dropdown/dropdown.result-item.tsx +26 -0
  90. package/src/extensions/dropdown/dropdown.styled.tsx +48 -0
  91. package/src/extensions/dropdown/dropdown.trigger.tsx +72 -0
  92. package/src/extensions/dropdown/dropdown.tsx +222 -0
  93. package/src/extensions/dropdown/dropdown.types.ts +3 -0
  94. package/src/extensions/dropdown/dropdown.utils.ts +40 -0
  95. package/src/extensions/dropdown/index.ts +14 -0
  96. package/src/extensions/error-ui/index.ts +7 -0
  97. package/src/extensions/error-ui/network-error/index.ts +1 -0
  98. package/src/extensions/error-ui/network-error/network-error.styled.ts +16 -0
  99. package/src/extensions/error-ui/network-error/network-error.tsx +14 -0
  100. package/src/extensions/error-ui/unknown-error/index.ts +1 -0
  101. package/src/extensions/error-ui/unknown-error/unknown-error.styled.ts +16 -0
  102. package/src/extensions/error-ui/unknown-error/unknown-error.tsx +14 -0
  103. package/src/extensions/full-screen-modal/full-screen-modal.tsx +52 -0
  104. package/src/extensions/full-screen-modal/index.ts +1 -0
  105. package/src/extensions/grid-card-image/grid-card-image.tsx +11 -0
  106. package/src/extensions/grid-card-image/index.ts +1 -0
  107. package/src/extensions/grid-card-image-empty/grid-card-image-empty.tsx +11 -0
  108. package/src/extensions/grid-card-image-empty/index.ts +1 -0
  109. package/src/extensions/grid-card-item/grid-card-item.masonry.styled.tsx +95 -0
  110. package/src/extensions/grid-card-item/grid-card-item.masonry.tsx +63 -0
  111. package/src/extensions/grid-card-item/grid-card-item.styled.tsx +93 -0
  112. package/src/extensions/grid-card-item/grid-card-item.subscribe-btn-layout.tsx +30 -0
  113. package/src/extensions/grid-card-item/grid-card-item.tsx +81 -0
  114. package/src/extensions/grid-card-item/index.ts +2 -0
  115. package/src/extensions/grid-card-list/grid-card-list.masonry.styled.tsx +45 -0
  116. package/src/extensions/grid-card-list/grid-card-list.masonry.tsx +58 -0
  117. package/src/extensions/grid-card-list/grid-card-list.styled.tsx +40 -0
  118. package/src/extensions/grid-card-list/grid-card-list.tsx +59 -0
  119. package/src/extensions/grid-card-list/index.ts +2 -0
  120. package/src/extensions/grid-card-list-empty/grid-card-list-empty.tsx +38 -0
  121. package/src/extensions/grid-card-list-empty/index.ts +1 -0
  122. package/src/extensions/grid-card-list-load-more/grid-card-list-load-more.styled.tsx +15 -0
  123. package/src/extensions/grid-card-list-load-more/grid-card-list-load-more.tsx +43 -0
  124. package/src/extensions/grid-card-list-load-more/index.ts +1 -0
  125. package/src/extensions/index.ts +38 -0
  126. package/src/extensions/menu-item/index.ts +1 -0
  127. package/src/extensions/menu-item/menu-item.tsx +87 -0
  128. package/src/extensions/sns-icon/index.ts +1 -0
  129. package/src/extensions/sns-icon/sns-icon.facebook.tsx +11 -0
  130. package/src/extensions/sns-icon/sns-icon.instagram.tsx +11 -0
  131. package/src/extensions/sns-icon/sns-icon.tsx +24 -0
  132. package/src/extensions/sns-icon/sns-icon.x.tsx +11 -0
  133. package/src/extensions/sns-icon/sns-icon.youtube.tsx +11 -0
  134. package/src/index.ts +8 -0
  135. package/src/native/button/button.styled.tsx +99 -0
  136. package/src/native/button/button.tsx +42 -0
  137. package/src/native/button/index.ts +1 -0
  138. package/src/native/contexts/color-scheme-context/color-scheme-context.tsx +45 -0
  139. package/src/native/contexts/color-scheme-context/index.ts +1 -0
  140. package/src/native/contexts/index.ts +1 -0
  141. package/src/native/icon-button/icon-button.styled.ts +6 -0
  142. package/src/native/icon-button/icon-button.tsx +33 -0
  143. package/src/native/icon-button/icon-button.types.ts +14 -0
  144. package/src/native/icon-button/icon-button.utils.ts +114 -0
  145. package/src/native/icon-button/index.ts +1 -0
  146. package/src/native/index.ts +9 -0
  147. package/src/native/modal/index.ts +2 -0
  148. package/src/native/modal/modal.styled.ts +17 -0
  149. package/src/native/modal/modal.tsx +21 -0
  150. package/src/native/modal/modal.types.ts +8 -0
  151. package/src/native/profile-thumbnail/index.ts +1 -0
  152. package/src/native/profile-thumbnail/profile-thumbnail.tsx +91 -0
  153. package/src/native/spinner/index.ts +1 -0
  154. package/src/native/spinner/spinner.tsx +75 -0
  155. package/src/native/text/index.ts +2 -0
  156. package/src/native/text/text.tsx +51 -0
  157. package/src/native/text/text.types.ts +5 -0
  158. package/src/native/text-input/index.ts +2 -0
  159. package/src/native/text-input/text-input.tsx +72 -0
  160. package/src/native/text-input/text-input.types.ts +3 -0
  161. package/src/native/toast/index.ts +2 -0
  162. package/src/native/toast/toast.styled.ts +40 -0
  163. package/src/native/toast/toast.tsx +23 -0
  164. package/src/native/toast/toast.types.ts +10 -0
  165. package/src/next/app-footer/app-footer.tsx +250 -0
  166. package/src/next/app-footer/index.ts +1 -0
  167. package/src/next/app-header/app-header.fixed-header.tsx +83 -0
  168. package/src/next/app-header/app-header.full-screen-mobile-accordion-drawer.tsx +131 -0
  169. package/src/next/app-header/app-header.logo.tsx +50 -0
  170. package/src/next/app-header/app-header.modal-mobile-accordion-drawer.tsx +69 -0
  171. package/src/next/app-header/app-header.styled.ts +160 -0
  172. package/src/next/app-header/app-header.tsx +91 -0
  173. package/src/next/app-header/index.ts +13 -0
  174. package/src/next/global-link/global-link.store.ts +41 -0
  175. package/src/next/global-link/global-link.tsx +52 -0
  176. package/src/next/global-link/global-link.utils.ts +9 -0
  177. package/src/next/global-link/index.ts +3 -0
  178. package/src/next/grid-card-item/grid-card-item.masonry.tsx +23 -0
  179. package/src/next/grid-card-item/grid-card-item.tsx +23 -0
  180. package/src/next/grid-card-item/index.ts +2 -0
  181. package/src/next/index.ts +16 -0
  182. package/src/next/new-tab-link/index.ts +1 -0
  183. package/src/next/new-tab-link/new-tab-link.tsx +15 -0
  184. package/src/next/route-loading/index.ts +1 -0
  185. package/src/next/route-loading/route-loading.tsx +21 -0
  186. package/src/tokens/index.ts +2 -0
  187. package/src/tokens/tokens.ts +8 -0
  188. package/src/tokens/tokens.types.ts +7 -0
  189. package/src/utils/breakpoints.ts +9 -0
  190. package/src/utils/common-styles.ts +23 -0
  191. package/src/utils/index.ts +2 -0
  192. package/src/utils/media.ts +23 -0
  193. package/src/utils/use-prevent-scroll-effect.ts +19 -0
  194. package/src/utils/with-id.ts +3 -0
  195. package/src/utils/with-stop-propagation.ts +10 -0
@@ -0,0 +1,237 @@
1
+ import { overlay } from 'overlay-kit';
2
+ import {
3
+ type MouseEvent,
4
+ type MouseEventHandler,
5
+ type ReactNode,
6
+ type Ref,
7
+ forwardRef,
8
+ useCallback,
9
+ useEffect,
10
+ useImperativeHandle,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import { MenuItem } from '../menu-item';
15
+ import { Dropdown } from './dropdown';
16
+ import type { DropdownMenuItemRef } from './dropdown.types';
17
+
18
+ const SPACER_HEIGHT = 8;
19
+
20
+ type Props<DataItemT> = {
21
+ isCurrent: boolean;
22
+ icon?: ReactNode;
23
+ title: ReactNode;
24
+ dropdownData: Array<DataItemT>;
25
+ renderDropdownItem: (item: DataItemT) => ReactNode;
26
+ backdrop?: boolean;
27
+ absolute?: boolean;
28
+ isLoading?: boolean;
29
+ onClose?: () => void;
30
+ onMouseEnter?: (e: MouseEvent<HTMLDivElement>, params: { openDropdown: () => void }) => void;
31
+ onMouseLeave?: (e: MouseEvent<HTMLDivElement>, params: { closeDropdown: () => void }) => void;
32
+ onClick?: (e: MouseEvent<HTMLDivElement>, params: { openDropdown: () => void }) => void;
33
+ };
34
+
35
+ export const DropdownMenuItem = forwardRef(function DropdownMenuItemComponent<DataItemT>(
36
+ {
37
+ isCurrent,
38
+ icon,
39
+ title,
40
+ dropdownData,
41
+ renderDropdownItem,
42
+ backdrop = false,
43
+ absolute = false,
44
+ isLoading,
45
+ onClose,
46
+ onMouseEnter,
47
+ onMouseLeave,
48
+ onClick,
49
+ }: Props<DataItemT>,
50
+ ref: Ref<DropdownMenuItemRef>
51
+ ) {
52
+ const [isOpenDropdown, setIsOpenDropdown] = useState(false);
53
+
54
+ const menuItemRef = useRef<HTMLDivElement>(null);
55
+
56
+ const canShowDropdown = dropdownData.length > 0 || isLoading;
57
+
58
+ const openDropdown = useCallback(() => {
59
+ if (absolute) {
60
+ const rect = menuItemRef.current?.getBoundingClientRect();
61
+ if (!rect) return;
62
+ const renderItem = (item: (typeof dropdownData)[number], index: number) => (
63
+ <div
64
+ key={index.toString()}
65
+ onClick={(e) => e.stopPropagation()}
66
+ onKeyDown={(e) => e.stopPropagation()}
67
+ >
68
+ {renderDropdownItem(item)}
69
+ </div>
70
+ );
71
+ overlay.open(
72
+ ({ close, isOpen }) =>
73
+ isOpen && (
74
+ <Dropdown
75
+ edge="left"
76
+ backdrop={backdrop}
77
+ preventScroll={false}
78
+ isOpen={isOpen}
79
+ animate={backdrop}
80
+ onClose={close}
81
+ position={{
82
+ top: rect.bottom + window.scrollY, // Bottom of button
83
+ left: rect.left + window.scrollX, // Left of button
84
+ }}
85
+ >
86
+ {dropdownData.map(renderItem)}
87
+ </Dropdown>
88
+ )
89
+ );
90
+ } else {
91
+ requestAnimationFrame(() => {
92
+ if (canShowDropdown) {
93
+ const rect = menuItemRef.current?.getBoundingClientRect();
94
+ if (!rect) return;
95
+ setIsOpenDropdown(true);
96
+ }
97
+ });
98
+ }
99
+ }, [absolute, dropdownData, renderDropdownItem, backdrop, canShowDropdown]);
100
+
101
+ const closeDropdownHoverLeave = useCallback((_: MouseEvent<HTMLDivElement>) => {
102
+ // const container = e.currentTarget;
103
+ // const nextTarget = e.relatedTarget as Node | null;
104
+
105
+ // nextTarget 이 Container 내부라면: close 방지
106
+ // if (container.contains(nextTarget)) {
107
+ // return;
108
+ // }
109
+
110
+ setIsOpenDropdown(false);
111
+ }, []);
112
+
113
+ const close = useCallback(() => {
114
+ onClose?.();
115
+ setIsOpenDropdown(false);
116
+ overlay.closeAll();
117
+ }, [onClose]);
118
+
119
+ useImperativeHandle(
120
+ ref,
121
+ () => ({
122
+ close: () => {
123
+ setIsOpenDropdown(false);
124
+ overlay.closeAll();
125
+ },
126
+ }),
127
+ []
128
+ );
129
+
130
+ const handleClick = useCallback<MouseEventHandler<HTMLDivElement>>(
131
+ (e) => {
132
+ e.stopPropagation();
133
+ onClick?.(e, { openDropdown });
134
+ },
135
+ [onClick, openDropdown]
136
+ );
137
+
138
+ const handleMouseEnter = useCallback<MouseEventHandler<HTMLDivElement>>(
139
+ (evt) => {
140
+ onMouseEnter?.(evt, {
141
+ openDropdown,
142
+ });
143
+ },
144
+ [onMouseEnter, openDropdown]
145
+ );
146
+
147
+ const handleMouseLeave = useCallback<MouseEventHandler<HTMLDivElement>>(
148
+ (e) => {
149
+ onMouseLeave?.(e, {
150
+ closeDropdown: () => closeDropdownHoverLeave(e),
151
+ });
152
+ },
153
+ [closeDropdownHoverLeave, onMouseLeave]
154
+ );
155
+
156
+ useEffect(() => {
157
+ if (!menuItemRef.current) return;
158
+ const handleOutsideClick = (event: globalThis.MouseEvent) => {
159
+ const target = event.target as Node;
160
+
161
+ if (!target || !target.isConnected) {
162
+ return;
163
+ }
164
+
165
+ const isOutside = menuItemRef.current && !menuItemRef.current.contains(target);
166
+
167
+ if (isOutside) {
168
+ setIsOpenDropdown(false);
169
+ }
170
+ };
171
+
172
+ window.addEventListener('mousedown', handleOutsideClick, {});
173
+
174
+ return () => window.removeEventListener('mousedown', handleOutsideClick, {});
175
+ }, []);
176
+
177
+ return (
178
+ <>
179
+ <MenuItem
180
+ ref={menuItemRef}
181
+ isCurrent={isCurrent}
182
+ icon={icon}
183
+ onClick={handleClick}
184
+ onMouseEnter={handleMouseEnter}
185
+ onMouseLeave={handleMouseLeave}
186
+ style={{
187
+ position: 'relative',
188
+ }}
189
+ >
190
+ {title}
191
+ {!absolute && isOpenDropdown && canShowDropdown && (
192
+ <div
193
+ style={{
194
+ position: 'absolute',
195
+ top: '100%',
196
+ minWidth: '100%', // 버튼 너비만큼 확보
197
+ width: 'auto', // renderItem이 더 넓으면 확장 허용
198
+ left: 0,
199
+ }}
200
+ >
201
+ {/* Spacer */}
202
+ <div
203
+ style={{
204
+ height: `${SPACER_HEIGHT}px`,
205
+ background: 'transparent',
206
+ cursor: 'default',
207
+ }}
208
+ />
209
+ <Dropdown
210
+ // currently leave edge value to left
211
+ edge="left"
212
+ backdrop={backdrop}
213
+ preventScroll={false}
214
+ isOpen
215
+ animate={backdrop}
216
+ isLoading={isLoading}
217
+ onClose={close}
218
+ >
219
+ {dropdownData.map((item, index) => (
220
+ <div
221
+ key={index.toString()}
222
+ onClick={(e) => e.stopPropagation()}
223
+ onKeyDown={(e) => e.stopPropagation()}
224
+ >
225
+ {renderDropdownItem(item)}
226
+ </div>
227
+ ))}
228
+ </Dropdown>
229
+ </div>
230
+ )}
231
+ </MenuItem>
232
+ </>
233
+ );
234
+ }) as <DataItemT>(props: Props<DataItemT> & { ref?: Ref<DropdownMenuItemRef> }) => JSX.Element;
235
+
236
+ // @ts-expect-error
237
+ DropdownMenuItem.displayName = 'Dropdown.MenuItem';
@@ -0,0 +1,26 @@
1
+ import { semantics } from '@/tokens';
2
+ import styled from '@emotion/styled';
3
+
4
+ export const DropdownResultItem = styled.div<{ $isActive?: boolean }>`
5
+ padding-left: 1rem;
6
+ padding-right: 1rem;
7
+ padding-top: 0.6rem;
8
+ padding-bottom: 0.6rem;
9
+ cursor: pointer;
10
+
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: 0.25rem;
14
+
15
+ color: ${semantics.color.foreground[1]};
16
+
17
+ background-color: ${({ $isActive }) => ($isActive ? semantics.color.background[5] : semantics.color.background[3])};
18
+ white-space: nowrap;
19
+ display: block;
20
+
21
+ &:hover {
22
+ background-color: ${semantics.color.background[4]};
23
+ }
24
+ `;
25
+
26
+ DropdownResultItem.displayName = 'Dropdown.ResultItem';
@@ -0,0 +1,48 @@
1
+ import { semantics } from '@/tokens';
2
+ import { commonWebkitScrollHideCss } from '@/utils/common-styles';
3
+ import styled from '@emotion/styled';
4
+ import { motion } from 'framer-motion';
5
+
6
+ export const DropdownMotionDiv = styled(motion.div)<{ $zIndex?: number }>`
7
+ background-color: transparent;
8
+
9
+ position: absolute;
10
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
11
+ z-index: ${(props) => props.$zIndex};
12
+ border-radius: 8px;
13
+
14
+ ${commonWebkitScrollHideCss()}
15
+ `;
16
+
17
+ export const StyledDropdownSpinnerItem = styled.div`
18
+ padding-left: 1rem;
19
+ padding-right: 1rem;
20
+ padding-top: 0.6rem;
21
+ padding-bottom: 0.6rem;
22
+
23
+ background-color: ${semantics.color.background[4]};
24
+
25
+ display: flex;
26
+ justify-content: center;
27
+ align-items: center;
28
+ `;
29
+
30
+ export const StyledDropdownList = styled.ul`
31
+ list-style: none;
32
+ margin: 0;
33
+ padding: 0;
34
+ border-radius: 8px;
35
+ ${commonWebkitScrollHideCss()}
36
+ `;
37
+ export const StyledDropdownListItem = styled.li`
38
+ background-color: transparent;
39
+
40
+ &:first-of-type {
41
+ border-top-left-radius: 8px;
42
+ border-top-right-radius: 8px;
43
+ }
44
+ &:last-of-type {
45
+ border-bottom-left-radius: 8px;
46
+ border-bottom-right-radius: 8px;
47
+ }
48
+ `;
@@ -0,0 +1,72 @@
1
+ import {
2
+ type PropsWithChildren,
3
+ type ReactNode,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import { Dropdown, type DropdownCoreProps } from './dropdown';
10
+ import type { DropdownMenuItemRef } from './dropdown.types';
11
+
12
+ export const DropdownTrigger = ({
13
+ renderTriggerNode,
14
+ triggerRef,
15
+ children,
16
+ backdrop,
17
+ zIndex,
18
+ edge,
19
+ }: PropsWithChildren<{
20
+ renderTriggerNode: ({ openDropdown }: { openDropdown: () => void }) => ReactNode;
21
+ triggerRef: DropdownCoreProps['triggerRef'];
22
+ backdrop: DropdownCoreProps['backdrop'];
23
+ zIndex: DropdownCoreProps['zIndex'];
24
+ edge: DropdownCoreProps['edge'];
25
+ }>) => {
26
+ const dropdownRef = useRef<DropdownMenuItemRef>(null);
27
+ const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
28
+
29
+ // Handle dropdown open
30
+ const openDropdown = useCallback(() => {
31
+ setIsDropdownOpen(true);
32
+ }, []);
33
+
34
+ const closeDropdown = useCallback(() => {
35
+ setIsDropdownOpen(false);
36
+ }, []);
37
+
38
+ useEffect(() => {
39
+ if (backdrop) return;
40
+ if (!isDropdownOpen) return;
41
+ if (!dropdownRef.current) return;
42
+ const handleOutsideClick = (event: globalThis.MouseEvent) => {
43
+ const target = event.target as Node;
44
+
45
+ if (!target || !target.isConnected) {
46
+ return;
47
+ }
48
+ closeDropdown();
49
+ };
50
+
51
+ window.addEventListener('mousedown', handleOutsideClick, {});
52
+
53
+ return () => window.removeEventListener('mousedown', handleOutsideClick, {});
54
+ }, [backdrop, closeDropdown, isDropdownOpen]);
55
+
56
+ return (
57
+ <>
58
+ {renderTriggerNode({ openDropdown })}
59
+ <Dropdown
60
+ ref={dropdownRef}
61
+ isOpen={isDropdownOpen}
62
+ onClose={closeDropdown}
63
+ triggerRef={triggerRef}
64
+ backdrop={backdrop}
65
+ zIndex={zIndex}
66
+ edge={edge}
67
+ >
68
+ {children}
69
+ </Dropdown>
70
+ </>
71
+ );
72
+ };
@@ -0,0 +1,222 @@
1
+ import { Spinner } from '@/base';
2
+ import { usePreventScrollEffect } from '@/utils/use-prevent-scroll-effect';
3
+ import { AnimatePresence, type MotionStyle, motion } from 'framer-motion';
4
+ import {
5
+ type CSSProperties,
6
+ type PropsWithChildren,
7
+ type RefObject,
8
+ forwardRef,
9
+ memo,
10
+ useCallback,
11
+ useEffect,
12
+ useImperativeHandle,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react';
17
+ import {
18
+ DropdownMotionDiv,
19
+ StyledDropdownList,
20
+ StyledDropdownSpinnerItem,
21
+ } from './dropdown.styled';
22
+ import type { DropdownMenuItemRef } from './dropdown.types';
23
+ import { calculatePosition } from './dropdown.utils';
24
+
25
+ const POSITION_PADDING = 8;
26
+
27
+ type Position = {
28
+ top: number;
29
+ left?: number;
30
+ right?: number;
31
+ };
32
+
33
+ type DropdownCoreBaseProps = {
34
+ isOpen: boolean;
35
+ onClose: () => void;
36
+ position?: Position;
37
+ className?: string;
38
+ style?: CSSProperties;
39
+ isLoading?: boolean;
40
+ backdrop?: boolean;
41
+ preventScroll?: boolean;
42
+ animate?: boolean;
43
+ triggerRef?: RefObject<HTMLElement>;
44
+ zIndex?: number;
45
+ };
46
+
47
+ export type DropdownCoreProps = PropsWithChildren<
48
+ ({ edge: 'left' } & DropdownCoreBaseProps) | ({ edge: 'right' } & DropdownCoreBaseProps)
49
+ >;
50
+
51
+ const DropdownComponent = forwardRef<DropdownMenuItemRef, DropdownCoreProps>(
52
+ (
53
+ {
54
+ children,
55
+ isOpen,
56
+ onClose,
57
+ position,
58
+ className,
59
+ style,
60
+ isLoading,
61
+ backdrop = true,
62
+ preventScroll = true,
63
+ animate = true,
64
+ triggerRef,
65
+ zIndex,
66
+ edge,
67
+ },
68
+ ref
69
+ ) => {
70
+ const dropdownRef = useRef<HTMLDivElement>(null);
71
+ const dropdownVariants = {
72
+ hidden: { opacity: 0, y: -10 },
73
+ visible: { opacity: 1, y: 0 },
74
+ exit: { opacity: 0, y: -10 },
75
+ };
76
+
77
+ const [maxHeight, setMaxHeight] = useState(0);
78
+ const [innerPosition, setInnerPosition] = useState<Position | undefined>(position);
79
+
80
+ const calculateMaxHeight = useCallback(() => {
81
+ const vh = window.visualViewport?.height ?? window.innerHeight;
82
+ const domTop = dropdownRef.current?.getBoundingClientRect().top ?? 0;
83
+
84
+ const positionTop = domTop + POSITION_PADDING * 2;
85
+ const nextMax = vh - positionTop;
86
+
87
+ return nextMax;
88
+ }, []);
89
+
90
+ useEffect(() => {
91
+ if (!triggerRef?.current) return;
92
+
93
+ if (!isOpen) {
94
+ setMaxHeight(0);
95
+ setInnerPosition(undefined);
96
+ return;
97
+ }
98
+
99
+ const updatePosition = () => {
100
+ const nextPosition = calculatePosition({
101
+ triggerRef,
102
+ dropdownRef,
103
+ edge,
104
+ });
105
+ if (nextPosition) {
106
+ setInnerPosition(nextPosition);
107
+ }
108
+ const nextMax = calculateMaxHeight();
109
+ setMaxHeight(nextMax);
110
+ };
111
+
112
+ updatePosition(); // 최초 1회
113
+
114
+ window.addEventListener('resize', updatePosition);
115
+ window.addEventListener('scroll', updatePosition, { passive: true });
116
+
117
+ return () => {
118
+ window.removeEventListener('resize', updatePosition);
119
+ window.removeEventListener('scroll', updatePosition);
120
+ };
121
+ }, [calculateMaxHeight, edge, isOpen, triggerRef]);
122
+
123
+ usePreventScrollEffect({
124
+ shouldPrevent: preventScroll && isOpen,
125
+ });
126
+
127
+ useImperativeHandle(ref, () => ({
128
+ close: () => {
129
+ // @TODO: close
130
+ },
131
+ }));
132
+
133
+ const dropdownStyle = useMemo<MotionStyle>(() => {
134
+ if (edge === 'left') {
135
+ const value: MotionStyle = {
136
+ top: triggerRef?.current ? innerPosition?.top : undefined,
137
+ left: triggerRef?.current ? innerPosition?.left : undefined,
138
+ maxHeight: triggerRef?.current ? `${maxHeight}px` : undefined,
139
+ overflowY: 'scroll',
140
+ scrollbarWidth: 'none',
141
+ ...style,
142
+ };
143
+ return value;
144
+ }
145
+
146
+ const value: MotionStyle = {
147
+ top: triggerRef?.current ? innerPosition?.top : undefined,
148
+ right: triggerRef?.current ? innerPosition?.right : undefined,
149
+ maxHeight: triggerRef?.current ? `${maxHeight}px` : undefined,
150
+ overflowY: 'scroll',
151
+ scrollbarWidth: 'none',
152
+ ...style,
153
+ };
154
+ return value;
155
+ }, [
156
+ edge,
157
+ innerPosition?.left,
158
+ innerPosition?.right,
159
+ innerPosition?.top,
160
+ maxHeight,
161
+ style,
162
+ triggerRef,
163
+ ]);
164
+
165
+ return (
166
+ <AnimatePresence>
167
+ {isOpen && (
168
+ <>
169
+ {/* Backdrop */}
170
+ {backdrop && (
171
+ <motion.div
172
+ className="backdrop"
173
+ onClick={(e) => {
174
+ e.stopPropagation();
175
+ onClose();
176
+ }}
177
+ initial={animate ? { opacity: 0 } : undefined}
178
+ animate={animate ? { opacity: 0.5 } : undefined}
179
+ exit={animate ? { opacity: 0 } : undefined}
180
+ style={{
181
+ position: 'fixed',
182
+ top: 0,
183
+ left: 0,
184
+ width: '100%',
185
+ height: '100%',
186
+ backgroundColor: backdrop ? 'black' : 'transparent',
187
+ zIndex,
188
+ cursor: 'default',
189
+ }}
190
+ />
191
+ )}
192
+
193
+ {/* Dropdown */}
194
+ <DropdownMotionDiv
195
+ ref={dropdownRef}
196
+ className={className}
197
+ initial={animate ? 'hidden' : undefined}
198
+ animate={animate ? 'visible' : undefined}
199
+ exit={animate ? 'exit' : undefined}
200
+ variants={animate ? dropdownVariants : undefined}
201
+ style={dropdownStyle}
202
+ $zIndex={typeof zIndex === 'number' ? zIndex + 1 : undefined}
203
+ >
204
+ {/* Contents */}
205
+ {isLoading ? (
206
+ <StyledDropdownSpinnerItem>
207
+ <Spinner />
208
+ </StyledDropdownSpinnerItem>
209
+ ) : (
210
+ <StyledDropdownList>{children}</StyledDropdownList>
211
+ )}
212
+ </DropdownMotionDiv>
213
+ </>
214
+ )}
215
+ </AnimatePresence>
216
+ );
217
+ }
218
+ );
219
+
220
+ export const Dropdown = memo(DropdownComponent);
221
+
222
+ Dropdown.displayName = 'Dropdown.Core';
@@ -0,0 +1,3 @@
1
+ export type DropdownMenuItemRef = {
2
+ close: () => void;
3
+ };
@@ -0,0 +1,40 @@
1
+ import type { RefObject } from 'react';
2
+
3
+ const POSITION_PADDING = 8;
4
+
5
+ export function calculatePosition({
6
+ dropdownRef,
7
+ triggerRef,
8
+ edge,
9
+ }: {
10
+ dropdownRef: RefObject<HTMLElement>;
11
+ triggerRef: RefObject<HTMLElement>;
12
+ edge: 'left' | 'right';
13
+ }) {
14
+ if (!triggerRef?.current) return null;
15
+
16
+ const rect = triggerRef.current.getBoundingClientRect();
17
+
18
+ const left = rect.left + window.scrollX;
19
+ const selfWidth = dropdownRef.current?.getBoundingClientRect().width ?? 0;
20
+
21
+ if (edge === 'left') {
22
+ return {
23
+ top: rect.bottom + window.scrollY + POSITION_PADDING,
24
+ left:
25
+ left < 0
26
+ ? 0
27
+ : left > window.innerWidth - selfWidth
28
+ ? window.innerWidth - selfWidth - 10
29
+ : left,
30
+ };
31
+ }
32
+
33
+ const documentWidth = document.documentElement.getBoundingClientRect().width;
34
+ const right = documentWidth - rect.right;
35
+
36
+ return {
37
+ top: rect.bottom + window.scrollY + POSITION_PADDING,
38
+ right,
39
+ };
40
+ }
@@ -0,0 +1,14 @@
1
+ import { Dropdown as DropdownCore, type DropdownCoreProps } from './dropdown';
2
+ import { DropdownMenuItem } from './dropdown.menu-item';
3
+ import { DropdownResultItem } from './dropdown.result-item';
4
+ import { DropdownTrigger } from './dropdown.trigger';
5
+ import type { DropdownMenuItemRef } from './dropdown.types';
6
+
7
+ export const Dropdown = {
8
+ ResultItem: DropdownResultItem,
9
+ MenuItem: DropdownMenuItem,
10
+ Core: DropdownCore,
11
+ Trigger: DropdownTrigger,
12
+ };
13
+
14
+ export type { DropdownMenuItemRef, DropdownCoreProps };
@@ -0,0 +1,7 @@
1
+ import { NetworkError } from './network-error';
2
+ import { UnknownError } from './unknown-error';
3
+
4
+ export const ErrorUI = {
5
+ NetworkError,
6
+ UnknownError,
7
+ };
@@ -0,0 +1 @@
1
+ export * from './network-error';
@@ -0,0 +1,16 @@
1
+ import { Text } from '@/base';
2
+ import styled from '@emotion/styled';
3
+
4
+ export const StyledErrorContainer = styled.div`
5
+ height: 100vh;
6
+ display: flex;
7
+ flex-direction: column;
8
+ align-items: center;
9
+ justify-content: center;
10
+ `;
11
+
12
+ export const StyledErrorText = styled(Text)`
13
+ font-weight: bold;
14
+ margin: unset;
15
+ margin-bottom: 1rem;
16
+ `;