@idealyst/components 1.2.122 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idealyst/components",
3
- "version": "1.2.122",
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.122",
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.122",
115
- "@idealyst/tooling": "^1.2.122",
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",
@@ -155,6 +155,14 @@ export const menuStyles = defineStyle('Menu', (theme: Theme) => ({
155
155
  height: theme.sizes.$menu.iconSize,
156
156
  fontSize: theme.sizes.$menu.iconSize,
157
157
  },
158
+ intent: {
159
+ neutral: {},
160
+ primary: { color: theme.intents.primary.primary },
161
+ success: { color: theme.intents.success.primary },
162
+ danger: { color: theme.intents.danger.primary },
163
+ warning: { color: theme.intents.warning.primary },
164
+ info: { color: theme.intents.info.primary },
165
+ },
158
166
  },
159
167
  }),
160
168
 
@@ -165,6 +173,14 @@ export const menuStyles = defineStyle('Menu', (theme: Theme) => ({
165
173
  size: {
166
174
  fontSize: theme.sizes.$menu.labelFontSize,
167
175
  },
176
+ intent: {
177
+ neutral: {},
178
+ primary: { color: theme.intents.primary.primary },
179
+ success: { color: theme.intents.success.primary },
180
+ danger: { color: theme.intents.danger.primary },
181
+ warning: { color: theme.intents.warning.primary },
182
+ info: { color: theme.intents.info.primary },
183
+ },
168
184
  },
169
185
  }),
170
186
  }));
@@ -20,9 +20,10 @@ const MenuItem = forwardRef<IdealystElement, MenuItemProps>(({ item, onPress, si
20
20
  });
21
21
 
22
22
  // Call styles as functions to get theme-reactive styles
23
- const itemStyle = (menuItemStyles.item as any)({ intent: item.intent || 'neutral' });
24
- const iconStyle = (menuItemStyles.icon as any)({});
25
- const labelStyle = (menuItemStyles.label as any)({});
23
+ const intent = item.intent || 'neutral';
24
+ const itemStyle = (menuItemStyles.item as any)({ intent });
25
+ const iconStyle = (menuItemStyles.icon as any)({ intent });
26
+ const labelStyle = (menuItemStyles.label as any)({ intent });
26
27
 
27
28
  // Extract icon size from theme variant (fontSize is set by $menu.iconSize)
28
29
  const iconSize = iconStyle.fontSize || iconStyle.width || 20;
@@ -118,6 +118,14 @@ export const menuItemStyles = defineStyle('MenuItem', (theme: Theme) => ({
118
118
  height: theme.sizes.$menu.iconSize,
119
119
  fontSize: theme.sizes.$menu.iconSize,
120
120
  },
121
+ intent: {
122
+ neutral: {},
123
+ primary: { color: theme.intents.primary.primary },
124
+ success: { color: theme.intents.success.primary },
125
+ danger: { color: theme.intents.danger.primary },
126
+ warning: { color: theme.intents.warning.primary },
127
+ info: { color: theme.intents.info.primary },
128
+ },
121
129
  },
122
130
  _web: {
123
131
  display: 'flex',
@@ -131,6 +139,14 @@ export const menuItemStyles = defineStyle('MenuItem', (theme: Theme) => ({
131
139
  size: {
132
140
  fontSize: theme.sizes.$menu.labelFontSize,
133
141
  },
142
+ intent: {
143
+ neutral: {},
144
+ primary: { color: theme.intents.primary.primary },
145
+ success: { color: theme.intents.success.primary },
146
+ danger: { color: theme.intents.danger.primary },
147
+ warning: { color: theme.intents.warning.primary },
148
+ info: { color: theme.intents.info.primary },
149
+ },
134
150
  },
135
151
  }),
136
152
  }));
@@ -22,9 +22,10 @@ const MenuItem = forwardRef<IdealystElement, MenuItemProps>(({ item, onPress, si
22
22
  });
23
23
 
24
24
  // Compute dynamic styles - call as functions for theme reactivity
25
- const itemStyle = (menuItemStyles.item as any)({ intent: item.intent || 'neutral' });
26
- const iconStyle = (menuItemStyles.icon as any)({});
27
- const labelStyle = (menuItemStyles.label as any)({});
25
+ const intent = item.intent || 'neutral';
26
+ const itemStyle = (menuItemStyles.item as any)({ intent });
27
+ const iconStyle = (menuItemStyles.icon as any)({ intent });
28
+ const labelStyle = (menuItemStyles.label as any)({ intent });
28
29
 
29
30
  const itemProps = getWebProps([itemStyle]);
30
31
  const iconProps = getWebProps([iconStyle]);
@@ -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,9 @@
1
+ /**
2
+ * ScrollView Documentation Sample Props
3
+ */
4
+
5
+ import type { SampleProps } from '@idealyst/tooling';
6
+
7
+ export const sampleProps: SampleProps = {
8
+ children: 'Scrollable content',
9
+ };
@@ -0,0 +1,2 @@
1
+ export { default } from './ScrollView.native';
2
+ export * from './types';
@@ -0,0 +1,5 @@
1
+ import ScrollViewComponent from './ScrollView.web';
2
+
3
+ export default ScrollViewComponent;
4
+ export { ScrollViewComponent as ScrollView };
5
+ export * from './types';
@@ -0,0 +1,5 @@
1
+ import ScrollViewComponent from './ScrollView.web';
2
+
3
+ export default ScrollViewComponent;
4
+ export { ScrollViewComponent as ScrollView };
5
+ export * from './types';
@@ -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 paddingMap: Record<Size, { paddingVertical: number; paddingHorizontal: number }> = {
97
- xs: { paddingVertical: 2, paddingHorizontal: 10 },
98
- sm: { paddingVertical: 4, paddingHorizontal: 12 },
99
- md: { paddingVertical: 6, paddingHorizontal: 16 },
100
- lg: { paddingVertical: 8, paddingHorizontal: 20 },
101
- xl: { paddingVertical: 10, paddingHorizontal: 24 },
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' ? paddingMap[size] : {};
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,11 +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
- fontSize: theme.sizes.$tabBar.fontSize,
130
- padding: theme.sizes.$tabBar.padding,
131
- lineHeight: theme.sizes.$tabBar.lineHeight,
132
- },
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
+ },
133
139
  },
134
140
  _web: {
135
141
  border: 'none',
@@ -184,8 +190,8 @@ export const tabBarStyles = defineStyle('TabBar', (theme: Theme) => ({
184
190
 
185
191
  const typeStyles = type === 'pills' ? {
186
192
  borderRadius: 9999,
187
- bottom: 4,
188
- top: 4,
193
+ bottom: 3,
194
+ top: 3,
189
195
  left: 0,
190
196
  } : {
191
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
@@ -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 */