@idealyst/components 1.2.130 → 1.2.132

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.130",
3
+ "version": "1.2.132",
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.130",
59
+ "@idealyst/theme": "^1.2.132",
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.130",
115
- "@idealyst/tooling": "^1.2.130",
114
+ "@idealyst/theme": "^1.2.132",
115
+ "@idealyst/tooling": "^1.2.132",
116
116
  "@mdi/react": "^1.6.1",
117
117
  "@types/react": "^19.1.0",
118
118
  "react": "^19.1.0",
@@ -17,6 +17,7 @@ import type { IdealystElement } from '../utils/refTypes';
17
17
 
18
18
  const Menu = forwardRef<IdealystElement, MenuProps>(({
19
19
  children,
20
+ anchor,
20
21
  items,
21
22
  open,
22
23
  onOpenChange,
@@ -33,6 +34,8 @@ const Menu = forwardRef<IdealystElement, MenuProps>(({
33
34
  accessibilityRole,
34
35
  accessibilityExpanded,
35
36
  }, ref) => {
37
+ const isAnchorMode = anchor != null && typeof anchor === 'object' && 'current' in anchor;
38
+
36
39
  // Generate native accessibility props
37
40
  const nativeA11yProps = useMemo(() => {
38
41
  return getNativeInteractiveAccessibilityProps({
@@ -48,7 +51,7 @@ const Menu = forwardRef<IdealystElement, MenuProps>(({
48
51
  position: menuPosition,
49
52
  size: menuSize,
50
53
  isPositioned,
51
- anchorRef: triggerRef,
54
+ anchorRef: hookAnchorRef,
52
55
  measureAndPosition,
53
56
  handleLayout: handleMenuLayout,
54
57
  reset: resetPosition,
@@ -59,7 +62,16 @@ const Menu = forwardRef<IdealystElement, MenuProps>(({
59
62
  matchWidth: false,
60
63
  });
61
64
 
62
- const mergedTriggerRef = useMergeRefs(ref, triggerRef);
65
+ // In anchor mode, sync external anchor ref into the hook's ref and
66
+ // trigger measurement when open changes
67
+ useEffect(() => {
68
+ if (isAnchorMode && open && anchor?.current) {
69
+ hookAnchorRef.current = anchor.current;
70
+ measureAndPosition();
71
+ }
72
+ }, [isAnchorMode, open]);
73
+
74
+ const mergedTriggerRef = useMergeRefs(ref, hookAnchorRef);
63
75
 
64
76
  // Animation shared values
65
77
  const menuOpacity = useSharedValue(0);
@@ -162,24 +174,28 @@ const Menu = forwardRef<IdealystElement, MenuProps>(({
162
174
  );
163
175
  };
164
176
 
165
- // Clone the child element and merge onPress handler
166
- const trigger = isValidElement(children)
167
- ? cloneElement(children as React.ReactElement<any>, {
168
- onPress: () => {
169
- // Call original onPress if it exists
170
- const originalOnPress = (children as any).props?.onPress;
171
- originalOnPress?.();
172
- // Then handle menu toggle
173
- handleTriggerPress();
174
- },
175
- })
176
- : children;
177
+ // Clone the child element and merge onPress handler (children mode only)
178
+ const trigger = !isAnchorMode && children
179
+ ? (isValidElement(children)
180
+ ? cloneElement(children as React.ReactElement<any>, {
181
+ onPress: () => {
182
+ // Call original onPress if it exists
183
+ const originalOnPress = (children as any).props?.onPress;
184
+ originalOnPress?.();
185
+ // Then handle menu toggle
186
+ handleTriggerPress();
187
+ },
188
+ })
189
+ : children)
190
+ : null;
177
191
 
178
192
  return (
179
193
  <>
180
- <View ref={mergedTriggerRef} nativeID={id} collapsable={false} {...nativeA11yProps}>
181
- {trigger}
182
- </View>
194
+ {!isAnchorMode && (
195
+ <View ref={mergedTriggerRef} nativeID={id} collapsable={false} {...nativeA11yProps}>
196
+ {trigger}
197
+ </View>
198
+ )}
183
199
 
184
200
  {renderMenu()}
185
201
  </>
@@ -14,6 +14,7 @@ import type { IdealystElement } from '../utils/refTypes';
14
14
  */
15
15
  const Menu = forwardRef<IdealystElement, MenuProps>(({
16
16
  children,
17
+ anchor,
17
18
  items,
18
19
  open = false,
19
20
  onOpenChange,
@@ -33,10 +34,14 @@ const Menu = forwardRef<IdealystElement, MenuProps>(({
33
34
  accessibilityControls,
34
35
  accessibilityHasPopup,
35
36
  }, ref) => {
37
+ const isAnchorMode = anchor != null && 'current' in anchor;
36
38
  const triggerRef = useRef<HTMLDivElement>(null);
37
39
  const menuRef = useRef<HTMLDivElement>(null);
38
40
  const menuItemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
39
41
  const focusedIndex = useRef<number>(-1);
42
+ const effectiveAnchorRef = isAnchorMode
43
+ ? (anchor as React.RefObject<HTMLElement>)
44
+ : (triggerRef as React.RefObject<HTMLElement>);
40
45
 
41
46
  // Generate unique ID for menu
42
47
  const menuId = useMemo(() => id || generateAccessibilityId('menu'), [id]);
@@ -68,8 +73,8 @@ const Menu = forwardRef<IdealystElement, MenuProps>(({
68
73
  if (matchesKey(e, MENU_KEYS.close)) {
69
74
  e.preventDefault();
70
75
  onOpenChange?.(false);
71
- // Return focus to trigger
72
- triggerRef.current?.focus();
76
+ // Return focus to trigger or anchor
77
+ effectiveAnchorRef.current?.focus();
73
78
  return;
74
79
  }
75
80
 
@@ -138,29 +143,31 @@ const Menu = forwardRef<IdealystElement, MenuProps>(({
138
143
 
139
144
  return (
140
145
  <>
141
- <div
142
- ref={triggerRef}
143
- onClick={handleTriggerClick}
144
- style={{ display: 'inline-block' }}
145
- aria-haspopup={accessibilityHasPopup ?? 'menu'}
146
- aria-expanded={open}
147
- aria-controls={menuId}
148
- tabIndex={0}
149
- onKeyDown={(e) => {
150
- if (e.key === 'Enter' || e.key === ' ') {
151
- e.preventDefault();
152
- handleTriggerClick();
153
- }
154
- }}
155
- >
156
- {children}
157
- </div>
146
+ {!isAnchorMode && (
147
+ <div
148
+ ref={triggerRef}
149
+ onClick={handleTriggerClick}
150
+ style={{ display: 'inline-block' }}
151
+ aria-haspopup={accessibilityHasPopup ?? 'menu'}
152
+ aria-expanded={open}
153
+ aria-controls={menuId}
154
+ tabIndex={0}
155
+ onKeyDown={(e) => {
156
+ if (e.key === 'Enter' || e.key === ' ') {
157
+ e.preventDefault();
158
+ handleTriggerClick();
159
+ }
160
+ }}
161
+ >
162
+ {children}
163
+ </div>
164
+ )}
158
165
 
159
166
  {open && <div {...overlayProps} />}
160
167
 
161
168
  <PositionedPortal
162
169
  open={open}
163
- anchor={triggerRef as React.RefObject<HTMLElement>}
170
+ anchor={effectiveAnchorRef}
164
171
  placement={placement}
165
172
  offset={4}
166
173
  onClickOutside={() => onOpenChange?.(false)}
package/src/Menu/types.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import type { RefObject } from 'react';
1
2
  import type { StyleProp, ViewStyle } from 'react-native';
2
3
  import type { IconName } from '../Icon/icon-types';
3
4
  import { Intent, Size } from '@idealyst/theme';
4
5
  import { BaseProps } from '../utils/viewStyleProps';
5
6
  import { InteractiveAccessibilityProps } from '../utils/accessibility';
7
+ import type { IdealystElement } from '../utils/refTypes';
6
8
 
7
9
  // Component-specific type aliases for future extensibility
8
10
  export type MenuIntentVariant = Intent;
@@ -20,7 +22,25 @@ export interface MenuItem {
20
22
  }
21
23
 
22
24
  export interface MenuProps extends BaseProps, InteractiveAccessibilityProps {
23
- children: React.ReactNode;
25
+ /**
26
+ * Trigger element that opens the menu on press/click.
27
+ * Either `children` or `anchor` must be provided.
28
+ */
29
+ children?: React.ReactNode;
30
+
31
+ /**
32
+ * External anchor ref to position the menu relative to.
33
+ * When provided, no trigger wrapper is rendered — the consumer controls open/close.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * const ref = useRef<IdealystElement>(null);
38
+ * <Button ref={ref} onPress={() => setOpen(true)}>Actions</Button>
39
+ * <Menu anchor={ref} open={open} onOpenChange={setOpen} items={items} />
40
+ * ```
41
+ */
42
+ anchor?: RefObject<IdealystElement>;
43
+
24
44
  items: MenuItem[];
25
45
  open?: boolean;
26
46
  onOpenChange?: (open: boolean) => void;
@@ -90,6 +90,7 @@ const RadioButton = forwardRef<IdealystElement, RadioButtonProps>(({
90
90
  // Apply variants for radio styles
91
91
  radioButtonStyles.useVariants({
92
92
  size,
93
+ intent,
93
94
  checked,
94
95
  disabled,
95
96
  margin,
@@ -102,26 +103,20 @@ const RadioButton = forwardRef<IdealystElement, RadioButtonProps>(({
102
103
  outputRange: [0, 1],
103
104
  });
104
105
 
105
- // Get dynamic styles - call as functions for theme reactivity
106
- const containerStyle = (radioButtonStyles.container as any)({});
107
- const radioStyle = (radioButtonStyles.radio as any)({ intent, checked, disabled });
108
- const radioDotStyle = (radioButtonStyles.radioDot as any)({ intent });
109
- const labelStyle = (radioButtonStyles.label as any)({ disabled });
110
-
111
106
  return (
112
107
  <Pressable
113
108
  ref={ref as any}
114
109
  nativeID={id}
115
110
  onPress={handlePress}
116
111
  disabled={disabled}
117
- style={[containerStyle, style]}
112
+ style={[radioButtonStyles.container, style]}
118
113
  testID={testID}
119
114
  {...nativeA11yProps}
120
115
  >
121
- <View style={radioStyle}>
116
+ <View style={radioButtonStyles.radio}>
122
117
  <Animated.View
123
118
  style={[
124
- radioDotStyle,
119
+ radioButtonStyles.radioDot,
125
120
  {
126
121
  transform: [{ scale: dotScale }],
127
122
  },
@@ -129,7 +124,7 @@ const RadioButton = forwardRef<IdealystElement, RadioButtonProps>(({
129
124
  />
130
125
  </View>
131
126
  {label && (
132
- <Text style={labelStyle}>
127
+ <Text style={radioButtonStyles.label}>
133
128
  {label}
134
129
  </Text>
135
130
  )}
@@ -1,5 +1,8 @@
1
1
  /**
2
- * RadioButton styles using defineStyle with dynamic props.
2
+ * RadioButton styles using defineStyle.
3
+ *
4
+ * Static style entries are plain objects; dynamic entries are functions that
5
+ * receive runtime props. Variants are resolved via `useVariants()`.
3
6
  */
4
7
  import { StyleSheet } from 'react-native-unistyles';
5
8
  import { defineStyle, ThemeStyleWrapper } from '@idealyst/theme';
@@ -20,6 +23,7 @@ export type RadioButtonDynamicProps = {
20
23
  checked?: boolean;
21
24
  disabled?: boolean;
22
25
  orientation?: RadioGroupOrientation;
26
+ hasError?: boolean;
23
27
  margin?: ViewStyleSize;
24
28
  marginVertical?: ViewStyleSize;
25
29
  marginHorizontal?: ViewStyleSize;
@@ -29,7 +33,7 @@ export type RadioButtonDynamicProps = {
29
33
  * RadioButton styles with intent/checked/disabled handling.
30
34
  */
31
35
  export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
32
- container: (_props: RadioButtonDynamicProps) => ({
36
+ container: {
33
37
  flexDirection: 'row' as const,
34
38
  alignItems: 'center' as const,
35
39
  paddingVertical: 4,
@@ -47,9 +51,9 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
47
51
  marginHorizontal: theme.sizes.$view.padding,
48
52
  },
49
53
  },
50
- }),
54
+ },
51
55
 
52
- radio: (_props: RadioButtonDynamicProps) => ({
56
+ radio: {
53
57
  borderRadius: 9999,
54
58
  borderWidth: 1.5,
55
59
  borderStyle: 'solid' as const,
@@ -82,9 +86,9 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
82
86
  _web: {
83
87
  transition: 'all 0.2s ease',
84
88
  },
85
- }),
89
+ },
86
90
 
87
- radioDot: (_props: RadioButtonDynamicProps) => ({
91
+ radioDot: {
88
92
  borderRadius: 9999,
89
93
  variants: {
90
94
  size: {
@@ -95,9 +99,9 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
95
99
  backgroundColor: theme.$intents.primary,
96
100
  },
97
101
  },
98
- }),
102
+ },
99
103
 
100
- label: (_props: RadioButtonDynamicProps) => ({
104
+ label: {
101
105
  color: theme.colors.text.primary,
102
106
  variants: {
103
107
  size: {
@@ -108,9 +112,9 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
108
112
  false: { opacity: 1 },
109
113
  },
110
114
  },
111
- }),
115
+ },
112
116
 
113
- groupContainer: (_props: RadioButtonDynamicProps) => ({
117
+ groupContainer: {
114
118
  gap: 4,
115
119
  variants: {
116
120
  orientation: {
@@ -124,15 +128,15 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
124
128
  },
125
129
  },
126
130
  },
127
- }),
131
+ },
128
132
 
129
- groupWrapper: (_props: RadioButtonDynamicProps) => ({
133
+ groupWrapper: {
130
134
  display: 'flex' as const,
131
135
  flexDirection: 'column' as const,
132
136
  gap: 4,
133
- }),
137
+ },
134
138
 
135
- groupLabel: (_props: RadioButtonDynamicProps) => ({
139
+ groupLabel: {
136
140
  fontSize: 14,
137
141
  fontWeight: '500' as const,
138
142
  color: theme.colors.text.primary,
@@ -142,9 +146,9 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
142
146
  false: { opacity: 1 },
143
147
  },
144
148
  },
145
- }),
149
+ },
146
150
 
147
- groupHelperText: (_props: RadioButtonDynamicProps) => ({
151
+ groupHelperText: {
148
152
  fontSize: 12,
149
153
  variants: {
150
154
  hasError: {
@@ -152,5 +156,5 @@ export const radioButtonStyles = defineStyle('RadioButton', (theme: Theme) => ({
152
156
  false: { color: theme.colors.text.secondary },
153
157
  },
154
158
  },
155
- }),
159
+ },
156
160
  }));
@@ -86,6 +86,7 @@ const RadioButton: React.FC<RadioButtonProps> = ({
86
86
  // Apply variants using the correct Unistyles v3 pattern
87
87
  radioButtonStyles.useVariants({
88
88
  size,
89
+ intent,
89
90
  checked,
90
91
  disabled,
91
92
  margin,
@@ -93,10 +94,10 @@ const RadioButton: React.FC<RadioButtonProps> = ({
93
94
  marginHorizontal,
94
95
  });
95
96
 
96
- const containerProps = getWebProps([(radioButtonStyles.container as any)({}), style]);
97
- const radioProps = getWebProps([(radioButtonStyles.radio as any)({ intent, checked, disabled })]);
98
- const dotProps = getWebProps([(radioButtonStyles.radioDot as any)({ intent })]);
99
- const labelProps = getWebProps([(radioButtonStyles.label as any)({ disabled })]);
97
+ const containerProps = getWebProps([radioButtonStyles.container, style]);
98
+ const radioProps = getWebProps([radioButtonStyles.radio]);
99
+ const dotProps = getWebProps([radioButtonStyles.radioDot]);
100
+ const labelProps = getWebProps([radioButtonStyles.label]);
100
101
 
101
102
  return (
102
103
  <button
@@ -17,7 +17,7 @@ const RadioGroup = forwardRef<IdealystElement, RadioGroupProps>(({
17
17
  value,
18
18
  onValueChange,
19
19
  disabled = false,
20
- orientation: _orientation = 'vertical',
20
+ orientation = 'vertical',
21
21
  children,
22
22
  error,
23
23
  helperText,
@@ -46,9 +46,12 @@ const RadioGroup = forwardRef<IdealystElement, RadioGroupProps>(({
46
46
  });
47
47
  }, [accessibilityLabel, accessibilityHint, accessibilityDisabled, disabled, accessibilityHidden, accessibilityRole]);
48
48
 
49
- const wrapperStyle = (radioButtonStyles.groupWrapper as any)({});
50
- const labelStyle = (radioButtonStyles.groupLabel as any)({ disabled });
51
- const helperTextStyle = (radioButtonStyles.groupHelperText as any)({ hasError });
49
+ // Apply variants for group styles
50
+ radioButtonStyles.useVariants({
51
+ orientation,
52
+ disabled,
53
+ hasError,
54
+ });
52
55
 
53
56
  const content = (
54
57
  <RadioGroupContext.Provider value={{ value, onValueChange, disabled }}>
@@ -71,13 +74,13 @@ const RadioGroup = forwardRef<IdealystElement, RadioGroupProps>(({
71
74
  }
72
75
 
73
76
  return (
74
- <View ref={ref as any} style={wrapperStyle}>
75
- {label && <Text style={labelStyle}>{label}</Text>}
77
+ <View ref={ref as any} style={radioButtonStyles.groupWrapper}>
78
+ {label && <Text style={radioButtonStyles.groupLabel}>{label}</Text>}
76
79
  {content}
77
80
  {showFooter && (
78
81
  <View style={{ flex: 1 }}>
79
- {error && <Text style={helperTextStyle}>{error}</Text>}
80
- {!error && helperText && <Text style={helperTextStyle}>{helperText}</Text>}
82
+ {error && <Text style={radioButtonStyles.groupHelperText}>{error}</Text>}
83
+ {!error && helperText && <Text style={radioButtonStyles.groupHelperText}>{helperText}</Text>}
81
84
  </View>
82
85
  )}
83
86
  </View>
@@ -59,9 +59,9 @@ const RadioGroup: React.FC<RadioGroupProps> = ({
59
59
  style as any,
60
60
  ]);
61
61
 
62
- const wrapperProps = getWebProps([(radioButtonStyles.groupWrapper as any)({})]);
63
- const labelProps = getWebProps([(radioButtonStyles.groupLabel as any)({ disabled })]);
64
- const helperTextProps = getWebProps([(radioButtonStyles.groupHelperText as any)({ hasError })]);
62
+ const wrapperProps = getWebProps([radioButtonStyles.groupWrapper]);
63
+ const labelProps = getWebProps([radioButtonStyles.groupLabel]);
64
+ const helperTextProps = getWebProps([radioButtonStyles.groupHelperText]);
65
65
 
66
66
  const content = (
67
67
  <RadioGroupContext.Provider value={{ value, onValueChange, disabled }}>
@@ -137,12 +137,14 @@ export const tableStyles = defineStyle('Table', (theme: Theme) => ({
137
137
  return {
138
138
  ...typeStyles,
139
139
  _web: {
140
- transition: 'background-color 0.2s ease',
140
+ transition: 'background-color 0.15s ease',
141
141
  borderBottom: (type === 'bordered' || type === 'striped')
142
142
  ? `1px solid ${theme.colors.border.primary}`
143
143
  : undefined,
144
144
  cursor: clickable ? 'pointer' : undefined,
145
- _hover: clickable ? { backgroundColor: theme.colors.surface.secondary } : {},
145
+ _hover: {
146
+ backgroundColor: theme.colors.surface.secondary,
147
+ },
146
148
  // Striped rows handled via CSS pseudo-selector
147
149
  ...(type === 'striped' ? {
148
150
  ':nth-child(even)': {
@@ -158,7 +160,7 @@ export const tableStyles = defineStyle('Table', (theme: Theme) => ({
158
160
  alignItems: 'center' as const,
159
161
  fontWeight: '600' as const,
160
162
  color: theme.colors.text.primary,
161
- borderBottomWidth: 2,
163
+ borderBottomWidth: 1,
162
164
  borderBottomColor: theme.colors.border.primary,
163
165
  variants: {
164
166
  type: {