@skalfa/skalfa-component 1.0.50 → 1.0.51
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.
|
@@ -26,6 +26,21 @@ function sizeToPx(value) {
|
|
|
26
26
|
}
|
|
27
27
|
return parseFloat(v) || 0;
|
|
28
28
|
}
|
|
29
|
+
function findScrollableParent(target, stopAt) {
|
|
30
|
+
if (typeof window === "undefined")
|
|
31
|
+
return null;
|
|
32
|
+
let el = target;
|
|
33
|
+
while (el && el !== stopAt && el !== document.body) {
|
|
34
|
+
const style = window.getComputedStyle(el);
|
|
35
|
+
const overflowY = style.overflowY;
|
|
36
|
+
const canScroll = (overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight;
|
|
37
|
+
if (canScroll) {
|
|
38
|
+
return el;
|
|
39
|
+
}
|
|
40
|
+
el = el.parentElement;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
29
44
|
const BottomSheet = ({ show, children, onClose, size = 500, maxSize, footer, className = "", }) => {
|
|
30
45
|
const scrollRef = (0, react_1.useRef)(null);
|
|
31
46
|
const sheetRef = (0, react_1.useRef)(null);
|
|
@@ -34,104 +49,111 @@ const BottomSheet = ({ show, children, onClose, size = 500, maxSize, footer, cla
|
|
|
34
49
|
const dragging = (0, react_1.useRef)(false);
|
|
35
50
|
const [offset, setOffset] = (0, react_1.useState)(0);
|
|
36
51
|
const [isExpanded, setIsExpanded] = (0, react_1.useState)(false);
|
|
37
|
-
const [
|
|
38
|
-
const
|
|
39
|
-
const
|
|
52
|
+
const [isDragging, setIsDragging] = (0, react_1.useState)(false);
|
|
53
|
+
const isClosing = (0, react_1.useRef)(false);
|
|
54
|
+
const canExpand = Boolean(maxSize) && sizeToPx(maxSize) > sizeToPx(size);
|
|
55
|
+
const realMaxSize = canExpand && (typeof maxSize === "number" || typeof maxSize === "string") ? maxSize : size;
|
|
56
|
+
const canDragUp = canExpand && !isExpanded;
|
|
40
57
|
const clamp = (v) => {
|
|
41
58
|
const max = window.innerHeight;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const animateTo = (target, onFinish) => {
|
|
45
|
-
lastY.current = target;
|
|
46
|
-
setOffset(clamp(target));
|
|
47
|
-
onFinish?.();
|
|
59
|
+
const min = canDragUp ? -200 : 0;
|
|
60
|
+
return Math.max(min, Math.min(v, max));
|
|
48
61
|
};
|
|
49
62
|
(0, react_1.useEffect)(() => {
|
|
50
63
|
if (show) {
|
|
51
64
|
lastY.current = 0;
|
|
52
65
|
setOffset(0);
|
|
53
66
|
setIsExpanded(false);
|
|
67
|
+
isClosing.current = false;
|
|
54
68
|
}
|
|
55
69
|
else {
|
|
56
70
|
const t = setTimeout(() => {
|
|
57
71
|
lastY.current = 0;
|
|
58
72
|
setOffset(0);
|
|
59
73
|
setIsExpanded(false);
|
|
60
|
-
},
|
|
74
|
+
}, 300);
|
|
61
75
|
return () => clearTimeout(t);
|
|
62
76
|
}
|
|
63
77
|
}, [show]);
|
|
64
|
-
const onStart = (clientY) => {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
const onStart = (clientY, target) => {
|
|
79
|
+
const targetEl = target;
|
|
80
|
+
// Abaikan drag jika menekan elemen interaktif seperti button, input, link
|
|
81
|
+
if (targetEl?.closest("button, input, textarea, select, a, [role='button']")) {
|
|
82
|
+
dragging.current = false;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Abaikan drag jika sentuhan berada di dalam elemen yang sedang bisa di-scroll
|
|
86
|
+
const scrollableParent = findScrollableParent(targetEl, sheetRef.current);
|
|
87
|
+
if (scrollableParent) {
|
|
88
|
+
dragging.current = false;
|
|
68
89
|
return;
|
|
69
90
|
}
|
|
70
|
-
setScrollLocked(false);
|
|
71
91
|
dragging.current = true;
|
|
72
92
|
startY.current = clientY;
|
|
73
93
|
lastY.current = offset;
|
|
94
|
+
setIsDragging(true);
|
|
74
95
|
};
|
|
75
96
|
const onMove = (clientY) => {
|
|
76
|
-
if (scrollLocked)
|
|
77
|
-
return;
|
|
78
97
|
if (!dragging.current)
|
|
79
98
|
return;
|
|
80
99
|
const diff = clientY - startY.current;
|
|
81
100
|
setOffset(clamp(lastY.current + diff));
|
|
82
101
|
};
|
|
83
102
|
const onEnd = () => {
|
|
84
|
-
if (scrollLocked) {
|
|
85
|
-
setScrollLocked(false);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
103
|
if (!dragging.current)
|
|
89
104
|
return;
|
|
90
105
|
dragging.current = false;
|
|
106
|
+
setIsDragging(false);
|
|
91
107
|
const current = offset;
|
|
92
108
|
const thresholdDown = 120;
|
|
93
109
|
const thresholdUp = -40;
|
|
94
|
-
|
|
110
|
+
// 1. Expand ke maxSize jika di-drag ke atas melebihi threshold
|
|
111
|
+
if (!isExpanded && current < thresholdUp && canExpand) {
|
|
95
112
|
setIsExpanded(true);
|
|
96
|
-
|
|
113
|
+
setOffset(0);
|
|
114
|
+
lastY.current = 0;
|
|
97
115
|
return;
|
|
98
116
|
}
|
|
117
|
+
// 2. Collapse kembali ke size normal jika sedang expanded dan di-drag ke bawah
|
|
99
118
|
if (isExpanded && current > thresholdDown) {
|
|
100
119
|
setIsExpanded(false);
|
|
101
|
-
|
|
120
|
+
setOffset(0);
|
|
121
|
+
lastY.current = 0;
|
|
102
122
|
return;
|
|
103
123
|
}
|
|
124
|
+
// 3. Menutup bottom sheet jika di-drag ke bawah melebihi threshold
|
|
104
125
|
if (!isExpanded && current > thresholdDown) {
|
|
105
|
-
|
|
126
|
+
if (isClosing.current)
|
|
127
|
+
return;
|
|
128
|
+
isClosing.current = true;
|
|
129
|
+
setOffset(window.innerHeight);
|
|
130
|
+
setTimeout(() => {
|
|
106
131
|
onClose();
|
|
107
132
|
lastY.current = 0;
|
|
108
133
|
setOffset(0);
|
|
109
|
-
|
|
134
|
+
isClosing.current = false;
|
|
135
|
+
}, 300);
|
|
110
136
|
return;
|
|
111
137
|
}
|
|
112
|
-
|
|
138
|
+
// 4. Kembali ke posisi 0 (bouncing back smoothly)
|
|
139
|
+
setOffset(0);
|
|
140
|
+
lastY.current = 0;
|
|
113
141
|
};
|
|
114
142
|
const collapsedPx = sizeToPx(size);
|
|
115
143
|
const expandedPx = sizeToPx(realMaxSize);
|
|
116
144
|
const topPx = isExpanded ? window.innerHeight - expandedPx : window.innerHeight - collapsedPx;
|
|
117
145
|
const bindTouch = {
|
|
118
|
-
onTouchStart: (e) => onStart(e.touches[0].clientY),
|
|
146
|
+
onTouchStart: (e) => onStart(e.touches[0].clientY, e.target),
|
|
119
147
|
onTouchMove: (e) => onMove(e.touches[0].clientY),
|
|
120
148
|
onTouchEnd: () => onEnd(),
|
|
149
|
+
onTouchCancel: () => onEnd(),
|
|
121
150
|
};
|
|
122
151
|
const bindMouse = {
|
|
123
|
-
onMouseDown: (e) => onStart(e.clientY),
|
|
152
|
+
onMouseDown: (e) => onStart(e.clientY, e.target),
|
|
124
153
|
onMouseMove: (e) => dragging.current && onMove(e.clientY),
|
|
125
154
|
onMouseUp: () => onEnd(),
|
|
126
155
|
onMouseLeave: () => onEnd(),
|
|
127
156
|
};
|
|
128
|
-
(0, react_1.useEffect)(() => {
|
|
129
|
-
const sc = scrollRef.current;
|
|
130
|
-
if (!sc)
|
|
131
|
-
return;
|
|
132
|
-
const canScroll = sc.scrollHeight > sc.clientHeight;
|
|
133
|
-
setContentScrollable(canScroll);
|
|
134
|
-
}, [show, size, maxSize]);
|
|
135
157
|
(0, react_1.useEffect)(() => {
|
|
136
158
|
if (show) {
|
|
137
159
|
history.pushState({ bottomsheet: true }, "");
|
|
@@ -148,13 +170,19 @@ const BottomSheet = ({ show, children, onClose, size = 500, maxSize, footer, cla
|
|
|
148
170
|
window.addEventListener("popstate", onPopState);
|
|
149
171
|
return () => window.removeEventListener("popstate", onPopState);
|
|
150
172
|
}, [show, onClose]);
|
|
151
|
-
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { className: (0, _utils_1.cn)("modal-backdrop", !show && "translate-y-full", (0, _utils_1.pcn)(className, "backdrop")), onClick: onClose }), (0, jsx_runtime_1.jsx)("div", { ref: sheetRef, className: "bottom-sheet", style: {
|
|
173
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { className: (0, _utils_1.cn)("modal-backdrop transition-opacity duration-300", !show && "opacity-0 pointer-events-none translate-y-full", (0, _utils_1.pcn)(className, "backdrop")), onClick: onClose }), (0, jsx_runtime_1.jsx)("div", { ref: sheetRef, className: "bottom-sheet", style: {
|
|
152
174
|
top: show ? `${topPx}px` : "150vh",
|
|
153
175
|
transform: `translateY(${offset}px)`,
|
|
154
|
-
|
|
155
|
-
|
|
176
|
+
transition: isDragging
|
|
177
|
+
? "none"
|
|
178
|
+
: "top 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
179
|
+
touchAction: "pan-y",
|
|
180
|
+
}, ...bindTouch, ...bindMouse, children: (0, jsx_runtime_1.jsxs)("div", { className: "bottom-sheet-container", children: [(0, jsx_runtime_1.jsx)("div", { className: "bottom-sheet-handle-wrapper", style: { touchAction: "none" }, children: (0, jsx_runtime_1.jsx)("div", { className: "bottom-sheet-handle" }) }), (0, jsx_runtime_1.jsx)("div", { ref: scrollRef, className: "overflow-y-auto", style: {
|
|
156
181
|
height: isExpanded ? realMaxSize : size,
|
|
157
|
-
|
|
182
|
+
transition: isDragging
|
|
183
|
+
? "none"
|
|
184
|
+
: "height 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
185
|
+
touchAction: "pan-y",
|
|
158
186
|
overscrollBehaviorY: "contain",
|
|
159
187
|
}, children: children })] }) }), show && footer && ((0, jsx_runtime_1.jsx)("div", { className: "bottom-sheet-footer", children: footer }))] }));
|
|
160
188
|
};
|
package/package.json
CHANGED
|
@@ -36,6 +36,21 @@ function sizeToPx(value: string | number | undefined): number {
|
|
|
36
36
|
return parseFloat(v) || 0;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function findScrollableParent(target: HTMLElement | null, stopAt: HTMLElement | null): HTMLElement | null {
|
|
40
|
+
if (typeof window === "undefined") return null;
|
|
41
|
+
let el = target;
|
|
42
|
+
while (el && el !== stopAt && el !== document.body) {
|
|
43
|
+
const style = window.getComputedStyle(el);
|
|
44
|
+
const overflowY = style.overflowY;
|
|
45
|
+
const canScroll = (overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight;
|
|
46
|
+
if (canScroll) {
|
|
47
|
+
return el;
|
|
48
|
+
}
|
|
49
|
+
el = el.parentElement;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
const BottomSheet = ({
|
|
40
55
|
show,
|
|
41
56
|
children,
|
|
@@ -52,22 +67,19 @@ const BottomSheet = ({
|
|
|
52
67
|
const lastY = useRef(0);
|
|
53
68
|
const dragging = useRef(false);
|
|
54
69
|
|
|
55
|
-
const [offset, setOffset]
|
|
56
|
-
const [isExpanded, setIsExpanded]
|
|
57
|
-
const [
|
|
58
|
-
const
|
|
70
|
+
const [offset, setOffset] = useState(0);
|
|
71
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
72
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
73
|
+
const isClosing = useRef(false);
|
|
59
74
|
|
|
60
|
-
const
|
|
75
|
+
const canExpand = Boolean(maxSize) && sizeToPx(maxSize) > sizeToPx(size);
|
|
76
|
+
const realMaxSize = canExpand && (typeof maxSize === "number" || typeof maxSize === "string") ? maxSize : size;
|
|
77
|
+
const canDragUp = canExpand && !isExpanded;
|
|
61
78
|
|
|
62
79
|
const clamp = (v: number) => {
|
|
63
80
|
const max = window.innerHeight;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const animateTo = (target: number, onFinish?: () => void) => {
|
|
68
|
-
lastY.current = target;
|
|
69
|
-
setOffset(clamp(target));
|
|
70
|
-
onFinish?.();
|
|
81
|
+
const min = canDragUp ? -200 : 0;
|
|
82
|
+
return Math.max(min, Math.min(v, max));
|
|
71
83
|
};
|
|
72
84
|
|
|
73
85
|
useEffect(() => {
|
|
@@ -75,33 +87,40 @@ const BottomSheet = ({
|
|
|
75
87
|
lastY.current = 0;
|
|
76
88
|
setOffset(0);
|
|
77
89
|
setIsExpanded(false);
|
|
90
|
+
isClosing.current = false;
|
|
78
91
|
} else {
|
|
79
92
|
const t = setTimeout(() => {
|
|
80
93
|
lastY.current = 0;
|
|
81
94
|
setOffset(0);
|
|
82
95
|
setIsExpanded(false);
|
|
83
|
-
},
|
|
96
|
+
}, 300);
|
|
84
97
|
return () => clearTimeout(t);
|
|
85
98
|
}
|
|
86
99
|
}, [show]);
|
|
87
100
|
|
|
88
|
-
const onStart = (clientY: number) => {
|
|
89
|
-
const
|
|
101
|
+
const onStart = (clientY: number, target: EventTarget | null) => {
|
|
102
|
+
const targetEl = target as HTMLElement | null;
|
|
90
103
|
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
// Abaikan drag jika menekan elemen interaktif seperti button, input, link
|
|
105
|
+
if (targetEl?.closest("button, input, textarea, select, a, [role='button']")) {
|
|
106
|
+
dragging.current = false;
|
|
93
107
|
return;
|
|
94
108
|
}
|
|
95
109
|
|
|
96
|
-
|
|
110
|
+
// Abaikan drag jika sentuhan berada di dalam elemen yang sedang bisa di-scroll
|
|
111
|
+
const scrollableParent = findScrollableParent(targetEl, sheetRef.current);
|
|
112
|
+
if (scrollableParent) {
|
|
113
|
+
dragging.current = false;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
97
116
|
|
|
98
117
|
dragging.current = true;
|
|
99
118
|
startY.current = clientY;
|
|
100
119
|
lastY.current = offset;
|
|
120
|
+
setIsDragging(true);
|
|
101
121
|
};
|
|
102
122
|
|
|
103
123
|
const onMove = (clientY: number) => {
|
|
104
|
-
if (scrollLocked) return;
|
|
105
124
|
if (!dragging.current) return;
|
|
106
125
|
|
|
107
126
|
const diff = clientY - startY.current;
|
|
@@ -109,41 +128,48 @@ const BottomSheet = ({
|
|
|
109
128
|
};
|
|
110
129
|
|
|
111
130
|
const onEnd = () => {
|
|
112
|
-
if (scrollLocked) {
|
|
113
|
-
setScrollLocked(false);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
131
|
if (!dragging.current) return;
|
|
118
132
|
dragging.current = false;
|
|
133
|
+
setIsDragging(false);
|
|
119
134
|
|
|
120
135
|
const current = offset;
|
|
121
136
|
|
|
122
137
|
const thresholdDown = 120;
|
|
123
138
|
const thresholdUp = -40;
|
|
124
139
|
|
|
125
|
-
|
|
140
|
+
// 1. Expand ke maxSize jika di-drag ke atas melebihi threshold
|
|
141
|
+
if (!isExpanded && current < thresholdUp && canExpand) {
|
|
126
142
|
setIsExpanded(true);
|
|
127
|
-
|
|
143
|
+
setOffset(0);
|
|
144
|
+
lastY.current = 0;
|
|
128
145
|
return;
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
// 2. Collapse kembali ke size normal jika sedang expanded dan di-drag ke bawah
|
|
131
149
|
if (isExpanded && current > thresholdDown) {
|
|
132
150
|
setIsExpanded(false);
|
|
133
|
-
|
|
151
|
+
setOffset(0);
|
|
152
|
+
lastY.current = 0;
|
|
134
153
|
return;
|
|
135
154
|
}
|
|
136
155
|
|
|
156
|
+
// 3. Menutup bottom sheet jika di-drag ke bawah melebihi threshold
|
|
137
157
|
if (!isExpanded && current > thresholdDown) {
|
|
138
|
-
|
|
158
|
+
if (isClosing.current) return;
|
|
159
|
+
isClosing.current = true;
|
|
160
|
+
setOffset(window.innerHeight);
|
|
161
|
+
setTimeout(() => {
|
|
139
162
|
onClose();
|
|
140
163
|
lastY.current = 0;
|
|
141
164
|
setOffset(0);
|
|
142
|
-
|
|
165
|
+
isClosing.current = false;
|
|
166
|
+
}, 300);
|
|
143
167
|
return;
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
|
|
170
|
+
// 4. Kembali ke posisi 0 (bouncing back smoothly)
|
|
171
|
+
setOffset(0);
|
|
172
|
+
lastY.current = 0;
|
|
147
173
|
};
|
|
148
174
|
|
|
149
175
|
const collapsedPx = sizeToPx(size);
|
|
@@ -152,26 +178,19 @@ const BottomSheet = ({
|
|
|
152
178
|
const topPx = isExpanded ? window.innerHeight - expandedPx : window.innerHeight - collapsedPx;
|
|
153
179
|
|
|
154
180
|
const bindTouch = {
|
|
155
|
-
onTouchStart : (e: TouchEvent) => onStart(e.touches[0].clientY),
|
|
181
|
+
onTouchStart : (e: TouchEvent) => onStart(e.touches[0].clientY, e.target),
|
|
156
182
|
onTouchMove : (e: TouchEvent) => onMove(e.touches[0].clientY),
|
|
157
|
-
onTouchEnd :
|
|
183
|
+
onTouchEnd : () => onEnd(),
|
|
184
|
+
onTouchCancel: () => onEnd(),
|
|
158
185
|
};
|
|
159
186
|
|
|
160
187
|
const bindMouse = {
|
|
161
|
-
onMouseDown :
|
|
162
|
-
onMouseMove :
|
|
163
|
-
onMouseUp :
|
|
164
|
-
onMouseLeave :
|
|
188
|
+
onMouseDown : (e: MouseEvent) => onStart(e.clientY, e.target),
|
|
189
|
+
onMouseMove : (e: MouseEvent) => dragging.current && onMove(e.clientY),
|
|
190
|
+
onMouseUp : () => onEnd(),
|
|
191
|
+
onMouseLeave : () => onEnd(),
|
|
165
192
|
};
|
|
166
193
|
|
|
167
|
-
useEffect(() => {
|
|
168
|
-
const sc = scrollRef.current;
|
|
169
|
-
if (!sc) return;
|
|
170
|
-
|
|
171
|
-
const canScroll = sc.scrollHeight > sc.clientHeight;
|
|
172
|
-
setContentScrollable(canScroll);
|
|
173
|
-
}, [show, size, maxSize]);
|
|
174
|
-
|
|
175
194
|
useEffect(() => {
|
|
176
195
|
if (show) {
|
|
177
196
|
history.pushState({ bottomsheet: true }, "");
|
|
@@ -196,8 +215,8 @@ const BottomSheet = ({
|
|
|
196
215
|
<>
|
|
197
216
|
<div
|
|
198
217
|
className={cn(
|
|
199
|
-
"modal-backdrop",
|
|
200
|
-
!show && "translate-y-full",
|
|
218
|
+
"modal-backdrop transition-opacity duration-300",
|
|
219
|
+
!show && "opacity-0 pointer-events-none translate-y-full",
|
|
201
220
|
pcn<CT>(className, "backdrop"),
|
|
202
221
|
)}
|
|
203
222
|
onClick={onClose}
|
|
@@ -209,13 +228,19 @@ const BottomSheet = ({
|
|
|
209
228
|
style={{
|
|
210
229
|
top: show ? `${topPx}px` : "150vh",
|
|
211
230
|
transform: `translateY(${offset}px)`,
|
|
212
|
-
|
|
231
|
+
transition: isDragging
|
|
232
|
+
? "none"
|
|
233
|
+
: "top 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
234
|
+
touchAction: "pan-y",
|
|
213
235
|
}}
|
|
214
236
|
{...bindTouch}
|
|
215
237
|
{...bindMouse}
|
|
216
238
|
>
|
|
217
239
|
<div className="bottom-sheet-container">
|
|
218
|
-
<div
|
|
240
|
+
<div
|
|
241
|
+
className="bottom-sheet-handle-wrapper"
|
|
242
|
+
style={{ touchAction: "none" }}
|
|
243
|
+
>
|
|
219
244
|
<div className="bottom-sheet-handle" />
|
|
220
245
|
</div>
|
|
221
246
|
|
|
@@ -224,7 +249,10 @@ const BottomSheet = ({
|
|
|
224
249
|
className="overflow-y-auto"
|
|
225
250
|
style={{
|
|
226
251
|
height: isExpanded ? realMaxSize : size,
|
|
227
|
-
|
|
252
|
+
transition: isDragging
|
|
253
|
+
? "none"
|
|
254
|
+
: "height 300ms cubic-bezier(0.16, 1, 0.3, 1)",
|
|
255
|
+
touchAction: "pan-y",
|
|
228
256
|
overscrollBehaviorY: "contain",
|
|
229
257
|
}}
|
|
230
258
|
>
|