Skip to content
This repository has been archived by the owner on Mar 4, 2019. It is now read-only.

Commit

Permalink
Feat: Added TagsInput component
Browse files Browse the repository at this point in the history
Summary: Created TagsInput component. Added tests and Storybook's stories for the TagsInput.
  • Loading branch information
Filip Messa committed Jan 15, 2019
1 parent 88e2808 commit f35dd03
Show file tree
Hide file tree
Showing 10 changed files with 575 additions and 1 deletion.
10 changes: 9 additions & 1 deletion src/Badge/Badge.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ export default function Badge({
children,
type = 'primary',
style,
fontSize,
}: BadgeProps) {
const dynamicStyle = StyleSheet.create({
fontSize: {
fontSize,
},
});
return (
<View style={[styles.wrapper, theme(type).wrapper, style]}>
<Text style={[styles.text, theme(type).text]}>{children}</Text>
<Text style={[styles.text, dynamicStyle.fontSize, theme(type).text]}>
{children}
</Text>
</View>
);
}
Expand Down
1 change: 1 addition & 0 deletions src/Badge/BadgeTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type BadgeProps = {|
+children: React.Node,
+type?: BadgeType,
+style?: StylePropType,
+fontSize?: number,
|};

export type BadgeType =
Expand Down
182 changes: 182 additions & 0 deletions src/TagsInput/TagsInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// @flow

import * as React from 'react';
import { View, Platform, TextInput } from 'react-native';
import type { ViewLayoutEvent } from 'react-native/Libraries/Components/View/ViewPropTypes';
import { defaultTokens } from '@kiwicom/orbit-design-tokens';

import DeleteButton from './components/DeleteButton';
import TagsContainer from './components/TagsContainer';
import InputField from './components/InputField';
import { StyleSheet } from '../PlatformStyleSheet';
import { Text } from '../Text';

import { INPUT_MIN_WIDTH, TAGS_MIN_WIDTH } from './constants';

type Props = {|
+label: string,
+onChangeText: (value: string) => void,
+placeholder: string,
+selected?: string[],
+disabled?: boolean,
+fontSize: number,
+onClearPress?: () => void,
+value?: string,
|};

type State = {
value: string,
isFocus: boolean,
containerWidth: ?number,
inputWidth: ?number,
};

export default class TagInput extends React.Component<Props, State> {
inputRef: ?{ current: null | React.Element<typeof TextInput> };
static defaultProps = {
fontSize: 16,
};

constructor(props: Props) {
super(props);
this.inputRef = React.createRef();

this.state = {
value: props.value ?? '',
containerWidth: null,
inputWidth: null,
isFocus: true,
};
}

componentDidUpdate = (prevProps: Props) => {
const { value } = this.props;
if (value !== prevProps.value) {
this.setState({ value });
}
};

setContainerWidth = (event: ViewLayoutEvent) => {
this.setState({ containerWidth: event.nativeEvent.layout.width });
};

setInputWidth = (event: ViewLayoutEvent) => {
this.setState({ inputWidth: event.nativeEvent.layout.width });
};

subtractFromContainerWidth = (width: ?number) => {
const { containerWidth } = this.state;
if (containerWidth && width) {
const newWidth = containerWidth - width;
return newWidth < 0 ? 'auto' : newWidth;
}
return null;
};

getPlaceholder = () => {
const { selected, placeholder } = this.props;
return selected?.length === 0 && placeholder ? placeholder : null;
};

handleChange = (value: string) => {
const { value: oldValue } = this.state;
const { onChangeText } = this.props;
if (value !== oldValue) {
this.setState({ value });
onChangeText(value);
}
};

handleClear = () => {
const { onClearPress } = this.props;
this.setState({ value: '' });

// $FlowFixMe property focus is missing in object type
Platform.OS === 'web' && this.inputRef.current.focus();

onClearPress?.();
};

handleOnFocus = () => {
this.setState({ isFocus: true });
};

handleOnBlur = () => {
this.setState({ isFocus: false });
};

render() {
const { selected, fontSize, label, disabled } = this.props;
const { value, inputWidth, isFocus } = this.state;
const isButtonDisabled = !value.length ?? disabled;

const dynamicStyle = StyleSheet.create({
deleteButton: {
opacity: value.length > 0 ? 1 : 0,
},
label: {
fontSize,
},
border: {
web: {
boxShadow: isFocus
? `${defaultTokens.borderColorInputFocus} 0 0 0 2px inset`
: `${defaultTokens.borderColorInput} 0 0 0 1px inset`,
},
},
});

return (
<View style={[styles.container, dynamicStyle.border, dynamicStyle.label]}>
<Text weight="bold" style={styles.label}>
{label}
</Text>
<View style={styles.fieldContainer} onLayout={this.setContainerWidth}>
<TagsContainer
minWidth={TAGS_MIN_WIDTH}
maxWidth={this.subtractFromContainerWidth(inputWidth)}
tags={selected}
fontSize={fontSize}
/>
<InputField
onFocus={this.handleOnFocus}
onBlur={this.handleOnBlur}
ref={this.inputRef}
fontSize={fontSize}
disabled={disabled}
minWidth={INPUT_MIN_WIDTH}
maxWidth={this.subtractFromContainerWidth(TAGS_MIN_WIDTH)}
onLayout={this.setInputWidth}
value={value}
placeholder={this.getPlaceholder()}
onChangeText={this.handleChange}
/>
</View>
<DeleteButton
style={dynamicStyle.deleteButton}
onPress={this.handleClear}
disabled={isButtonDisabled}
/>
</View>
);
}
}

const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 9,
paddingHorizontal: 8,
},
fieldContainer: {
flexDirection: 'row',
flex: 1,
marginStart: 4,
overflow: 'hidden',
},

label: {
color: defaultTokens.paletteInkDark,
},
});
56 changes: 56 additions & 0 deletions src/TagsInput/TagsInput.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// @flow

import React from 'react';
import { View } from 'react-native';
import { storiesOf } from '@storybook/react-native';
import { text, withKnobs, object, number } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';

import TagsInput from './TagsInput';

const selectedMock = ['Prague', 'Bratislava', 'London'];

storiesOf('TagsInput', module)
.addDecorator(withKnobs)
.add('Default', () => (
<View style={{ margin: 20 }}>
<TagsInput
onChangeText={action('onChangeText')}
label="From:"
placeholder="Departure point"
/>
</View>
))
.add('with Tags', () => (
<View style={{ margin: 20 }}>
<TagsInput
selected={selectedMock}
onChangeText={action('onChangeText')}
label="From:"
placeholder="Departure point"
/>
</View>
))
.add('Playground', () => {
const tags = object('Tags', ['Prague']);
const fontSize = number('fontSize', 16, {
range: true,
min: 14,
max: 25,
step: 1,
});
const label = text('Label', 'From:');
const placeholder = text('Placeholder', 'Departure point');
return (
<View style={{ margin: 20 }}>
<TagsInput
fontSize={fontSize}
selected={tags}
onChangeText={action('onChangeText')}
onClearPress={action('onClearPress')}
label={label}
placeholder={placeholder}
/>
</View>
);
});
68 changes: 68 additions & 0 deletions src/TagsInput/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @flow

import * as React from 'react';
import { TextInput } from 'react-native';
import { render, fireEvent } from 'react-native-testing-library';
import { TagsInput } from '../index';

jest.mock('NativeAnimatedHelper');

describe('TagsInput', () => {
const fontSize = 15;
const label = 'label';
const onChangeText = jest.fn();
const onClearPress = jest.fn();
const placeholder = 'placeholder';
const tags = ['Prague', 'London'];
const value = 'value';

const { getByType, getByText, getAllByProps, getByTestId } = render(
<TagsInput
selected={tags}
onChangeText={onChangeText}
onClearPress={onClearPress}
label={label}
placeholder={placeholder}
fontSize={fontSize}
value={value}
/>
);

it('should contain an input', () => {
expect(getByType(TextInput)).toBeDefined();
});

it('should contain a label', () => {
expect(getByText(label)).toBeDefined();
});

it('should have passed props', () => {
expect(
getAllByProps({
selected: tags,
label,
placeholder,
fontSize,
onChangeText,
value,
})
).toBeDefined();
});

it('should execute onChangeText method', () => {
const input = 'content';
fireEvent.changeText(getByType(TextInput), input);
expect(onChangeText).toHaveBeenCalledWith(input);
expect(onChangeText).toHaveBeenCalledTimes(1);
expect(getByText(input)).toBeDefined();
});

it('should execute onClearPress method', () => {
fireEvent.press(getByTestId('delete-button'));
expect(onClearPress).toHaveBeenCalledTimes(1);
});

it('should render Tags', () => {
tags.map(tag => expect(getByText(tag)).toBeDefined());
});
});
35 changes: 35 additions & 0 deletions src/TagsInput/components/DeleteButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @flow

import * as React from 'react';

import { defaultTokens } from '@kiwicom/orbit-design-tokens';
import { Touchable } from '../../Touchable';
import type { StylePropType } from '../../PlatformStyleSheet/StyleTypes';

import { Icon } from '../../Icon';

type Props = {|
+onPress: () => void,
+style: StylePropType,
+disabled?: boolean,
|};

export default class DeleteButton extends React.PureComponent<Props> {
render() {
const { onPress, disabled, style } = this.props;
return (
<Touchable
testID="delete-button"
onPress={onPress}
disabled={disabled}
style={style}
>
<Icon
name="close"
color={defaultTokens.colorIconSecondary}
size="small"
/>
</Touchable>
);
}
}
Loading

0 comments on commit f35dd03

Please sign in to comment.