@idealyst/components 1.2.123 → 1.2.124
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/package.json +4 -4
- package/src/ScrollView/ScrollView.native.tsx +172 -0
- package/src/ScrollView/ScrollView.styles.tsx +104 -0
- package/src/ScrollView/ScrollView.web.tsx +242 -0
- package/src/ScrollView/docs.ts +9 -0
- package/src/ScrollView/index.native.ts +2 -0
- package/src/ScrollView/index.ts +5 -0
- package/src/ScrollView/index.web.ts +5 -0
- package/src/ScrollView/types.ts +169 -0
- package/src/TabBar/TabBar.styles.tsx +21 -17
- package/src/View/types.ts +4 -0
- package/src/index.native.ts +4 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idealyst/components",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.124",
|
|
4
4
|
"description": "Shared component library for React and React Native",
|
|
5
5
|
"documentation": "https://github.com/IdealystIO/idealyst-framework/tree/main/packages/components#readme",
|
|
6
6
|
"readme": "README.md",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"publish:npm": "npm publish"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@idealyst/theme": "^1.2.
|
|
59
|
+
"@idealyst/theme": "^1.2.124",
|
|
60
60
|
"@mdi/js": ">=7.0.0",
|
|
61
61
|
"@mdi/react": ">=1.0.0",
|
|
62
62
|
"@react-native-vector-icons/common": ">=12.0.0",
|
|
@@ -111,8 +111,8 @@
|
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
113
113
|
"@idealyst/blur": "^1.2.40",
|
|
114
|
-
"@idealyst/theme": "^1.2.
|
|
115
|
-
"@idealyst/tooling": "^1.2.
|
|
114
|
+
"@idealyst/theme": "^1.2.124",
|
|
115
|
+
"@idealyst/tooling": "^1.2.124",
|
|
116
116
|
"@mdi/react": "^1.6.1",
|
|
117
117
|
"@types/react": "^19.1.0",
|
|
118
118
|
"react": "^19.1.0",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ScrollView as RNScrollView,
|
|
4
|
+
NativeScrollEvent,
|
|
5
|
+
NativeSyntheticEvent,
|
|
6
|
+
ViewStyle,
|
|
7
|
+
} from 'react-native';
|
|
8
|
+
import { scrollViewStyles } from './ScrollView.styles';
|
|
9
|
+
import type { ScrollViewProps, ScrollViewRef, ScrollEvent } from './types';
|
|
10
|
+
import type { IdealystElement } from '../utils/refTypes';
|
|
11
|
+
|
|
12
|
+
function buildScrollEvent(e: NativeScrollEvent): ScrollEvent {
|
|
13
|
+
const { contentOffset, contentSize, layoutMeasurement } = e;
|
|
14
|
+
const isVerticalEnd =
|
|
15
|
+
contentOffset.y + layoutMeasurement.height >= contentSize.height - 1;
|
|
16
|
+
const isHorizontalEnd =
|
|
17
|
+
contentOffset.x + layoutMeasurement.width >= contentSize.width - 1;
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
position: { x: contentOffset.x, y: contentOffset.y },
|
|
21
|
+
contentSize: { width: contentSize.width, height: contentSize.height },
|
|
22
|
+
layoutSize: { width: layoutMeasurement.width, height: layoutMeasurement.height },
|
|
23
|
+
isAtEnd: isVerticalEnd || isHorizontalEnd,
|
|
24
|
+
isAtStart: contentOffset.x <= 0 && contentOffset.y <= 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ScrollView = forwardRef<ScrollViewRef, ScrollViewProps>(({
|
|
29
|
+
children,
|
|
30
|
+
direction = 'vertical',
|
|
31
|
+
background = 'transparent',
|
|
32
|
+
radius = 'none',
|
|
33
|
+
border = 'none',
|
|
34
|
+
gap,
|
|
35
|
+
padding,
|
|
36
|
+
paddingVertical,
|
|
37
|
+
paddingHorizontal,
|
|
38
|
+
margin,
|
|
39
|
+
marginVertical,
|
|
40
|
+
marginHorizontal,
|
|
41
|
+
backgroundColor,
|
|
42
|
+
borderRadius,
|
|
43
|
+
borderWidth,
|
|
44
|
+
borderColor,
|
|
45
|
+
style,
|
|
46
|
+
contentContainerStyle,
|
|
47
|
+
showsIndicator = true,
|
|
48
|
+
pagingEnabled = false,
|
|
49
|
+
bounces = true,
|
|
50
|
+
onScroll,
|
|
51
|
+
onScrollBegin,
|
|
52
|
+
onScrollEnd,
|
|
53
|
+
onEndReached,
|
|
54
|
+
onEndReachedThreshold = 0,
|
|
55
|
+
scrollEventThrottle = 16,
|
|
56
|
+
scrollEnabled = true,
|
|
57
|
+
keyboardDismissMode = 'none',
|
|
58
|
+
testID,
|
|
59
|
+
id,
|
|
60
|
+
onLayout,
|
|
61
|
+
}, ref) => {
|
|
62
|
+
const scrollRef = useRef<RNScrollView>(null);
|
|
63
|
+
const lastPositionRef = useRef({ x: 0, y: 0 });
|
|
64
|
+
const endReachedRef = useRef(false);
|
|
65
|
+
|
|
66
|
+
const backgroundVariant = background === 'transparent' ? undefined : background;
|
|
67
|
+
|
|
68
|
+
scrollViewStyles.useVariants({
|
|
69
|
+
background: backgroundVariant,
|
|
70
|
+
radius,
|
|
71
|
+
border,
|
|
72
|
+
gap,
|
|
73
|
+
padding,
|
|
74
|
+
paddingVertical,
|
|
75
|
+
paddingHorizontal,
|
|
76
|
+
margin,
|
|
77
|
+
marginVertical,
|
|
78
|
+
marginHorizontal,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const containerStyle = (scrollViewStyles.container as any)({});
|
|
82
|
+
const contentStyle = (scrollViewStyles.contentContainer as any)({});
|
|
83
|
+
|
|
84
|
+
const overrideStyles: ViewStyle = {};
|
|
85
|
+
if (backgroundColor) overrideStyles.backgroundColor = backgroundColor;
|
|
86
|
+
if (borderRadius !== undefined) overrideStyles.borderRadius = borderRadius;
|
|
87
|
+
if (borderWidth !== undefined) overrideStyles.borderWidth = borderWidth;
|
|
88
|
+
if (borderColor) overrideStyles.borderColor = borderColor;
|
|
89
|
+
|
|
90
|
+
// Imperative handle
|
|
91
|
+
useImperativeHandle(ref, () => ({
|
|
92
|
+
scrollTo: (options) => {
|
|
93
|
+
scrollRef.current?.scrollTo({
|
|
94
|
+
x: options.x ?? 0,
|
|
95
|
+
y: options.y ?? 0,
|
|
96
|
+
animated: options.animated ?? true,
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
scrollToEnd: (options) => {
|
|
100
|
+
scrollRef.current?.scrollToEnd({ animated: options?.animated ?? true });
|
|
101
|
+
},
|
|
102
|
+
scrollToStart: (options) => {
|
|
103
|
+
scrollRef.current?.scrollTo({
|
|
104
|
+
x: 0,
|
|
105
|
+
y: 0,
|
|
106
|
+
animated: options?.animated ?? true,
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
getScrollPosition: () => lastPositionRef.current,
|
|
110
|
+
getInnerElement: () => scrollRef.current,
|
|
111
|
+
}), []);
|
|
112
|
+
|
|
113
|
+
const handleScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
114
|
+
const event = buildScrollEvent(e.nativeEvent);
|
|
115
|
+
lastPositionRef.current = event.position;
|
|
116
|
+
|
|
117
|
+
onScroll?.(event);
|
|
118
|
+
|
|
119
|
+
// End reached detection
|
|
120
|
+
if (onEndReached) {
|
|
121
|
+
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
|
122
|
+
const distanceFromEnd = direction === 'horizontal'
|
|
123
|
+
? contentSize.width - layoutMeasurement.width - contentOffset.x
|
|
124
|
+
: contentSize.height - layoutMeasurement.height - contentOffset.y;
|
|
125
|
+
|
|
126
|
+
if (distanceFromEnd <= onEndReachedThreshold && !endReachedRef.current) {
|
|
127
|
+
endReachedRef.current = true;
|
|
128
|
+
onEndReached();
|
|
129
|
+
} else if (distanceFromEnd > onEndReachedThreshold) {
|
|
130
|
+
endReachedRef.current = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}, [onScroll, onEndReached, onEndReachedThreshold, direction]);
|
|
134
|
+
|
|
135
|
+
const handleScrollBeginDrag = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
136
|
+
onScrollBegin?.(buildScrollEvent(e.nativeEvent));
|
|
137
|
+
}, [onScrollBegin]);
|
|
138
|
+
|
|
139
|
+
const handleMomentumScrollEnd = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
140
|
+
onScrollEnd?.(buildScrollEvent(e.nativeEvent));
|
|
141
|
+
}, [onScrollEnd]);
|
|
142
|
+
|
|
143
|
+
const isHorizontal = direction === 'horizontal';
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<RNScrollView
|
|
147
|
+
ref={scrollRef}
|
|
148
|
+
style={[containerStyle, overrideStyles, style]}
|
|
149
|
+
contentContainerStyle={[contentStyle, contentContainerStyle]}
|
|
150
|
+
horizontal={isHorizontal}
|
|
151
|
+
showsVerticalScrollIndicator={!isHorizontal && showsIndicator}
|
|
152
|
+
showsHorizontalScrollIndicator={isHorizontal && showsIndicator}
|
|
153
|
+
pagingEnabled={pagingEnabled}
|
|
154
|
+
bounces={bounces}
|
|
155
|
+
scrollEnabled={scrollEnabled}
|
|
156
|
+
scrollEventThrottle={scrollEventThrottle}
|
|
157
|
+
keyboardDismissMode={keyboardDismissMode}
|
|
158
|
+
onScroll={handleScroll}
|
|
159
|
+
onScrollBeginDrag={onScrollBegin ? handleScrollBeginDrag : undefined}
|
|
160
|
+
onMomentumScrollEnd={onScrollEnd ? handleMomentumScrollEnd : undefined}
|
|
161
|
+
onLayout={onLayout}
|
|
162
|
+
testID={testID}
|
|
163
|
+
nativeID={id}
|
|
164
|
+
>
|
|
165
|
+
{children}
|
|
166
|
+
</RNScrollView>
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
ScrollView.displayName = 'ScrollView';
|
|
171
|
+
|
|
172
|
+
export default ScrollView;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollView styles using defineStyle with $iterator expansion.
|
|
3
|
+
* Reuses the same visual variant patterns as View.
|
|
4
|
+
*/
|
|
5
|
+
import { StyleSheet } from 'react-native-unistyles';
|
|
6
|
+
import { defineStyle, ThemeStyleWrapper } from '@idealyst/theme';
|
|
7
|
+
import type { Theme as BaseTheme } from '@idealyst/theme';
|
|
8
|
+
import { ScrollViewBackgroundVariant, ScrollViewBorderVariant, ScrollViewRadiusVariant } from './types';
|
|
9
|
+
import { ViewStyleSize } from '../utils/viewStyleProps';
|
|
10
|
+
|
|
11
|
+
// Required: Unistyles must see StyleSheet usage in original source to process this file
|
|
12
|
+
void StyleSheet;
|
|
13
|
+
|
|
14
|
+
// Wrap theme for $iterator support
|
|
15
|
+
type Theme = ThemeStyleWrapper<BaseTheme>;
|
|
16
|
+
|
|
17
|
+
export type ScrollViewVariants = {
|
|
18
|
+
background: ScrollViewBackgroundVariant;
|
|
19
|
+
radius: ScrollViewRadiusVariant;
|
|
20
|
+
border: ScrollViewBorderVariant;
|
|
21
|
+
gap: ViewStyleSize;
|
|
22
|
+
padding: ViewStyleSize;
|
|
23
|
+
paddingVertical: ViewStyleSize;
|
|
24
|
+
paddingHorizontal: ViewStyleSize;
|
|
25
|
+
margin: ViewStyleSize;
|
|
26
|
+
marginVertical: ViewStyleSize;
|
|
27
|
+
marginHorizontal: ViewStyleSize;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ScrollViewDynamicProps = Partial<ScrollViewVariants>;
|
|
31
|
+
|
|
32
|
+
export const scrollViewStyles = defineStyle('ScrollView', (theme: Theme) => ({
|
|
33
|
+
container: (_props: ScrollViewDynamicProps) => ({
|
|
34
|
+
display: 'flex' as const,
|
|
35
|
+
flex: 1,
|
|
36
|
+
borderColor: theme.colors.border.primary,
|
|
37
|
+
borderWidth: 0,
|
|
38
|
+
variants: {
|
|
39
|
+
background: {
|
|
40
|
+
backgroundColor: theme.colors.$surface,
|
|
41
|
+
},
|
|
42
|
+
radius: {
|
|
43
|
+
none: { borderRadius: 0 },
|
|
44
|
+
xs: { borderRadius: 2 },
|
|
45
|
+
sm: { borderRadius: 4 },
|
|
46
|
+
md: { borderRadius: 8 },
|
|
47
|
+
lg: { borderRadius: 12 },
|
|
48
|
+
xl: { borderRadius: 16 },
|
|
49
|
+
},
|
|
50
|
+
border: {
|
|
51
|
+
none: { borderWidth: 0 },
|
|
52
|
+
thin: { borderWidth: 1, borderStyle: 'solid' as const, borderColor: theme.colors.pallet.gray[300] },
|
|
53
|
+
thick: { borderWidth: 2, borderStyle: 'solid' as const, borderColor: theme.colors.pallet.gray[300] },
|
|
54
|
+
},
|
|
55
|
+
margin: {
|
|
56
|
+
margin: theme.sizes.$view.padding,
|
|
57
|
+
},
|
|
58
|
+
marginVertical: {
|
|
59
|
+
marginVertical: theme.sizes.$view.padding,
|
|
60
|
+
},
|
|
61
|
+
marginHorizontal: {
|
|
62
|
+
marginHorizontal: theme.sizes.$view.padding,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
_web: {
|
|
66
|
+
display: 'flex',
|
|
67
|
+
flexDirection: 'column',
|
|
68
|
+
boxSizing: 'border-box',
|
|
69
|
+
borderStyle: 'solid',
|
|
70
|
+
position: 'relative',
|
|
71
|
+
minHeight: 0,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
contentContainer: (_props: ScrollViewDynamicProps) => ({
|
|
75
|
+
borderColor: theme.colors.border.primary,
|
|
76
|
+
borderWidth: 0,
|
|
77
|
+
variants: {
|
|
78
|
+
gap: {
|
|
79
|
+
gap: theme.sizes.$view.spacing,
|
|
80
|
+
},
|
|
81
|
+
padding: {
|
|
82
|
+
padding: theme.sizes.$view.padding,
|
|
83
|
+
},
|
|
84
|
+
paddingVertical: {
|
|
85
|
+
paddingVertical: theme.sizes.$view.padding,
|
|
86
|
+
},
|
|
87
|
+
paddingHorizontal: {
|
|
88
|
+
paddingHorizontal: theme.sizes.$view.padding,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
_web: {
|
|
92
|
+
boxSizing: 'border-box',
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
// Web-only: the actual scrolling element (absolute positioned within container)
|
|
96
|
+
scrollableRegion: {
|
|
97
|
+
_web: {
|
|
98
|
+
position: 'absolute',
|
|
99
|
+
inset: 0,
|
|
100
|
+
overflow: 'auto',
|
|
101
|
+
boxSizing: 'border-box',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}));
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
|
2
|
+
import { getWebProps } from 'react-native-unistyles/web';
|
|
3
|
+
import { scrollViewStyles } from './ScrollView.styles';
|
|
4
|
+
import type { ScrollViewProps, ScrollViewRef, ScrollEvent } from './types';
|
|
5
|
+
import useMergeRefs from '../hooks/useMergeRefs';
|
|
6
|
+
import { useWebLayout } from '../hooks/useWebLayout';
|
|
7
|
+
import { flattenStyle } from '../utils/flattenStyle';
|
|
8
|
+
import type { IdealystElement } from '../utils/refTypes';
|
|
9
|
+
|
|
10
|
+
function buildScrollEvent(el: HTMLElement): ScrollEvent {
|
|
11
|
+
const position = { x: el.scrollLeft, y: el.scrollTop };
|
|
12
|
+
const contentSize = { width: el.scrollWidth, height: el.scrollHeight };
|
|
13
|
+
const layoutSize = { width: el.clientWidth, height: el.clientHeight };
|
|
14
|
+
const isAtEnd =
|
|
15
|
+
el.scrollTop + el.clientHeight >= el.scrollHeight - 1 ||
|
|
16
|
+
el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
|
|
17
|
+
const isAtStart = el.scrollLeft <= 0 && el.scrollTop <= 0;
|
|
18
|
+
|
|
19
|
+
return { position, contentSize, layoutSize, isAtEnd, isAtStart };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Scrollable container with scroll event abstractions and imperative scroll controls.
|
|
24
|
+
* Web implementation uses a container + absolutely positioned scrollable region.
|
|
25
|
+
*/
|
|
26
|
+
const ScrollView = forwardRef<ScrollViewRef, ScrollViewProps>(({
|
|
27
|
+
children,
|
|
28
|
+
direction = 'vertical',
|
|
29
|
+
background = 'transparent',
|
|
30
|
+
radius = 'none',
|
|
31
|
+
border = 'none',
|
|
32
|
+
gap,
|
|
33
|
+
padding,
|
|
34
|
+
paddingVertical,
|
|
35
|
+
paddingHorizontal,
|
|
36
|
+
margin,
|
|
37
|
+
marginVertical,
|
|
38
|
+
marginHorizontal,
|
|
39
|
+
backgroundColor: _backgroundColor,
|
|
40
|
+
borderRadius: _borderRadius,
|
|
41
|
+
borderWidth: _borderWidth,
|
|
42
|
+
borderColor: _borderColor,
|
|
43
|
+
style,
|
|
44
|
+
contentContainerStyle,
|
|
45
|
+
showsIndicator = true,
|
|
46
|
+
onScroll,
|
|
47
|
+
onScrollBegin,
|
|
48
|
+
onScrollEnd,
|
|
49
|
+
onEndReached,
|
|
50
|
+
onEndReachedThreshold = 0,
|
|
51
|
+
scrollEnabled = true,
|
|
52
|
+
testID,
|
|
53
|
+
id,
|
|
54
|
+
onLayout,
|
|
55
|
+
}, ref) => {
|
|
56
|
+
const scrollElRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
const isDraggingRef = useRef(false);
|
|
58
|
+
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
59
|
+
const endReachedRef = useRef(false);
|
|
60
|
+
const layoutRef = useWebLayout<HTMLDivElement>(onLayout);
|
|
61
|
+
|
|
62
|
+
const backgroundVariant = background === 'transparent' ? undefined : background;
|
|
63
|
+
|
|
64
|
+
scrollViewStyles.useVariants({
|
|
65
|
+
background: backgroundVariant,
|
|
66
|
+
radius,
|
|
67
|
+
border,
|
|
68
|
+
gap,
|
|
69
|
+
padding,
|
|
70
|
+
paddingVertical,
|
|
71
|
+
paddingHorizontal,
|
|
72
|
+
margin,
|
|
73
|
+
marginVertical,
|
|
74
|
+
marginHorizontal,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** @ts-ignore */
|
|
78
|
+
const containerWebProps = getWebProps((scrollViewStyles.container as any)({}));
|
|
79
|
+
/** @ts-ignore */
|
|
80
|
+
const scrollRegionWebProps = getWebProps(scrollViewStyles.scrollableRegion);
|
|
81
|
+
/** @ts-ignore */
|
|
82
|
+
const contentWebProps = getWebProps((scrollViewStyles.contentContainer as any)({}));
|
|
83
|
+
|
|
84
|
+
// Imperative handle
|
|
85
|
+
useImperativeHandle(ref, () => ({
|
|
86
|
+
scrollTo: (options) => {
|
|
87
|
+
scrollElRef.current?.scrollTo({
|
|
88
|
+
left: options.x ?? 0,
|
|
89
|
+
top: options.y ?? 0,
|
|
90
|
+
behavior: (options.animated ?? true) ? 'smooth' : 'instant',
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
scrollToEnd: (options) => {
|
|
94
|
+
const el = scrollElRef.current;
|
|
95
|
+
if (!el) return;
|
|
96
|
+
const behavior = (options?.animated ?? true) ? 'smooth' : 'instant';
|
|
97
|
+
if (direction === 'horizontal') {
|
|
98
|
+
el.scrollTo({ left: el.scrollWidth, behavior });
|
|
99
|
+
} else {
|
|
100
|
+
el.scrollTo({ top: el.scrollHeight, behavior });
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
scrollToStart: (options) => {
|
|
104
|
+
scrollElRef.current?.scrollTo({
|
|
105
|
+
left: 0,
|
|
106
|
+
top: 0,
|
|
107
|
+
behavior: (options?.animated ?? true) ? 'smooth' : 'instant',
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
getScrollPosition: () => {
|
|
111
|
+
const el = scrollElRef.current;
|
|
112
|
+
return { x: el?.scrollLeft ?? 0, y: el?.scrollTop ?? 0 };
|
|
113
|
+
},
|
|
114
|
+
getInnerElement: () => scrollElRef.current,
|
|
115
|
+
}), [direction]);
|
|
116
|
+
|
|
117
|
+
// Scroll event handling
|
|
118
|
+
const handleScroll = useCallback(() => {
|
|
119
|
+
const el = scrollElRef.current;
|
|
120
|
+
if (!el) return;
|
|
121
|
+
|
|
122
|
+
const event = buildScrollEvent(el);
|
|
123
|
+
onScroll?.(event);
|
|
124
|
+
|
|
125
|
+
// Scroll begin detection (first scroll after idle)
|
|
126
|
+
if (!isDraggingRef.current) {
|
|
127
|
+
isDraggingRef.current = true;
|
|
128
|
+
onScrollBegin?.(event);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Scroll end detection via timeout
|
|
132
|
+
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
|
133
|
+
scrollTimeoutRef.current = setTimeout(() => {
|
|
134
|
+
isDraggingRef.current = false;
|
|
135
|
+
if (el) onScrollEnd?.(buildScrollEvent(el));
|
|
136
|
+
}, 150);
|
|
137
|
+
|
|
138
|
+
// End reached detection
|
|
139
|
+
if (onEndReached) {
|
|
140
|
+
const distanceFromEnd = direction === 'horizontal'
|
|
141
|
+
? el.scrollWidth - el.clientWidth - el.scrollLeft
|
|
142
|
+
: el.scrollHeight - el.clientHeight - el.scrollTop;
|
|
143
|
+
|
|
144
|
+
if (distanceFromEnd <= onEndReachedThreshold && !endReachedRef.current) {
|
|
145
|
+
endReachedRef.current = true;
|
|
146
|
+
onEndReached();
|
|
147
|
+
} else if (distanceFromEnd > onEndReachedThreshold) {
|
|
148
|
+
endReachedRef.current = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}, [onScroll, onScrollBegin, onScrollEnd, onEndReached, onEndReachedThreshold, direction]);
|
|
152
|
+
|
|
153
|
+
// Attach scroll listener
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const el = scrollElRef.current;
|
|
156
|
+
if (!el) return;
|
|
157
|
+
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
158
|
+
return () => {
|
|
159
|
+
el.removeEventListener('scroll', handleScroll);
|
|
160
|
+
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
|
161
|
+
};
|
|
162
|
+
}, [handleScroll]);
|
|
163
|
+
|
|
164
|
+
const mergedRef = useMergeRefs(layoutRef, containerWebProps.ref);
|
|
165
|
+
|
|
166
|
+
const flatStyle = flattenStyle(style);
|
|
167
|
+
const flatContentStyle = flattenStyle(contentContainerStyle);
|
|
168
|
+
|
|
169
|
+
// Split user styles: sizing/margin to container, visual to content
|
|
170
|
+
const {
|
|
171
|
+
width, height, minWidth, minHeight, maxWidth, maxHeight,
|
|
172
|
+
flex, flexGrow, flexShrink, flexBasis, alignSelf,
|
|
173
|
+
margin: userMargin, marginTop, marginRight, marginBottom, marginLeft,
|
|
174
|
+
marginBlock, marginInline,
|
|
175
|
+
...restStyle
|
|
176
|
+
} = flatStyle;
|
|
177
|
+
|
|
178
|
+
const containerUserStyles: React.CSSProperties = {
|
|
179
|
+
...(width !== undefined && { width }),
|
|
180
|
+
...(height !== undefined && { height }),
|
|
181
|
+
...(minWidth !== undefined && { minWidth }),
|
|
182
|
+
...(minHeight !== undefined && { minHeight }),
|
|
183
|
+
...(maxWidth !== undefined && { maxWidth }),
|
|
184
|
+
...(maxHeight !== undefined && { maxHeight }),
|
|
185
|
+
...(flex !== undefined && { flex }),
|
|
186
|
+
...(flexGrow !== undefined && { flexGrow }),
|
|
187
|
+
...(flexShrink !== undefined && { flexShrink }),
|
|
188
|
+
...(flexBasis !== undefined && { flexBasis }),
|
|
189
|
+
...(alignSelf !== undefined && { alignSelf }),
|
|
190
|
+
...(userMargin !== undefined && { margin: userMargin }),
|
|
191
|
+
...(marginTop !== undefined && { marginTop }),
|
|
192
|
+
...(marginRight !== undefined && { marginRight }),
|
|
193
|
+
...(marginBottom !== undefined && { marginBottom }),
|
|
194
|
+
...(marginLeft !== undefined && { marginLeft }),
|
|
195
|
+
...(marginBlock !== undefined && { marginBlock }),
|
|
196
|
+
...(marginInline !== undefined && { marginInline }),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Determine overflow direction
|
|
200
|
+
const overflowX = direction === 'horizontal' || direction === 'both' ? 'auto' : 'hidden';
|
|
201
|
+
const overflowY = direction === 'vertical' || direction === 'both' ? 'auto' : 'hidden';
|
|
202
|
+
|
|
203
|
+
// Hide scrollbar CSS
|
|
204
|
+
const scrollbarStyle: React.CSSProperties = showsIndicator ? {} : {
|
|
205
|
+
scrollbarWidth: 'none',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
{...containerWebProps}
|
|
211
|
+
style={containerUserStyles}
|
|
212
|
+
ref={mergedRef}
|
|
213
|
+
id={id}
|
|
214
|
+
data-testid={testID}
|
|
215
|
+
>
|
|
216
|
+
<div
|
|
217
|
+
{...scrollRegionWebProps}
|
|
218
|
+
ref={scrollElRef}
|
|
219
|
+
style={{
|
|
220
|
+
position: 'absolute',
|
|
221
|
+
inset: 0,
|
|
222
|
+
overflowX: scrollEnabled ? overflowX : 'hidden',
|
|
223
|
+
overflowY: scrollEnabled ? overflowY : 'hidden',
|
|
224
|
+
boxSizing: 'border-box',
|
|
225
|
+
...scrollbarStyle,
|
|
226
|
+
...restStyle,
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<div
|
|
230
|
+
{...contentWebProps}
|
|
231
|
+
style={flatContentStyle}
|
|
232
|
+
>
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
ScrollView.displayName = 'ScrollView';
|
|
241
|
+
|
|
242
|
+
export default ScrollView;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Size, Surface } from '@idealyst/theme';
|
|
2
|
+
import type { ReactNode, RefObject } from 'react';
|
|
3
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
4
|
+
import { ContainerStyleProps } from '../utils/viewStyleProps';
|
|
5
|
+
import type { LayoutChangeEvent } from '../hooks/useWebLayout';
|
|
6
|
+
|
|
7
|
+
export type { LayoutChangeEvent };
|
|
8
|
+
|
|
9
|
+
// Component-specific type aliases
|
|
10
|
+
export type ScrollViewBackgroundVariant = Surface | 'transparent';
|
|
11
|
+
export type ScrollViewRadiusVariant = Size | 'none';
|
|
12
|
+
export type ScrollViewBorderVariant = 'none' | 'thin' | 'thick';
|
|
13
|
+
export type ScrollViewDirection = 'vertical' | 'horizontal' | 'both';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scroll position information.
|
|
17
|
+
*/
|
|
18
|
+
export interface ScrollPosition {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Content size information for the scrollable content.
|
|
25
|
+
*/
|
|
26
|
+
export interface ScrollContentSize {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Layout size of the scroll view container.
|
|
33
|
+
*/
|
|
34
|
+
export interface ScrollLayoutSize {
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalized scroll event provided to all scroll callbacks.
|
|
41
|
+
*/
|
|
42
|
+
export interface ScrollEvent {
|
|
43
|
+
/** Current scroll offset */
|
|
44
|
+
position: ScrollPosition;
|
|
45
|
+
/** Size of the scrollable content */
|
|
46
|
+
contentSize: ScrollContentSize;
|
|
47
|
+
/** Size of the visible scroll container */
|
|
48
|
+
layoutSize: ScrollLayoutSize;
|
|
49
|
+
/** Whether the content is scrolled to the end (vertical or horizontal depending on direction) */
|
|
50
|
+
isAtEnd: boolean;
|
|
51
|
+
/** Whether the content is scrolled to the start */
|
|
52
|
+
isAtStart: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options for scrollTo operations.
|
|
57
|
+
*/
|
|
58
|
+
export interface ScrollToOptions {
|
|
59
|
+
x?: number;
|
|
60
|
+
y?: number;
|
|
61
|
+
animated?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Imperative handle exposed via ref for controlling scroll programmatically.
|
|
66
|
+
*/
|
|
67
|
+
export interface ScrollViewRef {
|
|
68
|
+
/** Scroll to a specific position */
|
|
69
|
+
scrollTo: (options: ScrollToOptions) => void;
|
|
70
|
+
/** Scroll to the end of the content */
|
|
71
|
+
scrollToEnd: (options?: { animated?: boolean }) => void;
|
|
72
|
+
/** Scroll to the top/start of the content */
|
|
73
|
+
scrollToStart: (options?: { animated?: boolean }) => void;
|
|
74
|
+
/** Get current scroll position */
|
|
75
|
+
getScrollPosition: () => ScrollPosition;
|
|
76
|
+
/** Get the underlying native element */
|
|
77
|
+
getInnerElement: () => any;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Scrollable container with scroll event abstractions and imperative scroll controls.
|
|
82
|
+
*/
|
|
83
|
+
export interface ScrollViewProps extends ContainerStyleProps {
|
|
84
|
+
children?: ReactNode;
|
|
85
|
+
|
|
86
|
+
/** Scroll direction. Defaults to 'vertical'. */
|
|
87
|
+
direction?: ScrollViewDirection;
|
|
88
|
+
|
|
89
|
+
/** Background variant */
|
|
90
|
+
background?: ScrollViewBackgroundVariant;
|
|
91
|
+
|
|
92
|
+
/** Border radius variant */
|
|
93
|
+
radius?: ScrollViewRadiusVariant;
|
|
94
|
+
|
|
95
|
+
/** Border variant */
|
|
96
|
+
border?: ScrollViewBorderVariant;
|
|
97
|
+
|
|
98
|
+
/** Custom background color (overrides background variant) */
|
|
99
|
+
backgroundColor?: string;
|
|
100
|
+
|
|
101
|
+
/** Custom border radius (overrides radius variant) */
|
|
102
|
+
borderRadius?: number;
|
|
103
|
+
|
|
104
|
+
/** Custom border width (overrides border variant) */
|
|
105
|
+
borderWidth?: number;
|
|
106
|
+
|
|
107
|
+
/** Custom border color */
|
|
108
|
+
borderColor?: string;
|
|
109
|
+
|
|
110
|
+
/** Additional styles */
|
|
111
|
+
style?: StyleProp<ViewStyle>;
|
|
112
|
+
|
|
113
|
+
/** Styles applied to the content container */
|
|
114
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
115
|
+
|
|
116
|
+
/** Whether to show scroll indicators. Defaults to true. */
|
|
117
|
+
showsIndicator?: boolean;
|
|
118
|
+
|
|
119
|
+
/** Whether to enable paging behavior */
|
|
120
|
+
pagingEnabled?: boolean;
|
|
121
|
+
|
|
122
|
+
/** Whether the scroll view bounces at the edges (iOS). Defaults to true. */
|
|
123
|
+
bounces?: boolean;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Called continuously as the user scrolls.
|
|
127
|
+
* Use `scrollEventThrottle` to control frequency.
|
|
128
|
+
*/
|
|
129
|
+
onScroll?: (event: ScrollEvent) => void;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Called when scrolling begins (user starts dragging).
|
|
133
|
+
*/
|
|
134
|
+
onScrollBegin?: (event: ScrollEvent) => void;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Called when scrolling ends (momentum settles or user lifts finger).
|
|
138
|
+
*/
|
|
139
|
+
onScrollEnd?: (event: ScrollEvent) => void;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Called when the scroll position reaches the end of the content.
|
|
143
|
+
* Useful for infinite scroll / load-more patterns.
|
|
144
|
+
*/
|
|
145
|
+
onEndReached?: () => void;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Distance from the end (in pixels) at which onEndReached fires.
|
|
149
|
+
* Defaults to 0.
|
|
150
|
+
*/
|
|
151
|
+
onEndReachedThreshold?: number;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Throttle interval (ms) for onScroll events on native. Defaults to 16 (~60fps).
|
|
155
|
+
*/
|
|
156
|
+
scrollEventThrottle?: number;
|
|
157
|
+
|
|
158
|
+
/** Whether scrolling is enabled. Defaults to true. */
|
|
159
|
+
scrollEnabled?: boolean;
|
|
160
|
+
|
|
161
|
+
/** Whether the keyboard should dismiss on drag. */
|
|
162
|
+
keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
|
|
163
|
+
|
|
164
|
+
/** Test ID for testing */
|
|
165
|
+
testID?: string;
|
|
166
|
+
|
|
167
|
+
/** Callback when layout changes */
|
|
168
|
+
onLayout?: (event: LayoutChangeEvent) => void;
|
|
169
|
+
}
|
|
@@ -92,15 +92,15 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
|
|
|
92
92
|
},
|
|
93
93
|
|
|
94
94
|
tab: ({ type = 'standard', size = 'md', active = false, pillMode: _pillMode = 'light', disabled = false, iconPosition = 'left', justify = 'start' }: TabBarDynamicProps) => {
|
|
95
|
-
// Tab padding for pills
|
|
96
|
-
const
|
|
97
|
-
xs: { paddingVertical: 2, paddingHorizontal:
|
|
98
|
-
sm: { paddingVertical:
|
|
99
|
-
md: { paddingVertical:
|
|
100
|
-
lg: { paddingVertical:
|
|
101
|
-
xl: { paddingVertical:
|
|
95
|
+
// Tab padding for pills — narrower than standard tabs
|
|
96
|
+
const pillsPaddingMap: Record<Size, { paddingVertical: number; paddingHorizontal: number }> = {
|
|
97
|
+
xs: { paddingVertical: 2, paddingHorizontal: 8 },
|
|
98
|
+
sm: { paddingVertical: 3, paddingHorizontal: 10 },
|
|
99
|
+
md: { paddingVertical: 4, paddingHorizontal: 12 },
|
|
100
|
+
lg: { paddingVertical: 6, paddingHorizontal: 16 },
|
|
101
|
+
xl: { paddingVertical: 8, paddingHorizontal: 20 },
|
|
102
102
|
};
|
|
103
|
-
const tabPadding = type === 'pills' ?
|
|
103
|
+
const tabPadding = type === 'pills' ? pillsPaddingMap[size] : {};
|
|
104
104
|
|
|
105
105
|
// Color based on type and active state
|
|
106
106
|
let color = active ? theme.colors.text.primary : theme.colors.text.secondary;
|
|
@@ -125,13 +125,17 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
|
|
|
125
125
|
opacity: disabled ? 0.5 : 1,
|
|
126
126
|
...tabPadding,
|
|
127
127
|
variants: {
|
|
128
|
-
size:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
size: type === 'pills'
|
|
129
|
+
? {
|
|
130
|
+
fontSize: theme.sizes.$tabBar.fontSize,
|
|
131
|
+
lineHeight: theme.sizes.$tabBar.lineHeight,
|
|
132
|
+
}
|
|
133
|
+
: {
|
|
134
|
+
fontSize: theme.sizes.$tabBar.fontSize,
|
|
135
|
+
paddingVertical: theme.sizes.$tabBar.padding,
|
|
136
|
+
paddingHorizontal: theme.sizes.$tabBar.padding,
|
|
137
|
+
lineHeight: theme.sizes.$tabBar.lineHeight,
|
|
138
|
+
},
|
|
135
139
|
},
|
|
136
140
|
_web: {
|
|
137
141
|
border: 'none',
|
|
@@ -186,8 +190,8 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
|
|
|
186
190
|
|
|
187
191
|
const typeStyles = type === 'pills' ? {
|
|
188
192
|
borderRadius: 9999,
|
|
189
|
-
bottom:
|
|
190
|
-
top:
|
|
193
|
+
bottom: 3,
|
|
194
|
+
top: 3,
|
|
191
195
|
left: 0,
|
|
192
196
|
} : {
|
|
193
197
|
bottom: -1,
|
package/src/View/types.ts
CHANGED
|
@@ -90,6 +90,10 @@ export interface ViewProps extends ContainerStyleProps {
|
|
|
90
90
|
style?: ViewStyleProp;
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
+
* @deprecated Use the ScrollView component instead, which provides scroll event
|
|
94
|
+
* abstractions (onScroll, onScrollBegin, onScrollEnd, onEndReached) and imperative
|
|
95
|
+
* scroll controls (scrollTo, scrollToEnd, scrollToStart) via ref.
|
|
96
|
+
*
|
|
93
97
|
* Enable scrollable content.
|
|
94
98
|
* - Native: Wraps children in a ScrollView
|
|
95
99
|
* - Web: Renders a wrapper + content structure where the wrapper fills available
|
package/src/index.native.ts
CHANGED
|
@@ -10,6 +10,9 @@ export * from './Text/types';
|
|
|
10
10
|
export { default as View } from './View';
|
|
11
11
|
export * from './View/types';
|
|
12
12
|
|
|
13
|
+
export { default as ScrollView } from './ScrollView';
|
|
14
|
+
export * from './ScrollView/types';
|
|
15
|
+
|
|
13
16
|
export { default as Pressable } from './Pressable';
|
|
14
17
|
export * from './Pressable/types';
|
|
15
18
|
|
|
@@ -126,6 +129,7 @@ export type { ButtonProps } from './Button/types';
|
|
|
126
129
|
export type { IconButtonProps } from './IconButton/types';
|
|
127
130
|
export type { TextProps } from './Text/types';
|
|
128
131
|
export type { ViewProps } from './View/types';
|
|
132
|
+
export type { ScrollViewProps, ScrollViewRef, ScrollEvent, ScrollPosition, ScrollToOptions } from './ScrollView/types';
|
|
129
133
|
export type { InputProps } from './Input/types';
|
|
130
134
|
export type { TextInputProps } from './TextInput/types';
|
|
131
135
|
export type { CheckboxProps } from './Checkbox/types';
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,9 @@ export * from './Text/types';
|
|
|
15
15
|
export { default as View } from './View';
|
|
16
16
|
export * from './View/types';
|
|
17
17
|
|
|
18
|
+
export { default as ScrollView } from './ScrollView';
|
|
19
|
+
export * from './ScrollView/types';
|
|
20
|
+
|
|
18
21
|
export { default as Pressable } from './Pressable';
|
|
19
22
|
export * from './Pressable/types';
|
|
20
23
|
|
|
@@ -134,6 +137,7 @@ export type { ButtonProps } from './Button/types';
|
|
|
134
137
|
export type { IconButtonProps } from './IconButton/types';
|
|
135
138
|
export type { TextProps } from './Text/types';
|
|
136
139
|
export type { ViewProps } from './View/types';
|
|
140
|
+
export type { ScrollViewProps, ScrollViewRef, ScrollEvent, ScrollPosition, ScrollToOptions } from './ScrollView/types';
|
|
137
141
|
export type { LinkProps } from './Link/types';
|
|
138
142
|
export type { TextInputProps } from './TextInput/types';
|
|
139
143
|
/** @deprecated Use TextInputProps instead */
|