@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.
@@ -0,0 +1,198 @@
1
+ import React, { isValidElement, forwardRef, useMemo } from 'react';
2
+ import { getWebProps } from 'react-native-unistyles/web';
3
+ import { IconButtonProps } from './types';
4
+ import { iconButtonStyles } from './IconButton.styles';
5
+ import { IconSvg } from '../Icon/IconSvg/IconSvg.web';
6
+ import useMergeRefs from '../hooks/useMergeRefs';
7
+ import { getWebInteractiveAriaProps, generateAccessibilityId } from '../utils/accessibility';
8
+ import type { IdealystElement } from '../utils/refTypes';
9
+
10
+ /**
11
+ * Circular icon button component with multiple visual variants and sizes.
12
+ * Supports contained, outlined, and text styles with customizable intent colors.
13
+ */
14
+ const IconButton = forwardRef<IdealystElement, IconButtonProps>((props, ref) => {
15
+ const {
16
+ icon,
17
+ onPress,
18
+ onClick,
19
+ disabled = false,
20
+ loading = false,
21
+ type = 'contained',
22
+ intent = 'primary',
23
+ size = 'md',
24
+ gradient,
25
+ style,
26
+ testID,
27
+ id,
28
+ // Accessibility props
29
+ accessibilityLabel,
30
+ accessibilityHint,
31
+ accessibilityDisabled,
32
+ accessibilityHidden,
33
+ accessibilityRole,
34
+ accessibilityLabelledBy,
35
+ accessibilityDescribedBy,
36
+ accessibilityControls,
37
+ accessibilityExpanded,
38
+ accessibilityPressed,
39
+ accessibilityOwns,
40
+ accessibilityHasPopup,
41
+ } = props;
42
+
43
+ // Button is effectively disabled when loading
44
+ const isDisabled = disabled || loading;
45
+
46
+ // Apply variants for size, disabled, gradient
47
+ iconButtonStyles.useVariants({
48
+ size,
49
+ disabled: isDisabled,
50
+ gradient,
51
+ });
52
+
53
+ // Determine the handler to use - onPress takes precedence
54
+ const pressHandler = onPress ?? onClick;
55
+
56
+ // Warn about deprecated onClick usage in development
57
+ if (process.env.NODE_ENV !== 'production' && onClick && !onPress) {
58
+ console.warn(
59
+ 'IconButton: onClick prop is deprecated. Use onPress instead for cross-platform compatibility.'
60
+ );
61
+ }
62
+
63
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
64
+ e.preventDefault();
65
+ if (!isDisabled && pressHandler) {
66
+ e.stopPropagation();
67
+ pressHandler();
68
+ }
69
+ };
70
+
71
+ // Generate unique ID for accessibility
72
+ const buttonId = useMemo(() => id || generateAccessibilityId('icon-button'), [id]);
73
+
74
+ // Generate ARIA props - accessibilityLabel is critical for icon-only buttons
75
+ const ariaProps = useMemo(() => {
76
+ const computedLabel = accessibilityLabel ?? (typeof icon === 'string' ? icon : undefined);
77
+
78
+ return getWebInteractiveAriaProps({
79
+ accessibilityLabel: computedLabel,
80
+ accessibilityHint,
81
+ accessibilityDisabled: accessibilityDisabled ?? isDisabled,
82
+ accessibilityHidden,
83
+ accessibilityRole: accessibilityRole ?? 'button',
84
+ accessibilityLabelledBy,
85
+ accessibilityDescribedBy,
86
+ accessibilityControls,
87
+ accessibilityExpanded,
88
+ accessibilityPressed,
89
+ accessibilityOwns,
90
+ accessibilityHasPopup,
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
+ accessibilityOwns,
106
+ accessibilityHasPopup,
107
+ ]);
108
+
109
+ // Compute dynamic styles with all props for full flexibility
110
+ const dynamicProps = { intent, type, size, disabled: isDisabled, gradient };
111
+ const buttonStyleArray = [
112
+ (iconButtonStyles.button as any)(dynamicProps),
113
+ style as any,
114
+ ];
115
+
116
+ // Use getWebProps to generate className and ref for web
117
+ const webProps = getWebProps(buttonStyleArray);
118
+
119
+ // Icon styles with dynamic function
120
+ const iconStyleArray = [(iconButtonStyles.icon as any)(dynamicProps)];
121
+ const iconProps = getWebProps(iconStyleArray);
122
+
123
+ // Spinner styles that match the icon color
124
+ const spinnerStyleArray = [(iconButtonStyles.spinner as any)(dynamicProps)];
125
+ const spinnerProps = getWebProps(spinnerStyleArray);
126
+
127
+ // Helper to render icon
128
+ const renderIcon = () => {
129
+ if (typeof icon === 'string') {
130
+ return (
131
+ <IconSvg
132
+ name={icon}
133
+ {...iconProps}
134
+ aria-label={icon}
135
+ />
136
+ );
137
+ } else if (isValidElement(icon)) {
138
+ return icon;
139
+ }
140
+ return null;
141
+ };
142
+
143
+ // Render spinner with inline CSS animation (absolutely centered)
144
+ const renderSpinner = () => (
145
+ <>
146
+ <style>
147
+ {`
148
+ @keyframes icon-button-spin {
149
+ from { transform: rotate(0deg); }
150
+ to { transform: rotate(360deg); }
151
+ }
152
+ `}
153
+ </style>
154
+ <span
155
+ {...spinnerProps}
156
+ style={{
157
+ position: 'absolute',
158
+ display: 'inline-block',
159
+ width: '1em',
160
+ height: '1em',
161
+ border: '2px solid currentColor',
162
+ borderTopColor: 'transparent',
163
+ borderRadius: '50%',
164
+ animation: 'icon-button-spin 0.8s linear infinite',
165
+ }}
166
+ role="status"
167
+ aria-label="Loading"
168
+ />
169
+ </>
170
+ );
171
+
172
+ // Merge unistyles web ref with forwarded ref
173
+ const mergedRef = useMergeRefs(ref, webProps.ref);
174
+
175
+ // Content opacity - hide when loading but keep for sizing
176
+ const contentStyle = loading ? { opacity: 0 } : undefined;
177
+
178
+ return (
179
+ <button
180
+ {...webProps}
181
+ {...ariaProps}
182
+ ref={mergedRef}
183
+ id={buttonId}
184
+ onClick={handleClick}
185
+ disabled={isDisabled}
186
+ data-testid={testID}
187
+ aria-busy={loading ? 'true' : undefined}
188
+ style={{ position: 'relative' }}
189
+ >
190
+ {loading && renderSpinner()}
191
+ <span style={contentStyle}>{renderIcon()}</span>
192
+ </button>
193
+ );
194
+ });
195
+
196
+ IconButton.displayName = 'IconButton';
197
+
198
+ export default IconButton;
@@ -0,0 +1,5 @@
1
+ import IconButtonComponent from './IconButton.native';
2
+
3
+ export default IconButtonComponent;
4
+ export { IconButtonComponent as IconButton };
5
+ export * from './types';
@@ -0,0 +1,5 @@
1
+ import IconButtonComponent from './IconButton.web';
2
+
3
+ export default IconButtonComponent;
4
+ export { IconButtonComponent as IconButton };
5
+ export * from './types';
@@ -0,0 +1,5 @@
1
+ import IconButtonComponent from './IconButton.web';
2
+
3
+ export default IconButtonComponent;
4
+ export { IconButtonComponent as IconButton };
5
+ export * from './types';
@@ -0,0 +1,84 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+ import type { IconName } from '../Icon/icon-types';
4
+ import { Intent, Size } from '@idealyst/theme';
5
+ import { BaseProps } from '../utils/viewStyleProps';
6
+ import { InteractiveAccessibilityProps } from '../utils/accessibility';
7
+
8
+ // Component-specific type aliases for future extensibility
9
+ export type IconButtonType = 'contained' | 'outlined' | 'text';
10
+ export type IconButtonIntentVariant = Intent;
11
+ export type IconButtonSizeVariant = Size;
12
+
13
+ /**
14
+ * Gradient overlay options for icon buttons.
15
+ * Applies a transparent gradient over the intent background color.
16
+ * - 'darken': Transparent to semi-transparent black (darkens one corner)
17
+ * - 'lighten': Transparent to semi-transparent white (lightens one corner)
18
+ */
19
+ export type IconButtonGradient = 'darken' | 'lighten';
20
+
21
+ /**
22
+ * Circular icon button component with multiple visual variants, sizes, and a single icon.
23
+ * Supports contained, outlined, and text styles with customizable intent colors.
24
+ */
25
+ export interface IconButtonProps extends BaseProps, InteractiveAccessibilityProps {
26
+ /**
27
+ * The icon to display. Can be an icon name string or a custom ReactNode.
28
+ */
29
+ icon: IconName | ReactNode;
30
+
31
+ /**
32
+ * Called when the button is pressed
33
+ */
34
+ onPress?: () => void;
35
+
36
+ /**
37
+ * @deprecated Use `onPress` instead. This prop exists for web compatibility only.
38
+ * Using onClick will log a deprecation warning in development.
39
+ */
40
+ onClick?: () => void;
41
+
42
+ /**
43
+ * Whether the button is disabled
44
+ */
45
+ disabled?: boolean;
46
+
47
+ /**
48
+ * The visual style type of the button
49
+ */
50
+ type?: IconButtonType;
51
+
52
+ /**
53
+ * The intent/color scheme of the button
54
+ */
55
+ intent?: IconButtonIntentVariant;
56
+
57
+ /**
58
+ * The size of the button
59
+ */
60
+ size?: IconButtonSizeVariant;
61
+
62
+ /**
63
+ * Apply a gradient background enhancement.
64
+ * Only applies to 'contained' button type.
65
+ */
66
+ gradient?: IconButtonGradient;
67
+
68
+ /**
69
+ * Whether the button is in a loading state.
70
+ * When true, shows a spinner and disables interaction.
71
+ * The spinner color matches the icon color.
72
+ */
73
+ loading?: boolean;
74
+
75
+ /**
76
+ * Additional styles (platform-specific)
77
+ */
78
+ style?: StyleProp<ViewStyle>;
79
+
80
+ /**
81
+ * Test ID for testing
82
+ */
83
+ testID?: string;
84
+ }
@@ -39,7 +39,7 @@ const Skeleton: React.FC<SkeletonProps> = ({
39
39
  animation,
40
40
  });
41
41
 
42
- const skeletonProps = getWebProps([skeletonStyles.skeleton, style as any]);
42
+ const skeletonProps = getWebProps([skeletonStyles.skeleton({}), style as any]);
43
43
 
44
44
  // Apply custom border radius if provided and shape is 'rounded'
45
45
  const customStyles: React.CSSProperties = {
@@ -48,6 +48,12 @@ export const switchStyles = defineStyle('Switch', (theme: Theme) => ({
48
48
 
49
49
  switchContainer: (_props: SwitchDynamicProps) => ({
50
50
  justifyContent: 'center' as const,
51
+ variants: {
52
+ disabled: {
53
+ true: { _web: { cursor: 'not-allowed' } },
54
+ false: { _web: { cursor: 'pointer' } },
55
+ },
56
+ },
51
57
  _web: {
52
58
  border: 'none',
53
59
  padding: 0,
@@ -59,15 +65,13 @@ export const switchStyles = defineStyle('Switch', (theme: Theme) => ({
59
65
  switchTrack: (_props: SwitchDynamicProps) => ({
60
66
  borderRadius: 9999,
61
67
  position: 'relative' as const,
68
+ pointerEvents: 'none' as const,
62
69
  variants: {
63
70
  size: {
64
71
  width: theme.sizes.$switch.trackWidth,
65
72
  height: theme.sizes.$switch.trackHeight,
66
73
  },
67
74
  checked: {
68
- true: {
69
- backgroundColor: theme.$intents.primary,
70
- },
71
75
  false: {
72
76
  backgroundColor: theme.colors.border.secondary,
73
77
  },
@@ -77,6 +81,14 @@ export const switchStyles = defineStyle('Switch', (theme: Theme) => ({
77
81
  false: { opacity: 1 },
78
82
  },
79
83
  },
84
+ compoundVariants: [
85
+ {
86
+ checked: true,
87
+ styles: {
88
+ backgroundColor: theme.$intents.primary,
89
+ },
90
+ },
91
+ ],
80
92
  _web: {
81
93
  transition: 'background-color 0.2s ease',
82
94
  },
@@ -125,14 +137,19 @@ export const switchStyles = defineStyle('Switch', (theme: Theme) => ({
125
137
  height: theme.sizes.$switch.thumbIconSize,
126
138
  },
127
139
  checked: {
128
- true: {
129
- color: theme.$intents.primary,
130
- },
131
140
  false: {
132
141
  color: theme.colors.border.secondary,
133
142
  },
134
143
  },
135
144
  },
145
+ compoundVariants: [
146
+ {
147
+ checked: true,
148
+ styles: {
149
+ color: theme.$intents.primary,
150
+ },
151
+ },
152
+ ],
136
153
  }),
137
154
 
138
155
  label: (_props: SwitchDynamicProps) => ({
@@ -77,12 +77,15 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
77
77
  checked,
78
78
  ]);
79
79
 
80
+
80
81
  // Apply variants using the correct Unistyles v3 pattern
81
82
  switchStyles.useVariants({
82
- size: size as 'sm' | 'md' | 'lg',
83
+ size,
84
+ checked,
83
85
  disabled: disabled as boolean,
84
- position: labelPosition as 'left' | 'right',
86
+ labelPosition,
85
87
  margin,
88
+ intent,
86
89
  marginVertical,
87
90
  marginHorizontal,
88
91
  });
@@ -119,13 +122,7 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
119
122
 
120
123
  // Computed container props with dynamic styles (for when label exists)
121
124
  const computedContainerProps = getWebProps([
122
- (switchStyles.container as any)({}),
123
- style as any,
124
- {
125
- cursor: disabled ? 'not-allowed' : 'pointer',
126
- display: 'inline-flex',
127
- alignItems: 'center',
128
- }
125
+ (switchStyles.container as any)({ disabled }),
129
126
  ]);
130
127
 
131
128
  const mergedButtonRef = useMergeRefs(ref as React.Ref<HTMLButtonElement>, computedButtonProps.ref);
@@ -135,6 +132,7 @@ const Switch = forwardRef<IdealystElement, SwitchProps>(({
135
132
  <button
136
133
  {...computedButtonProps}
137
134
  {...ariaProps}
135
+ style={style as any}
138
136
  ref={mergedButtonRef}
139
137
  onClick={handleClick}
140
138
  disabled={disabled}
@@ -90,6 +90,7 @@ export const viewStyles = defineStyle('View', (theme: Theme) => ({
90
90
  display: 'flex',
91
91
  flexDirection: 'column',
92
92
  boxSizing: 'border-box',
93
+ borderStyle: 'solid',
93
94
  },
94
95
  }),
95
96
  // Web-only: Wrapper for scrollable view
@@ -73,6 +73,9 @@ const View = forwardRef<IdealystElement, ViewProps>(({
73
73
  flexShrink,
74
74
  flexBasis,
75
75
  alignSelf,
76
+ // Flex content alignment - goes to content (for aligning children)
77
+ alignItems,
78
+ justifyContent,
76
79
  // Margin - goes to wrapper (positioning in parent)
77
80
  margin,
78
81
  marginTop,
@@ -124,6 +127,9 @@ const View = forwardRef<IdealystElement, ViewProps>(({
124
127
  inset: 0,
125
128
  overflow: 'auto',
126
129
  boxSizing: 'border-box',
130
+ // Flex alignment for children
131
+ ...(alignItems !== undefined && { alignItems }),
132
+ ...(justifyContent !== undefined && { justifyContent }),
127
133
  // User's visual styles
128
134
  ...contentStyles,
129
135
  }}
@@ -139,8 +145,8 @@ const View = forwardRef<IdealystElement, ViewProps>(({
139
145
 
140
146
  return (
141
147
  <div
142
- style={style as any}
143
148
  {...webProps}
149
+ style={style as any}
144
150
  ref={mergedRef}
145
151
  id={id}
146
152
  data-testid={testID}
@@ -0,0 +1,177 @@
1
+ import React, { useState } from 'react';
2
+ import { Screen, View, Text, Button } from '@idealyst/components';
3
+ import ActivityIndicator from '../ActivityIndicator';
4
+
5
+ export const ActivityIndicatorExamples: React.FC = () => {
6
+ const [isAnimating, setIsAnimating] = useState(true);
7
+
8
+ return (
9
+ <Screen background="primary" padding="lg">
10
+ <View gap="lg">
11
+ <Text typography="h3">ActivityIndicator Examples</Text>
12
+
13
+ <View gap="md">
14
+ <Text typography="h5">Basic</Text>
15
+ <View gap="sm" style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
16
+ <ActivityIndicator />
17
+ </View>
18
+ </View>
19
+
20
+ <View gap="md">
21
+ <Text typography="h5">Sizes</Text>
22
+ <View gap="sm" style={{ flexDirection: 'row', alignItems: 'center', gap: 24 }}>
23
+ <View style={{ alignItems: 'center', gap: 8 }}>
24
+ <ActivityIndicator size="xs" />
25
+ <Text typography="caption">xs</Text>
26
+ </View>
27
+ <View style={{ alignItems: 'center', gap: 8 }}>
28
+ <ActivityIndicator size="sm" />
29
+ <Text typography="caption">sm</Text>
30
+ </View>
31
+ <View style={{ alignItems: 'center', gap: 8 }}>
32
+ <ActivityIndicator size="md" />
33
+ <Text typography="caption">md</Text>
34
+ </View>
35
+ <View style={{ alignItems: 'center', gap: 8 }}>
36
+ <ActivityIndicator size="lg" />
37
+ <Text typography="caption">lg</Text>
38
+ </View>
39
+ <View style={{ alignItems: 'center', gap: 8 }}>
40
+ <ActivityIndicator size="xl" />
41
+ <Text typography="caption">xl</Text>
42
+ </View>
43
+ </View>
44
+ </View>
45
+
46
+ <View gap="md">
47
+ <Text typography="h5">Intent Colors</Text>
48
+ <View gap="sm" style={{ flexDirection: 'row', alignItems: 'center', gap: 24 }}>
49
+ <View style={{ alignItems: 'center', gap: 8 }}>
50
+ <ActivityIndicator intent="primary" />
51
+ <Text typography="caption">primary</Text>
52
+ </View>
53
+ <View style={{ alignItems: 'center', gap: 8 }}>
54
+ <ActivityIndicator intent="success" />
55
+ <Text typography="caption">success</Text>
56
+ </View>
57
+ <View style={{ alignItems: 'center', gap: 8 }}>
58
+ <ActivityIndicator intent="warning" />
59
+ <Text typography="caption">warning</Text>
60
+ </View>
61
+ <View style={{ alignItems: 'center', gap: 8 }}>
62
+ <ActivityIndicator intent="danger" />
63
+ <Text typography="caption">danger</Text>
64
+ </View>
65
+ <View style={{ alignItems: 'center', gap: 8 }}>
66
+ <ActivityIndicator intent="neutral" />
67
+ <Text typography="caption">neutral</Text>
68
+ </View>
69
+ </View>
70
+ </View>
71
+
72
+ <View gap="md">
73
+ <Text typography="h5">Custom Color</Text>
74
+ <View gap="sm" style={{ flexDirection: 'row', alignItems: 'center', gap: 24 }}>
75
+ <ActivityIndicator color="#9333ea" />
76
+ <ActivityIndicator color="#ec4899" />
77
+ <ActivityIndicator color="#06b6d4" />
78
+ <ActivityIndicator color="#f97316" />
79
+ </View>
80
+ </View>
81
+
82
+ <View gap="md">
83
+ <Text typography="h5">Toggle Animation</Text>
84
+ <View gap="sm" style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
85
+ <ActivityIndicator animating={isAnimating} hidesWhenStopped={false} />
86
+ <Button size="sm" onPress={() => setIsAnimating(!isAnimating)}>
87
+ {isAnimating ? 'Stop' : 'Start'}
88
+ </Button>
89
+ </View>
90
+ <Text typography="caption" color="secondary">
91
+ hidesWhenStopped=false keeps the indicator visible when stopped
92
+ </Text>
93
+ </View>
94
+
95
+ <View gap="md">
96
+ <Text typography="h5">Hides When Stopped (default)</Text>
97
+ <View gap="sm" style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
98
+ <View style={{ width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }}>
99
+ <ActivityIndicator animating={isAnimating} />
100
+ </View>
101
+ <Text typography="body2">
102
+ {isAnimating ? 'Visible (animating)' : 'Hidden (stopped)'}
103
+ </Text>
104
+ </View>
105
+ </View>
106
+
107
+ <View gap="md">
108
+ <Text typography="h5">Loading States</Text>
109
+ <View gap="sm">
110
+ <View
111
+ style={{
112
+ flexDirection: 'row',
113
+ alignItems: 'center',
114
+ gap: 12,
115
+ padding: 16,
116
+ backgroundColor: 'rgba(0,0,0,0.05)',
117
+ borderRadius: 8,
118
+ }}
119
+ >
120
+ <ActivityIndicator size="sm" />
121
+ <Text>Loading data...</Text>
122
+ </View>
123
+ <View
124
+ style={{
125
+ alignItems: 'center',
126
+ justifyContent: 'center',
127
+ padding: 32,
128
+ backgroundColor: 'rgba(0,0,0,0.05)',
129
+ borderRadius: 8,
130
+ }}
131
+ >
132
+ <ActivityIndicator size="lg" />
133
+ <Text typography="body2" color="secondary" style={{ marginTop: 12 }}>
134
+ Please wait...
135
+ </Text>
136
+ </View>
137
+ </View>
138
+ </View>
139
+
140
+ <View gap="md">
141
+ <Text typography="h5">Inline with Text</Text>
142
+ <View gap="sm">
143
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
144
+ <Text>Saving</Text>
145
+ <ActivityIndicator size="xs" />
146
+ </View>
147
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
148
+ <Text>Processing request</Text>
149
+ <ActivityIndicator size="sm" intent="success" />
150
+ </View>
151
+ </View>
152
+ </View>
153
+
154
+ <View gap="md">
155
+ <Text typography="h5">On Dark Background</Text>
156
+ <View
157
+ style={{
158
+ flexDirection: 'row',
159
+ alignItems: 'center',
160
+ gap: 24,
161
+ padding: 24,
162
+ backgroundColor: '#1f2937',
163
+ borderRadius: 8,
164
+ }}
165
+ >
166
+ <ActivityIndicator color="#ffffff" />
167
+ <ActivityIndicator color="#60a5fa" />
168
+ <ActivityIndicator color="#34d399" />
169
+ <ActivityIndicator color="#fbbf24" />
170
+ </View>
171
+ </View>
172
+ </View>
173
+ </Screen>
174
+ );
175
+ };
176
+
177
+ export default ActivityIndicatorExamples;