@idealyst/components 1.2.7 → 1.2.9
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 +3 -3
- package/plugin/__tests__/web.test.ts +611 -0
- package/plugin/web.js +30 -0
- package/src/Accordion/Accordion.native.tsx +1 -1
- package/src/Accordion/Accordion.styles.tsx +3 -0
- package/src/Accordion/Accordion.web.tsx +21 -30
- package/src/Alert/Alert.styles.tsx +21 -8
- package/src/Alert/Alert.web.tsx +4 -4
- package/src/Button/Button.native.tsx +46 -20
- package/src/Button/Button.styles.tsx +15 -0
- package/src/Button/Button.web.tsx +51 -8
- package/src/Button/types.ts +7 -0
- package/src/Icon/Icon.native.tsx +2 -1
- package/src/Icon/Icon.styles.tsx +8 -4
- package/src/Icon/Icon.web.tsx +8 -4
- package/src/Icon/types.ts +37 -7
- package/src/Input/Input.styles.tsx +7 -1
- package/src/Input/Input.web.tsx +18 -12
- package/src/List/List.native.tsx +14 -2
- package/src/List/List.styles.tsx +6 -3
- package/src/List/List.web.tsx +14 -2
- package/src/List/ListItem.native.tsx +3 -2
- package/src/List/ListItem.web.tsx +3 -2
- package/src/Screen/Screen.styles.tsx +0 -1
- package/src/examples/ButtonExamples.tsx +110 -0
- package/src/examples/IconExamples.tsx +38 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useState, useRef,
|
|
1
|
+
import React, { useState, useRef, useMemo, useCallback } from 'react';
|
|
2
2
|
import { getWebProps } from 'react-native-unistyles/web';
|
|
3
|
+
import { useUnistyles } from 'react-native-unistyles';
|
|
3
4
|
import { accordionStyles } from './Accordion.styles';
|
|
4
5
|
import type { AccordionProps, AccordionItem as AccordionItemType } from './types';
|
|
5
6
|
import { IconSvg } from '../Icon/IconSvg/IconSvg.web';
|
|
@@ -23,15 +24,17 @@ const AccordionItem: React.FC<AccordionItemProps> = ({
|
|
|
23
24
|
isExpanded,
|
|
24
25
|
onToggle,
|
|
25
26
|
type,
|
|
26
|
-
size,
|
|
27
|
+
size = 'md',
|
|
27
28
|
isLast,
|
|
28
29
|
testID,
|
|
29
30
|
headerId,
|
|
30
31
|
panelId,
|
|
31
32
|
onKeyDown,
|
|
32
33
|
}) => {
|
|
33
|
-
|
|
34
|
-
const
|
|
34
|
+
// Get theme for icon size
|
|
35
|
+
const { theme } = useUnistyles();
|
|
36
|
+
const iconSize = theme.sizes.accordion[size].iconSize;
|
|
37
|
+
const iconColor = theme.intents.primary.primary;
|
|
35
38
|
|
|
36
39
|
// Apply item-specific variants (for size, expanded, disabled)
|
|
37
40
|
accordionStyles.useVariants({
|
|
@@ -46,22 +49,9 @@ const AccordionItem: React.FC<AccordionItemProps> = ({
|
|
|
46
49
|
const headerProps = getWebProps([(accordionStyles.header as any)({})]);
|
|
47
50
|
const titleProps = getWebProps([accordionStyles.title]);
|
|
48
51
|
const iconProps = getWebProps([(accordionStyles.icon as any)({})]);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
height: isExpanded ? contentHeight : 0,
|
|
53
|
-
overflow: 'hidden' as const,
|
|
54
|
-
}
|
|
55
|
-
]);
|
|
56
|
-
const contentInnerProps = getWebProps([accordionStyles.contentInner]);
|
|
57
|
-
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (isExpanded) {
|
|
60
|
-
setContentHeight(contentInnerRef.current.getBoundingClientRect().height);
|
|
61
|
-
} else {
|
|
62
|
-
setContentHeight(0);
|
|
63
|
-
}
|
|
64
|
-
}, [isExpanded]);
|
|
52
|
+
// Pass expanded state to get correct maxHeight from styles
|
|
53
|
+
const contentProps = getWebProps([(accordionStyles.content as any)({ expanded: isExpanded })]);
|
|
54
|
+
const contentInnerProps = getWebProps([(accordionStyles.contentInner as any)({})]);
|
|
65
55
|
|
|
66
56
|
return (
|
|
67
57
|
<div
|
|
@@ -83,8 +73,9 @@ const AccordionItem: React.FC<AccordionItemProps> = ({
|
|
|
83
73
|
</span>
|
|
84
74
|
<span {...iconProps}>
|
|
85
75
|
<IconSvg
|
|
86
|
-
style={{ width: 12, height: 12 }}
|
|
87
76
|
name="chevron-down"
|
|
77
|
+
size={iconSize}
|
|
78
|
+
color={iconColor}
|
|
88
79
|
aria-label="chevron-down"
|
|
89
80
|
/>
|
|
90
81
|
</span>
|
|
@@ -97,10 +88,8 @@ const AccordionItem: React.FC<AccordionItemProps> = ({
|
|
|
97
88
|
aria-labelledby={headerId}
|
|
98
89
|
aria-hidden={!isExpanded}
|
|
99
90
|
>
|
|
100
|
-
<div
|
|
101
|
-
|
|
102
|
-
{item.content}
|
|
103
|
-
</div>
|
|
91
|
+
<div {...contentInnerProps}>
|
|
92
|
+
{item.content}
|
|
104
93
|
</div>
|
|
105
94
|
</div>
|
|
106
95
|
</div>
|
|
@@ -154,16 +143,18 @@ const Accordion: React.FC<AccordionProps> = ({
|
|
|
154
143
|
const currentIndex = enabledItems.findIndex(item => item.id === itemId);
|
|
155
144
|
let nextIndex = -1;
|
|
156
145
|
|
|
157
|
-
|
|
146
|
+
// ArrowDown moves to next item
|
|
147
|
+
if (key === 'ArrowDown') {
|
|
158
148
|
e.preventDefault();
|
|
159
149
|
nextIndex = currentIndex < enabledItems.length - 1 ? currentIndex + 1 : 0;
|
|
160
|
-
|
|
150
|
+
// ArrowUp moves to previous item
|
|
151
|
+
} else if (key === 'ArrowUp') {
|
|
161
152
|
e.preventDefault();
|
|
162
153
|
nextIndex = currentIndex > 0 ? currentIndex - 1 : enabledItems.length - 1;
|
|
163
|
-
} else if (ACCORDION_KEYS.first.includes(key)) {
|
|
154
|
+
} else if (ACCORDION_KEYS.first.includes(key as 'Home')) {
|
|
164
155
|
e.preventDefault();
|
|
165
156
|
nextIndex = 0;
|
|
166
|
-
} else if (ACCORDION_KEYS.last.includes(key)) {
|
|
157
|
+
} else if (ACCORDION_KEYS.last.includes(key as 'End')) {
|
|
167
158
|
e.preventDefault();
|
|
168
159
|
nextIndex = enabledItems.length - 1;
|
|
169
160
|
}
|
|
@@ -199,7 +190,7 @@ const Accordion: React.FC<AccordionProps> = ({
|
|
|
199
190
|
marginHorizontal,
|
|
200
191
|
});
|
|
201
192
|
|
|
202
|
-
const containerProps = getWebProps([(accordionStyles.container as any)({}), style as any]);
|
|
193
|
+
const containerProps = getWebProps([(accordionStyles.container as any)({ type }), style as any]);
|
|
203
194
|
|
|
204
195
|
const toggleItem = (itemId: string, disabled?: boolean) => {
|
|
205
196
|
if (disabled) return;
|
|
@@ -59,9 +59,11 @@ export const alertStyles = defineStyle('Alert', (theme: Theme) => ({
|
|
|
59
59
|
display: 'flex' as const,
|
|
60
60
|
alignItems: 'center' as const,
|
|
61
61
|
justifyContent: 'center' as const,
|
|
62
|
+
alignSelf: 'flex-start' as const,
|
|
62
63
|
flexShrink: 0,
|
|
63
64
|
width: 24,
|
|
64
65
|
height: 24,
|
|
66
|
+
marginTop: 2,
|
|
65
67
|
color,
|
|
66
68
|
} as const;
|
|
67
69
|
},
|
|
@@ -105,27 +107,38 @@ export const alertStyles = defineStyle('Alert', (theme: Theme) => ({
|
|
|
105
107
|
|
|
106
108
|
closeButton: (_props: AlertDynamicProps) => ({
|
|
107
109
|
padding: 4,
|
|
108
|
-
backgroundColor: 'transparent' as const,
|
|
109
110
|
borderRadius: 4,
|
|
110
111
|
display: 'flex' as const,
|
|
111
112
|
alignItems: 'center' as const,
|
|
112
113
|
justifyContent: 'center' as const,
|
|
113
114
|
flexShrink: 0,
|
|
115
|
+
alignSelf: 'flex-start' as const,
|
|
116
|
+
marginTop: 2,
|
|
114
117
|
_web: {
|
|
118
|
+
appearance: 'none',
|
|
119
|
+
background: 'none',
|
|
120
|
+
backgroundColor: 'transparent',
|
|
115
121
|
border: 'none',
|
|
116
122
|
cursor: 'pointer',
|
|
117
123
|
outline: 'none',
|
|
124
|
+
margin: 0,
|
|
118
125
|
_hover: {
|
|
119
126
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
|
120
127
|
},
|
|
121
128
|
},
|
|
122
129
|
}),
|
|
123
130
|
|
|
124
|
-
closeIcon: (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
closeIcon: ({ intent = 'neutral', type = 'soft' }: AlertDynamicProps) => {
|
|
132
|
+
const intentValue = theme.intents[intent];
|
|
133
|
+
const color = type === 'filled' ? intentValue.contrast : intentValue.primary;
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
display: 'flex' as const,
|
|
137
|
+
alignItems: 'center' as const,
|
|
138
|
+
justifyContent: 'center' as const,
|
|
139
|
+
width: 16,
|
|
140
|
+
height: 16,
|
|
141
|
+
color,
|
|
142
|
+
} as const;
|
|
143
|
+
},
|
|
131
144
|
}));
|
package/src/Alert/Alert.web.tsx
CHANGED
|
@@ -39,12 +39,12 @@ const Alert = forwardRef<HTMLDivElement, AlertProps>(({
|
|
|
39
39
|
const dynamicProps = { intent, type };
|
|
40
40
|
const containerProps = getWebProps([(alertStyles.container as any)(dynamicProps), style as any]);
|
|
41
41
|
const iconContainerProps = getWebProps([(alertStyles.iconContainer as any)(dynamicProps)]);
|
|
42
|
-
const contentProps = getWebProps([alertStyles.content]);
|
|
42
|
+
const contentProps = getWebProps([(alertStyles.content as any)({})]);
|
|
43
43
|
const titleProps = getWebProps([(alertStyles.title as any)(dynamicProps)]);
|
|
44
44
|
const messageProps = getWebProps([(alertStyles.message as any)(dynamicProps)]);
|
|
45
|
-
const actionsProps = getWebProps([alertStyles.actions]);
|
|
46
|
-
const closeButtonProps = getWebProps([alertStyles.closeButton]);
|
|
47
|
-
const closeIconProps = getWebProps([alertStyles.closeIcon]);
|
|
45
|
+
const actionsProps = getWebProps([(alertStyles.actions as any)({})]);
|
|
46
|
+
const closeButtonProps = getWebProps([(alertStyles.closeButton as any)(dynamicProps)]);
|
|
47
|
+
const closeIconProps = getWebProps([(alertStyles.closeIcon as any)(dynamicProps)]);
|
|
48
48
|
|
|
49
49
|
const displayIcon = icon !== undefined ? icon : (showIcon ? defaultIcons[intent] : null);
|
|
50
50
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { ComponentRef, forwardRef, isValidElement, useMemo } from 'react';
|
|
2
|
-
import { StyleSheet as RNStyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
2
|
+
import { ActivityIndicator, StyleSheet as RNStyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
3
3
|
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
|
4
4
|
import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg';
|
|
5
5
|
import { buttonStyles } from './Button.styles';
|
|
@@ -13,6 +13,7 @@ const Button = forwardRef<ComponentRef<typeof TouchableOpacity>, ButtonProps>((p
|
|
|
13
13
|
title,
|
|
14
14
|
onPress,
|
|
15
15
|
disabled = false,
|
|
16
|
+
loading = false,
|
|
16
17
|
type = 'contained',
|
|
17
18
|
intent = 'primary',
|
|
18
19
|
size = 'md',
|
|
@@ -35,19 +36,23 @@ const Button = forwardRef<ComponentRef<typeof TouchableOpacity>, ButtonProps>((p
|
|
|
35
36
|
accessibilityPressed,
|
|
36
37
|
} = props;
|
|
37
38
|
|
|
39
|
+
// Button is effectively disabled when loading
|
|
40
|
+
const isDisabled = disabled || loading;
|
|
41
|
+
|
|
38
42
|
// Apply variants for size, disabled, gradient
|
|
39
43
|
buttonStyles.useVariants({
|
|
40
44
|
size,
|
|
41
|
-
disabled,
|
|
45
|
+
disabled: isDisabled,
|
|
42
46
|
gradient,
|
|
43
47
|
});
|
|
44
48
|
|
|
45
49
|
// Compute dynamic styles with all props for full flexibility
|
|
46
|
-
const dynamicProps = { intent, type, size, disabled, gradient };
|
|
50
|
+
const dynamicProps = { intent, type, size, disabled: isDisabled, gradient };
|
|
47
51
|
const buttonStyle = (buttonStyles.button as any)(dynamicProps);
|
|
48
52
|
const textStyle = (buttonStyles.text as any)(dynamicProps);
|
|
49
53
|
const iconStyle = (buttonStyles.icon as any)(dynamicProps);
|
|
50
54
|
const iconContainerStyle = (buttonStyles.iconContainer as any)(dynamicProps);
|
|
55
|
+
const spinnerStyle = (buttonStyles.spinner as any)(dynamicProps);
|
|
51
56
|
|
|
52
57
|
// Gradient is only applicable to contained buttons
|
|
53
58
|
const showGradient = gradient && type === 'contained';
|
|
@@ -87,7 +92,7 @@ const Button = forwardRef<ComponentRef<typeof TouchableOpacity>, ButtonProps>((p
|
|
|
87
92
|
return getNativeInteractiveAccessibilityProps({
|
|
88
93
|
accessibilityLabel: computedLabel,
|
|
89
94
|
accessibilityHint,
|
|
90
|
-
accessibilityDisabled: accessibilityDisabled ??
|
|
95
|
+
accessibilityDisabled: accessibilityDisabled ?? isDisabled,
|
|
91
96
|
accessibilityHidden,
|
|
92
97
|
accessibilityRole: accessibilityRole ?? 'button',
|
|
93
98
|
accessibilityLabelledBy,
|
|
@@ -103,7 +108,7 @@ const Button = forwardRef<ComponentRef<typeof TouchableOpacity>, ButtonProps>((p
|
|
|
103
108
|
rightIcon,
|
|
104
109
|
accessibilityHint,
|
|
105
110
|
accessibilityDisabled,
|
|
106
|
-
|
|
111
|
+
isDisabled,
|
|
107
112
|
accessibilityHidden,
|
|
108
113
|
accessibilityRole,
|
|
109
114
|
accessibilityLabelledBy,
|
|
@@ -166,7 +171,7 @@ const Button = forwardRef<ComponentRef<typeof TouchableOpacity>, ButtonProps>((p
|
|
|
166
171
|
const touchableProps = {
|
|
167
172
|
ref,
|
|
168
173
|
onPress,
|
|
169
|
-
disabled,
|
|
174
|
+
disabled: isDisabled,
|
|
170
175
|
testID,
|
|
171
176
|
nativeID: id,
|
|
172
177
|
activeOpacity: 0.7,
|
|
@@ -175,32 +180,53 @@ const Button = forwardRef<ComponentRef<typeof TouchableOpacity>, ButtonProps>((p
|
|
|
175
180
|
showGradient && { overflow: 'hidden' },
|
|
176
181
|
style,
|
|
177
182
|
],
|
|
183
|
+
accessibilityState: loading ? { busy: true } : undefined,
|
|
178
184
|
...nativeA11yProps,
|
|
179
185
|
};
|
|
180
186
|
|
|
187
|
+
// Get spinner color from the spinner style (matches text color)
|
|
188
|
+
const spinnerColor = spinnerStyle?.color || (type === 'contained' ? '#fff' : undefined);
|
|
189
|
+
|
|
190
|
+
// Content opacity - hide when loading but keep for sizing
|
|
191
|
+
const contentOpacity = loading ? 0 : 1;
|
|
192
|
+
|
|
181
193
|
return (
|
|
182
194
|
<TouchableOpacity {...touchableProps as any}>
|
|
183
195
|
{renderGradientLayer()}
|
|
196
|
+
{/* Centered spinner overlay */}
|
|
197
|
+
{loading && (
|
|
198
|
+
<View style={RNStyleSheet.absoluteFill}>
|
|
199
|
+
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
200
|
+
<ActivityIndicator
|
|
201
|
+
size="small"
|
|
202
|
+
color={spinnerColor}
|
|
203
|
+
/>
|
|
204
|
+
</View>
|
|
205
|
+
</View>
|
|
206
|
+
)}
|
|
207
|
+
{/* Content with opacity 0 when loading to maintain size */}
|
|
184
208
|
{hasIcons ? (
|
|
185
|
-
<View
|
|
186
|
-
{leftIcon &&
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
209
|
+
<View style={[iconContainerStyle, { opacity: contentOpacity }]}>
|
|
210
|
+
{leftIcon && (
|
|
211
|
+
<MaterialCommunityIcons
|
|
212
|
+
name={leftIcon}
|
|
213
|
+
size={iconSize}
|
|
214
|
+
style={iconStyle}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
192
217
|
<Text style={textStyle}>
|
|
193
218
|
{buttonContent}
|
|
194
219
|
</Text>
|
|
195
|
-
{rightIcon &&
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
{rightIcon && (
|
|
221
|
+
<MaterialCommunityIcons
|
|
222
|
+
name={rightIcon}
|
|
223
|
+
size={iconSize}
|
|
224
|
+
style={iconStyle}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
201
227
|
</View>
|
|
202
228
|
) : (
|
|
203
|
-
<Text style={textStyle}>
|
|
229
|
+
<Text style={[textStyle, { opacity: contentOpacity }]}>
|
|
204
230
|
{buttonContent}
|
|
205
231
|
</Text>
|
|
206
232
|
)}
|
|
@@ -137,4 +137,19 @@ export const buttonStyles = defineStyle('Button', (theme: Theme) => ({
|
|
|
137
137
|
justifyContent: 'center' as const,
|
|
138
138
|
gap: 4,
|
|
139
139
|
}),
|
|
140
|
+
spinner: ({ intent = 'primary', type = 'contained' }: ButtonDynamicProps) => ({
|
|
141
|
+
display: 'flex',
|
|
142
|
+
alignItems: 'center',
|
|
143
|
+
justifyContent: 'center',
|
|
144
|
+
// Match the text color based on button type
|
|
145
|
+
color: type === 'contained'
|
|
146
|
+
? theme.intents[intent].contrast
|
|
147
|
+
: theme.intents[intent].primary,
|
|
148
|
+
variants: {
|
|
149
|
+
size: {
|
|
150
|
+
width: theme.sizes.$button.iconSize,
|
|
151
|
+
height: theme.sizes.$button.iconSize,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
140
155
|
}));
|
|
@@ -16,6 +16,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
16
16
|
children,
|
|
17
17
|
onPress,
|
|
18
18
|
disabled = false,
|
|
19
|
+
loading = false,
|
|
19
20
|
type = 'contained',
|
|
20
21
|
intent = 'primary',
|
|
21
22
|
size = 'md',
|
|
@@ -40,10 +41,13 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
40
41
|
accessibilityHasPopup,
|
|
41
42
|
} = props;
|
|
42
43
|
|
|
44
|
+
// Button is effectively disabled when loading
|
|
45
|
+
const isDisabled = disabled || loading;
|
|
46
|
+
|
|
43
47
|
// Apply variants for size, disabled, gradient
|
|
44
48
|
buttonStyles.useVariants({
|
|
45
49
|
size,
|
|
46
|
-
disabled,
|
|
50
|
+
disabled: isDisabled,
|
|
47
51
|
gradient,
|
|
48
52
|
});
|
|
49
53
|
|
|
@@ -51,7 +55,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
51
55
|
e.preventDefault();
|
|
52
56
|
// Only stop propagation if we have an onPress handler
|
|
53
57
|
// Otherwise, let clicks bubble up to parent handlers (e.g., Menu triggers)
|
|
54
|
-
if (!
|
|
58
|
+
if (!isDisabled && onPress) {
|
|
55
59
|
e.stopPropagation();
|
|
56
60
|
onPress();
|
|
57
61
|
}
|
|
@@ -70,7 +74,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
70
74
|
return getWebInteractiveAriaProps({
|
|
71
75
|
accessibilityLabel: computedLabel,
|
|
72
76
|
accessibilityHint,
|
|
73
|
-
accessibilityDisabled: accessibilityDisabled ??
|
|
77
|
+
accessibilityDisabled: accessibilityDisabled ?? isDisabled,
|
|
74
78
|
accessibilityHidden,
|
|
75
79
|
accessibilityRole: accessibilityRole ?? 'button',
|
|
76
80
|
accessibilityLabelledBy,
|
|
@@ -89,7 +93,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
89
93
|
rightIcon,
|
|
90
94
|
accessibilityHint,
|
|
91
95
|
accessibilityDisabled,
|
|
92
|
-
|
|
96
|
+
isDisabled,
|
|
93
97
|
accessibilityHidden,
|
|
94
98
|
accessibilityRole,
|
|
95
99
|
accessibilityLabelledBy,
|
|
@@ -102,7 +106,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
102
106
|
]);
|
|
103
107
|
|
|
104
108
|
// Compute dynamic styles with all props for full flexibility
|
|
105
|
-
const dynamicProps = { intent, type, size, disabled, gradient };
|
|
109
|
+
const dynamicProps = { intent, type, size, disabled: isDisabled, gradient };
|
|
106
110
|
const buttonStyleArray = [
|
|
107
111
|
(buttonStyles.button as any)(dynamicProps),
|
|
108
112
|
(buttonStyles.text as any)(dynamicProps),
|
|
@@ -119,6 +123,10 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
119
123
|
const iconStyleArray = [(buttonStyles.icon as any)(dynamicProps)];
|
|
120
124
|
const iconProps = getWebProps(iconStyleArray);
|
|
121
125
|
|
|
126
|
+
// Spinner styles that match the text color
|
|
127
|
+
const spinnerStyleArray = [(buttonStyles.spinner as any)(dynamicProps)];
|
|
128
|
+
const spinnerProps = getWebProps(spinnerStyleArray);
|
|
129
|
+
|
|
122
130
|
// Helper to render icon - now uses icon name directly
|
|
123
131
|
const renderIcon = (icon: string | React.ReactNode) => {
|
|
124
132
|
if (typeof icon === 'string') {
|
|
@@ -143,9 +151,41 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
143
151
|
// Determine if we need to wrap content in icon container
|
|
144
152
|
const hasIcons = leftIcon || rightIcon;
|
|
145
153
|
|
|
154
|
+
// Render spinner with inline CSS animation (absolutely centered)
|
|
155
|
+
const renderSpinner = () => (
|
|
156
|
+
<>
|
|
157
|
+
<style>
|
|
158
|
+
{`
|
|
159
|
+
@keyframes button-spin {
|
|
160
|
+
from { transform: rotate(0deg); }
|
|
161
|
+
to { transform: rotate(360deg); }
|
|
162
|
+
}
|
|
163
|
+
`}
|
|
164
|
+
</style>
|
|
165
|
+
<span
|
|
166
|
+
{...spinnerProps}
|
|
167
|
+
style={{
|
|
168
|
+
position: 'absolute',
|
|
169
|
+
display: 'inline-block',
|
|
170
|
+
width: '1em',
|
|
171
|
+
height: '1em',
|
|
172
|
+
border: '2px solid currentColor',
|
|
173
|
+
borderTopColor: 'transparent',
|
|
174
|
+
borderRadius: '50%',
|
|
175
|
+
animation: 'button-spin 0.8s linear infinite',
|
|
176
|
+
}}
|
|
177
|
+
role="status"
|
|
178
|
+
aria-label="Loading"
|
|
179
|
+
/>
|
|
180
|
+
</>
|
|
181
|
+
);
|
|
182
|
+
|
|
146
183
|
// Merge unistyles web ref with forwarded ref
|
|
147
184
|
const mergedRef = useMergeRefs(ref, webProps.ref);
|
|
148
185
|
|
|
186
|
+
// Content opacity - hide when loading but keep for sizing
|
|
187
|
+
const contentStyle = loading ? { opacity: 0 } : undefined;
|
|
188
|
+
|
|
149
189
|
return (
|
|
150
190
|
<button
|
|
151
191
|
{...webProps}
|
|
@@ -153,17 +193,20 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
|
153
193
|
ref={mergedRef}
|
|
154
194
|
id={buttonId}
|
|
155
195
|
onClick={handleClick}
|
|
156
|
-
disabled={
|
|
196
|
+
disabled={isDisabled}
|
|
157
197
|
data-testid={testID}
|
|
198
|
+
aria-busy={loading ? 'true' : undefined}
|
|
199
|
+
style={{ position: 'relative' }}
|
|
158
200
|
>
|
|
201
|
+
{loading && renderSpinner()}
|
|
159
202
|
{hasIcons ? (
|
|
160
|
-
<div {...iconContainerProps}>
|
|
203
|
+
<div {...iconContainerProps} style={contentStyle}>
|
|
161
204
|
{leftIcon && renderIcon(leftIcon)}
|
|
162
205
|
{buttonContent}
|
|
163
206
|
{rightIcon && renderIcon(rightIcon)}
|
|
164
207
|
</div>
|
|
165
208
|
) : (
|
|
166
|
-
buttonContent
|
|
209
|
+
<span style={contentStyle}>{buttonContent}</span>
|
|
167
210
|
)}
|
|
168
211
|
</button>
|
|
169
212
|
);
|
package/src/Button/types.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface ButtonProps extends BaseProps, InteractiveAccessibilityProps {
|
|
|
75
75
|
*/
|
|
76
76
|
rightIcon?: IconName | ReactNode;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Whether the button is in a loading state.
|
|
80
|
+
* When true, shows a spinner and disables interaction.
|
|
81
|
+
* The spinner color matches the button text color.
|
|
82
|
+
*/
|
|
83
|
+
loading?: boolean;
|
|
84
|
+
|
|
78
85
|
/**
|
|
79
86
|
* Additional styles (platform-specific)
|
|
80
87
|
*/
|
package/src/Icon/Icon.native.tsx
CHANGED
|
@@ -8,6 +8,7 @@ const Icon = forwardRef<any, IconProps>(({
|
|
|
8
8
|
name,
|
|
9
9
|
size = 'md',
|
|
10
10
|
color,
|
|
11
|
+
textColor,
|
|
11
12
|
intent,
|
|
12
13
|
style,
|
|
13
14
|
testID,
|
|
@@ -17,7 +18,7 @@ const Icon = forwardRef<any, IconProps>(({
|
|
|
17
18
|
const { theme } = useUnistyles();
|
|
18
19
|
|
|
19
20
|
// Call dynamic style with variants - includes theme-reactive color
|
|
20
|
-
const iconStyle = (iconStyles.icon as any)({ color, intent, size });
|
|
21
|
+
const iconStyle = (iconStyles.icon as any)({ color, textColor, intent, size });
|
|
21
22
|
|
|
22
23
|
const iconSize = useMemo(() => {
|
|
23
24
|
return iconStyle.width;
|
package/src/Icon/Icon.styles.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { StyleSheet } from 'react-native-unistyles';
|
|
5
5
|
import { defineStyle, ThemeStyleWrapper, getColorFromString } from '@idealyst/theme';
|
|
6
|
-
import type { Theme as BaseTheme, Intent, Color } from '@idealyst/theme';
|
|
6
|
+
import type { Theme as BaseTheme, Intent, Color, Text } from '@idealyst/theme';
|
|
7
7
|
import { IconSizeVariant } from './types';
|
|
8
8
|
|
|
9
9
|
// Required: Unistyles must see StyleSheet usage in original source to process this file
|
|
@@ -16,6 +16,7 @@ export type IconVariants = {
|
|
|
16
16
|
size: IconSizeVariant;
|
|
17
17
|
intent?: Intent;
|
|
18
18
|
color?: Color;
|
|
19
|
+
textColor?: Text;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export type IconDynamicProps = Partial<IconVariants>;
|
|
@@ -24,7 +25,7 @@ export type IconDynamicProps = Partial<IconVariants>;
|
|
|
24
25
|
* Icon styles with dynamic color/size handling.
|
|
25
26
|
*/
|
|
26
27
|
export const iconStyles = defineStyle('Icon', (theme: Theme) => ({
|
|
27
|
-
icon: ({ color, intent, size = 'md' }: IconDynamicProps) => {
|
|
28
|
+
icon: ({ color, textColor, intent, size = 'md' }: IconDynamicProps) => {
|
|
28
29
|
// Handle size - can be a named size or number
|
|
29
30
|
let iconWidth: number;
|
|
30
31
|
let iconHeight: number;
|
|
@@ -44,12 +45,15 @@ export const iconStyles = defineStyle('Icon', (theme: Theme) => ({
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// Get color - intent
|
|
48
|
+
// Get color - priority: intent > color > textColor > default (textColor="primary")
|
|
49
|
+
// When no color prop is specified, defaults to theme's primary text color
|
|
48
50
|
const iconColor = intent
|
|
49
51
|
? theme.intents[intent]?.primary
|
|
50
52
|
: color
|
|
51
53
|
? getColorFromString(theme as unknown as BaseTheme, color)
|
|
52
|
-
:
|
|
54
|
+
: textColor
|
|
55
|
+
? theme.colors.text[textColor]
|
|
56
|
+
: theme.colors.text.primary;
|
|
53
57
|
|
|
54
58
|
return {
|
|
55
59
|
width: iconWidth,
|
package/src/Icon/Icon.web.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { iconStyles } from './Icon.styles';
|
|
|
5
5
|
import { getWebProps } from 'react-native-unistyles/web';
|
|
6
6
|
import { useUnistyles } from 'react-native-unistyles';
|
|
7
7
|
import useMergeRefs from '../hooks/useMergeRefs';
|
|
8
|
-
import { getColorFromString, Intent, Color } from '@idealyst/theme';
|
|
8
|
+
import { getColorFromString, Intent, Color, Text } from '@idealyst/theme';
|
|
9
9
|
import { IconRegistry } from './IconRegistry';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -17,6 +17,7 @@ const Icon = forwardRef<HTMLSpanElement, IconProps>((props, ref) => {
|
|
|
17
17
|
name,
|
|
18
18
|
size = 'md',
|
|
19
19
|
color,
|
|
20
|
+
textColor,
|
|
20
21
|
intent,
|
|
21
22
|
style,
|
|
22
23
|
testID,
|
|
@@ -47,15 +48,18 @@ const Icon = forwardRef<HTMLSpanElement, IconProps>((props, ref) => {
|
|
|
47
48
|
iconSize = typeof themeSize === 'number' ? themeSize : (themeSize?.width ?? 24);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
// Compute color
|
|
51
|
+
// Compute color - priority: intent > color > textColor > default
|
|
52
|
+
// color takes precedence over textColor (as per design)
|
|
51
53
|
const iconColor = intent
|
|
52
54
|
? theme.intents[intent as Intent]?.primary
|
|
53
55
|
: color
|
|
54
56
|
? getColorFromString(theme, color as Color)
|
|
55
|
-
:
|
|
57
|
+
: textColor
|
|
58
|
+
? theme.colors.text[textColor as Text]
|
|
59
|
+
: theme.colors.text.primary;
|
|
56
60
|
|
|
57
61
|
// Use getWebProps for className generation but override with computed values
|
|
58
|
-
const iconStyle = (iconStyles.icon as any)({ intent, color, size });
|
|
62
|
+
const iconStyle = (iconStyles.icon as any)({ intent, color, textColor, size });
|
|
59
63
|
const iconProps = getWebProps([iconStyle, style]);
|
|
60
64
|
|
|
61
65
|
const mergedRef = useMergeRefs(ref, iconProps.ref);
|
package/src/Icon/types.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
2
|
import type { IconName } from "./icon-types";
|
|
3
|
-
import type { Size } from '@idealyst/theme';
|
|
3
|
+
import type { Size, Text } from '@idealyst/theme';
|
|
4
4
|
import { Color, Intent } from '@idealyst/theme';
|
|
5
5
|
import { BaseProps } from '../utils/viewStyleProps';
|
|
6
6
|
|
|
7
7
|
export type IconSizeVariant = Size | number;
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Base props shared by all Icon variants
|
|
11
|
+
*/
|
|
12
|
+
interface IconBaseProps extends BaseProps {
|
|
10
13
|
/**
|
|
11
14
|
* The name of the icon to display
|
|
12
15
|
*/
|
|
@@ -17,10 +20,6 @@ export interface IconProps extends BaseProps {
|
|
|
17
20
|
*/
|
|
18
21
|
size?: IconSizeVariant;
|
|
19
22
|
|
|
20
|
-
/**
|
|
21
|
-
* Predefined color variant based on theme
|
|
22
|
-
*/
|
|
23
|
-
color?: Color;
|
|
24
23
|
/**
|
|
25
24
|
* Intent variant for the icon
|
|
26
25
|
*/
|
|
@@ -40,4 +39,35 @@ export interface IconProps extends BaseProps {
|
|
|
40
39
|
* Accessibility label for screen readers
|
|
41
40
|
*/
|
|
42
41
|
accessibilityLabel?: string;
|
|
43
|
-
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Icon props with palette color (e.g., 'blue.500', 'red.100')
|
|
46
|
+
*/
|
|
47
|
+
interface IconWithColor extends IconBaseProps {
|
|
48
|
+
/**
|
|
49
|
+
* Predefined color variant based on theme palette
|
|
50
|
+
*/
|
|
51
|
+
color?: Color;
|
|
52
|
+
textColor?: never;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Icon props with text color (e.g., 'primary', 'secondary')
|
|
57
|
+
*/
|
|
58
|
+
interface IconWithTextColor extends IconBaseProps {
|
|
59
|
+
color?: never;
|
|
60
|
+
/**
|
|
61
|
+
* Text color variant from theme (e.g., 'primary', 'secondary')
|
|
62
|
+
* Cannot be used together with `color` prop
|
|
63
|
+
*/
|
|
64
|
+
textColor?: Text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Icon component props - accepts either `color` (palette) or `textColor` (text colors), but not both.
|
|
69
|
+
*
|
|
70
|
+
* Color priority: intent > color > textColor > default (textColor="primary")
|
|
71
|
+
* When no color prop is specified, the icon defaults to the theme's primary text color.
|
|
72
|
+
*/
|
|
73
|
+
export type IconProps = IconWithColor | IconWithTextColor;
|
|
@@ -168,12 +168,18 @@ export const inputStyles = defineStyle('Input', (theme: Theme) => ({
|
|
|
168
168
|
variants: {
|
|
169
169
|
size: {
|
|
170
170
|
marginLeft: theme.sizes.$input.iconMargin,
|
|
171
|
+
width: theme.sizes.$input.iconSize,
|
|
172
|
+
height: theme.sizes.$input.iconSize,
|
|
171
173
|
},
|
|
172
174
|
},
|
|
173
175
|
_web: {
|
|
174
|
-
|
|
176
|
+
appearance: 'none',
|
|
177
|
+
background: 'none',
|
|
178
|
+
backgroundColor: 'transparent',
|
|
175
179
|
border: 'none',
|
|
180
|
+
outline: 'none',
|
|
176
181
|
cursor: 'pointer',
|
|
182
|
+
margin: 0,
|
|
177
183
|
_hover: { opacity: 0.7 },
|
|
178
184
|
_active: { opacity: 0.5 },
|
|
179
185
|
},
|