@idealyst/components 1.2.32 → 1.2.34

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.32",
3
+ "version": "1.2.34",
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.32",
59
+ "@idealyst/theme": "^1.2.34",
60
60
  "@mdi/js": ">=7.0.0",
61
61
  "@mdi/react": ">=1.0.0",
62
62
  "@react-native-vector-icons/common": ">=12.0.0",
@@ -106,7 +106,7 @@
106
106
  }
107
107
  },
108
108
  "devDependencies": {
109
- "@idealyst/theme": "^1.2.32",
109
+ "@idealyst/theme": "^1.2.34",
110
110
  "@idealyst/tooling": "^1.2.30",
111
111
  "@mdi/react": "^1.6.1",
112
112
  "@types/react": "^19.1.0",
@@ -61,7 +61,11 @@ const AccordionItem: React.FC<AccordionItemProps> = ({
61
61
  <button
62
62
  {...headerProps}
63
63
  id={headerId}
64
- onClick={onToggle}
64
+ onClick={(e: React.MouseEvent) => {
65
+ e.preventDefault();
66
+ e.stopPropagation();
67
+ onToggle();
68
+ }}
65
69
  onKeyDown={(e) => onKeyDown(e, item.id)}
66
70
  disabled={item.disabled}
67
71
  aria-expanded={isExpanded}
@@ -146,16 +150,20 @@ const Accordion: React.FC<AccordionProps> = ({
146
150
  // ArrowDown moves to next item
147
151
  if (key === 'ArrowDown') {
148
152
  e.preventDefault();
153
+ e.stopPropagation();
149
154
  nextIndex = currentIndex < enabledItems.length - 1 ? currentIndex + 1 : 0;
150
155
  // ArrowUp moves to previous item
151
156
  } else if (key === 'ArrowUp') {
152
157
  e.preventDefault();
158
+ e.stopPropagation();
153
159
  nextIndex = currentIndex > 0 ? currentIndex - 1 : enabledItems.length - 1;
154
160
  } else if (ACCORDION_KEYS.first.includes(key as 'Home')) {
155
161
  e.preventDefault();
162
+ e.stopPropagation();
156
163
  nextIndex = 0;
157
164
  } else if (ACCORDION_KEYS.last.includes(key as 'End')) {
158
165
  e.preventDefault();
166
+ e.stopPropagation();
159
167
  nextIndex = enabledItems.length - 1;
160
168
  }
161
169
 
@@ -64,10 +64,8 @@ const Button = forwardRef<IdealystElement, ButtonProps>((props, ref) => {
64
64
 
65
65
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
66
66
  e.preventDefault();
67
- // Only stop propagation if we have a handler
68
- // Otherwise, let clicks bubble up to parent handlers (e.g., Menu triggers)
67
+ e.stopPropagation();
69
68
  if (!isDisabled && pressHandler) {
70
- e.stopPropagation();
71
69
  pressHandler();
72
70
  }
73
71
  };
@@ -17,6 +17,7 @@ const Card = forwardRef<IdealystElement, CardProps>(({
17
17
  onPress,
18
18
  onClick,
19
19
  disabled = false,
20
+ onLayout,
20
21
  // Spacing variants from ContainerStyleProps
21
22
  gap,
22
23
  padding,
@@ -93,6 +94,7 @@ const Card = forwardRef<IdealystElement, CardProps>(({
93
94
  nativeID: id,
94
95
  style: [cardStyle, style],
95
96
  testID,
97
+ onLayout,
96
98
  ...nativeA11yProps,
97
99
  ...(clickable && {
98
100
  onPress: disabled ? undefined : pressHandler,
@@ -3,6 +3,7 @@ import { getWebProps } from 'react-native-unistyles/web';
3
3
  import { CardProps } from './types';
4
4
  import { cardStyles } from './Card.styles';
5
5
  import useMergeRefs from '../hooks/useMergeRefs';
6
+ import { useWebLayout } from '../hooks/useWebLayout';
6
7
  import { getWebInteractiveAriaProps } from '../utils/accessibility';
7
8
  import type { IdealystElement } from '../utils/refTypes';
8
9
 
@@ -23,6 +24,7 @@ const Card = forwardRef<IdealystElement, CardProps>(({
23
24
  onPress,
24
25
  onClick,
25
26
  disabled = false,
27
+ onLayout,
26
28
  // Spacing variants from ContainerStyleProps
27
29
  gap,
28
30
  padding,
@@ -43,6 +45,7 @@ const Card = forwardRef<IdealystElement, CardProps>(({
43
45
  accessibilityPressed,
44
46
  }, ref) => {
45
47
  const hasWarnedRef = useRef(false);
48
+ const layoutRef = useWebLayout<HTMLElement>(onLayout);
46
49
 
47
50
  // Warn about onClick usage (deprecated)
48
51
  useEffect(() => {
@@ -72,7 +75,9 @@ const Card = forwardRef<IdealystElement, CardProps>(({
72
75
  });
73
76
  }, [accessibilityLabel, accessibilityHint, accessibilityDisabled, disabled, accessibilityHidden, accessibilityRole, clickable, accessibilityPressed]);
74
77
 
75
- const handleClick = () => {
78
+ const handleClick = (e: React.MouseEvent) => {
79
+ e.preventDefault();
80
+ e.stopPropagation();
76
81
  if (!disabled && clickable) {
77
82
  // Prefer onPress, fall back to deprecated onClick
78
83
  const handler = onPress ?? onClick;
@@ -103,7 +108,7 @@ const Card = forwardRef<IdealystElement, CardProps>(({
103
108
  // Generate web props
104
109
  const webProps = getWebProps([cardStyle, style as any]);
105
110
 
106
- const mergedRef = useMergeRefs(ref, webProps.ref);
111
+ const mergedRef = useMergeRefs(ref, webProps.ref, layoutRef);
107
112
 
108
113
  // Use appropriate HTML element based on clickable state
109
114
  const Component: any = clickable ? 'button' : 'div';
package/src/Card/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Intent, Size } from '@idealyst/theme';
2
2
  import type { ReactNode } from 'react';
3
- import type { StyleProp, ViewStyle } from 'react-native';
3
+ import type { StyleProp, ViewStyle, LayoutChangeEvent } from 'react-native';
4
4
  import { ContainerStyleProps } from '../utils/viewStyleProps';
5
5
  import { InteractiveAccessibilityProps } from '../utils/accessibility';
6
6
 
@@ -78,4 +78,10 @@ export interface CardProps extends ContainerStyleProps, InteractiveAccessibility
78
78
  * Test ID for testing
79
79
  */
80
80
  testID?: string;
81
+
82
+ /**
83
+ * Called when the layout of the card changes.
84
+ * Provides the new width, height, x, and y coordinates.
85
+ */
86
+ onLayout?: (event: LayoutChangeEvent) => void;
81
87
  }
@@ -53,6 +53,8 @@ const Checkbox = forwardRef<IdealystElement, CheckboxProps>(({
53
53
  }, [checked]);
54
54
 
55
55
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
56
+ event.preventDefault();
57
+ event.stopPropagation();
56
58
  if (disabled) return;
57
59
 
58
60
  const newChecked = event.target.checked;
@@ -61,7 +61,9 @@ const Chip = forwardRef<IdealystElement, ChipProps>(({
61
61
  const deleteButtonProps = getWebProps([(chipStyles.deleteButton as any)({ size })]);
62
62
  const deleteIconProps = getWebProps([(chipStyles.deleteIcon as any)({ size, intent, type, selected: isSelected })]);
63
63
 
64
- const handleClick = () => {
64
+ const handleClick = (e: React.MouseEvent) => {
65
+ e.preventDefault();
66
+ e.stopPropagation();
65
67
  if (disabled) return;
66
68
  // Prefer onPress, fall back to deprecated onClick
67
69
  const handler = onPress ?? onClick;
@@ -71,6 +73,7 @@ const Chip = forwardRef<IdealystElement, ChipProps>(({
71
73
  };
72
74
 
73
75
  const handleDelete = (e: React.MouseEvent) => {
76
+ e.preventDefault();
74
77
  e.stopPropagation();
75
78
  if (disabled) return;
76
79
  if (onDelete) {
@@ -62,8 +62,8 @@ const IconButton = forwardRef<IdealystElement, IconButtonProps>((props, ref) =>
62
62
 
63
63
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
64
64
  e.preventDefault();
65
+ e.stopPropagation();
65
66
  if (!isDisabled && pressHandler) {
66
- e.stopPropagation();
67
67
  pressHandler();
68
68
  }
69
69
  };
@@ -53,7 +53,9 @@ const ListItem: React.FC<ListItemProps & { isLast?: boolean }> = ({
53
53
  const leadingProps = getWebProps([leadingStyle]);
54
54
  const trailingProps = getWebProps([trailingStyle]);
55
55
 
56
- const handleClick = () => {
56
+ const handleClick = (e: React.MouseEvent) => {
57
+ e.preventDefault();
58
+ e.stopPropagation();
57
59
  if (!disabled && onPress) {
58
60
  onPress();
59
61
  }
@@ -69,7 +69,11 @@ const MenuItem = forwardRef<IdealystElement, MenuItemProps>(({ item, onPress, si
69
69
  {...itemProps}
70
70
  ref={mergedRef}
71
71
  style={buttonResetStyles}
72
- onClick={() => onPress(item)}
72
+ onClick={(e: React.MouseEvent) => {
73
+ e.preventDefault();
74
+ e.stopPropagation();
75
+ onPress(item);
76
+ }}
73
77
  disabled={item.disabled}
74
78
  role="menuitem"
75
79
  aria-disabled={item.disabled}
@@ -23,19 +23,25 @@ const Pressable = forwardRef<IdealystElement, PressableProps>(({
23
23
  }, ref) => {
24
24
  const [_isPressed, setIsPressed] = useState(false);
25
25
 
26
- const handleMouseDown = useCallback(() => {
26
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
27
+ e.preventDefault();
28
+ e.stopPropagation();
27
29
  if (disabled) return;
28
30
  setIsPressed(true);
29
31
  onPressIn?.();
30
32
  }, [disabled, onPressIn]);
31
33
 
32
- const handleMouseUp = useCallback(() => {
34
+ const handleMouseUp = useCallback((e: React.MouseEvent) => {
35
+ e.preventDefault();
36
+ e.stopPropagation();
33
37
  if (disabled) return;
34
38
  setIsPressed(false);
35
39
  onPressOut?.();
36
40
  }, [disabled, onPressOut]);
37
41
 
38
- const handleClick = useCallback(() => {
42
+ const handleClick = useCallback((e: React.MouseEvent) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
39
45
  if (disabled) return;
40
46
  onPress?.();
41
47
  }, [disabled, onPress]);
@@ -44,6 +50,7 @@ const Pressable = forwardRef<IdealystElement, PressableProps>(({
44
50
  if (disabled) return;
45
51
  if (event.key === 'Enter' || event.key === ' ') {
46
52
  event.preventDefault();
53
+ event.stopPropagation();
47
54
  onPress?.();
48
55
  }
49
56
  }, [disabled, onPress]);
@@ -39,7 +39,9 @@ const RadioButton: React.FC<RadioButtonProps> = ({
39
39
  const checked = group.value !== undefined ? group.value === value : checkedProp;
40
40
  const disabled = group.disabled || disabledProp;
41
41
 
42
- const handleClick = () => {
42
+ const handleClick = (e: React.MouseEvent) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
43
45
  if (!disabled) {
44
46
  if (group.onValueChange) {
45
47
  group.onValueChange(value);
@@ -11,6 +11,7 @@ const Screen = forwardRef<IdealystElement, ScreenProps>(({
11
11
  safeArea = true,
12
12
  scrollable = true,
13
13
  contentInset,
14
+ onLayout,
14
15
  // Spacing variants from ContainerStyleProps
15
16
  gap,
16
17
  padding,
@@ -66,6 +67,7 @@ const Screen = forwardRef<IdealystElement, ScreenProps>(({
66
67
  style={[screenStyle, style]}
67
68
  contentContainerStyle={{ flexGrow: 1 }}
68
69
  testID={testID}
70
+ onLayout={onLayout}
69
71
  >
70
72
  <RNView style={[contentInsetStyle, { flex: 1 }]}>
71
73
  {children}
@@ -75,7 +77,7 @@ const Screen = forwardRef<IdealystElement, ScreenProps>(({
75
77
  }
76
78
 
77
79
  return (
78
- <RNView ref={ref as any} nativeID={id} style={[screenStyle, safeAreaStyle, style]} testID={testID}>
80
+ <RNView ref={ref as any} nativeID={id} style={[screenStyle, safeAreaStyle, style]} testID={testID} onLayout={onLayout}>
79
81
  {children}
80
82
  </RNView>
81
83
  );
@@ -3,6 +3,7 @@ import { getWebProps } from 'react-native-unistyles/web';
3
3
  import { ScreenProps } from './types';
4
4
  import { screenStyles } from './Screen.styles';
5
5
  import useMergeRefs from '../hooks/useMergeRefs';
6
+ import { useWebLayout } from '../hooks/useWebLayout';
6
7
  import type { IdealystElement } from '../utils/refTypes';
7
8
 
8
9
  /**
@@ -13,6 +14,7 @@ const Screen = forwardRef<IdealystElement, ScreenProps>(({
13
14
  children,
14
15
  background = 'screen',
15
16
  safeArea = false,
17
+ onLayout,
16
18
  // Spacing variants from ContainerStyleProps
17
19
  gap,
18
20
  padding,
@@ -25,6 +27,8 @@ const Screen = forwardRef<IdealystElement, ScreenProps>(({
25
27
  testID,
26
28
  id,
27
29
  }, ref) => {
30
+ const layoutRef = useWebLayout<HTMLDivElement>(onLayout);
31
+
28
32
  screenStyles.useVariants({
29
33
  background,
30
34
  safeArea,
@@ -40,7 +44,7 @@ const Screen = forwardRef<IdealystElement, ScreenProps>(({
40
44
  // Call style as function to get theme-reactive styles
41
45
  const webProps = getWebProps([(screenStyles.screen as any)({}), style as any]);
42
46
 
43
- const mergedRef = useMergeRefs(ref, webProps.ref);
47
+ const mergedRef = useMergeRefs(ref, webProps.ref, layoutRef);
44
48
 
45
49
  return (
46
50
  <div
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { StyleProp, ViewStyle } from 'react-native';
2
+ import type { StyleProp, ViewStyle, LayoutChangeEvent } from 'react-native';
3
3
  import { Surface } from '@idealyst/theme';
4
4
  import { ContainerStyleProps } from '../utils/viewStyleProps';
5
5
 
@@ -49,4 +49,10 @@ export interface ScreenProps extends ContainerStyleProps {
49
49
  * Scrollable content
50
50
  */
51
51
  scrollable?: boolean;
52
+
53
+ /**
54
+ * Called when the layout of the screen changes.
55
+ * Provides the new width, height, x, and y coordinates.
56
+ */
57
+ onLayout?: (event: LayoutChangeEvent) => void;
52
58
  }
@@ -56,39 +56,43 @@ export const selectStyles = defineStyle('Select', (theme: Theme) => ({
56
56
  marginBottom: 4,
57
57
  }),
58
58
 
59
- trigger: ({ type: _type = 'outlined', intent: _intent = 'neutral', disabled = false, error: _error = false, focused: _focused = false }: SelectDynamicProps) => ({
60
- position: 'relative' as const,
61
- flexDirection: 'row' as const,
62
- alignItems: 'center' as const,
63
- justifyContent: 'space-between' as const,
64
- borderWidth: 1,
65
- borderStyle: 'solid' as const,
66
- opacity: disabled ? 0.6 : 1,
67
- variants: {
68
- type: {
69
- filled: {
70
- backgroundColor: theme.colors.surface.secondary,
71
- borderColor: 'transparent',
72
- },
73
- outlined: {
74
- backgroundColor: theme.colors.surface.primary,
75
- borderWidth: 1,
76
- borderColor: theme.colors.border.primary,
77
- }
59
+ trigger: ({ type = 'outlined', intent = 'neutral', disabled = false, error = false, focused = false }: SelectDynamicProps) => {
60
+ // Determine border color based on state priority: error > focused > default
61
+ const getBorderColor = () => {
62
+ if (error) return theme.intents.danger.primary;
63
+ if (focused) return theme.intents[intent]?.primary ?? theme.intents.primary.primary;
64
+ return type === 'filled' ? 'transparent' : theme.colors.border.primary;
65
+ };
66
+
67
+ const borderColor = getBorderColor();
68
+
69
+ return {
70
+ position: 'relative' as const,
71
+ flexDirection: 'row' as const,
72
+ alignItems: 'center' as const,
73
+ justifyContent: 'space-between' as const,
74
+ borderWidth: 1,
75
+ borderStyle: 'solid' as const,
76
+ borderColor,
77
+ borderRadius: 8,
78
+ opacity: disabled ? 0.6 : 1,
79
+ backgroundColor: type === 'filled' ? theme.colors.surface.secondary : theme.colors.surface.primary,
80
+ variants: {
81
+ size: theme.sizes.$select,
78
82
  },
79
- size: theme.sizes.$select,
80
- },
81
- _web: {
82
- display: 'flex',
83
- boxSizing: 'border-box',
84
- cursor: disabled ? 'not-allowed' : 'pointer',
85
- border: `1px solid`,
86
- transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
87
- _hover: disabled ? {} : { opacity: 0.9 },
88
- _active: disabled ? {} : { opacity: 0.8 },
89
- _focus: { outline: 'none' },
90
- },
91
- }),
83
+ _web: {
84
+ display: 'flex',
85
+ boxSizing: 'border-box',
86
+ cursor: disabled ? 'not-allowed' : 'pointer',
87
+ border: `1px solid ${borderColor}`,
88
+ boxShadow: focused && !error ? `0 0 0 2px ${theme.intents[intent]?.primary ?? theme.intents.primary.primary}33` : 'none',
89
+ transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
90
+ _hover: disabled ? {} : { borderColor: focused || error ? borderColor : theme.colors.border.secondary },
91
+ _active: disabled ? {} : { opacity: 0.9 },
92
+ _focus: { outline: 'none' },
93
+ },
94
+ };
95
+ },
92
96
 
93
97
  triggerContent: (_props: SelectDynamicProps) => ({
94
98
  flex: 1,
@@ -101,6 +101,7 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
101
101
  if (disabled) return;
102
102
 
103
103
  e.preventDefault();
104
+ e.stopPropagation();
104
105
 
105
106
  // Check if click is on the thumb
106
107
  const isThumbClick = thumbRef.current && thumbRef.current.contains(e.target as Node);
@@ -145,21 +146,27 @@ const Slider = forwardRef<IdealystElement, SliderProps>(({
145
146
 
146
147
  if (matchesKey(e, SLIDER_KEYS.increase)) {
147
148
  e.preventDefault();
149
+ e.stopPropagation();
148
150
  newValue = clampValue(value + step);
149
151
  } else if (matchesKey(e, SLIDER_KEYS.decrease)) {
150
152
  e.preventDefault();
153
+ e.stopPropagation();
151
154
  newValue = clampValue(value - step);
152
155
  } else if (matchesKey(e, SLIDER_KEYS.min)) {
153
156
  e.preventDefault();
157
+ e.stopPropagation();
154
158
  newValue = min;
155
159
  } else if (matchesKey(e, SLIDER_KEYS.max)) {
156
160
  e.preventDefault();
161
+ e.stopPropagation();
157
162
  newValue = max;
158
163
  } else if (matchesKey(e, SLIDER_KEYS.increaseLarge)) {
159
164
  e.preventDefault();
165
+ e.stopPropagation();
160
166
  newValue = clampValue(value + largeStep);
161
167
  } else if (matchesKey(e, SLIDER_KEYS.decreaseLarge)) {
162
168
  e.preventDefault();
169
+ e.stopPropagation();
163
170
  newValue = clampValue(value - largeStep);
164
171
  }
165
172
 
@@ -39,7 +39,9 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
39
39
  accessibilityDescribedBy,
40
40
  accessibilityChecked,
41
41
  }, ref) => {
42
- const handleClick = () => {
42
+ const handleClick = (e: React.MouseEvent) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
43
45
  if (!disabled && onChange) {
44
46
  onChange(!checked);
45
47
  }
@@ -36,7 +36,7 @@ function renderIcon(
36
36
  interface TabProps {
37
37
  item: TabBarItem;
38
38
  isActive: boolean;
39
- onClick: () => void;
39
+ onClick: (e: React.MouseEvent) => void;
40
40
  onKeyDown: (e: React.KeyboardEvent) => void;
41
41
  size: TabBarProps['size'];
42
42
  type: TabBarProps['type'];
@@ -168,15 +168,19 @@ const TabBar: React.FC<TabBarProps> = ({
168
168
 
169
169
  if (key === 'ArrowRight') {
170
170
  e.preventDefault();
171
+ e.stopPropagation();
171
172
  nextIndex = currentIndex < enabledItems.length - 1 ? currentIndex + 1 : 0;
172
173
  } else if (key === 'ArrowLeft') {
173
174
  e.preventDefault();
175
+ e.stopPropagation();
174
176
  nextIndex = currentIndex > 0 ? currentIndex - 1 : enabledItems.length - 1;
175
177
  } else if (key === 'Home') {
176
178
  e.preventDefault();
179
+ e.stopPropagation();
177
180
  nextIndex = 0;
178
181
  } else if (key === 'End') {
179
182
  e.preventDefault();
183
+ e.stopPropagation();
180
184
  nextIndex = enabledItems.length - 1;
181
185
  }
182
186
 
@@ -237,7 +241,9 @@ const TabBar: React.FC<TabBarProps> = ({
237
241
  updateIndicator();
238
242
  }, [items]);
239
243
 
240
- const handleTabClick = (itemValue: string, disabled?: boolean) => {
244
+ const handleTabClick = (e: React.MouseEvent, itemValue: string, disabled?: boolean) => {
245
+ e.preventDefault();
246
+ e.stopPropagation();
241
247
  if (disabled) return;
242
248
 
243
249
  if (controlledValue === undefined) {
@@ -306,7 +312,7 @@ const TabBar: React.FC<TabBarProps> = ({
306
312
  key={item.value}
307
313
  item={item}
308
314
  isActive={isActive}
309
- onClick={() => handleTabClick(item.value, item.disabled)}
315
+ onClick={(e) => handleTabClick(e, item.value, item.disabled)}
310
316
  onKeyDown={(e) => handleKeyDown(e, item.value)}
311
317
  size={size}
312
318
  type={type}
@@ -168,6 +168,7 @@ const TextArea = forwardRef<IdealystElement, TextAreaProps>(({
168
168
  }, [value, autoGrow, minHeight, maxHeight]);
169
169
 
170
170
  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
171
+ e.stopPropagation();
171
172
  const newValue = e.target.value;
172
173
 
173
174
  if (maxLength && newValue.length > maxLength) {
@@ -88,6 +88,7 @@ const TextInput = React.forwardRef<IdealystElement, TextInputProps>(({
88
88
  };
89
89
 
90
90
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
91
+ e.stopPropagation();
91
92
  if (onChangeText) {
92
93
  onChangeText(e.target.value);
93
94
  }
@@ -45,6 +45,7 @@ const View = forwardRef<IdealystElement, ViewProps>(({
45
45
  style,
46
46
  testID,
47
47
  id,
48
+ onLayout,
48
49
  }, ref) => {
49
50
  // Set active variants for this render
50
51
  viewStyles.useVariants({
@@ -89,6 +90,7 @@ const View = forwardRef<IdealystElement, ViewProps>(({
89
90
  contentContainerStyle={[viewStyle, overrideStyles]}
90
91
  testID={testID}
91
92
  nativeID={id}
93
+ onLayout={onLayout}
92
94
  >
93
95
  {children}
94
96
  </RNScrollView>
@@ -96,7 +98,7 @@ const View = forwardRef<IdealystElement, ViewProps>(({
96
98
  }
97
99
 
98
100
  return (
99
- <RNView ref={ref as any} style={[viewStyle, overrideStyles, finalStyle]} testID={testID} nativeID={id}>
101
+ <RNView ref={ref as any} style={[viewStyle, overrideStyles, finalStyle]} testID={testID} nativeID={id} onLayout={onLayout}>
100
102
  {children}
101
103
  </RNView>
102
104
  );
@@ -3,6 +3,7 @@ import { getWebProps } from 'react-native-unistyles/web';
3
3
  import { ViewProps } from './types';
4
4
  import { viewStyles } from './View.styles';
5
5
  import useMergeRefs from '../hooks/useMergeRefs';
6
+ import { useWebLayout } from '../hooks/useWebLayout';
6
7
  import type { IdealystElement } from '../utils/refTypes';
7
8
 
8
9
  /**
@@ -31,7 +32,10 @@ const View = forwardRef<IdealystElement, ViewProps>(({
31
32
  style,
32
33
  testID,
33
34
  id,
35
+ onLayout,
34
36
  }, ref) => {
37
+ const layoutRef = useWebLayout<HTMLDivElement>(onLayout);
38
+
35
39
  viewStyles.useVariants({
36
40
  background,
37
41
  radius,
@@ -51,7 +55,7 @@ const View = forwardRef<IdealystElement, ViewProps>(({
51
55
  /** @ts-ignore */
52
56
  const wrapperWebProps = getWebProps(viewStyles.scrollableWrapper);
53
57
 
54
- const mergedRef = useMergeRefs(ref, webProps.ref);
58
+ const mergedRef = useMergeRefs(ref, webProps.ref, layoutRef);
55
59
 
56
60
  // When scrollable, render a wrapper + content structure
57
61
  // Wrapper: sizing and margin (positioning in parent layout)
package/src/View/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Size, Surface, ResponsiveStyle } from '@idealyst/theme';
2
2
  import type { ReactNode } from 'react';
3
- import type { StyleProp, ViewStyle } from 'react-native';
3
+ import type { StyleProp, ViewStyle, LayoutChangeEvent } from 'react-native';
4
4
  import { ContainerStyleProps } from '../utils/viewStyleProps';
5
5
 
6
6
  /**
@@ -99,4 +99,11 @@ export interface ViewProps extends ContainerStyleProps {
99
99
  * Test ID for testing
100
100
  */
101
101
  testID?: string;
102
+
103
+ /**
104
+ * Callback when the view's layout changes.
105
+ * Called with layout information (x, y, width, height) when the component
106
+ * mounts or when its dimensions change.
107
+ */
108
+ onLayout?: (event: LayoutChangeEvent) => void;
102
109
  }
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react';
1
2
  import { Screen, View, Text, Card, Button } from '../index';
2
3
 
3
4
  export const CardExamples = () => {
@@ -5,6 +6,8 @@ export const CardExamples = () => {
5
6
  console.log(`Card pressed: ${cardType}`);
6
7
  };
7
8
 
9
+ const [cardDimensions, setCardDimensions] = useState({ width: 0, height: 0 });
10
+
8
11
  return (
9
12
  <Screen background="primary" padding="lg">
10
13
  <View gap="xl">
@@ -163,6 +166,30 @@ export const CardExamples = () => {
163
166
  </View>
164
167
  </Card>
165
168
  </View>
169
+
170
+ {/* onLayout Example */}
171
+ <View gap="md">
172
+ <Text typography="subtitle1">onLayout Callback</Text>
173
+ <Text typography="caption" color="secondary">
174
+ Track card dimensions as they change
175
+ </Text>
176
+ <Card
177
+ type="outlined"
178
+ padding="md"
179
+ onLayout={(event) => {
180
+ const { width, height } = event.nativeEvent.layout;
181
+ setCardDimensions({ width, height });
182
+ }}
183
+ >
184
+ <Text weight="semibold">Responsive Card</Text>
185
+ <Text typography="caption" color="secondary">
186
+ Width: {Math.round(cardDimensions.width)}px
187
+ </Text>
188
+ <Text typography="caption" color="secondary">
189
+ Height: {Math.round(cardDimensions.height)}px
190
+ </Text>
191
+ </Card>
192
+ </View>
166
193
  </View>
167
194
  </Screen>
168
195
  );
@@ -1,6 +1,9 @@
1
+ import { useState } from 'react';
1
2
  import { Screen, View, Text } from '../index';
2
3
 
3
4
  export const ScreenExamples = () => {
5
+ const [screenDimensions, setScreenDimensions] = useState({ width: 0, height: 0 });
6
+
4
7
  return (
5
8
  <Screen background="primary" padding="lg">
6
9
  <View gap="lg">
@@ -147,6 +150,34 @@ export const ScreenExamples = () => {
147
150
  </Screen>
148
151
  </View>
149
152
  </View>
153
+
154
+ {/* onLayout Example */}
155
+ <View gap="md">
156
+ <Text typography="subtitle1">onLayout Callback</Text>
157
+ <Text typography="caption" color="secondary">
158
+ Track screen dimensions as they change
159
+ </Text>
160
+ <View style={{ height: 120, borderWidth: 1, borderColor: '#ccc' }}>
161
+ <Screen
162
+ background="secondary"
163
+ padding="md"
164
+ onLayout={(event) => {
165
+ const { width, height } = event.nativeEvent.layout;
166
+ setScreenDimensions({ width, height });
167
+ }}
168
+ >
169
+ <View style={{ alignItems: 'center', justifyContent: 'center', flex: 1 }}>
170
+ <Text weight="semibold">Responsive Screen</Text>
171
+ <Text typography="caption" color="secondary">
172
+ Width: {Math.round(screenDimensions.width)}px
173
+ </Text>
174
+ <Text typography="caption" color="secondary">
175
+ Height: {Math.round(screenDimensions.height)}px
176
+ </Text>
177
+ </View>
178
+ </Screen>
179
+ </View>
180
+ </View>
150
181
  </View>
151
182
  </Screen>
152
183
  );
@@ -0,0 +1 @@
1
+ export { useWebLayout } from './useWebLayout.native';
@@ -0,0 +1 @@
1
+ export { useWebLayout } from './useWebLayout';
@@ -0,0 +1 @@
1
+ export { useWebLayout } from './useWebLayout.web';
@@ -0,0 +1,15 @@
1
+ import { useRef } from 'react';
2
+ import type { LayoutChangeEvent } from 'react-native';
3
+
4
+ /**
5
+ * No-op hook for native - onLayout is handled natively by React Native components.
6
+ * Returns a ref for API compatibility, but it's not used on native.
7
+ *
8
+ * @param _onLayout - Unused on native (React Native components handle onLayout directly)
9
+ * @returns A ref (unused on native, for API compatibility)
10
+ */
11
+ export function useWebLayout<T = any>(
12
+ _onLayout: ((event: LayoutChangeEvent) => void) | undefined
13
+ ) {
14
+ return useRef<T>(null);
15
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { LayoutChangeEvent } from 'react-native';
3
+
4
+ /**
5
+ * Hook that provides onLayout functionality for web components using ResizeObserver.
6
+ * Returns a ref that should be attached to the element you want to observe.
7
+ *
8
+ * @param onLayout - Callback fired when layout changes, with React Native compatible event shape
9
+ * @returns A ref to attach to the observed element
10
+ */
11
+ export function useWebLayout<T extends HTMLElement = HTMLElement>(
12
+ onLayout: ((event: LayoutChangeEvent) => void) | undefined
13
+ ) {
14
+ const ref = useRef<T>(null);
15
+
16
+ useEffect(() => {
17
+ if (!onLayout || !ref.current) return;
18
+
19
+ const element = ref.current;
20
+
21
+ // Call immediately with initial layout
22
+ const rect = element.getBoundingClientRect();
23
+ onLayout({
24
+ nativeEvent: {
25
+ layout: {
26
+ x: rect.left,
27
+ y: rect.top,
28
+ width: rect.width,
29
+ height: rect.height,
30
+ },
31
+ },
32
+ } as LayoutChangeEvent);
33
+
34
+ // Set up ResizeObserver for subsequent changes
35
+ const resizeObserver = new ResizeObserver((entries) => {
36
+ for (const entry of entries) {
37
+ const { width, height } = entry.contentRect;
38
+ const boundingRect = element.getBoundingClientRect();
39
+ onLayout({
40
+ nativeEvent: {
41
+ layout: {
42
+ x: boundingRect.left,
43
+ y: boundingRect.top,
44
+ width,
45
+ height,
46
+ },
47
+ },
48
+ } as LayoutChangeEvent);
49
+ }
50
+ });
51
+
52
+ resizeObserver.observe(element);
53
+
54
+ return () => {
55
+ resizeObserver.disconnect();
56
+ };
57
+ }, [onLayout]);
58
+
59
+ return ref;
60
+ }