cpk-ui 0.5.2 → 0.5.4
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/components/uis/SegmentedControl/SegmentedControl.d.ts +31 -0
- package/components/uis/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/components/uis/SegmentedControl/SegmentedControl.js +118 -0
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/components/uis/RadioGroup/RadioGroup.test.tsx +20 -13
- package/src/components/uis/SegmentedControl/SegmentedControl.stories.tsx +546 -0
- package/src/components/uis/SegmentedControl/SegmentedControl.test.tsx +274 -0
- package/src/components/uis/SegmentedControl/SegmentedControl.tsx +229 -0
- package/src/index.tsx +1 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React, { type ReactElement } from 'react';
|
|
2
|
+
import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
|
|
3
|
+
export type SegmentedControlSizeType = 'small' | 'medium' | 'large' | number;
|
|
4
|
+
export type SegmentedControlColorType = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light';
|
|
5
|
+
export type SegmentedControlItem = {
|
|
6
|
+
value: string | number;
|
|
7
|
+
text: string | ReactElement;
|
|
8
|
+
};
|
|
9
|
+
type Styles = {
|
|
10
|
+
container?: StyleProp<ViewStyle>;
|
|
11
|
+
segment?: StyleProp<ViewStyle>;
|
|
12
|
+
selectedSegment?: StyleProp<ViewStyle>;
|
|
13
|
+
text?: StyleProp<TextStyle>;
|
|
14
|
+
selectedText?: StyleProp<TextStyle>;
|
|
15
|
+
};
|
|
16
|
+
export type SegmentedControlProps = {
|
|
17
|
+
testID?: string;
|
|
18
|
+
values: SegmentedControlItem[];
|
|
19
|
+
selectedValue: string | number;
|
|
20
|
+
onValueChange?: (value: string | number) => void;
|
|
21
|
+
color?: SegmentedControlColorType;
|
|
22
|
+
size?: SegmentedControlSizeType;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
style?: StyleProp<ViewStyle>;
|
|
25
|
+
styles?: Styles;
|
|
26
|
+
borderRadius?: number;
|
|
27
|
+
};
|
|
28
|
+
declare function SegmentedControlContainer({ testID, values, selectedValue, onValueChange, color, size, disabled, style, styles, borderRadius, }: SegmentedControlProps): ReactElement;
|
|
29
|
+
export declare const SegmentedControl: React.MemoExoticComponent<typeof SegmentedControlContainer>;
|
|
30
|
+
export default SegmentedControl;
|
|
31
|
+
//# sourceMappingURL=SegmentedControl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SegmentedControl.d.ts","sourceRoot":"","sources":["../../../../src/components/uis/SegmentedControl/SegmentedControl.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAuB,KAAK,YAAY,EAAC,MAAM,OAAO,CAAC;AACrE,OAAO,KAAK,EAAC,SAAS,EAAE,SAAS,EAAE,SAAS,EAAC,MAAM,cAAc,CAAC;AAQlE,MAAM,MAAM,wBAAwB,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAC7E,MAAM,MAAM,yBAAyB,GACjC,SAAS,GACT,WAAW,GACX,SAAS,GACT,QAAQ,GACR,SAAS,GACT,MAAM,GACN,OAAO,CAAC;AAEZ,MAAM,MAAM,oBAAoB,GAAG;IACjC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,GAAG,YAAY,CAAC;CAC7B,CAAC;AAEF,KAAK,MAAM,GAAG;IACZ,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,OAAO,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC/B,eAAe,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACvC,IAAI,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5B,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CACrC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,oBAAoB,EAAE,CAAC;IAC/B,aAAa,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,KAAK,IAAI,CAAC;IACjD,KAAK,CAAC,EAAE,yBAAyB,CAAC;IAClC,IAAI,CAAC,EAAE,wBAAwB,CAAC;IAChC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AA6EF,iBAAS,yBAAyB,CAAC,EACjC,MAAM,EACN,MAAM,EACN,aAAa,EACb,aAAa,EACb,KAAiB,EACjB,IAAe,EACf,QAAgB,EAChB,KAAK,EACL,MAAM,EACN,YAAgB,GACjB,EAAE,qBAAqB,GAAG,YAAY,CA0FtC;AAGD,eAAO,MAAM,gBAAgB,6DAE5B,CAAC;AAEF,eAAe,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback, useMemo } from 'react';
|
|
3
|
+
import { TouchableOpacity } from 'react-native';
|
|
4
|
+
import { styled, css } from 'kstyled';
|
|
5
|
+
import { useTheme } from '../../../providers/ThemeProvider';
|
|
6
|
+
import { Typography } from '../Typography/Typography';
|
|
7
|
+
const Container = styled.View `
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
`;
|
|
10
|
+
// Calculate styles based on theme and props
|
|
11
|
+
const calculateStyles = ({ theme, color, size, borderRadius, styles, }) => {
|
|
12
|
+
const padding = typeof size === 'number'
|
|
13
|
+
? `${size * 0.4}px ${size * 0.8}px`
|
|
14
|
+
: size === 'large'
|
|
15
|
+
? '12px 16px'
|
|
16
|
+
: size === 'medium'
|
|
17
|
+
? '8px 12px'
|
|
18
|
+
: size === 'small'
|
|
19
|
+
? '6px 8px'
|
|
20
|
+
: '8px 12px';
|
|
21
|
+
return {
|
|
22
|
+
wrapper: [
|
|
23
|
+
css `
|
|
24
|
+
flex-direction: row;
|
|
25
|
+
background-color: ${theme.bg.paper};
|
|
26
|
+
border-top-color: ${theme.bg.disabled};
|
|
27
|
+
border-bottom-color: ${theme.bg.disabled};
|
|
28
|
+
border-left-color: ${theme.bg.disabled};
|
|
29
|
+
border-right-color: ${theme.bg.disabled};
|
|
30
|
+
border-width: 1px;
|
|
31
|
+
border-radius: ${borderRadius}px;
|
|
32
|
+
`,
|
|
33
|
+
],
|
|
34
|
+
container: styles?.container,
|
|
35
|
+
segment: [
|
|
36
|
+
css `
|
|
37
|
+
flex: 1;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
padding: ${padding};
|
|
41
|
+
background-color: transparent;
|
|
42
|
+
`,
|
|
43
|
+
styles?.segment,
|
|
44
|
+
],
|
|
45
|
+
selectedSegment: [
|
|
46
|
+
css `
|
|
47
|
+
background-color: ${theme.button[color].bg};
|
|
48
|
+
`,
|
|
49
|
+
styles?.selectedSegment,
|
|
50
|
+
],
|
|
51
|
+
text: [
|
|
52
|
+
css `
|
|
53
|
+
color: ${theme.text.basic};
|
|
54
|
+
`,
|
|
55
|
+
styles?.text,
|
|
56
|
+
],
|
|
57
|
+
selectedText: [
|
|
58
|
+
css `
|
|
59
|
+
color: ${theme.button[color].text};
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
`,
|
|
62
|
+
styles?.selectedText,
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
function SegmentedControlContainer({ testID, values, selectedValue, onValueChange, color = 'primary', size = 'medium', disabled = false, style, styles, borderRadius = 8, }) {
|
|
67
|
+
const { theme } = useTheme();
|
|
68
|
+
// Memoize styles calculation
|
|
69
|
+
const compositeStyles = useMemo(() => calculateStyles({
|
|
70
|
+
theme,
|
|
71
|
+
color,
|
|
72
|
+
size,
|
|
73
|
+
borderRadius,
|
|
74
|
+
styles,
|
|
75
|
+
}), [theme, color, size, borderRadius, styles]);
|
|
76
|
+
// Memoize segment press handler
|
|
77
|
+
const handlePress = useCallback((value) => {
|
|
78
|
+
if (!disabled) {
|
|
79
|
+
onValueChange?.(value);
|
|
80
|
+
}
|
|
81
|
+
}, [disabled, onValueChange]);
|
|
82
|
+
// Memoize segments rendering
|
|
83
|
+
const segments = useMemo(() => {
|
|
84
|
+
const cornerRadius = Math.max(borderRadius - 1, 0);
|
|
85
|
+
return values.map((item, index) => {
|
|
86
|
+
const isSelected = selectedValue === item.value;
|
|
87
|
+
const isFirst = index === 0;
|
|
88
|
+
const isLast = index === values.length - 1;
|
|
89
|
+
return (_jsx(TouchableOpacity, { testID: `segment-${index}`, activeOpacity: 0.7, disabled: disabled, onPress: () => handlePress(item.value), style: [
|
|
90
|
+
compositeStyles.segment,
|
|
91
|
+
isSelected && compositeStyles.selectedSegment,
|
|
92
|
+
{
|
|
93
|
+
borderTopLeftRadius: isFirst ? cornerRadius : 0,
|
|
94
|
+
borderBottomLeftRadius: isFirst ? cornerRadius : 0,
|
|
95
|
+
borderTopRightRadius: isLast ? cornerRadius : 0,
|
|
96
|
+
borderBottomRightRadius: isLast ? cornerRadius : 0,
|
|
97
|
+
borderRightWidth: isLast ? 0 : 1,
|
|
98
|
+
borderRightColor: theme.bg.disabled,
|
|
99
|
+
},
|
|
100
|
+
], children: typeof item.text === 'string' ? (_jsx(Typography.Body2, { style: [
|
|
101
|
+
compositeStyles.text,
|
|
102
|
+
isSelected && compositeStyles.selectedText,
|
|
103
|
+
], children: item.text })) : (item.text) }, `segment-${index}`));
|
|
104
|
+
});
|
|
105
|
+
}, [
|
|
106
|
+
values,
|
|
107
|
+
selectedValue,
|
|
108
|
+
disabled,
|
|
109
|
+
handlePress,
|
|
110
|
+
compositeStyles,
|
|
111
|
+
borderRadius,
|
|
112
|
+
theme.bg.disabled,
|
|
113
|
+
]);
|
|
114
|
+
return (_jsx(Container, { style: style, testID: testID, children: _jsx(Container, { style: [compositeStyles.wrapper, compositeStyles.container], children: segments }) }));
|
|
115
|
+
}
|
|
116
|
+
// Export memoized component for better performance
|
|
117
|
+
export const SegmentedControl = React.memo(SegmentedControlContainer);
|
|
118
|
+
export default SegmentedControl;
|
package/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export * from './components/uis/LoadingIndicator/LoadingIndicator';
|
|
|
16
16
|
export * from './components/uis/PinchZoom/PinchZoom';
|
|
17
17
|
export * from './components/uis/Rating/Rating';
|
|
18
18
|
export * from './components/uis/RadioGroup/RadioGroup';
|
|
19
|
+
export * from './components/uis/SegmentedControl/SegmentedControl';
|
|
19
20
|
export * from './components/uis/StatusbarBrightness/StatusBarBrightness';
|
|
20
21
|
export * from './components/uis/SwitchToggle/SwitchToggle';
|
|
21
22
|
export * from './components/uis/Typography/Typography';
|
package/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AACA,cAAc,6CAA6C,CAAC;AAC5D,cAAc,uCAAuC,CAAC;AAGtD,cAAc,iDAAiD,CAAC;AAGhE,cAAc,aAAa,CAAC;AAG5B,cAAc,sCAAsC,CAAC;AACrD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oCAAoC,CAAC;AACnD,cAAc,kDAAkD,CAAC;AACjE,cAAc,oCAAoC,CAAC;AACnD,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oDAAoD,CAAC;AACnE,cAAc,sCAAsC,CAAC;AACrD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,wCAAwC,CAAC;AACvD,cAAc,0DAA0D,CAAC;AACzE,cAAc,4CAA4C,CAAC;AAC3D,cAAc,wCAAwC,CAAC;AAGvD,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AACA,cAAc,6CAA6C,CAAC;AAC5D,cAAc,uCAAuC,CAAC;AAGtD,cAAc,iDAAiD,CAAC;AAGhE,cAAc,aAAa,CAAC;AAG5B,cAAc,sCAAsC,CAAC;AACrD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,oCAAoC,CAAC;AACnD,cAAc,kDAAkD,CAAC;AACjE,cAAc,oCAAoC,CAAC;AACnD,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oDAAoD,CAAC;AACnE,cAAc,sCAAsC,CAAC;AACrD,cAAc,gCAAgC,CAAC;AAC/C,cAAc,wCAAwC,CAAC;AACvD,cAAc,oDAAoD,CAAC;AACnE,cAAc,0DAA0D,CAAC;AACzE,cAAc,4CAA4C,CAAC;AAC3D,cAAc,wCAAwC,CAAC;AAGvD,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC"}
|
package/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export * from './components/uis/LoadingIndicator/LoadingIndicator';
|
|
|
20
20
|
export * from './components/uis/PinchZoom/PinchZoom';
|
|
21
21
|
export * from './components/uis/Rating/Rating';
|
|
22
22
|
export * from './components/uis/RadioGroup/RadioGroup';
|
|
23
|
+
export * from './components/uis/SegmentedControl/SegmentedControl';
|
|
23
24
|
export * from './components/uis/StatusbarBrightness/StatusBarBrightness';
|
|
24
25
|
export * from './components/uis/SwitchToggle/SwitchToggle';
|
|
25
26
|
export * from './components/uis/Typography/Typography';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import {type ReactElement} from 'react';
|
|
3
|
-
import {View} from 'react-native';
|
|
3
|
+
import {StyleSheet, View} from 'react-native';
|
|
4
4
|
import type {RenderAPI} from '@testing-library/react-native';
|
|
5
5
|
import {act, fireEvent, render, waitFor} from '@testing-library/react-native';
|
|
6
6
|
|
|
@@ -133,12 +133,14 @@ describe('[RadioButton]', () => {
|
|
|
133
133
|
testingLib = render(component);
|
|
134
134
|
|
|
135
135
|
const circleRadio = testingLib.getByTestId('circle-radio-0');
|
|
136
|
-
|
|
137
136
|
// Get initial styles before layout
|
|
138
|
-
const initialStyle = circleRadio.props.style;
|
|
139
|
-
const initialMargin =
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
const initialStyle = StyleSheet.flatten(circleRadio.props.style) ?? {};
|
|
138
|
+
const initialMargin =
|
|
139
|
+
initialStyle.margin ??
|
|
140
|
+
initialStyle.marginTop ??
|
|
141
|
+
initialStyle.marginRight ??
|
|
142
|
+
initialStyle.marginBottom ??
|
|
143
|
+
initialStyle.marginLeft;
|
|
142
144
|
|
|
143
145
|
// Initially margin should be 0 since innerLayout is not set
|
|
144
146
|
expect(initialMargin).toBe(0);
|
|
@@ -156,13 +158,18 @@ describe('[RadioButton]', () => {
|
|
|
156
158
|
|
|
157
159
|
// After layout, the styles should reflect the innerLayout values
|
|
158
160
|
// margin should be 2 and borderRadius should be width/2 = 20
|
|
159
|
-
const updatedStyle =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
const updatedStyle =
|
|
162
|
+
StyleSheet.flatten(testingLib.getByTestId('circle-radio-0').props.style) ??
|
|
163
|
+
{};
|
|
164
|
+
const updatedMargin =
|
|
165
|
+
updatedStyle.margin ??
|
|
166
|
+
updatedStyle.marginTop ??
|
|
167
|
+
updatedStyle.marginRight ??
|
|
168
|
+
updatedStyle.marginBottom ??
|
|
169
|
+
updatedStyle.marginLeft;
|
|
170
|
+
|
|
171
|
+
expect(updatedMargin).toBe(2);
|
|
172
|
+
expect(updatedStyle.borderRadius).toBe(20); // 40 / 2
|
|
166
173
|
});
|
|
167
174
|
|
|
168
175
|
describe('colors', () => {
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import {useState} from 'react';
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react';
|
|
3
|
+
import {View} from 'react-native';
|
|
4
|
+
|
|
5
|
+
import {withThemeProvider} from '../../../../.storybook/decorators';
|
|
6
|
+
import {SegmentedControl, SegmentedControlColorType, SegmentedControlSizeType} from './SegmentedControl';
|
|
7
|
+
import {Typography} from '../Typography/Typography';
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: 'SegmentedControl',
|
|
11
|
+
component: (props) => <SegmentedControl {...props} />,
|
|
12
|
+
parameters: {
|
|
13
|
+
notes: `
|
|
14
|
+
A iOS-style SegmentedControl component that allows users to select a single option from a set of choices.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
- **iOS-style Design**: Native iOS segmented control appearance
|
|
18
|
+
- **Flexible Sizing**: Supports preset sizes (small, medium, large) and custom numeric values
|
|
19
|
+
- **Color Variants**: Multiple color options for different contexts
|
|
20
|
+
- **Disabled State**: Can disable the entire control
|
|
21
|
+
- **Smooth Transitions**: Animated selection with visual feedback
|
|
22
|
+
|
|
23
|
+
## Size Options
|
|
24
|
+
- \`small\`: 6px/8px padding
|
|
25
|
+
- \`medium\`: 8px/12px padding (default)
|
|
26
|
+
- \`large\`: 12px/16px padding
|
|
27
|
+
- Custom number: Any pixel value for custom padding
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
\`\`\`tsx
|
|
31
|
+
<SegmentedControl
|
|
32
|
+
values={[
|
|
33
|
+
{value: 'day', text: 'Day'},
|
|
34
|
+
{value: 'week', text: 'Week'},
|
|
35
|
+
{value: 'month', text: 'Month'},
|
|
36
|
+
]}
|
|
37
|
+
selectedValue="day"
|
|
38
|
+
onValueChange={setSelectedValue}
|
|
39
|
+
size="medium"
|
|
40
|
+
color="primary"
|
|
41
|
+
/>
|
|
42
|
+
\`\`\`
|
|
43
|
+
`,
|
|
44
|
+
docs: {
|
|
45
|
+
description: {
|
|
46
|
+
component: `
|
|
47
|
+
A iOS-style SegmentedControl component that allows users to select a single option from a set of choices.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
- **iOS-style Design**: Native iOS segmented control appearance
|
|
51
|
+
- **Flexible Sizing**: Supports preset sizes (small, medium, large) and custom numeric values
|
|
52
|
+
- **Color Variants**: Multiple color options for different contexts
|
|
53
|
+
- **Disabled State**: Can disable the entire control
|
|
54
|
+
- **Smooth Transitions**: Animated selection with visual feedback
|
|
55
|
+
|
|
56
|
+
## Size Options
|
|
57
|
+
- \`small\`: 6px/8px padding
|
|
58
|
+
- \`medium\`: 8px/12px padding (default)
|
|
59
|
+
- \`large\`: 12px/16px padding
|
|
60
|
+
- Custom number: Any pixel value for custom padding
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
\`\`\`tsx
|
|
64
|
+
<SegmentedControl
|
|
65
|
+
values={[
|
|
66
|
+
{value: 'day', text: 'Day'},
|
|
67
|
+
{value: 'week', text: 'Week'},
|
|
68
|
+
{value: 'month', text: 'Month'},
|
|
69
|
+
]}
|
|
70
|
+
selectedValue="day"
|
|
71
|
+
onValueChange={setSelectedValue}
|
|
72
|
+
size="medium"
|
|
73
|
+
color="primary"
|
|
74
|
+
/>
|
|
75
|
+
\`\`\`
|
|
76
|
+
`,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
argTypes: {
|
|
81
|
+
color: {
|
|
82
|
+
control: 'select',
|
|
83
|
+
options: [
|
|
84
|
+
'primary',
|
|
85
|
+
'secondary',
|
|
86
|
+
'success',
|
|
87
|
+
'danger',
|
|
88
|
+
'warning',
|
|
89
|
+
'info',
|
|
90
|
+
'light',
|
|
91
|
+
] as SegmentedControlColorType[],
|
|
92
|
+
description: 'Color variant of the selected segment',
|
|
93
|
+
},
|
|
94
|
+
size: {
|
|
95
|
+
control: 'radio',
|
|
96
|
+
options: ['small', 'medium', 'large', 16, 20, 24, 32],
|
|
97
|
+
description: 'Size can be "small", "medium", "large" or a custom number in pixels',
|
|
98
|
+
},
|
|
99
|
+
disabled: {
|
|
100
|
+
control: 'boolean',
|
|
101
|
+
description: 'Disables the entire segmented control',
|
|
102
|
+
},
|
|
103
|
+
borderRadius: {
|
|
104
|
+
control: {type: 'number', min: 0, max: 20, step: 1},
|
|
105
|
+
description: 'Border radius of the control',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
decorators: [
|
|
109
|
+
(Story, context) =>
|
|
110
|
+
withThemeProvider(
|
|
111
|
+
Story,
|
|
112
|
+
context,
|
|
113
|
+
// @ts-ignore
|
|
114
|
+
context.args.theme,
|
|
115
|
+
),
|
|
116
|
+
],
|
|
117
|
+
} satisfies Meta<typeof SegmentedControl>;
|
|
118
|
+
|
|
119
|
+
export default meta;
|
|
120
|
+
|
|
121
|
+
type Story = StoryObj<typeof meta>;
|
|
122
|
+
|
|
123
|
+
export const Basic: Story = {
|
|
124
|
+
render: (args) => {
|
|
125
|
+
const [selectedValue, setSelectedValue] = useState<string | number>('day');
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<View style={{gap: 20}}>
|
|
129
|
+
<SegmentedControl
|
|
130
|
+
values={[
|
|
131
|
+
{value: 'day', text: 'Day'},
|
|
132
|
+
{value: 'week', text: 'Week'},
|
|
133
|
+
{value: 'month', text: 'Month'},
|
|
134
|
+
]}
|
|
135
|
+
color={args.color}
|
|
136
|
+
size={args.size}
|
|
137
|
+
disabled={args.disabled}
|
|
138
|
+
borderRadius={args.borderRadius}
|
|
139
|
+
selectedValue={selectedValue}
|
|
140
|
+
onValueChange={setSelectedValue}
|
|
141
|
+
/>
|
|
142
|
+
<View style={{padding: 16, alignItems: 'center'}}>
|
|
143
|
+
<Typography.Heading2>Selected: {selectedValue}</Typography.Heading2>
|
|
144
|
+
</View>
|
|
145
|
+
</View>
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
args: {
|
|
149
|
+
theme: 'light',
|
|
150
|
+
color: 'primary',
|
|
151
|
+
size: 'medium',
|
|
152
|
+
disabled: false,
|
|
153
|
+
borderRadius: 8,
|
|
154
|
+
},
|
|
155
|
+
argTypes: {
|
|
156
|
+
theme: {
|
|
157
|
+
control: 'select',
|
|
158
|
+
options: ['light', 'dark'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const WithDynamicContent: Story = {
|
|
164
|
+
render: (args) => {
|
|
165
|
+
const [selectedValue, setSelectedValue] = useState<string | number>('day');
|
|
166
|
+
|
|
167
|
+
const contentMap = {
|
|
168
|
+
day: {
|
|
169
|
+
title: 'Daily View',
|
|
170
|
+
description: 'View your daily activities and statistics',
|
|
171
|
+
emoji: '📅',
|
|
172
|
+
},
|
|
173
|
+
week: {
|
|
174
|
+
title: 'Weekly View',
|
|
175
|
+
description: 'See your weekly progress and trends',
|
|
176
|
+
emoji: '📊',
|
|
177
|
+
},
|
|
178
|
+
month: {
|
|
179
|
+
title: 'Monthly View',
|
|
180
|
+
description: 'Analyze your monthly performance',
|
|
181
|
+
emoji: '📈',
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const content = contentMap[selectedValue as keyof typeof contentMap];
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<View style={{gap: 20}}>
|
|
189
|
+
<SegmentedControl
|
|
190
|
+
values={[
|
|
191
|
+
{value: 'day', text: 'Day'},
|
|
192
|
+
{value: 'week', text: 'Week'},
|
|
193
|
+
{value: 'month', text: 'Month'},
|
|
194
|
+
]}
|
|
195
|
+
color={args.color}
|
|
196
|
+
size={args.size}
|
|
197
|
+
disabled={args.disabled}
|
|
198
|
+
borderRadius={args.borderRadius}
|
|
199
|
+
selectedValue={selectedValue}
|
|
200
|
+
onValueChange={setSelectedValue}
|
|
201
|
+
/>
|
|
202
|
+
<View style={{padding: 24, alignItems: 'center', gap: 12}}>
|
|
203
|
+
<Typography.Heading1 style={{fontSize: 48}}>
|
|
204
|
+
{content.emoji}
|
|
205
|
+
</Typography.Heading1>
|
|
206
|
+
<Typography.Heading2>{content.title}</Typography.Heading2>
|
|
207
|
+
<Typography.Body1 style={{textAlign: 'center', opacity: 0.7}}>
|
|
208
|
+
{content.description}
|
|
209
|
+
</Typography.Body1>
|
|
210
|
+
</View>
|
|
211
|
+
</View>
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
args: {
|
|
215
|
+
theme: 'light',
|
|
216
|
+
color: 'primary',
|
|
217
|
+
size: 'medium',
|
|
218
|
+
disabled: false,
|
|
219
|
+
borderRadius: 8,
|
|
220
|
+
},
|
|
221
|
+
argTypes: {
|
|
222
|
+
theme: {
|
|
223
|
+
control: 'select',
|
|
224
|
+
options: ['light', 'dark'],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
export const PlatformSelector: Story = {
|
|
230
|
+
render: (args) => {
|
|
231
|
+
const [selectedValue, setSelectedValue] = useState<string | number>('ios');
|
|
232
|
+
|
|
233
|
+
const platformInfo = {
|
|
234
|
+
ios: {
|
|
235
|
+
icon: '🍎',
|
|
236
|
+
name: 'iOS',
|
|
237
|
+
version: 'iOS 17+',
|
|
238
|
+
features: ['Swift', 'SwiftUI', 'UIKit'],
|
|
239
|
+
},
|
|
240
|
+
android: {
|
|
241
|
+
icon: '🤖',
|
|
242
|
+
name: 'Android',
|
|
243
|
+
version: 'Android 14+',
|
|
244
|
+
features: ['Kotlin', 'Jetpack Compose', 'Material Design'],
|
|
245
|
+
},
|
|
246
|
+
web: {
|
|
247
|
+
icon: '🌐',
|
|
248
|
+
name: 'Web',
|
|
249
|
+
version: 'Modern Browsers',
|
|
250
|
+
features: ['React', 'TypeScript', 'Responsive Design'],
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const info = platformInfo[selectedValue as keyof typeof platformInfo];
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<View style={{gap: 20}}>
|
|
258
|
+
<SegmentedControl
|
|
259
|
+
values={[
|
|
260
|
+
{value: 'ios', text: 'iOS'},
|
|
261
|
+
{value: 'android', text: 'Android'},
|
|
262
|
+
{value: 'web', text: 'Web'},
|
|
263
|
+
]}
|
|
264
|
+
color={args.color}
|
|
265
|
+
size={args.size}
|
|
266
|
+
disabled={args.disabled}
|
|
267
|
+
borderRadius={args.borderRadius}
|
|
268
|
+
selectedValue={selectedValue}
|
|
269
|
+
onValueChange={setSelectedValue}
|
|
270
|
+
/>
|
|
271
|
+
<View style={{padding: 24, gap: 16}}>
|
|
272
|
+
<View style={{alignItems: 'center', gap: 8}}>
|
|
273
|
+
<Typography.Heading1 style={{fontSize: 56}}>
|
|
274
|
+
{info.icon}
|
|
275
|
+
</Typography.Heading1>
|
|
276
|
+
<Typography.Heading2>{info.name}</Typography.Heading2>
|
|
277
|
+
<Typography.Body2 style={{opacity: 0.6}}>
|
|
278
|
+
{info.version}
|
|
279
|
+
</Typography.Body2>
|
|
280
|
+
</View>
|
|
281
|
+
<View style={{gap: 8, marginTop: 8}}>
|
|
282
|
+
<Typography.Heading3>Key Technologies:</Typography.Heading3>
|
|
283
|
+
{info.features.map((feature) => (
|
|
284
|
+
<Typography.Body1 key={feature} style={{paddingLeft: 12}}>
|
|
285
|
+
• {feature}
|
|
286
|
+
</Typography.Body1>
|
|
287
|
+
))}
|
|
288
|
+
</View>
|
|
289
|
+
</View>
|
|
290
|
+
</View>
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
args: {
|
|
294
|
+
theme: 'light',
|
|
295
|
+
color: 'primary',
|
|
296
|
+
size: 'medium',
|
|
297
|
+
disabled: false,
|
|
298
|
+
borderRadius: 8,
|
|
299
|
+
},
|
|
300
|
+
argTypes: {
|
|
301
|
+
theme: {
|
|
302
|
+
control: 'select',
|
|
303
|
+
options: ['light', 'dark'],
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const AllSizes: Story = {
|
|
309
|
+
render: (args) => {
|
|
310
|
+
const [selectedSmall, setSelectedSmall] = useState<string | number>('day');
|
|
311
|
+
const [selectedMedium, setSelectedMedium] = useState<string | number>('day');
|
|
312
|
+
const [selectedLarge, setSelectedLarge] = useState<string | number>('day');
|
|
313
|
+
|
|
314
|
+
const values = [
|
|
315
|
+
{value: 'day', text: 'Day'},
|
|
316
|
+
{value: 'week', text: 'Week'},
|
|
317
|
+
{value: 'month', text: 'Month'},
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<View style={{gap: 24}}>
|
|
322
|
+
<View style={{gap: 8}}>
|
|
323
|
+
<Typography.Heading3>Small</Typography.Heading3>
|
|
324
|
+
<SegmentedControl
|
|
325
|
+
values={values}
|
|
326
|
+
color="primary"
|
|
327
|
+
size="small"
|
|
328
|
+
borderRadius={8}
|
|
329
|
+
selectedValue={selectedSmall}
|
|
330
|
+
onValueChange={setSelectedSmall}
|
|
331
|
+
/>
|
|
332
|
+
</View>
|
|
333
|
+
<View style={{gap: 8}}>
|
|
334
|
+
<Typography.Heading3>Medium</Typography.Heading3>
|
|
335
|
+
<SegmentedControl
|
|
336
|
+
values={values}
|
|
337
|
+
color="primary"
|
|
338
|
+
size="medium"
|
|
339
|
+
borderRadius={8}
|
|
340
|
+
selectedValue={selectedMedium}
|
|
341
|
+
onValueChange={setSelectedMedium}
|
|
342
|
+
/>
|
|
343
|
+
</View>
|
|
344
|
+
<View style={{gap: 8}}>
|
|
345
|
+
<Typography.Heading3>Large</Typography.Heading3>
|
|
346
|
+
<SegmentedControl
|
|
347
|
+
values={values}
|
|
348
|
+
color="primary"
|
|
349
|
+
size="large"
|
|
350
|
+
borderRadius={8}
|
|
351
|
+
selectedValue={selectedLarge}
|
|
352
|
+
onValueChange={setSelectedLarge}
|
|
353
|
+
/>
|
|
354
|
+
</View>
|
|
355
|
+
</View>
|
|
356
|
+
);
|
|
357
|
+
},
|
|
358
|
+
args: {
|
|
359
|
+
theme: 'light',
|
|
360
|
+
},
|
|
361
|
+
argTypes: {
|
|
362
|
+
theme: {
|
|
363
|
+
control: 'select',
|
|
364
|
+
options: ['light', 'dark'],
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export const AllColors: Story = {
|
|
370
|
+
render: (args) => {
|
|
371
|
+
const [selected1, setSelected1] = useState<string | number>(1);
|
|
372
|
+
const [selected2, setSelected2] = useState<string | number>(1);
|
|
373
|
+
const [selected3, setSelected3] = useState<string | number>(1);
|
|
374
|
+
const [selected4, setSelected4] = useState<string | number>(1);
|
|
375
|
+
const [selected5, setSelected5] = useState<string | number>(1);
|
|
376
|
+
const [selected6, setSelected6] = useState<string | number>(1);
|
|
377
|
+
|
|
378
|
+
const values = [
|
|
379
|
+
{value: 1, text: 'Option 1'},
|
|
380
|
+
{value: 2, text: 'Option 2'},
|
|
381
|
+
{value: 3, text: 'Option 3'},
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<View style={{gap: 20}}>
|
|
386
|
+
<View style={{gap: 8}}>
|
|
387
|
+
<Typography.Heading3>Primary</Typography.Heading3>
|
|
388
|
+
<SegmentedControl
|
|
389
|
+
values={values}
|
|
390
|
+
color="primary"
|
|
391
|
+
size="medium"
|
|
392
|
+
borderRadius={8}
|
|
393
|
+
selectedValue={selected1}
|
|
394
|
+
onValueChange={setSelected1}
|
|
395
|
+
/>
|
|
396
|
+
</View>
|
|
397
|
+
<View style={{gap: 8}}>
|
|
398
|
+
<Typography.Heading3>Secondary</Typography.Heading3>
|
|
399
|
+
<SegmentedControl
|
|
400
|
+
values={values}
|
|
401
|
+
color="secondary"
|
|
402
|
+
size="medium"
|
|
403
|
+
borderRadius={8}
|
|
404
|
+
selectedValue={selected2}
|
|
405
|
+
onValueChange={setSelected2}
|
|
406
|
+
/>
|
|
407
|
+
</View>
|
|
408
|
+
<View style={{gap: 8}}>
|
|
409
|
+
<Typography.Heading3>Success</Typography.Heading3>
|
|
410
|
+
<SegmentedControl
|
|
411
|
+
values={values}
|
|
412
|
+
color="success"
|
|
413
|
+
size="medium"
|
|
414
|
+
borderRadius={8}
|
|
415
|
+
selectedValue={selected3}
|
|
416
|
+
onValueChange={setSelected3}
|
|
417
|
+
/>
|
|
418
|
+
</View>
|
|
419
|
+
<View style={{gap: 8}}>
|
|
420
|
+
<Typography.Heading3>Danger</Typography.Heading3>
|
|
421
|
+
<SegmentedControl
|
|
422
|
+
values={values}
|
|
423
|
+
color="danger"
|
|
424
|
+
size="medium"
|
|
425
|
+
borderRadius={8}
|
|
426
|
+
selectedValue={selected4}
|
|
427
|
+
onValueChange={setSelected4}
|
|
428
|
+
/>
|
|
429
|
+
</View>
|
|
430
|
+
<View style={{gap: 8}}>
|
|
431
|
+
<Typography.Heading3>Warning</Typography.Heading3>
|
|
432
|
+
<SegmentedControl
|
|
433
|
+
values={values}
|
|
434
|
+
color="warning"
|
|
435
|
+
size="medium"
|
|
436
|
+
borderRadius={8}
|
|
437
|
+
selectedValue={selected5}
|
|
438
|
+
onValueChange={setSelected5}
|
|
439
|
+
/>
|
|
440
|
+
</View>
|
|
441
|
+
<View style={{gap: 8}}>
|
|
442
|
+
<Typography.Heading3>Info</Typography.Heading3>
|
|
443
|
+
<SegmentedControl
|
|
444
|
+
values={values}
|
|
445
|
+
color="info"
|
|
446
|
+
size="medium"
|
|
447
|
+
borderRadius={8}
|
|
448
|
+
selectedValue={selected6}
|
|
449
|
+
onValueChange={setSelected6}
|
|
450
|
+
/>
|
|
451
|
+
</View>
|
|
452
|
+
</View>
|
|
453
|
+
);
|
|
454
|
+
},
|
|
455
|
+
args: {
|
|
456
|
+
theme: 'light',
|
|
457
|
+
},
|
|
458
|
+
argTypes: {
|
|
459
|
+
theme: {
|
|
460
|
+
control: 'select',
|
|
461
|
+
options: ['light', 'dark'],
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
export const WithIcons: Story = {
|
|
467
|
+
render: (args) => {
|
|
468
|
+
const [selectedValue, setSelectedValue] = useState<string | number>('list');
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<View style={{gap: 20}}>
|
|
472
|
+
<SegmentedControl
|
|
473
|
+
values={[
|
|
474
|
+
{value: 'grid', text: <Typography.Body2>📱 Grid</Typography.Body2>},
|
|
475
|
+
{value: 'list', text: <Typography.Body2>📋 List</Typography.Body2>},
|
|
476
|
+
{value: 'map', text: <Typography.Body2>🗺️ Map</Typography.Body2>},
|
|
477
|
+
]}
|
|
478
|
+
color={args.color}
|
|
479
|
+
size={args.size}
|
|
480
|
+
disabled={args.disabled}
|
|
481
|
+
borderRadius={args.borderRadius}
|
|
482
|
+
selectedValue={selectedValue}
|
|
483
|
+
onValueChange={setSelectedValue}
|
|
484
|
+
/>
|
|
485
|
+
<View style={{padding: 16, alignItems: 'center'}}>
|
|
486
|
+
<Typography.Body1>View mode: {selectedValue}</Typography.Body1>
|
|
487
|
+
</View>
|
|
488
|
+
</View>
|
|
489
|
+
);
|
|
490
|
+
},
|
|
491
|
+
args: {
|
|
492
|
+
theme: 'light',
|
|
493
|
+
color: 'primary',
|
|
494
|
+
size: 'medium',
|
|
495
|
+
disabled: false,
|
|
496
|
+
borderRadius: 8,
|
|
497
|
+
},
|
|
498
|
+
argTypes: {
|
|
499
|
+
theme: {
|
|
500
|
+
control: 'select',
|
|
501
|
+
options: ['light', 'dark'],
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
export const Disabled: Story = {
|
|
507
|
+
render: (args) => {
|
|
508
|
+
const [selectedValue, setSelectedValue] = useState<string | number>('second');
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<View style={{gap: 20}}>
|
|
512
|
+
<SegmentedControl
|
|
513
|
+
values={[
|
|
514
|
+
{value: 'first', text: 'First'},
|
|
515
|
+
{value: 'second', text: 'Second'},
|
|
516
|
+
{value: 'third', text: 'Third'},
|
|
517
|
+
]}
|
|
518
|
+
color={args.color}
|
|
519
|
+
size={args.size}
|
|
520
|
+
disabled={args.disabled}
|
|
521
|
+
borderRadius={args.borderRadius}
|
|
522
|
+
selectedValue={selectedValue}
|
|
523
|
+
onValueChange={setSelectedValue}
|
|
524
|
+
/>
|
|
525
|
+
<View style={{padding: 16, alignItems: 'center'}}>
|
|
526
|
+
<Typography.Body1 style={{opacity: 0.5}}>
|
|
527
|
+
This control is disabled
|
|
528
|
+
</Typography.Body1>
|
|
529
|
+
</View>
|
|
530
|
+
</View>
|
|
531
|
+
);
|
|
532
|
+
},
|
|
533
|
+
args: {
|
|
534
|
+
theme: 'light',
|
|
535
|
+
color: 'primary',
|
|
536
|
+
size: 'medium',
|
|
537
|
+
disabled: true,
|
|
538
|
+
borderRadius: 8,
|
|
539
|
+
},
|
|
540
|
+
argTypes: {
|
|
541
|
+
theme: {
|
|
542
|
+
control: 'select',
|
|
543
|
+
options: ['light', 'dark'],
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
};
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {type ReactElement} from 'react';
|
|
3
|
+
import {View, Text} from 'react-native';
|
|
4
|
+
import type {RenderAPI} from '@testing-library/react-native';
|
|
5
|
+
import {fireEvent, render, waitFor} from '@testing-library/react-native';
|
|
6
|
+
|
|
7
|
+
import {createComponent, createTestProps} from '../../../../test/testUtils';
|
|
8
|
+
import {type SegmentedControlColorType, SegmentedControl} from './SegmentedControl';
|
|
9
|
+
import {Typography} from '../Typography/Typography';
|
|
10
|
+
|
|
11
|
+
let props: any;
|
|
12
|
+
let component: ReactElement;
|
|
13
|
+
let testingLib: RenderAPI;
|
|
14
|
+
|
|
15
|
+
const values = [
|
|
16
|
+
{value: 'day', text: 'Day'},
|
|
17
|
+
{value: 'week', text: 'Week'},
|
|
18
|
+
{value: 'month', text: 'Month'},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('[SegmentedControl] render', () => {
|
|
22
|
+
it('should render without crashing', async () => {
|
|
23
|
+
props = createTestProps({
|
|
24
|
+
selectedValue: 'day',
|
|
25
|
+
onValueChange: (value: string | number) => (props.selectedValue = value),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
component = createComponent(
|
|
29
|
+
<View>
|
|
30
|
+
<SegmentedControl
|
|
31
|
+
values={values}
|
|
32
|
+
onValueChange={props.onValueChange}
|
|
33
|
+
selectedValue={props.selectedValue}
|
|
34
|
+
/>
|
|
35
|
+
</View>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
testingLib = render(component);
|
|
39
|
+
|
|
40
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
41
|
+
expect(baseElement).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should trigger `onValueChange` and change `selectedValue` props', () => {
|
|
45
|
+
props = createTestProps({
|
|
46
|
+
selectedValue: 'day',
|
|
47
|
+
onValueChange: (value: string | number) => (props.selectedValue = value),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
component = createComponent(
|
|
51
|
+
<View>
|
|
52
|
+
<SegmentedControl
|
|
53
|
+
values={values}
|
|
54
|
+
onValueChange={props.onValueChange}
|
|
55
|
+
selectedValue={props.selectedValue}
|
|
56
|
+
/>
|
|
57
|
+
</View>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
testingLib = render(component);
|
|
61
|
+
|
|
62
|
+
const secondOption = testingLib.getByTestId('segment-1');
|
|
63
|
+
expect(props.selectedValue).toEqual('day');
|
|
64
|
+
|
|
65
|
+
fireEvent.press(secondOption);
|
|
66
|
+
expect(props.selectedValue).toEqual('week');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should not trigger `onValueChange` when disabled', () => {
|
|
70
|
+
props = createTestProps({
|
|
71
|
+
selectedValue: 'day',
|
|
72
|
+
onValueChange: jest.fn(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
component = createComponent(
|
|
76
|
+
<View>
|
|
77
|
+
<SegmentedControl
|
|
78
|
+
values={values}
|
|
79
|
+
disabled={true}
|
|
80
|
+
onValueChange={props.onValueChange}
|
|
81
|
+
selectedValue={props.selectedValue}
|
|
82
|
+
/>
|
|
83
|
+
</View>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
testingLib = render(component);
|
|
87
|
+
|
|
88
|
+
const secondOption = testingLib.getByTestId('segment-1');
|
|
89
|
+
fireEvent.press(secondOption);
|
|
90
|
+
|
|
91
|
+
expect(props.onValueChange).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('[SegmentedControl] colors', () => {
|
|
96
|
+
const colors = [
|
|
97
|
+
'primary',
|
|
98
|
+
'secondary',
|
|
99
|
+
'success',
|
|
100
|
+
'info',
|
|
101
|
+
'warning',
|
|
102
|
+
'danger',
|
|
103
|
+
'light',
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
it('should render all colors', async () => {
|
|
107
|
+
component = createComponent(
|
|
108
|
+
<View>
|
|
109
|
+
{colors.map((color) => {
|
|
110
|
+
return (
|
|
111
|
+
<View key={color} style={{marginTop: 24}}>
|
|
112
|
+
<SegmentedControl
|
|
113
|
+
color={color as SegmentedControlColorType}
|
|
114
|
+
values={values}
|
|
115
|
+
selectedValue="day"
|
|
116
|
+
/>
|
|
117
|
+
</View>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
</View>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
testingLib = render(component);
|
|
124
|
+
|
|
125
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
126
|
+
expect(baseElement).toBeTruthy();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('[SegmentedControl] sizes', () => {
|
|
131
|
+
it('should render with small size', async () => {
|
|
132
|
+
component = createComponent(
|
|
133
|
+
<SegmentedControl
|
|
134
|
+
values={values}
|
|
135
|
+
selectedValue="day"
|
|
136
|
+
size="small"
|
|
137
|
+
/>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
testingLib = render(component);
|
|
141
|
+
|
|
142
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
143
|
+
expect(baseElement).toBeTruthy();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should render with medium size', async () => {
|
|
147
|
+
component = createComponent(
|
|
148
|
+
<SegmentedControl
|
|
149
|
+
values={values}
|
|
150
|
+
selectedValue="day"
|
|
151
|
+
size="medium"
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
testingLib = render(component);
|
|
156
|
+
|
|
157
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
158
|
+
expect(baseElement).toBeTruthy();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should render with large size', async () => {
|
|
162
|
+
component = createComponent(
|
|
163
|
+
<SegmentedControl
|
|
164
|
+
values={values}
|
|
165
|
+
selectedValue="day"
|
|
166
|
+
size="large"
|
|
167
|
+
/>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
testingLib = render(component);
|
|
171
|
+
|
|
172
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
173
|
+
expect(baseElement).toBeTruthy();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should render with custom numeric size', async () => {
|
|
177
|
+
component = createComponent(
|
|
178
|
+
<SegmentedControl
|
|
179
|
+
values={values}
|
|
180
|
+
selectedValue="day"
|
|
181
|
+
size={28}
|
|
182
|
+
/>,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
testingLib = render(component);
|
|
186
|
+
|
|
187
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
188
|
+
expect(baseElement).toBeTruthy();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('[SegmentedControl] with different data lengths', () => {
|
|
193
|
+
it('should render with 2 segments', async () => {
|
|
194
|
+
const twoSegments = [
|
|
195
|
+
{value: 'first', text: 'First'},
|
|
196
|
+
{value: 'second', text: 'Second'},
|
|
197
|
+
];
|
|
198
|
+
component = createComponent(
|
|
199
|
+
<SegmentedControl
|
|
200
|
+
values={twoSegments}
|
|
201
|
+
selectedValue="first"
|
|
202
|
+
/>,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
testingLib = render(component);
|
|
206
|
+
|
|
207
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
208
|
+
expect(baseElement).toBeTruthy();
|
|
209
|
+
expect(testingLib.getByTestId('segment-0')).toBeTruthy();
|
|
210
|
+
expect(testingLib.getByTestId('segment-1')).toBeTruthy();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should render with 4 segments', async () => {
|
|
214
|
+
const fourSegments = [
|
|
215
|
+
{value: 1, text: 'One'},
|
|
216
|
+
{value: 2, text: 'Two'},
|
|
217
|
+
{value: 3, text: 'Three'},
|
|
218
|
+
{value: 4, text: 'Four'},
|
|
219
|
+
];
|
|
220
|
+
component = createComponent(
|
|
221
|
+
<SegmentedControl
|
|
222
|
+
values={fourSegments}
|
|
223
|
+
selectedValue={1}
|
|
224
|
+
/>,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
testingLib = render(component);
|
|
228
|
+
|
|
229
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
230
|
+
expect(baseElement).toBeTruthy();
|
|
231
|
+
expect(testingLib.getByTestId('segment-0')).toBeTruthy();
|
|
232
|
+
expect(testingLib.getByTestId('segment-1')).toBeTruthy();
|
|
233
|
+
expect(testingLib.getByTestId('segment-2')).toBeTruthy();
|
|
234
|
+
expect(testingLib.getByTestId('segment-3')).toBeTruthy();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('[SegmentedControl] custom borderRadius', () => {
|
|
239
|
+
it('should render with custom border radius', async () => {
|
|
240
|
+
component = createComponent(
|
|
241
|
+
<SegmentedControl
|
|
242
|
+
borderRadius={16}
|
|
243
|
+
values={values}
|
|
244
|
+
selectedValue="day"
|
|
245
|
+
/>,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
testingLib = render(component);
|
|
249
|
+
|
|
250
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
251
|
+
expect(baseElement).toBeTruthy();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('[SegmentedControl] with ReactElement text', () => {
|
|
256
|
+
it('should render with React elements as text', async () => {
|
|
257
|
+
const valuesWithElements = [
|
|
258
|
+
{value: 'grid', text: <View><Text>Grid</Text></View>},
|
|
259
|
+
{value: 'list', text: <View><Text>List</Text></View>},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
component = createComponent(
|
|
263
|
+
<SegmentedControl
|
|
264
|
+
values={valuesWithElements}
|
|
265
|
+
selectedValue="grid"
|
|
266
|
+
/>,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
testingLib = render(component);
|
|
270
|
+
|
|
271
|
+
const baseElement = await waitFor(() => testingLib.toJSON());
|
|
272
|
+
expect(baseElement).toBeTruthy();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import React, {useCallback, useMemo, type ReactElement} from 'react';
|
|
2
|
+
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
|
|
3
|
+
import {TouchableOpacity} from 'react-native';
|
|
4
|
+
import {styled, css} from 'kstyled';
|
|
5
|
+
|
|
6
|
+
import {useTheme} from '../../../providers/ThemeProvider';
|
|
7
|
+
import {Typography} from '../Typography/Typography';
|
|
8
|
+
import type {CpkTheme} from '../../../utils/theme';
|
|
9
|
+
|
|
10
|
+
export type SegmentedControlSizeType = 'small' | 'medium' | 'large' | number;
|
|
11
|
+
export type SegmentedControlColorType =
|
|
12
|
+
| 'primary'
|
|
13
|
+
| 'secondary'
|
|
14
|
+
| 'success'
|
|
15
|
+
| 'danger'
|
|
16
|
+
| 'warning'
|
|
17
|
+
| 'info'
|
|
18
|
+
| 'light';
|
|
19
|
+
|
|
20
|
+
export type SegmentedControlItem = {
|
|
21
|
+
value: string | number;
|
|
22
|
+
text: string | ReactElement;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Styles = {
|
|
26
|
+
container?: StyleProp<ViewStyle>;
|
|
27
|
+
segment?: StyleProp<ViewStyle>;
|
|
28
|
+
selectedSegment?: StyleProp<ViewStyle>;
|
|
29
|
+
text?: StyleProp<TextStyle>;
|
|
30
|
+
selectedText?: StyleProp<TextStyle>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SegmentedControlProps = {
|
|
34
|
+
testID?: string;
|
|
35
|
+
values: SegmentedControlItem[];
|
|
36
|
+
selectedValue: string | number;
|
|
37
|
+
onValueChange?: (value: string | number) => void;
|
|
38
|
+
color?: SegmentedControlColorType;
|
|
39
|
+
size?: SegmentedControlSizeType;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
style?: StyleProp<ViewStyle>;
|
|
42
|
+
styles?: Styles;
|
|
43
|
+
borderRadius?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const Container = styled.View`
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
// Calculate styles based on theme and props
|
|
51
|
+
const calculateStyles = ({
|
|
52
|
+
theme,
|
|
53
|
+
color,
|
|
54
|
+
size,
|
|
55
|
+
borderRadius,
|
|
56
|
+
styles,
|
|
57
|
+
}: {
|
|
58
|
+
theme: CpkTheme;
|
|
59
|
+
color: SegmentedControlColorType;
|
|
60
|
+
size: SegmentedControlSizeType;
|
|
61
|
+
borderRadius: number;
|
|
62
|
+
styles?: Styles;
|
|
63
|
+
}) => {
|
|
64
|
+
const padding =
|
|
65
|
+
typeof size === 'number'
|
|
66
|
+
? `${size * 0.4}px ${size * 0.8}px`
|
|
67
|
+
: size === 'large'
|
|
68
|
+
? '12px 16px'
|
|
69
|
+
: size === 'medium'
|
|
70
|
+
? '8px 12px'
|
|
71
|
+
: size === 'small'
|
|
72
|
+
? '6px 8px'
|
|
73
|
+
: '8px 12px';
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
wrapper: [
|
|
77
|
+
css`
|
|
78
|
+
flex-direction: row;
|
|
79
|
+
background-color: ${theme.bg.paper};
|
|
80
|
+
border-top-color: ${theme.bg.disabled};
|
|
81
|
+
border-bottom-color: ${theme.bg.disabled};
|
|
82
|
+
border-left-color: ${theme.bg.disabled};
|
|
83
|
+
border-right-color: ${theme.bg.disabled};
|
|
84
|
+
border-width: 1px;
|
|
85
|
+
border-radius: ${borderRadius}px;
|
|
86
|
+
`,
|
|
87
|
+
],
|
|
88
|
+
container: styles?.container,
|
|
89
|
+
segment: [
|
|
90
|
+
css`
|
|
91
|
+
flex: 1;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
padding: ${padding};
|
|
95
|
+
background-color: transparent;
|
|
96
|
+
`,
|
|
97
|
+
styles?.segment,
|
|
98
|
+
],
|
|
99
|
+
selectedSegment: [
|
|
100
|
+
css`
|
|
101
|
+
background-color: ${theme.button[color].bg};
|
|
102
|
+
`,
|
|
103
|
+
styles?.selectedSegment,
|
|
104
|
+
],
|
|
105
|
+
text: [
|
|
106
|
+
css`
|
|
107
|
+
color: ${theme.text.basic};
|
|
108
|
+
`,
|
|
109
|
+
styles?.text,
|
|
110
|
+
],
|
|
111
|
+
selectedText: [
|
|
112
|
+
css`
|
|
113
|
+
color: ${theme.button[color].text};
|
|
114
|
+
font-weight: 600;
|
|
115
|
+
`,
|
|
116
|
+
styles?.selectedText,
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function SegmentedControlContainer({
|
|
122
|
+
testID,
|
|
123
|
+
values,
|
|
124
|
+
selectedValue,
|
|
125
|
+
onValueChange,
|
|
126
|
+
color = 'primary',
|
|
127
|
+
size = 'medium',
|
|
128
|
+
disabled = false,
|
|
129
|
+
style,
|
|
130
|
+
styles,
|
|
131
|
+
borderRadius = 8,
|
|
132
|
+
}: SegmentedControlProps): ReactElement {
|
|
133
|
+
const {theme} = useTheme();
|
|
134
|
+
|
|
135
|
+
// Memoize styles calculation
|
|
136
|
+
const compositeStyles = useMemo(
|
|
137
|
+
() =>
|
|
138
|
+
calculateStyles({
|
|
139
|
+
theme,
|
|
140
|
+
color,
|
|
141
|
+
size,
|
|
142
|
+
borderRadius,
|
|
143
|
+
styles,
|
|
144
|
+
}),
|
|
145
|
+
[theme, color, size, borderRadius, styles],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Memoize segment press handler
|
|
149
|
+
const handlePress = useCallback(
|
|
150
|
+
(value: string | number) => {
|
|
151
|
+
if (!disabled) {
|
|
152
|
+
onValueChange?.(value);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[disabled, onValueChange],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Memoize segments rendering
|
|
159
|
+
const segments = useMemo(
|
|
160
|
+
() => {
|
|
161
|
+
const cornerRadius = Math.max(borderRadius - 1, 0);
|
|
162
|
+
|
|
163
|
+
return values.map((item: SegmentedControlItem, index: number) => {
|
|
164
|
+
const isSelected = selectedValue === item.value;
|
|
165
|
+
const isFirst = index === 0;
|
|
166
|
+
const isLast = index === values.length - 1;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<TouchableOpacity
|
|
170
|
+
key={`segment-${index}`}
|
|
171
|
+
testID={`segment-${index}`}
|
|
172
|
+
activeOpacity={0.7}
|
|
173
|
+
disabled={disabled}
|
|
174
|
+
onPress={() => handlePress(item.value)}
|
|
175
|
+
style={[
|
|
176
|
+
compositeStyles.segment,
|
|
177
|
+
isSelected && compositeStyles.selectedSegment,
|
|
178
|
+
{
|
|
179
|
+
borderTopLeftRadius: isFirst ? cornerRadius : 0,
|
|
180
|
+
borderBottomLeftRadius: isFirst ? cornerRadius : 0,
|
|
181
|
+
borderTopRightRadius: isLast ? cornerRadius : 0,
|
|
182
|
+
borderBottomRightRadius: isLast ? cornerRadius : 0,
|
|
183
|
+
borderRightWidth: isLast ? 0 : 1,
|
|
184
|
+
borderRightColor: theme.bg.disabled,
|
|
185
|
+
},
|
|
186
|
+
]}
|
|
187
|
+
>
|
|
188
|
+
{typeof item.text === 'string' ? (
|
|
189
|
+
<Typography.Body2
|
|
190
|
+
style={[
|
|
191
|
+
compositeStyles.text,
|
|
192
|
+
isSelected && compositeStyles.selectedText,
|
|
193
|
+
]}
|
|
194
|
+
>
|
|
195
|
+
{item.text}
|
|
196
|
+
</Typography.Body2>
|
|
197
|
+
) : (
|
|
198
|
+
item.text
|
|
199
|
+
)}
|
|
200
|
+
</TouchableOpacity>
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
[
|
|
205
|
+
values,
|
|
206
|
+
selectedValue,
|
|
207
|
+
disabled,
|
|
208
|
+
handlePress,
|
|
209
|
+
compositeStyles,
|
|
210
|
+
borderRadius,
|
|
211
|
+
theme.bg.disabled,
|
|
212
|
+
],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Container style={style} testID={testID}>
|
|
217
|
+
<Container style={[compositeStyles.wrapper, compositeStyles.container]}>
|
|
218
|
+
{segments}
|
|
219
|
+
</Container>
|
|
220
|
+
</Container>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Export memoized component for better performance
|
|
225
|
+
export const SegmentedControl = React.memo(
|
|
226
|
+
SegmentedControlContainer,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
export default SegmentedControl;
|
package/src/index.tsx
CHANGED
|
@@ -23,6 +23,7 @@ export * from './components/uis/LoadingIndicator/LoadingIndicator';
|
|
|
23
23
|
export * from './components/uis/PinchZoom/PinchZoom';
|
|
24
24
|
export * from './components/uis/Rating/Rating';
|
|
25
25
|
export * from './components/uis/RadioGroup/RadioGroup';
|
|
26
|
+
export * from './components/uis/SegmentedControl/SegmentedControl';
|
|
26
27
|
export * from './components/uis/StatusbarBrightness/StatusBarBrightness';
|
|
27
28
|
export * from './components/uis/SwitchToggle/SwitchToggle';
|
|
28
29
|
export * from './components/uis/Typography/Typography';
|