@idealyst/components 1.2.136 → 1.2.138

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.136",
3
+ "version": "1.2.138",
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.136",
59
+ "@idealyst/theme": "^1.2.138",
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.136",
115
- "@idealyst/tooling": "^1.2.136",
114
+ "@idealyst/theme": "^1.2.138",
115
+ "@idealyst/tooling": "^1.2.138",
116
116
  "@mdi/react": "^1.6.1",
117
117
  "@types/react": "^19.1.0",
118
118
  "react": "^19.1.0",
@@ -65,8 +65,6 @@ const Button = forwardRef<IdealystElement, ButtonProps>((props, ref) => {
65
65
  }
66
66
 
67
67
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
68
- e.preventDefault();
69
- e.stopPropagation();
70
68
  if (!isDisabled && pressHandler) {
71
69
  pressHandler(createPressEvent(e));
72
70
  }
@@ -1,6 +1,6 @@
1
- import { useEffect, forwardRef, useMemo, useState } from 'react';
1
+ import { useEffect, forwardRef, useMemo } from 'react';
2
2
  import { Modal, View, Text, TouchableOpacity, TouchableWithoutFeedback, BackHandler, Platform, Keyboard, useWindowDimensions } from 'react-native';
3
- import Animated, { useSharedValue, useAnimatedStyle, withTiming, Easing } from 'react-native-reanimated';
3
+ import Animated, { useSharedValue, useDerivedValue, useAnimatedStyle, withTiming, Easing } from 'react-native-reanimated';
4
4
  import { useSafeAreaInsets } from '@idealyst/theme';
5
5
  import { DialogProps } from './types';
6
6
  import { dialogStyles } from './Dialog.styles';
@@ -51,27 +51,35 @@ const Dialog = forwardRef<View, DialogProps>(({
51
51
  // Get safe area insets
52
52
  const insets = useSafeAreaInsets();
53
53
 
54
- // Track keyboard height for avoidKeyboard
55
- const [keyboardHeight, setKeyboardHeight] = useState(0);
54
+ // Animated keyboard height for smooth keyboard avoidance
55
+ const keyboardHeight = useSharedValue(0);
56
56
  const { height: screenHeight } = useWindowDimensions();
57
57
 
58
58
  useEffect(() => {
59
- if (!avoidKeyboard || !open) return;
59
+ if (!avoidKeyboard || !open) {
60
+ // Animate back to 0 when dialog closes or avoidKeyboard is disabled
61
+ keyboardHeight.value = withTiming(0, { duration: 250 });
62
+ return;
63
+ }
60
64
 
61
65
  const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
62
66
  const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
63
67
 
64
68
  const showSub = Keyboard.addListener(showEvent, (e) => {
65
- setKeyboardHeight(e.endCoordinates.height);
69
+ const duration = Platform.OS === 'ios' ? e.duration : 250;
70
+ keyboardHeight.value = withTiming(e.endCoordinates.height, { duration });
66
71
  });
67
72
 
68
- const hideSub = Keyboard.addListener(hideEvent, () => {
69
- setKeyboardHeight(0);
73
+ const hideSub = Keyboard.addListener(hideEvent, (e) => {
74
+ const duration = Platform.OS === 'ios' ? (e?.duration ?? 250) : 250;
75
+ keyboardHeight.value = withTiming(0, { duration });
70
76
  });
71
77
 
72
78
  return () => {
73
79
  showSub.remove();
74
80
  hideSub.remove();
81
+ // Animate back to 0 on cleanup so we don't get stuck at a stale value
82
+ keyboardHeight.value = withTiming(0, { duration: 250 });
75
83
  };
76
84
  }, [avoidKeyboard, open]);
77
85
 
@@ -168,48 +176,69 @@ const Dialog = forwardRef<View, DialogProps>(({
168
176
  bottom: 0,
169
177
  };
170
178
 
171
- // Position offsets for the container view
172
- // Top: always safe area + padding
173
- // Bottom: safe area + padding (no keyboard) or keyboard + padding (with keyboard)
179
+ // Derived bottom offset animates smoothly with keyboard
174
180
  const topOffset = insets.top + paddingProp;
175
- const bottomOffset = keyboardHeight > 0
176
- ? keyboardHeight + paddingProp
177
- : insets.bottom + paddingProp;
178
-
179
- // Max height is the available space (used as a ceiling, children can be smaller)
180
- const maxAvailableHeight = screenHeight - topOffset - bottomOffset;
181
+ const bottomOffset = useDerivedValue(() => {
182
+ 'worklet';
183
+ return keyboardHeight.value > 0
184
+ ? keyboardHeight.value + paddingProp
185
+ : insets.bottom + paddingProp;
186
+ });
181
187
 
182
- // Use the smaller of user's preferred max height and available space
183
- const effectiveMaxHeight = maxContentHeight
184
- ? Math.min(maxContentHeight, maxAvailableHeight)
185
- : maxAvailableHeight;
188
+ // Animated style for the positioning wrapper
189
+ const positioningStyle = useAnimatedStyle(() => {
190
+ 'worklet';
191
+ return {
192
+ bottom: bottomOffset.value,
193
+ };
194
+ });
186
195
 
187
196
  // Resolve explicit height (number or percentage string)
188
197
  const resolvedHeight = typeof height === 'string'
189
198
  ? height.endsWith('%')
190
- ? (parseFloat(height) / 100) * maxAvailableHeight
199
+ ? (parseFloat(height) / 100) * screenHeight // approximate; animated version below handles it
191
200
  : parseFloat(height)
192
201
  : height;
193
202
 
194
- // Dialog uses the effective max height, with a definite height when requested
195
- // so children can resolve flex: 1 against it
196
- const dialogContainerStyle = {
203
+ // Only apply flex: 1 to content when the dialog has a definite height to flex against.
204
+ // Without a definite height, flex: 1 collapses content instead of wrapping naturally.
205
+ const hasDefiniteHeight = Boolean(resolvedHeight || maxContentHeight);
206
+
207
+ // Static container styles (not dependent on keyboard)
208
+ const staticContainerStyle = {
197
209
  ...containerStyle,
198
- maxHeight: effectiveMaxHeight,
199
- height: resolvedHeight
200
- ? Math.min(resolvedHeight, effectiveMaxHeight)
201
- : maxContentHeight
202
- ? effectiveMaxHeight
203
- : undefined,
204
210
  flex: undefined,
205
211
  };
206
212
 
207
- // Only apply flex: 1 to content when the dialog has a definite height to flex against.
208
- // Without a definite height, flex: 1 collapses content instead of wrapping naturally.
209
- const hasDefiniteHeight = Boolean(resolvedHeight || maxContentHeight);
213
+ // Animated maxHeight/height that responds to keyboard changes
214
+ const dialogSizeStyle = useAnimatedStyle(() => {
215
+ 'worklet';
216
+ const currentBottom = bottomOffset.value;
217
+ const maxAvailable = screenHeight - topOffset - currentBottom;
218
+ const effectiveMax = maxContentHeight
219
+ ? Math.min(maxContentHeight, maxAvailable)
220
+ : maxAvailable;
221
+
222
+ const result: { maxHeight: number; height?: number } = {
223
+ maxHeight: effectiveMax,
224
+ };
225
+
226
+ if (resolvedHeight) {
227
+ result.height = Math.min(resolvedHeight, effectiveMax);
228
+ } else if (maxContentHeight) {
229
+ result.height = effectiveMax;
230
+ }
231
+
232
+ return result;
233
+ });
210
234
 
211
235
  const dialogContainer = (
212
- <Animated.View ref={ref as any} style={[dialogContainerStyle, style, containerAnimatedStyle]} nativeID={id} {...nativeA11yProps}>
236
+ <Animated.View
237
+ ref={ref as any}
238
+ style={[staticContainerStyle, style, dialogSizeStyle, containerAnimatedStyle]}
239
+ nativeID={id}
240
+ {...nativeA11yProps}
241
+ >
213
242
  {(title || showCloseButton) && (
214
243
  <View style={[headerStyle, { flexShrink: 0 }]}>
215
244
  {title && (
@@ -259,25 +288,24 @@ const Dialog = forwardRef<View, DialogProps>(({
259
288
  </TouchableWithoutFeedback>
260
289
  )}
261
290
  {/* Dialog content - positioned absolute, accounts for keyboard and safe areas */}
262
- <View
263
- style={{
291
+ <Animated.View
292
+ style={[{
264
293
  position: 'absolute',
265
294
  top: topOffset,
266
295
  left: 0,
267
296
  right: 0,
268
- bottom: bottomOffset,
269
297
  alignItems: 'center',
270
298
  justifyContent: 'center',
271
299
  zIndex: 1001,
272
- }}
300
+ }, positioningStyle]}
273
301
  pointerEvents="box-none"
274
302
  >
275
303
  {dialogContainer}
276
- </View>
304
+ </Animated.View>
277
305
  </Modal>
278
306
  );
279
307
  });
280
308
 
281
309
  Dialog.displayName = 'Dialog';
282
310
 
283
- export default Dialog;
311
+ export default Dialog;
@@ -70,8 +70,6 @@ const IconButton = forwardRef<IdealystElement, IconButtonProps>((props, ref) =>
70
70
  }
71
71
 
72
72
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
73
- e.preventDefault();
74
- e.stopPropagation();
75
73
  if (!isDisabled && pressHandler) {
76
74
  pressHandler(createPressEvent(e));
77
75
  }
@@ -28,24 +28,18 @@ const Pressable = forwardRef<IdealystElement, PressableProps>(({
28
28
  const [_isPressed, setIsPressed] = useState(false);
29
29
 
30
30
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
31
- e.preventDefault();
32
- e.stopPropagation();
33
31
  if (disabled) return;
34
32
  setIsPressed(true);
35
33
  onPressIn?.(createPressEvent(e as React.MouseEvent<HTMLElement>, 'pressIn'));
36
34
  }, [disabled, onPressIn]);
37
35
 
38
36
  const handleMouseUp = useCallback((e: React.MouseEvent) => {
39
- e.preventDefault();
40
- e.stopPropagation();
41
37
  if (disabled) return;
42
38
  setIsPressed(false);
43
39
  onPressOut?.(createPressEvent(e as React.MouseEvent<HTMLElement>, 'pressOut'));
44
40
  }, [disabled, onPressOut]);
45
41
 
46
42
  const handleClick = useCallback((e: React.MouseEvent) => {
47
- e.preventDefault();
48
- e.stopPropagation();
49
43
  if (disabled) return;
50
44
  onPress?.(createPressEvent(e as React.MouseEvent<HTMLElement>));
51
45
  }, [disabled, onPress]);
@@ -1,13 +1,18 @@
1
- import React, { useState } from 'react';
1
+ import React, { useRef, useState } from 'react';
2
2
  import { Screen, View, Text, Button, Menu, MenuItem } from '@idealyst/components';
3
3
 
4
4
  export const MenuExamples: React.FC = () => {
5
5
  const [basicMenuOpen, setBasicMenuOpen] = useState(false);
6
+ const [anchorMenuOpen, setAnchorMenuOpen] = useState(false);
6
7
  const [placementMenuOpen, setPlacementMenuOpen] = useState(false);
7
8
  const [iconNameMenuOpen, setIconNameMenuOpen] = useState(false);
8
9
  const [intentMenuOpen, setIntentMenuOpen] = useState(false);
9
10
  const [separatorMenuOpen, setSeparatorMenuOpen] = useState(false);
10
11
  const [disabledMenuOpen, setDisabledMenuOpen] = useState(false);
12
+ const [rightEdgeMenuOpen, setRightEdgeMenuOpen] = useState(false);
13
+ const [bottomEdgeMenuOpen, setBottomEdgeMenuOpen] = useState(false);
14
+
15
+ const anchorRef = useRef(null);
11
16
 
12
17
  const [selectedAction, setSelectedAction] = useState<string>('');
13
18
 
@@ -59,7 +64,7 @@ export const MenuExamples: React.FC = () => {
59
64
  )}
60
65
 
61
66
  <View gap="md">
62
- <Text typography="h5">Basic Menu</Text>
67
+ <Text typography="h5">Basic Menu (Children Mode)</Text>
63
68
  <Menu
64
69
  items={basicItems}
65
70
  open={basicMenuOpen}
@@ -71,6 +76,27 @@ export const MenuExamples: React.FC = () => {
71
76
  </Menu>
72
77
  </View>
73
78
 
79
+ <View gap="md">
80
+ <Text typography="h5">Anchor Mode</Text>
81
+ <Text typography="body2" color="secondary">
82
+ The menu appears next to the anchor element, not the button.
83
+ </Text>
84
+ <View direction="row" gap="md" align="center">
85
+ <Button type="outlined" onPress={() => setAnchorMenuOpen(true)}>
86
+ Open Anchored Menu
87
+ </Button>
88
+ <View ref={anchorRef} style={{ padding: 8, borderWidth: 1, borderStyle: 'dashed', borderColor: '#999', borderRadius: 8 }}>
89
+ <Text color="secondary">Anchor</Text>
90
+ </View>
91
+ </View>
92
+ <Menu
93
+ items={iconNameItems}
94
+ anchor={anchorRef}
95
+ open={anchorMenuOpen}
96
+ onOpenChange={setAnchorMenuOpen}
97
+ />
98
+ </View>
99
+
74
100
  <View gap="md">
75
101
  <Text typography="h5">Placement Options</Text>
76
102
  <Menu
@@ -136,6 +162,37 @@ export const MenuExamples: React.FC = () => {
136
162
  </Button>
137
163
  </Menu>
138
164
  </View>
165
+
166
+ <Text typography="h4">Edge-of-Screen Tests</Text>
167
+ <Text typography="body2" color="secondary">
168
+ These menus should flip or clamp to stay within the viewport.
169
+ </Text>
170
+
171
+ <View style={{ alignItems: 'flex-end' }}>
172
+ <Menu
173
+ items={iconNameItems}
174
+ open={rightEdgeMenuOpen}
175
+ onOpenChange={setRightEdgeMenuOpen}
176
+ placement="bottom-end"
177
+ >
178
+ <Button type="outlined">
179
+ Right Edge
180
+ </Button>
181
+ </Menu>
182
+ </View>
183
+
184
+ <View style={{ marginTop: 200 }}>
185
+ <Menu
186
+ items={separatorItems}
187
+ open={bottomEdgeMenuOpen}
188
+ onOpenChange={setBottomEdgeMenuOpen}
189
+ placement="bottom-start"
190
+ >
191
+ <Button type="outlined">
192
+ Bottom Edge (scroll down)
193
+ </Button>
194
+ </Menu>
195
+ </View>
139
196
  </View>
140
197
  </Screen>
141
198
  );
@@ -120,28 +120,42 @@ const calculatePosition = (
120
120
  position.width = anchorRect.width;
121
121
  }
122
122
 
123
- // Clamp to viewport bounds (viewport-relative for fixed positioning)
124
123
  const padding = 8;
125
- position.left = Math.max(padding, Math.min(position.left, vpWidth - contentSize.width - padding));
126
- position.top = Math.max(padding, Math.min(position.top, vpHeight - contentSize.height - padding));
127
124
 
128
- // Flip vertical placement if it overflows
125
+ // Flip placement if it overflows (must run BEFORE clamping)
129
126
  const isAbove = placement.startsWith('top');
130
127
  const isBelow = placement.startsWith('bottom');
128
+ const isLeft = placement.startsWith('left');
129
+ const isRight = placement.startsWith('right');
130
+
131
131
  if (isBelow && position.top + contentSize.height > vpHeight - padding) {
132
- // Not enough space below — try above
133
132
  const aboveTop = anchorRect.top - contentSize.height - offset;
134
133
  if (aboveTop >= padding) {
135
134
  position.top = aboveTop;
136
135
  }
137
136
  } else if (isAbove && position.top < padding) {
138
- // Not enough space above — try below
139
137
  const belowTop = anchorRect.bottom + offset;
140
138
  if (belowTop + contentSize.height <= vpHeight - padding) {
141
139
  position.top = belowTop;
142
140
  }
143
141
  }
144
142
 
143
+ if (isRight && position.left + contentSize.width > vpWidth - padding) {
144
+ const leftPos = anchorRect.left - contentSize.width - offset;
145
+ if (leftPos >= padding) {
146
+ position.left = leftPos;
147
+ }
148
+ } else if (isLeft && position.left < padding) {
149
+ const rightPos = anchorRect.right + offset;
150
+ if (rightPos + contentSize.width <= vpWidth - padding) {
151
+ position.left = rightPos;
152
+ }
153
+ }
154
+
155
+ // Clamp to viewport bounds as final safety net
156
+ position.left = Math.max(padding, Math.min(position.left, vpWidth - contentSize.width - padding));
157
+ position.top = Math.max(padding, Math.min(position.top, vpHeight - contentSize.height - padding));
158
+
145
159
  return position;
146
160
  };
147
161
 
@@ -160,19 +174,20 @@ export const PositionedPortal: React.FC<PositionedPortalProps> = ({
160
174
  const [position, setPosition] = useState<Position>({ top: 0, left: 0 });
161
175
  const [isPositioned, setIsPositioned] = useState(false);
162
176
 
163
- // Calculate position from current DOM measurements
177
+ // Calculate position
164
178
  const updatePosition = useCallback(() => {
165
179
  if (!contentRef.current || !anchor.current) return;
166
180
 
167
181
  const anchorRect = anchor.current.getBoundingClientRect();
168
- const contentRect = contentRef.current.getBoundingClientRect();
169
-
170
- // Skip if content hasn't laid out yet
171
- if (contentRect.width === 0 && contentRect.height === 0) return;
182
+ // Use scrollWidth/scrollHeight for intrinsic content size —
183
+ // getBoundingClientRect() can be wrong when the element is at position 0,0
184
+ // because the container div has no explicit width and may stretch.
185
+ const contentWidth = contentRef.current.scrollWidth;
186
+ const contentHeight = contentRef.current.scrollHeight;
172
187
 
173
188
  const newPosition = calculatePosition(
174
189
  anchorRect,
175
- { width: contentRect.width, height: contentRect.height },
190
+ { width: contentWidth, height: contentHeight },
176
191
  placement,
177
192
  offset,
178
193
  matchWidth
@@ -182,26 +197,11 @@ export const PositionedPortal: React.FC<PositionedPortalProps> = ({
182
197
  setIsPositioned(true);
183
198
  }, [anchor, placement, offset, matchWidth]);
184
199
 
185
- // Observe content size changes so we re-position once children lay out
186
- useEffect(() => {
187
- if (!open || !contentRef.current) return;
188
-
189
- const observer = new ResizeObserver(() => {
190
- updatePosition();
191
- });
192
- observer.observe(contentRef.current);
193
-
194
- return () => observer.disconnect();
195
- }, [open, updatePosition]);
196
-
197
- // Initial positioning after portal mounts
200
+ // Position after DOM is ready
198
201
  useLayoutEffect(() => {
199
202
  if (open) {
200
- // Double-rAF to ensure portal content is in the DOM and laid out
201
203
  const rafId = requestAnimationFrame(() => {
202
- requestAnimationFrame(() => {
203
- updatePosition();
204
- });
204
+ updatePosition();
205
205
  });
206
206
  return () => cancelAnimationFrame(rafId);
207
207
  } else {
@@ -209,10 +209,12 @@ export const PositionedPortal: React.FC<PositionedPortalProps> = ({
209
209
  }
210
210
  }, [open, updatePosition]);
211
211
 
212
- // Re-position on scroll/resize
212
+ // Update position on scroll/resize
213
213
  useEffect(() => {
214
214
  if (!open) return;
215
215
 
216
+ updatePosition();
217
+
216
218
  const handleUpdate = () => updatePosition();
217
219
  window.addEventListener('resize', handleUpdate);
218
220
  window.addEventListener('scroll', handleUpdate, true);
@@ -33,21 +33,25 @@ const noop = () => {};
33
33
  /**
34
34
  * Wraps a React Native GestureResponderEvent into a standardized PressEvent.
35
35
  * Pass the component's ref object as `targetRef` so consumers can use it for anchoring.
36
+ * preventDefault() and stopPropagation() are no-ops on native but still update flags.
36
37
  */
37
38
  export function createPressEvent(
38
39
  event: GestureResponderEvent,
39
40
  type: PressEvent['type'] = 'press',
40
41
  targetRef?: RefObject<IdealystElement>
41
42
  ): PressEvent {
43
+ let defaultPrevented = false;
44
+ let propagationStopped = false;
45
+
42
46
  return {
43
47
  nativeEvent: event.nativeEvent,
44
48
  timestamp: event.nativeEvent.timestamp,
45
- defaultPrevented: false,
46
- propagationStopped: false,
49
+ get defaultPrevented() { return defaultPrevented; },
50
+ get propagationStopped() { return propagationStopped; },
47
51
  type,
48
52
  targetRef: targetRef ?? { current: event.target },
49
- preventDefault: noop,
50
- stopPropagation: noop,
53
+ preventDefault() { defaultPrevented = true; },
54
+ stopPropagation() { propagationStopped = true; },
51
55
  };
52
56
  }
53
57
 
@@ -23,21 +23,32 @@ type ReactFormEvent = React.FormEvent<HTMLFormElement>;
23
23
  type ReactUIEvent = React.UIEvent<HTMLElement>;
24
24
 
25
25
  /**
26
- * Wraps a React mouse/click event into a standardized PressEvent
26
+ * Wraps a React mouse/click event into a standardized PressEvent.
27
+ * preventDefault() and stopPropagation() call through to the underlying
28
+ * React event and also update the PressEvent's own flags.
27
29
  */
28
30
  export function createPressEvent(
29
31
  event: ReactMouseEvent,
30
32
  type: PressEvent['type'] = 'press'
31
33
  ): PressEvent {
34
+ let defaultPrevented = event.defaultPrevented;
35
+ let propagationStopped = false;
36
+
32
37
  return {
33
38
  nativeEvent: event.nativeEvent,
34
39
  timestamp: event.timeStamp,
35
- defaultPrevented: event.defaultPrevented,
36
- propagationStopped: false,
40
+ get defaultPrevented() { return defaultPrevented; },
41
+ get propagationStopped() { return propagationStopped; },
37
42
  type,
38
43
  targetRef: { current: event.currentTarget },
39
- preventDefault: () => event.preventDefault(),
40
- stopPropagation: () => event.stopPropagation(),
44
+ preventDefault() {
45
+ defaultPrevented = true;
46
+ event.preventDefault();
47
+ },
48
+ stopPropagation() {
49
+ propagationStopped = true;
50
+ event.stopPropagation();
51
+ },
41
52
  };
42
53
  }
43
54