@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.
- package/dist/css/global.css +30 -0
- package/dist/index.d.ts +641 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +733 -0
- package/dist/index.js.map +1 -0
- package/dist/native.cjs +94 -0
- package/dist/native.cjs.map +1 -0
- package/dist/native.d.cts +304 -0
- package/dist/native.d.cts.map +1 -0
- package/dist/native.d.ts +304 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +94 -0
- package/dist/native.js.map +1 -0
- package/dist/next.cjs +949 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +270 -0
- package/dist/next.d.cts.map +1 -0
- package/dist/next.d.ts +270 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +949 -0
- package/dist/next.js.map +1 -0
- package/native/index.d.ts +7 -0
- package/next/index.d.ts +7 -0
- package/package.json +126 -0
- package/src/GlobalStyle.tsx +111 -0
- package/src/base/badge/badge.tsx +50 -0
- package/src/base/badge/index.ts +1 -0
- package/src/base/button/button.styled.tsx +123 -0
- package/src/base/button/button.tsx +60 -0
- package/src/base/button/button.types.ts +20 -0
- package/src/base/button/button.utils.ts +36 -0
- package/src/base/button/index.tsx +2 -0
- package/src/base/checkbox/checkbox.styled.ts +52 -0
- package/src/base/checkbox/checkbox.tsx +26 -0
- package/src/base/checkbox/index.ts +1 -0
- package/src/base/icon-button/icon-button.styled.ts +8 -0
- package/src/base/icon-button/icon-button.tsx +15 -0
- package/src/base/icon-button/icon-button.types.ts +3 -0
- package/src/base/icon-button/index.ts +2 -0
- package/src/base/index.ts +11 -0
- package/src/base/label/index.ts +1 -0
- package/src/base/label/label.styled.ts +7 -0
- package/src/base/label/label.tsx +27 -0
- package/src/base/modal/index.ts +1 -0
- package/src/base/modal/modal.tsx +59 -0
- package/src/base/spinner/index.ts +2 -0
- package/src/base/spinner/spinner.styled.ts +25 -0
- package/src/base/spinner/spinner.tsx +36 -0
- package/src/base/spinner/spinner.types.ts +1 -0
- package/src/base/switch/index.ts +1 -0
- package/src/base/switch/switch.styled.tsx +49 -0
- package/src/base/switch/switch.tsx +29 -0
- package/src/base/text/index.ts +1 -0
- package/src/base/text/text.styled.ts +17 -0
- package/src/base/text/text.tsx +37 -0
- package/src/base/text-area/index.ts +2 -0
- package/src/base/text-area/text-area.styled.ts +16 -0
- package/src/base/text-area/text-area.tsx +29 -0
- package/src/base/text-area/text-area.types.ts +11 -0
- package/src/base/text-input/index.ts +2 -0
- package/src/base/text-input/text-input.styled.ts +40 -0
- package/src/base/text-input/text-input.tsx +59 -0
- package/src/base/text-input/text-input.types.ts +15 -0
- package/src/base/toast/index.ts +2 -0
- package/src/base/toast/toast.tsx +60 -0
- package/src/base/toast/toast.types.ts +5 -0
- package/src/constants.ts +1 -0
- package/src/contexts/ColorSchemeProvider.tsx +154 -0
- package/src/css/global.css +30 -0
- package/src/extensions/accordion/accordion.hooks.ts +11 -0
- package/src/extensions/accordion/accordion.tsx +80 -0
- package/src/extensions/accordion/index.ts +1 -0
- package/src/extensions/app-header/app-header.hooks.ts +94 -0
- package/src/extensions/app-header/app-header.tsx +31 -0
- package/src/extensions/app-header/app-header.types.ts +1 -0
- package/src/extensions/app-header/index.ts +8 -0
- package/src/extensions/app-logo/app-logo.tsx +40 -0
- package/src/extensions/app-logo/index.ts +1 -0
- package/src/extensions/app-store-button/app-store-button.tsx +64 -0
- package/src/extensions/app-store-button/index.ts +1 -0
- package/src/extensions/brand-icon/brand-icon.android.tsx +11 -0
- package/src/extensions/brand-icon/brand-icon.apple.tsx +11 -0
- package/src/extensions/brand-icon/brand-icon.google.tsx +11 -0
- package/src/extensions/brand-icon/brand-icon.tsx +22 -0
- package/src/extensions/brand-icon/index.ts +1 -0
- package/src/extensions/color-scheme-toggle/color-scheme-toggle.tsx +76 -0
- package/src/extensions/color-scheme-toggle/index.ts +1 -0
- package/src/extensions/dropdown/dropdown.menu-item.tsx +237 -0
- package/src/extensions/dropdown/dropdown.result-item.tsx +26 -0
- package/src/extensions/dropdown/dropdown.styled.tsx +48 -0
- package/src/extensions/dropdown/dropdown.trigger.tsx +72 -0
- package/src/extensions/dropdown/dropdown.tsx +222 -0
- package/src/extensions/dropdown/dropdown.types.ts +3 -0
- package/src/extensions/dropdown/dropdown.utils.ts +40 -0
- package/src/extensions/dropdown/index.ts +14 -0
- package/src/extensions/error-ui/index.ts +7 -0
- package/src/extensions/error-ui/network-error/index.ts +1 -0
- package/src/extensions/error-ui/network-error/network-error.styled.ts +16 -0
- package/src/extensions/error-ui/network-error/network-error.tsx +14 -0
- package/src/extensions/error-ui/unknown-error/index.ts +1 -0
- package/src/extensions/error-ui/unknown-error/unknown-error.styled.ts +16 -0
- package/src/extensions/error-ui/unknown-error/unknown-error.tsx +14 -0
- package/src/extensions/full-screen-modal/full-screen-modal.tsx +52 -0
- package/src/extensions/full-screen-modal/index.ts +1 -0
- package/src/extensions/grid-card-image/grid-card-image.tsx +11 -0
- package/src/extensions/grid-card-image/index.ts +1 -0
- package/src/extensions/grid-card-image-empty/grid-card-image-empty.tsx +11 -0
- package/src/extensions/grid-card-image-empty/index.ts +1 -0
- package/src/extensions/grid-card-item/grid-card-item.masonry.styled.tsx +95 -0
- package/src/extensions/grid-card-item/grid-card-item.masonry.tsx +63 -0
- package/src/extensions/grid-card-item/grid-card-item.styled.tsx +93 -0
- package/src/extensions/grid-card-item/grid-card-item.subscribe-btn-layout.tsx +30 -0
- package/src/extensions/grid-card-item/grid-card-item.tsx +81 -0
- package/src/extensions/grid-card-item/index.ts +2 -0
- package/src/extensions/grid-card-list/grid-card-list.masonry.styled.tsx +45 -0
- package/src/extensions/grid-card-list/grid-card-list.masonry.tsx +58 -0
- package/src/extensions/grid-card-list/grid-card-list.styled.tsx +40 -0
- package/src/extensions/grid-card-list/grid-card-list.tsx +59 -0
- package/src/extensions/grid-card-list/index.ts +2 -0
- package/src/extensions/grid-card-list-empty/grid-card-list-empty.tsx +38 -0
- package/src/extensions/grid-card-list-empty/index.ts +1 -0
- package/src/extensions/grid-card-list-load-more/grid-card-list-load-more.styled.tsx +15 -0
- package/src/extensions/grid-card-list-load-more/grid-card-list-load-more.tsx +43 -0
- package/src/extensions/grid-card-list-load-more/index.ts +1 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/menu-item/index.ts +1 -0
- package/src/extensions/menu-item/menu-item.tsx +87 -0
- package/src/extensions/sns-icon/index.ts +1 -0
- package/src/extensions/sns-icon/sns-icon.facebook.tsx +11 -0
- package/src/extensions/sns-icon/sns-icon.instagram.tsx +11 -0
- package/src/extensions/sns-icon/sns-icon.tsx +24 -0
- package/src/extensions/sns-icon/sns-icon.x.tsx +11 -0
- package/src/extensions/sns-icon/sns-icon.youtube.tsx +11 -0
- package/src/index.ts +8 -0
- package/src/native/button/button.styled.tsx +99 -0
- package/src/native/button/button.tsx +42 -0
- package/src/native/button/index.ts +1 -0
- package/src/native/contexts/color-scheme-context/color-scheme-context.tsx +45 -0
- package/src/native/contexts/color-scheme-context/index.ts +1 -0
- package/src/native/contexts/index.ts +1 -0
- package/src/native/icon-button/icon-button.styled.ts +6 -0
- package/src/native/icon-button/icon-button.tsx +33 -0
- package/src/native/icon-button/icon-button.types.ts +14 -0
- package/src/native/icon-button/icon-button.utils.ts +114 -0
- package/src/native/icon-button/index.ts +1 -0
- package/src/native/index.ts +9 -0
- package/src/native/modal/index.ts +2 -0
- package/src/native/modal/modal.styled.ts +17 -0
- package/src/native/modal/modal.tsx +21 -0
- package/src/native/modal/modal.types.ts +8 -0
- package/src/native/profile-thumbnail/index.ts +1 -0
- package/src/native/profile-thumbnail/profile-thumbnail.tsx +91 -0
- package/src/native/spinner/index.ts +1 -0
- package/src/native/spinner/spinner.tsx +75 -0
- package/src/native/text/index.ts +2 -0
- package/src/native/text/text.tsx +51 -0
- package/src/native/text/text.types.ts +5 -0
- package/src/native/text-input/index.ts +2 -0
- package/src/native/text-input/text-input.tsx +72 -0
- package/src/native/text-input/text-input.types.ts +3 -0
- package/src/native/toast/index.ts +2 -0
- package/src/native/toast/toast.styled.ts +40 -0
- package/src/native/toast/toast.tsx +23 -0
- package/src/native/toast/toast.types.ts +10 -0
- package/src/next/app-footer/app-footer.tsx +250 -0
- package/src/next/app-footer/index.ts +1 -0
- package/src/next/app-header/app-header.fixed-header.tsx +83 -0
- package/src/next/app-header/app-header.full-screen-mobile-accordion-drawer.tsx +131 -0
- package/src/next/app-header/app-header.logo.tsx +50 -0
- package/src/next/app-header/app-header.modal-mobile-accordion-drawer.tsx +69 -0
- package/src/next/app-header/app-header.styled.ts +160 -0
- package/src/next/app-header/app-header.tsx +91 -0
- package/src/next/app-header/index.ts +13 -0
- package/src/next/global-link/global-link.store.ts +41 -0
- package/src/next/global-link/global-link.tsx +52 -0
- package/src/next/global-link/global-link.utils.ts +9 -0
- package/src/next/global-link/index.ts +3 -0
- package/src/next/grid-card-item/grid-card-item.masonry.tsx +23 -0
- package/src/next/grid-card-item/grid-card-item.tsx +23 -0
- package/src/next/grid-card-item/index.ts +2 -0
- package/src/next/index.ts +16 -0
- package/src/next/new-tab-link/index.ts +1 -0
- package/src/next/new-tab-link/new-tab-link.tsx +15 -0
- package/src/next/route-loading/index.ts +1 -0
- package/src/next/route-loading/route-loading.tsx +21 -0
- package/src/tokens/index.ts +2 -0
- package/src/tokens/tokens.ts +8 -0
- package/src/tokens/tokens.types.ts +7 -0
- package/src/utils/breakpoints.ts +9 -0
- package/src/utils/common-styles.ts +23 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/media.ts +23 -0
- package/src/utils/use-prevent-scroll-effect.ts +19 -0
- package/src/utils/with-id.ts +3 -0
- 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,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 @@
|
|
|
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
|
+
`;
|