@idealyst/components 1.2.30 → 1.2.31

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.
@@ -1,8 +1,5 @@
1
1
  /**
2
- * Chip styles using defineStyle with variant expansion.
3
- *
4
- * Chip has compound logic between type+selected+intent that's handled via
5
- * nested variants. The $intents iterator expands for all intent values.
2
+ * Chip styles using defineStyle with dynamic size/intent/type/selected handling.
6
3
  */
7
4
  import { StyleSheet } from 'react-native-unistyles';
8
5
  import { defineStyle, ThemeStyleWrapper } from '@idealyst/theme';
@@ -25,144 +22,129 @@ export type ChipDynamicProps = {
25
22
  };
26
23
 
27
24
  /**
28
- * Chip styles with variant expansion for size/intent/type/selected.
25
+ * Chip styles with size/intent/type/selected handling.
29
26
  */
30
27
  export const chipStyles = defineStyle('Chip', (theme: Theme) => ({
31
- container: (_props: ChipDynamicProps) => ({
32
- display: 'flex' as const,
33
- flexDirection: 'row' as const,
34
- alignItems: 'center' as const,
35
- justifyContent: 'center' as const,
36
- gap: 4,
37
- borderStyle: 'solid' as const,
38
- variants: {
39
- size: {
40
- paddingHorizontal: theme.sizes.$chip.paddingHorizontal,
41
- paddingVertical: theme.sizes.$chip.paddingVertical,
42
- minHeight: theme.sizes.$chip.minHeight,
43
- borderRadius: theme.sizes.$chip.borderRadius,
44
- },
45
- type: {
46
- filled: {
47
- borderWidth: 1,
48
- backgroundColor: theme.$intents.primary,
49
- borderColor: 'transparent',
50
- },
51
- outlined: {
52
- borderWidth: 1,
53
- backgroundColor: 'transparent',
54
- borderColor: theme.$intents.primary,
55
- },
56
- soft: {
57
- borderWidth: 0,
58
- backgroundColor: theme.$intents.light,
59
- borderColor: 'transparent',
60
- },
61
- },
62
- selected: {
63
- true: {},
64
- false: {},
65
- },
66
- disabled: {
67
- true: { opacity: 0.5 },
68
- false: { opacity: 1 },
69
- },
70
- },
71
- compoundVariants: [
72
- // filled + selected: swap bg/border
73
- { type: 'filled', selected: true, styles: { backgroundColor: theme.$intents.contrast, borderColor: theme.$intents.primary } },
74
- // outlined + selected: fill with primary
75
- { type: 'outlined', selected: true, styles: { backgroundColor: theme.$intents.primary } },
76
- // soft + selected: fill with primary
77
- { type: 'soft', selected: true, styles: { backgroundColor: theme.$intents.primary } },
78
- ],
79
- }),
80
-
81
- label: (_props: ChipDynamicProps) => ({
82
- fontFamily: 'inherit' as const,
83
- fontWeight: '500' as const,
84
- variants: {
85
- size: {
86
- fontSize: theme.sizes.$chip.fontSize,
87
- lineHeight: theme.sizes.$chip.lineHeight,
88
- },
89
- type: {
90
- filled: { color: theme.$intents.contrast },
91
- outlined: { color: theme.$intents.primary },
92
- soft: { color: theme.$intents.dark },
93
- },
94
- selected: {
95
- true: {},
96
- false: {},
97
- },
98
- },
99
- compoundVariants: [
100
- { type: 'filled', selected: true, styles: { color: theme.$intents.primary } },
101
- { type: 'outlined', selected: true, styles: { color: theme.colors.text.inverse } },
102
- { type: 'soft', selected: true, styles: { color: theme.colors.text.inverse } },
103
- ],
104
- }),
105
-
106
- icon: (_props: ChipDynamicProps) => ({
107
- display: 'flex' as const,
108
- alignItems: 'center' as const,
109
- justifyContent: 'center' as const,
110
- variants: {
111
- size: {
112
- width: theme.sizes.$chip.iconSize,
113
- height: theme.sizes.$chip.iconSize,
114
- },
115
- type: {
116
- filled: { color: theme.$intents.contrast },
117
- outlined: { color: theme.$intents.primary },
118
- soft: { color: theme.$intents.dark },
119
- },
120
- selected: {
121
- true: {},
122
- false: {},
123
- },
124
- },
125
- compoundVariants: [
126
- { type: 'filled', selected: true, styles: { color: theme.$intents.primary } },
127
- { type: 'outlined', selected: true, styles: { color: theme.colors.text.inverse } },
128
- { type: 'soft', selected: true, styles: { color: theme.colors.text.inverse } },
129
- ],
130
- }),
131
-
132
- deleteButton: (_props: ChipDynamicProps) => ({
133
- display: 'flex' as const,
134
- alignItems: 'center' as const,
135
- justifyContent: 'center' as const,
136
- padding: 0,
137
- marginLeft: 4,
138
- borderRadius: 12,
139
- variants: {
140
- size: {
141
- width: theme.sizes.$chip.iconSize,
142
- height: theme.sizes.$chip.iconSize,
143
- },
144
- },
145
- }),
146
-
147
- deleteIcon: (_props: ChipDynamicProps) => ({
148
- variants: {
149
- size: {
150
- fontSize: theme.sizes.$chip.iconSize,
151
- },
152
- type: {
153
- filled: { color: theme.$intents.contrast },
154
- outlined: { color: theme.$intents.primary },
155
- soft: { color: theme.$intents.dark },
156
- },
157
- selected: {
158
- true: {},
159
- false: {},
160
- },
161
- },
162
- compoundVariants: [
163
- { type: 'filled', selected: true, styles: { color: theme.$intents.primary } },
164
- { type: 'outlined', selected: true, styles: { color: theme.colors.text.inverse } },
165
- { type: 'soft', selected: true, styles: { color: theme.colors.text.inverse } },
166
- ],
167
- }),
28
+ container: ({ size = 'md', intent = 'primary', type = 'filled', selected = false, disabled = false }: ChipDynamicProps) => {
29
+ const intentValue = theme.intents[intent];
30
+ const sizeValue = theme.sizes.chip[size];
31
+
32
+ // Compute colors based on type and selected state
33
+ let backgroundColor: string;
34
+ let borderColor: string;
35
+ let borderWidth: number;
36
+
37
+ if (type === 'filled') {
38
+ borderWidth = 1;
39
+ backgroundColor = selected ? intentValue.contrast : intentValue.primary;
40
+ borderColor = selected ? intentValue.primary : 'transparent';
41
+ } else if (type === 'outlined') {
42
+ borderWidth = 1;
43
+ backgroundColor = selected ? intentValue.primary : 'transparent';
44
+ borderColor = intentValue.primary;
45
+ } else { // soft
46
+ borderWidth = 0;
47
+ backgroundColor = selected ? intentValue.primary : intentValue.light;
48
+ borderColor = 'transparent';
49
+ }
50
+
51
+ return {
52
+ display: 'flex' as const,
53
+ flexDirection: 'row' as const,
54
+ alignItems: 'center' as const,
55
+ justifyContent: 'center' as const,
56
+ gap: 4,
57
+ paddingHorizontal: sizeValue.paddingHorizontal as number,
58
+ paddingVertical: sizeValue.paddingVertical as number,
59
+ minHeight: sizeValue.minHeight as number,
60
+ borderRadius: sizeValue.borderRadius as number,
61
+ backgroundColor,
62
+ borderColor,
63
+ borderWidth,
64
+ borderStyle: borderWidth > 0 ? ('solid' as const) : undefined,
65
+ opacity: disabled ? 0.5 : 1,
66
+ } as const;
67
+ },
68
+
69
+ label: ({ size = 'md', intent = 'primary', type = 'filled', selected = false }: ChipDynamicProps) => {
70
+ const intentValue = theme.intents[intent];
71
+ const sizeValue = theme.sizes.chip[size];
72
+
73
+ // Compute color based on type and selected state
74
+ let color: string;
75
+ if (type === 'filled') {
76
+ color = selected ? intentValue.primary : intentValue.contrast;
77
+ } else if (type === 'outlined') {
78
+ color = selected ? theme.colors.text.inverse : intentValue.primary;
79
+ } else { // soft
80
+ color = selected ? theme.colors.text.inverse : intentValue.dark;
81
+ }
82
+
83
+ return {
84
+ fontFamily: 'inherit' as const,
85
+ fontWeight: '500' as const,
86
+ fontSize: sizeValue.fontSize as number,
87
+ lineHeight: sizeValue.lineHeight as number,
88
+ color,
89
+ } as const;
90
+ },
91
+
92
+ icon: ({ size = 'md', intent = 'primary', type = 'filled', selected = false }: ChipDynamicProps) => {
93
+ const intentValue = theme.intents[intent];
94
+ const sizeValue = theme.sizes.chip[size];
95
+
96
+ // Same color logic as label
97
+ let color: string;
98
+ if (type === 'filled') {
99
+ color = selected ? intentValue.primary : intentValue.contrast;
100
+ } else if (type === 'outlined') {
101
+ color = selected ? theme.colors.text.inverse : intentValue.primary;
102
+ } else {
103
+ color = selected ? theme.colors.text.inverse : intentValue.dark;
104
+ }
105
+
106
+ return {
107
+ display: 'flex' as const,
108
+ alignItems: 'center' as const,
109
+ justifyContent: 'center' as const,
110
+ width: sizeValue.iconSize as number,
111
+ height: sizeValue.iconSize as number,
112
+ color,
113
+ } as const;
114
+ },
115
+
116
+ deleteButton: ({ size = 'md' }: ChipDynamicProps) => {
117
+ const sizeValue = theme.sizes.chip[size];
118
+
119
+ return {
120
+ display: 'flex' as const,
121
+ alignItems: 'center' as const,
122
+ justifyContent: 'center' as const,
123
+ padding: 0,
124
+ marginLeft: 4,
125
+ borderRadius: 12,
126
+ width: sizeValue.iconSize as number,
127
+ height: sizeValue.iconSize as number,
128
+ } as const;
129
+ },
130
+
131
+ deleteIcon: ({ size = 'md', intent = 'primary', type = 'filled', selected = false }: ChipDynamicProps) => {
132
+ const intentValue = theme.intents[intent];
133
+ const sizeValue = theme.sizes.chip[size];
134
+
135
+ // Same color logic as label/icon
136
+ let color: string;
137
+ if (type === 'filled') {
138
+ color = selected ? intentValue.primary : intentValue.contrast;
139
+ } else if (type === 'outlined') {
140
+ color = selected ? theme.colors.text.inverse : intentValue.primary;
141
+ } else {
142
+ color = selected ? theme.colors.text.inverse : intentValue.dark;
143
+ }
144
+
145
+ return {
146
+ fontSize: sizeValue.iconSize as number,
147
+ color,
148
+ } as const;
149
+ },
168
150
  }));
@@ -0,0 +1,219 @@
1
+ import { forwardRef, useMemo } from 'react';
2
+ import { ActivityIndicator, StyleSheet as RNStyleSheet, TouchableOpacity, View } from 'react-native';
3
+ import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons';
4
+ import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg';
5
+ import { iconButtonStyles } from './IconButton.styles';
6
+ import { IconButtonProps } from './types';
7
+ import { getNativeInteractiveAccessibilityProps } from '../utils/accessibility';
8
+ import type { IdealystElement } from '../utils/refTypes';
9
+
10
+ const IconButton = forwardRef<IdealystElement, IconButtonProps>((props, ref) => {
11
+ const {
12
+ icon,
13
+ onPress,
14
+ onClick,
15
+ disabled = false,
16
+ loading = false,
17
+ type = 'contained',
18
+ intent = 'primary',
19
+ size = 'md',
20
+ gradient,
21
+ style,
22
+ testID,
23
+ id,
24
+ // Accessibility props
25
+ accessibilityLabel,
26
+ accessibilityHint,
27
+ accessibilityDisabled,
28
+ accessibilityHidden,
29
+ accessibilityRole,
30
+ accessibilityLabelledBy,
31
+ accessibilityDescribedBy,
32
+ accessibilityControls,
33
+ accessibilityExpanded,
34
+ accessibilityPressed,
35
+ } = props;
36
+
37
+ // Button is effectively disabled when loading
38
+ const isDisabled = disabled || loading;
39
+
40
+ // Determine the handler to use - onPress takes precedence
41
+ const pressHandler = onPress ?? onClick;
42
+
43
+ // Warn about deprecated onClick usage in development
44
+ if (__DEV__ && onClick && !onPress) {
45
+ console.warn(
46
+ 'IconButton: onClick prop is deprecated. Use onPress instead for cross-platform compatibility.'
47
+ );
48
+ }
49
+
50
+ // Apply variants for size, disabled, gradient
51
+ iconButtonStyles.useVariants({
52
+ size,
53
+ disabled: isDisabled,
54
+ gradient,
55
+ });
56
+
57
+ // Compute dynamic styles with all props for full flexibility
58
+ const dynamicProps = { intent, type, size, disabled: isDisabled, gradient };
59
+ const buttonStyle = (iconButtonStyles.button as any)(dynamicProps);
60
+ const iconStyle = (iconButtonStyles.icon as any)(dynamicProps);
61
+ const spinnerStyle = (iconButtonStyles.spinner as any)(dynamicProps);
62
+
63
+ // Gradient is only applicable to contained buttons
64
+ const showGradient = gradient && type === 'contained';
65
+
66
+ // Map button size to icon size
67
+ const iconSizeMap: Record<string, number> = {
68
+ xs: 12,
69
+ sm: 14,
70
+ md: 16,
71
+ lg: 18,
72
+ xl: 20,
73
+ };
74
+ const iconSize = iconSizeMap[size] ?? 16;
75
+
76
+ // Generate native accessibility props
77
+ const nativeA11yProps = useMemo(() => {
78
+ const computedLabel = accessibilityLabel ?? (typeof icon === 'string' ? icon : undefined);
79
+
80
+ return getNativeInteractiveAccessibilityProps({
81
+ accessibilityLabel: computedLabel,
82
+ accessibilityHint,
83
+ accessibilityDisabled: accessibilityDisabled ?? isDisabled,
84
+ accessibilityHidden,
85
+ accessibilityRole: accessibilityRole ?? 'button',
86
+ accessibilityLabelledBy,
87
+ accessibilityDescribedBy,
88
+ accessibilityControls,
89
+ accessibilityExpanded,
90
+ accessibilityPressed,
91
+ });
92
+ }, [
93
+ accessibilityLabel,
94
+ icon,
95
+ accessibilityHint,
96
+ accessibilityDisabled,
97
+ isDisabled,
98
+ accessibilityHidden,
99
+ accessibilityRole,
100
+ accessibilityLabelledBy,
101
+ accessibilityDescribedBy,
102
+ accessibilityControls,
103
+ accessibilityExpanded,
104
+ accessibilityPressed,
105
+ ]);
106
+
107
+ // Render gradient background layer
108
+ const renderGradientLayer = () => {
109
+ if (!showGradient) return null;
110
+
111
+ const [startColor, endColor] = useMemo(() => {
112
+ switch (gradient) {
113
+ case 'darken': return [{
114
+ stopColor: 'black',
115
+ stopOpacity: 0,
116
+ }, {
117
+ stopColor: 'black',
118
+ stopOpacity: 0.15,
119
+ }];
120
+ case 'lighten': return [{
121
+ stopColor: 'white',
122
+ stopOpacity: 0,
123
+ }, {
124
+ stopColor: 'white',
125
+ stopOpacity: 0.2,
126
+ }];
127
+ default: return [{
128
+ stopColor: 'black',
129
+ stopOpacity: 0,
130
+ }, {
131
+ stopColor: 'black',
132
+ stopOpacity: 0,
133
+ }];
134
+ }
135
+ }, [gradient]);
136
+
137
+ return (
138
+ <Svg style={RNStyleSheet.absoluteFill}>
139
+ <Defs>
140
+ <LinearGradient id="iconButtonGradient" x1="0%" y1="0%" x2="100%" y2="100%">
141
+ <Stop offset="0%" {...startColor} />
142
+ <Stop offset="100%" {...endColor} />
143
+ </LinearGradient>
144
+ </Defs>
145
+ <Rect
146
+ width="100%"
147
+ height="100%"
148
+ fill="url(#iconButtonGradient)"
149
+ rx={9999}
150
+ ry={9999}
151
+ />
152
+ </Svg>
153
+ );
154
+ };
155
+
156
+ // TouchableOpacity types don't include nativeID but it's a valid RN prop
157
+ const touchableProps = {
158
+ ref,
159
+ onPress: pressHandler,
160
+ disabled: isDisabled,
161
+ testID,
162
+ nativeID: id,
163
+ activeOpacity: 0.7,
164
+ style: [
165
+ buttonStyle,
166
+ showGradient && { overflow: 'hidden' },
167
+ style,
168
+ ],
169
+ accessibilityState: loading ? { busy: true } : undefined,
170
+ ...nativeA11yProps,
171
+ };
172
+
173
+ // Get spinner color from the spinner style (matches icon color)
174
+ const spinnerColor = spinnerStyle?.color || (type === 'contained' ? '#fff' : undefined);
175
+
176
+ // Content opacity - hide when loading but keep for sizing
177
+ const contentOpacity = loading ? 0 : 1;
178
+
179
+ // Render icon
180
+ const renderIcon = () => {
181
+ if (typeof icon === 'string') {
182
+ return (
183
+ <MaterialDesignIcons
184
+ name={icon}
185
+ size={iconSize}
186
+ style={[iconStyle, { opacity: contentOpacity }]}
187
+ />
188
+ );
189
+ }
190
+ // Custom ReactNode icon
191
+ return (
192
+ <View style={{ opacity: contentOpacity }}>
193
+ {icon}
194
+ </View>
195
+ );
196
+ };
197
+
198
+ return (
199
+ <TouchableOpacity {...touchableProps as any}>
200
+ {renderGradientLayer()}
201
+ {/* Centered spinner overlay */}
202
+ {loading && (
203
+ <View style={RNStyleSheet.absoluteFill}>
204
+ <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
205
+ <ActivityIndicator
206
+ size="small"
207
+ color={spinnerColor}
208
+ />
209
+ </View>
210
+ </View>
211
+ )}
212
+ {renderIcon()}
213
+ </TouchableOpacity>
214
+ );
215
+ });
216
+
217
+ IconButton.displayName = 'IconButton';
218
+
219
+ export default IconButton;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * IconButton styles using defineStyle with $iterator expansion.
3
+ *
4
+ * Dynamic style functions are used for intent/type combinations since
5
+ * the color depends on both values (compound logic).
6
+ */
7
+ import { StyleSheet } from 'react-native-unistyles';
8
+ import { defineStyle, ThemeStyleWrapper } from '@idealyst/theme';
9
+ import type { Theme as BaseTheme, Intent, Size } from '@idealyst/theme';
10
+ import { IconButtonGradient } from './types';
11
+
12
+ // Required: Unistyles must see StyleSheet usage in original source to process this file
13
+ void StyleSheet;
14
+
15
+ // Wrap theme for $iterator support
16
+ type Theme = ThemeStyleWrapper<BaseTheme>;
17
+
18
+ type IconButtonSize = Size;
19
+ type IconButtonType = 'contained' | 'outlined' | 'text';
20
+
21
+ export type IconButtonVariants = {
22
+ size: IconButtonSize;
23
+ intent: Intent;
24
+ type: IconButtonType;
25
+ disabled: boolean;
26
+ gradient?: IconButtonGradient;
27
+ }
28
+
29
+ /**
30
+ * All dynamic props passed to icon button style functions.
31
+ */
32
+ export type IconButtonDynamicProps = {
33
+ intent?: Intent;
34
+ type?: IconButtonType;
35
+ size?: Size;
36
+ disabled?: boolean;
37
+ gradient?: IconButtonGradient;
38
+ };
39
+
40
+ /**
41
+ * IconButton styles with $iterator expansion for size variants.
42
+ * Circular button that only contains an icon.
43
+ */
44
+ export const iconButtonStyles = defineStyle('IconButton', (theme: Theme) => ({
45
+ button: ({ intent = 'primary', type = 'contained' }: IconButtonDynamicProps) => ({
46
+ boxSizing: 'border-box',
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ borderRadius: 9999, // Fully circular
50
+ // Inline theme accesses so Unistyles can trace them
51
+ backgroundColor: type === 'contained'
52
+ ? theme.intents[intent].primary
53
+ : type === 'outlined'
54
+ ? theme.colors.surface.primary
55
+ : 'transparent',
56
+ borderColor: type === 'outlined'
57
+ ? theme.intents[intent].primary
58
+ : 'transparent',
59
+ borderWidth: type === 'outlined' ? 1 : 0,
60
+ borderStyle: type === 'outlined' ? 'solid' as const : undefined,
61
+ _web: {
62
+ display: 'flex',
63
+ transition: 'all 0.1s ease',
64
+ },
65
+ variants: {
66
+ type: {
67
+ contained: {
68
+ backgroundColor: theme.$intents.primary,
69
+ borderColor: 'transparent',
70
+ },
71
+ outlined: {
72
+ backgroundColor: 'transparent',
73
+ borderColor: theme.$intents.primary,
74
+ },
75
+ text: {
76
+ backgroundColor: 'transparent',
77
+ borderColor: 'transparent',
78
+ borderWidth: 0,
79
+ }
80
+ },
81
+ // Size variants - circular so width equals height
82
+ size: {
83
+ width: theme.sizes.$iconButton.size,
84
+ height: theme.sizes.$iconButton.size,
85
+ minWidth: theme.sizes.$iconButton.size,
86
+ minHeight: theme.sizes.$iconButton.size,
87
+ },
88
+ disabled: {
89
+ true: { opacity: 0.6 },
90
+ false: { opacity: 1, _web: { cursor: 'pointer', _hover: { opacity: 0.90 }, _active: { opacity: 0.75 } } },
91
+ },
92
+ gradient: {
93
+ darken: { _web: { backgroundImage: 'linear-gradient(135deg, transparent 0%, rgba(0, 0, 0, 0.15) 100%)' } },
94
+ lighten: { _web: { backgroundImage: 'linear-gradient(135deg, transparent 0%, rgba(255, 255, 255, 0.2) 100%)' } },
95
+ },
96
+ },
97
+ }),
98
+ icon: ({ intent = 'primary', type = 'contained' }: IconButtonDynamicProps) => ({
99
+ display: 'flex',
100
+ alignItems: 'center',
101
+ justifyContent: 'center',
102
+ color: type === 'contained'
103
+ ? theme.intents[intent].contrast
104
+ : theme.intents[intent].primary,
105
+ variants: {
106
+ size: {
107
+ width: theme.sizes.$iconButton.iconSize,
108
+ height: theme.sizes.$iconButton.iconSize,
109
+ },
110
+ },
111
+ }),
112
+ spinner: ({ intent = 'primary', type = 'contained' }: IconButtonDynamicProps) => ({
113
+ display: 'flex',
114
+ alignItems: 'center',
115
+ justifyContent: 'center',
116
+ // Match the icon color based on button type
117
+ color: type === 'contained'
118
+ ? theme.intents[intent].contrast
119
+ : theme.intents[intent].primary,
120
+ variants: {
121
+ size: {
122
+ width: theme.sizes.$iconButton.iconSize,
123
+ height: theme.sizes.$iconButton.iconSize,
124
+ },
125
+ },
126
+ }),
127
+ }));