Skip to content

Commit 766f43c

Browse files
authored
Feat/number input 2 (#2333)
* NumberInput - initial commit * NumberInput - complete * Fix docs * Improve example screen * Fix non en languages * Add tests * Use TextField's formatter and the new centered prop * Review changes * Remove tests from npm package * Use only one screen * Prettify * Improve screen (requires next commit to work) * Add some function component support * Support centered for validationMessage, memoize and fix typescript * result to data * Remove margins and fix TextField's centered * Remove more code and fix placeholder RN bug * Fix ExampleScreenPresenter * Fix tests * Last two comments
1 parent 96c1046 commit 766f43c

File tree

17 files changed

+926
-20
lines changed

17 files changed

+926
-20
lines changed

demo/src/screens/ExampleScreenPresenter.tsx

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ interface RadioGroupOptions {
2525
interface BooleanGroupOptions {
2626
spread?: boolean;
2727
afterValueChanged?: () => void;
28+
state?: boolean;
29+
setState?: React.Dispatch<React.SetStateAction<boolean>>;
30+
}
31+
32+
interface SegmentsExtraOptions {
33+
state?: string;
34+
setState?: React.Dispatch<React.SetStateAction<any /** no suitable solution for enum */>>;
2835
}
2936

3037
export function renderHeader(title: string, others?: TextProps) {
@@ -35,19 +42,29 @@ export function renderHeader(title: string, others?: TextProps) {
3542
);
3643
}
3744

38-
export function renderBooleanOption(title: string, key: string, {spread, afterValueChanged}: BooleanGroupOptions = {spread: true}) {
45+
export function renderBooleanOption(title: string,
46+
key: string,
47+
{spread, afterValueChanged, state, setState}: BooleanGroupOptions = {spread: true}) {
3948
// @ts-ignore
40-
const value = this.state[key];
49+
const value = state ?? this.state[key];
4150
return (
4251
<View row centerV spread={spread} marginB-s4 key={key}>
43-
<Text $textDefault flex={spread} marginR-s4={!spread}>{title}</Text>
52+
<Text $textDefault flex={spread} marginR-s4={!spread}>
53+
{title}
54+
</Text>
4455
<Switch
4556
useCustomTheme
4657
key={key}
4758
testID={key}
4859
value={value}
49-
// @ts-ignore
50-
onValueChange={value => this.setState({[key]: value}, afterValueChanged)}
60+
onValueChange={value => {
61+
if (setState) {
62+
setState(value);
63+
} else {
64+
// @ts-ignore
65+
this.setState({[key]: value}, afterValueChanged);
66+
}
67+
}}
5168
/>
5269
</View>
5370
);
@@ -130,7 +147,9 @@ export function renderColorOption(title: string,
130147
const value = this.state[key];
131148
return (
132149
<View marginV-s2>
133-
<Text text70M $textDefault>{title}</Text>
150+
<Text text70M $textDefault>
151+
{title}
152+
</Text>
134153
<ColorPalette
135154
value={value}
136155
colors={colors}
@@ -171,19 +190,34 @@ export function renderSliderOption(title: string,
171190
);
172191
}
173192

174-
export function renderMultipleSegmentOptions(title: string, key: string, options: (SegmentedControlItemProps & {value: any})[]) {
193+
export function renderMultipleSegmentOptions(title: string,
194+
key: string,
195+
options: (SegmentedControlItemProps & {value: any})[],
196+
{state, setState}: SegmentsExtraOptions = {}) {
175197
// @ts-ignore
176-
const value = this.state[key];
198+
const value = state ?? this.state[key];
177199
const index = _.findIndex(options, {value});
178200

179201
return (
180202
<View row centerV spread marginB-s4 key={key}>
181-
{!!title && <Text $textDefault marginR-s2>{title}</Text>}
203+
{!!title && (
204+
<Text $textDefault marginR-s2>
205+
{title}
206+
</Text>
207+
)}
182208
<SegmentedControl
183209
initialIndex={index}
184210
segments={options}
185211
// @ts-ignore
186-
onChangeIndex={index => this.setState({[key]: options[index].value})}
212+
onChangeIndex={index => {
213+
const value = options[index].value;
214+
if (setState) {
215+
setState(value);
216+
} else {
217+
// @ts-ignore
218+
this.setState({[key]: value});
219+
}
220+
}}
187221
/>
188222
</View>
189223
);

demo/src/screens/MenuStructure.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const navigationData = {
5454
{title: 'Color Picker', tags: 'color picker control', screen: 'unicorn.components.ColorPickerScreen'},
5555
{title: 'Color Swatch', tags: 'color swatch and palette', screen: 'unicorn.components.ColorSwatchScreen'},
5656
{title: 'TextField', tags: 'text input field form', screen: 'unicorn.components.TextFieldScreen'},
57+
{title: 'NumberInput', tags: 'number input', screen: 'unicorn.components.NumberInputScreen'},
5758
{title: 'Picker', tags: 'picker form', screen: 'unicorn.components.PickerScreen'},
5859
{title: 'DateTimePicker', tags: 'date time picker form', screen: 'unicorn.components.DateTimePickerScreen'},
5960
{title: 'RadioButton', tags: 'radio button group controls', screen: 'unicorn.components.RadioButtonScreen'},
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import React, {useState, useCallback, useRef, useMemo} from 'react';
2+
import {StyleSheet, TouchableWithoutFeedback, Keyboard as RNKeyboard} from 'react-native';
3+
import {gestureHandlerRootHOC} from 'react-native-gesture-handler';
4+
import {
5+
Text,
6+
Spacings,
7+
NumberInput,
8+
NumberInputData,
9+
View,
10+
Typography,
11+
Constants,
12+
Incubator
13+
} from 'react-native-ui-lib';
14+
import {renderBooleanOption, renderMultipleSegmentOptions} from '../ExampleScreenPresenter';
15+
16+
enum ExampleTypeEnum {
17+
PRICE = 'price',
18+
PERCENTAGE = 'percentage',
19+
ANY_NUMBER = 'number'
20+
}
21+
22+
type ExampleType = ExampleTypeEnum | `${ExampleTypeEnum}`;
23+
24+
const VALIDATION_MESSAGE = 'Please enter a valid number';
25+
const MINIMUM_PRICE = 5000;
26+
const MINIMUM_PRICE_VALIDATION_MESSAGE = `Make sure your number is above ${MINIMUM_PRICE}`;
27+
const DISCOUNT_PERCENTAGE = {min: 1, max: 80};
28+
// eslint-disable-next-line max-len
29+
const DISCOUNT_PERCENTAGE_VALIDATION_MESSAGE = `Make sure your number is between ${DISCOUNT_PERCENTAGE.min} and ${DISCOUNT_PERCENTAGE.max}`;
30+
31+
const NumberInputScreen = () => {
32+
const currentData = useRef<NumberInputData>();
33+
const [text, setText] = useState<string>('');
34+
const [showLabel, setShowLabel] = useState<boolean>(true);
35+
const [exampleType, setExampleType] = useState<ExampleType>('price');
36+
37+
const processInput = useCallback(() => {
38+
let newText = '';
39+
if (currentData.current) {
40+
switch (currentData.current.type) {
41+
case 'valid':
42+
newText = currentData.current.formattedNumber;
43+
break;
44+
case 'empty':
45+
newText = 'Empty';
46+
break;
47+
case 'error':
48+
newText = `Error: value '${currentData.current.userInput}' is invalid`;
49+
break;
50+
}
51+
}
52+
53+
setText(newText);
54+
}, []);
55+
56+
const onChangeNumber = useCallback((data: NumberInputData) => {
57+
currentData.current = data;
58+
processInput();
59+
},
60+
[processInput]);
61+
62+
const label = useMemo(() => {
63+
if (showLabel) {
64+
switch (exampleType) {
65+
case 'price':
66+
default:
67+
return 'Enter price';
68+
case 'percentage':
69+
return 'Enter discount percentage';
70+
case 'number':
71+
return 'Enter any number';
72+
}
73+
}
74+
}, [showLabel, exampleType]);
75+
76+
const placeholder = useMemo(() => {
77+
switch (exampleType) {
78+
case 'price':
79+
default:
80+
return 'Price';
81+
case 'percentage':
82+
return 'Discount';
83+
case 'number':
84+
return 'Any number';
85+
}
86+
}, [exampleType]);
87+
88+
const fractionDigits = useMemo(() => {
89+
switch (exampleType) {
90+
case 'price':
91+
case 'number':
92+
default:
93+
return undefined;
94+
case 'percentage':
95+
return 0;
96+
}
97+
}, [exampleType]);
98+
99+
const leadingText = useMemo(() => {
100+
switch (exampleType) {
101+
case 'price':
102+
return '$';
103+
case 'percentage':
104+
case 'number':
105+
default:
106+
return undefined;
107+
}
108+
}, [exampleType]);
109+
110+
const trailingText = useMemo(() => {
111+
switch (exampleType) {
112+
case 'percentage':
113+
return '%';
114+
case 'price':
115+
case 'number':
116+
default:
117+
return undefined;
118+
}
119+
}, [exampleType]);
120+
121+
const isValid = useCallback(() => {
122+
return currentData.current?.type === 'valid';
123+
}, []);
124+
125+
const isAboveMinimumPrice = useCallback(() => {
126+
if (currentData.current?.type === 'valid') {
127+
return currentData.current.number > MINIMUM_PRICE;
128+
}
129+
}, []);
130+
131+
const isWithinDiscountPercentage = useCallback(() => {
132+
if (currentData.current?.type === 'valid') {
133+
return (
134+
currentData.current.number >= DISCOUNT_PERCENTAGE.min && currentData.current.number <= DISCOUNT_PERCENTAGE.max
135+
);
136+
}
137+
}, []);
138+
139+
const validate = useMemo((): Incubator.TextFieldProps['validate'] => {
140+
switch (exampleType) {
141+
case 'price':
142+
return [isValid, isAboveMinimumPrice];
143+
case 'percentage':
144+
return [isValid, isWithinDiscountPercentage];
145+
default:
146+
return isValid;
147+
}
148+
}, [exampleType, isValid, isAboveMinimumPrice, isWithinDiscountPercentage]);
149+
150+
const validationMessage = useMemo((): Incubator.TextFieldProps['validationMessage'] => {
151+
switch (exampleType) {
152+
case 'price':
153+
return [VALIDATION_MESSAGE, MINIMUM_PRICE_VALIDATION_MESSAGE];
154+
case 'percentage':
155+
return [VALIDATION_MESSAGE, DISCOUNT_PERCENTAGE_VALIDATION_MESSAGE];
156+
default:
157+
return VALIDATION_MESSAGE;
158+
}
159+
}, [exampleType]);
160+
161+
return (
162+
<TouchableWithoutFeedback onPress={RNKeyboard.dismiss}>
163+
<View flex centerH>
164+
<Text text40 margin-s10>
165+
Number Input
166+
</Text>
167+
{renderBooleanOption('Show label', 'showLabel', {spread: false, state: showLabel, setState: setShowLabel})}
168+
{renderMultipleSegmentOptions('',
169+
'exampleType',
170+
[
171+
{label: 'Price', value: ExampleTypeEnum.PRICE},
172+
{label: 'Percentage', value: ExampleTypeEnum.PERCENTAGE},
173+
{label: 'Number', value: ExampleTypeEnum.ANY_NUMBER}
174+
],
175+
{state: exampleType, setState: setExampleType})}
176+
177+
<View flex center>
178+
<NumberInput
179+
key={exampleType}
180+
// initialNumber={100}
181+
label={label}
182+
labelStyle={styles.label}
183+
placeholder={placeholder}
184+
fractionDigits={fractionDigits}
185+
onChangeNumber={onChangeNumber}
186+
leadingText={leadingText}
187+
leadingTextStyle={leadingText && [styles.infoText, {marginLeft: Spacings.s4}]}
188+
trailingText={trailingText}
189+
trailingTextStyle={trailingText && [styles.infoText, {marginRight: Spacings.s4}]}
190+
style={[
191+
styles.mainText,
192+
!leadingText && {marginLeft: Spacings.s4},
193+
!trailingText && {marginRight: Spacings.s4}
194+
]}
195+
containerStyle={styles.containerStyle}
196+
validate={validate}
197+
validationMessage={validationMessage}
198+
validationMessageStyle={Typography.text80M}
199+
validateOnChange
200+
centered
201+
/>
202+
<Text marginT-s5>{text}</Text>
203+
</View>
204+
</View>
205+
</TouchableWithoutFeedback>
206+
);
207+
};
208+
209+
export default gestureHandlerRootHOC(NumberInputScreen);
210+
211+
const styles = StyleSheet.create({
212+
containerStyle: {
213+
marginBottom: 30,
214+
marginLeft: Spacings.s5,
215+
marginRight: Spacings.s5
216+
},
217+
mainText: {
218+
height: 36,
219+
marginVertical: Spacings.s1,
220+
...Typography.text30M
221+
},
222+
infoText: {
223+
marginTop: Constants.isIOS ? Spacings.s2 : 0,
224+
...Typography.text50M
225+
},
226+
label: {
227+
marginBottom: Spacings.s1,
228+
...Typography.text80M
229+
}
230+
});

demo/src/screens/componentScreens/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function registerScreens(registrar) {
3131
registrar('unicorn.components.KeyboardAwareScrollViewScreen', () => require('./KeyboardAwareScrollViewScreen').default);
3232
registrar('unicorn.components.MaskedInputScreen', () => require('./MaskedInputScreen').default);
3333
registrar('unicorn.components.MarqueeScreen', () => require('./MarqueeScreen').default);
34+
registrar('unicorn.components.NumberInputScreen', () => require('./NumberInputScreen').default);
3435
registrar('unicorn.components.OverlaysScreen', () => require('./OverlaysScreen').default);
3536
registrar('unicorn.components.PageControlScreen', () => require('./PageControlScreen').default);
3637
registrar('unicorn.components.PanDismissibleScreen', () => require('./PanDismissibleScreen').default);

jest-setup.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,10 @@ jest.mock('react-native', () => {
2727
reactNative.NativeModules.KeyboardTrackingViewTempManager = {};
2828
return reactNative;
2929
});
30+
31+
if (typeof String.prototype.replaceAll === 'undefined') {
32+
// eslint-disable-next-line no-extend-native
33+
String.prototype.replaceAll = function (match, replace) {
34+
return this.split(match).join(replace);
35+
};
36+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@
139139
"./jest-setup.js"
140140
],
141141
"testMatch": [
142-
"**/*.spec.(js|tsx)"
142+
"**/*.spec.(js|ts|tsx)"
143143
]
144144
}
145145
}

src/components/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export default {
9898
get Modal() {
9999
return require('./modal').default;
100100
},
101+
get NumberInput() {
102+
return require('./numberInput').default;
103+
},
101104
get ListItem() {
102105
return require('./listItem').default;
103106
},

src/components/inputs/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,8 @@ module.exports = {
1010
},
1111
get MaskedInput() {
1212
return require('../maskedInput').default;
13+
},
14+
get NumberInput() {
15+
return require('../numberInput').default;
1316
}
1417
};

0 commit comments

Comments
 (0)