diff --git a/demo/src/screens/MenuStructure.js b/demo/src/screens/MenuStructure.js index 2c4ae9d31b..592691871e 100644 --- a/demo/src/screens/MenuStructure.js +++ b/demo/src/screens/MenuStructure.js @@ -71,7 +71,6 @@ export const navigationData = { {title: 'Stepper', tags: 'stepper form', screen: 'unicorn.components.StepperScreen'}, {title: 'Slider', tags: 'slider', screen: 'unicorn.components.SliderScreen'}, {title: 'Switch', tags: 'switch toggle', screen: 'unicorn.components.SwitchScreen'}, - {title: 'ChipsInput', tags: 'chips tags input form', screen: 'unicorn.components.ChipsInputScreen'}, {title: 'Masked Inputs', tags: 'text input form mask', screen: 'unicorn.components.MaskedInputScreen'} ] }, @@ -175,7 +174,7 @@ export const navigationData = { title: 'Incubator (Experimental)', screens: [ {title: 'Calendar', tags: 'calendar', screen: 'unicorn.components.IncubatorCalendarScreen'}, - {title: 'ChipsInput (New)', tags: 'chips input', screen: 'unicorn.components.IncubatorChipsInputScreen'}, + {title: 'ChipsInput', tags: 'chips input', screen: 'unicorn.components.ChipsInputScreen'}, {title: 'Native TouchableOpacity', tags: 'touchable native', screen: 'unicorn.incubator.TouchableOpacityScreen'}, {title: 'Dialog (New)', tags: 'dialog modal popup alert', screen: 'unicorn.incubator.IncubatorDialogScreen'}, {title: 'TextField (New)', tags: 'text field input', screen: 'unicorn.components.IncubatorTextFieldScreen'}, diff --git a/demo/src/screens/componentScreens/ChipsInputScreen.tsx b/demo/src/screens/componentScreens/ChipsInputScreen.tsx index ad3ce81be0..8b5ff6673c 100644 --- a/demo/src/screens/componentScreens/ChipsInputScreen.tsx +++ b/demo/src/screens/componentScreens/ChipsInputScreen.tsx @@ -1,176 +1,60 @@ import React, {Component} from 'react'; -import {StyleSheet, ScrollView} from 'react-native'; -import {View, Colors, Text, Typography, ChipsInput, ChipsInputChipProps} from 'react-native-ui-lib'; // eslint-disable-line +import {View, Text, Card, TextField, Button, Colors, ChipsInput} from 'react-native-ui-lib'; //eslint-disable-line +import _ from 'lodash'; -interface State { - chips: Array; - namesChips: Array; - nonRemovalChips: Array; - customChips: Array; - tags: Array; - tags2: Array; - tags3: Array; -} - -export default class ChipsInputScreen extends Component<{}, State> { - // @ts-ignore - customChipsInput = React.createRef(); - - constructor(props: any) { - super(props); - - this.state = { - chips: [{label: 'Falcon 9'}, {label: 'Enterprise'}, {label: 'Challenger', borderRadius: 0}, {label: 'Coca Cola', invalid: true}], - namesChips: [{label: 'Amit'}, {label: 'Ethan', invalid: true}], - nonRemovalChips: [{label: 'Non'}, {label: 'Removable'}, {label: 'Tags'}], - customChips: ['Chips', 'Input'], - tags: [{label: 'Amit'}, {label: 'Ethan', invalid: true}], - tags2: ['Non', 'Removable', 'Tags'], - tags3: ['Change', 'Typography'] - }; - } - - onTagPress = (tagIndex: number, markedTagIndex: number) => { - this.customChipsInput.current?.markTagIndex(tagIndex === markedTagIndex ? undefined : tagIndex); +export default class ChipsInputScreen extends Component { + state = { + chips: [{label: 'one'}, {label: 'two'}], + chips2: [] }; - renderCustomTag(tag: any, _: number, shouldMarkToRemove: boolean) { - return ( - - {tag.label} - - ); - } - - renderLeftElement = () => { + render() { return ( - - - To: + + + ChipsInput - - ); - }; - - renderSearchTypeInput = () => { - return ( - <> - Search Type - - - - - ); - }; - - renderFormTypeInput = () => { - return ( - - Form Type TO: } + onChange={newChips => { + _.flow(newChips => _.groupBy(newChips, 'label'), + newChips => + _.forEach(newChips, group => { + if (group.length === 1) { + delete group[0].invalid; + } else { + group[group.length - 1].invalid = true; + } + }), + _.values, + _.flatten)(newChips); + + this.setState({chips: newChips}); + }} /> - - ); - }; - - onCreateTag = (value: string) => { - return {label: value}; - } - - render() { - return ( - - - - ChipsInput - - - {this.renderSearchTypeInput()} - - {this.renderFormTypeInput()} - - - - - - Old Usage - - - - - - - - + this.setState({chips2: newChips})} + maxChips={3} + /> + ); } } - -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 15 - }, - customInput: { - ...Typography.text60, - color: Colors.blue30 - }, - bottomMargin: { - marginBottom: 25 - }, - customTag: { - backgroundColor: Colors.purple30, - paddingVertical: 2, - paddingHorizontal: 8, - borderRadius: 3, - marginRight: 10, - marginBottom: 10 - } -}); diff --git a/demo/src/screens/incubatorScreens/IncubatorChipsInputScreen.tsx b/demo/src/screens/incubatorScreens/IncubatorChipsInputScreen.tsx deleted file mode 100644 index d615002dab..0000000000 --- a/demo/src/screens/incubatorScreens/IncubatorChipsInputScreen.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, {Component} from 'react'; -import {View, Text, Card, TextField, Button, Colors, Incubator} from 'react-native-ui-lib'; //eslint-disable-line -import _ from 'lodash'; - -export default class ChipsInputScreen extends Component { - state = { - chips: [{label: 'one'}, {label: 'two'}], - chips2: [] - }; - - render() { - return ( - - - ChipsInput - - TO: } - onChange={newChips => { - _.flow(newChips => _.groupBy(newChips, 'label'), - newChips => - _.forEach(newChips, group => { - if (group.length === 1) { - delete group[0].invalid; - } else { - group[group.length - 1].invalid = true; - } - }), - _.values, - _.flatten)(newChips); - - this.setState({chips: newChips}); - }} - /> - - this.setState({chips2: newChips})} - maxChips={3} - /> - - ); - } -} diff --git a/demo/src/screens/incubatorScreens/index.js b/demo/src/screens/incubatorScreens/index.js index 79d39e0c92..0159bc777b 100644 --- a/demo/src/screens/incubatorScreens/index.js +++ b/demo/src/screens/incubatorScreens/index.js @@ -2,7 +2,6 @@ import {gestureHandlerRootHOC} from 'react-native-gesture-handler'; export function registerScreens(registrar) { registrar('unicorn.components.IncubatorCalendarScreen', () => require('./IncubatorCalendarScreen').default); - registrar('unicorn.components.IncubatorChipsInputScreen', () => require('./IncubatorChipsInputScreen').default); registrar('unicorn.incubator.TouchableOpacityScreen', () => gestureHandlerRootHOC(require('./TouchableOpacityScreen').default)); registrar('unicorn.incubator.IncubatorDialogScreen', () => require('./IncubatorDialogScreen').default); diff --git a/src/components/chipsInput/Presenter.ts b/src/components/chipsInput/Presenter.ts deleted file mode 100644 index 4c170b2aef..0000000000 --- a/src/components/chipsInput/Presenter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {Colors} from '../../style'; -import {ChipsInputChipProps, ChipsInputProps} from './index'; - -export const hasInvalidChip = (chips: Array) => { - return chips.filter((chip) => chip.invalid === true)[0] !== undefined; -}; - -export const getValidationBasedColor = (chips: Array, defaultChip?: ChipsInputChipProps) => { - const dismissColor = defaultChip?.dismissColor || Colors.red30; - - return hasInvalidChip(chips) ? dismissColor : Colors.$backgroundPrimaryHeavy; -}; - -export const getCounterTextColor = (stateChips: Array, props: ChipsInputProps) => { - const {maxLength} = props; - if (isDisabled(props)) { - return Colors.grey50; - } - return maxLength && stateChips.length >= maxLength ? Colors.red30 : Colors.grey30; -}; - -export const getCounterText = (count: number, maxLength: number) => { - return `${Math.min(count, maxLength)} / ${maxLength}`; -}; - -export const getChipDismissColor = ( - chip: ChipsInputChipProps, - isSelected: boolean, - defaultChipProps?: ChipsInputChipProps -) => { - const dismissColor = defaultChipProps?.dismissColor || Colors.white; - return !chip.invalid ? dismissColor : isSelected ? Colors.red10 : Colors.red30; -}; - -export const isDisabled = (props: ChipsInputProps) => { - const {disableTagRemoval, editable} = props; - return disableTagRemoval || editable === false; -}; - diff --git a/src/components/chipsInput/__tests__/index.spec.js b/src/components/chipsInput/__tests__/index.spec.js deleted file mode 100644 index a05e3cd900..0000000000 --- a/src/components/chipsInput/__tests__/index.spec.js +++ /dev/null @@ -1,140 +0,0 @@ -import _ from 'lodash'; -import {Constants} from '../../../commons'; -import {ChipsInput} from '../index'; - -describe('ChipsInput', () => { - let uut; - beforeEach(() => { - uut = new ChipsInput({}); - uut.setState = jest.fn(state => _.assign(uut.state, state)); - _.set(uut.state, 'chips', [{}, {}, {}]); - }); - - describe('getLabel', () => { - it('should return the string value in case item is a string', () => { - expect(uut.getLabel('value')).toBe('value'); - expect(uut.getLabel('label')).toBe('label'); - }); - - it('should return the label prop value in case item is an object and getLabel was not provided', () => { - expect(uut.getLabel({label: 'labelValue'})).toBe('labelValue'); - expect(uut.getLabel({label2: 'labelValue'})).toBe(undefined); - }); - - it('should return the label according to getLabel callback provided in props', () => { - const getLabel = jest.fn(item => item.value); - uut = new ChipsInput({getLabel}); - expect(uut.getLabel({value: 'label', label: 'bla'})).toBe('label'); - }); - - it('should return the label according to getLabel callback even if item is a string', () => { - const getLabel = jest.fn(item => `${item}1`); - uut = new ChipsInput({getLabel}); - expect(uut.getLabel('label')).toBe('label1'); - }); - }); - - describe('onKeyPress', () => { - let removeTagSpy; - beforeEach(() => { - removeTagSpy = jest.spyOn(uut, 'removeMarkedTag'); - }); - - it('should update state - tagIndexToRemove with last tag index', () => { - const pressEvent = {nativeEvent: {key: Constants.backspaceKey}}; - uut.onKeyPress(pressEvent); - expect(uut.state.chipIndexToRemove).toBe(2); - }); - - it('should not update state if keyCode is not backspace', () => { - const pressEvent = {nativeEvent: {key: 'space'}}; - uut.onKeyPress(pressEvent); - expect(uut.state.chipIndexToRemove).toBe(undefined); - expect(removeTagSpy).not.toHaveBeenCalled(); - }); - - it('should not update state if there are not tags', () => { - const pressEvent = {nativeEvent: {key: Constants.backspaceKey}}; - _.set(uut.state, 'chips', []); - uut.onKeyPress(pressEvent); - expect(uut.state.chipIndexToRemove).toBe(undefined); - expect(removeTagSpy).not.toHaveBeenCalled(); - }); - - it('should not update state if input value is not empty', () => { - const pressEvent = {nativeEvent: {key: Constants.backspaceKey}}; - _.set(uut.state, 'chips', [{}, {}, {}]); - _.set(uut.state, 'value', 'some text'); - uut.onKeyPress(pressEvent); - expect(uut.state.chipIndexToRemove).toBe(undefined); - expect(removeTagSpy).not.toHaveBeenCalled(); - }); - - it('should invoke onKeyPress callback provided in props with the event', () => { - const pressEvent = {nativeEvent: {key: 'space'}}; - const onKeyPressCallback = jest.fn(); - uut = new ChipsInput({onKeyPress: onKeyPressCallback}); - - uut.onKeyPress(pressEvent); - expect(onKeyPressCallback).toHaveBeenCalledWith(pressEvent); - }); - - it('should not set last tag index if it is already set to last index, instead call remove tag', () => { - const pressEvent = {nativeEvent: {key: Constants.backspaceKey}}; - _.set(uut.state, 'chipIndexToRemove', 2); - uut.onKeyPress(pressEvent); - expect(removeTagSpy).toHaveBeenCalled(); - expect(uut.state.chipIndexToRemove).toBe(undefined); - }); - - it('should not remove tag nor update chipIndexToRemove if pressed any key while chipIndexToRemove was set', () => { - const pressEvent = {nativeEvent: {key: 'space'}}; - _.set(uut.state, 'chipIndexToRemove', 2); - uut.onKeyPress(pressEvent); - expect(removeTagSpy).not.toHaveBeenCalled(); - expect(uut.state.chipIndexToRemove).toBe(2); - }); - }); - - describe('removeMarkedTag', () => { - const onChangeTagsCallback = jest.fn(); - beforeEach(() => { - _.set(uut, 'props.onChangeTags', onChangeTagsCallback); - }); - it('should not change tags if there is no chipIndexToRemove in state', () => { - const tags = [{}, {}]; - _.set(uut, 'state', {chipIndexToRemove: undefined, tags}); - uut.removeMarkedTag(); - expect(uut.state.tags).toEqual(tags); - expect(onChangeTagsCallback).not.toHaveBeenCalled(); - }); - - it('should remove tag according to the chipIndexToRemove in state and invoke ', () => { - const chips = [{}, {}, {}]; - const chipIndexToRemove = 2; - const removedTag = chips[chipIndexToRemove]; - _.set(uut, 'state', {chipIndexToRemove, chips}); - uut.removeMarkedTag(); - expect(uut.state.chips).toEqual([chips[0], chips[1]]); - expect(onChangeTagsCallback).toHaveBeenCalledWith([chips[0], chips[1]], 'removed', removedTag); - expect(uut.state.chipIndexToRemove).toBeUndefined(); - }); - }); - - describe('onTagPress', () => { - it('should set chipIndexToRemove according to given index', () => { - _.set(uut, 'state.chipIndexToRemove', undefined); - uut.onTagPress(1); - expect(uut.state.chipIndexToRemove).toBe(1); - uut.onTagPress(2); - expect(uut.state.chipIndexToRemove).toBe(2); - }); - - it('should call to removeMarkedTag if the given index is the same as the current chipIndexToRemove', () => { - const removeTagSpy = jest.spyOn(uut, 'removeMarkedTag'); - _.set(uut, 'state.chipIndexToRemove', 1); - uut.onTagPress(1); - expect(removeTagSpy).toHaveBeenCalledWith(); - }); - }); -}); diff --git a/src/incubator/ChipsInput/assets/xSmall.png b/src/components/chipsInput/assets/xSmall.png similarity index 100% rename from src/incubator/ChipsInput/assets/xSmall.png rename to src/components/chipsInput/assets/xSmall.png diff --git a/src/incubator/ChipsInput/assets/xSmall@1.5x.png b/src/components/chipsInput/assets/xSmall@1.5x.png similarity index 100% rename from src/incubator/ChipsInput/assets/xSmall@1.5x.png rename to src/components/chipsInput/assets/xSmall@1.5x.png diff --git a/src/incubator/ChipsInput/assets/xSmall@2x.png b/src/components/chipsInput/assets/xSmall@2x.png similarity index 100% rename from src/incubator/ChipsInput/assets/xSmall@2x.png rename to src/components/chipsInput/assets/xSmall@2x.png diff --git a/src/incubator/ChipsInput/assets/xSmall@3x.png b/src/components/chipsInput/assets/xSmall@3x.png similarity index 100% rename from src/incubator/ChipsInput/assets/xSmall@3x.png rename to src/components/chipsInput/assets/xSmall@3x.png diff --git a/src/incubator/ChipsInput/assets/xSmall@4x.png b/src/components/chipsInput/assets/xSmall@4x.png similarity index 100% rename from src/incubator/ChipsInput/assets/xSmall@4x.png rename to src/components/chipsInput/assets/xSmall@4x.png diff --git a/src/components/chipsInput/chipsInput.api.json b/src/components/chipsInput/chipsInput.api.json index 34577d560b..2b72227313 100644 --- a/src/components/chipsInput/chipsInput.api.json +++ b/src/components/chipsInput/chipsInput.api.json @@ -1,82 +1,29 @@ { "name": "ChipsInput", - "category": "form", - "description": "Chips input component", - "extends": ["incubator/TextField"], - "modifiers": ["typography"], - "example": "https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/ChipsInputScreen.tsx", - "images": ["https://github.com/wix/react-native-ui-lib/blob/master/demo/showcase/ChipsInput/ChipsInput.gif?raw=true"], + "category": "incubator", + "description": "A chips input", + "extends": ["Incubator/TextField"], + "modifiers": ["margin", "color", "typography"], + "example": "https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/incubatorScreens/IncubatorChipsInputScreen.tsx", + "images": [], "props": [ + {"name": "chips", "type": "ChipProps[]", "description": "List of chips to render"}, { - "name": "tags", - "type": "ChipType[]", - "description": "use chips instead. List of tags. can be string boolean or custom object when implementing getLabel", - "deprecated": true + "name": "defaultChipProps", + "type": "ChipProps", + "description": "Default set of props to pass by default to all chips" }, { - "name": "chips", - "type": "ChipsInputChipProps[]", - "description": "List of tags. can be string boolean or custom object when implementing getLabel" + "name": "onChange", + "type": "(newChips, changeReason, updatedChip) => void", + "description": "Callback for chips change (adding or removing chip)" }, - {"name": "defaultChipProps", "type": "ChipsInputChipProps", "description": "Style your chips"}, - { - "name": "getLabel", - "type": "(tag: ChipType) => any", - "description": "Callback for extracting the label out of the tag item" - }, - { - "name": "renderTag", - "type": "(tag: ChipType, index: number, shouldMarkTag: boolean, label: string) => React.ReactElement", - "description": "use chips instead. Callback for custom rendering tag item", - "deprecated": true - }, - {"name": "onChangeTags", "type": "() => void", "description": "Callback for 'onChangeTags' event"}, - { - "name": "onCreateTag", - "type": "(value: any) => void", - "description": "Use chips instead. callback for creating new tag out of input value (good for composing tag object)", - "deprecated": true - }, - { - "name": "onTagPress", - "type": "(index: number, toRemove?: number) => void", - "description": "use chips instead. callback for when pressing a tag in the following format (tagIndex, markedTagIndex) => {...}", - "deprecated": true - }, - { - "name": "validationErrorMessage", - "type": "string", - "description": "Validation message error appears when tag isn't validate" - }, - {"name": "disableTagRemoval", "type": "boolean", "description": "If true, tags *removal* UX won't be available"}, - { - "name": "disableTagAdding", - "type": "boolean", - "description": "If true, tags *adding* UX (i.e. by 'submitting' the input text) won't be available" - }, - {"name": "tagStyle", "type": "ViewStyle", "description": "Custom styling for the tag item"}, - {"name": "inputStyle", "type": "RNTextInputProps['style']", "description": "Custom styling for the text input"}, - {"name": "hideUnderline", "type": "boolean", "description": "Should hide input underline"}, - {"name": "maxLength", "type": "number", "description": "Maximum numbers of chips"}, - { - "name": "scrollViewProps", - "type": "ScrollViewProps", - "description": "Chips with 'maxHeigh' is inside a ScrollView" - }, - {"name": "maxHeight", "type": "number", "description": "Chips inside a ScrollView"}, - { - "name": "leftElement", - "type": "JSX.Element | JSX.Element[]", - "description": "Custom element before the chips, for example 'search' icon, 'To:' label etc'" - }, - {"name": "value", "type": "any", "description": "The input's value"}, - {"name": "selectionColor", "type": "string | number", "description": "The color for the selection state"}, - {"name": "containerStyle", "type": "ViewStyle", "description": "Component's container style"} + {"name": "maxChips", "type": "number", "description": "The maximum chips to allow adding"} ], "snippet": [ "" ] } diff --git a/src/components/chipsInput/index.tsx b/src/components/chipsInput/index.tsx index 68014384f4..aeef541a00 100644 --- a/src/components/chipsInput/index.tsx +++ b/src/components/chipsInput/index.tsx @@ -1,625 +1,170 @@ -import _ from 'lodash'; -import React, {Component} from 'react'; -import {NativeModules, StyleSheet, ViewStyle, TextInput, NativeSyntheticEvent, TextInputKeyPressEventData, findNodeHandle, ScrollView, ScrollViewProps, TextInputProps as RNTextInputProps} from 'react-native'; -import {Colors, BorderRadiuses, ThemeManager, Typography, Spacings} from '../../style'; -import Assets from '../../assets'; -import {LogService} from '../../services'; -import {Constants, asBaseComponent, BaseComponentInjectedProps, TypographyModifiers} from '../../commons/new'; -import TextFieldMigrator from '../textField/TextFieldMigrator'; -import View from '../view'; -import TouchableOpacity from '../touchableOpacity'; -import Text from '../text'; +import React, {useCallback, useMemo, useRef, useState, forwardRef} from 'react'; +import {StyleSheet, NativeSyntheticEvent, TextInputKeyPressEventData} from 'react-native'; +import {isUndefined, map} from 'lodash'; +import {Constants} from '../../commons/new'; +import {useCombinedRefs} from '../../hooks'; +import TextField, {TextFieldProps} from '../../incubator/TextField'; import Chip, {ChipProps} from '../chip'; -import Icon from '../icon'; -import {getValidationBasedColor, getCounterTextColor, getCounterText, getChipDismissColor, isDisabled} from './Presenter'; -import {TextFieldProps} from '../../../typings/components/Inputs'; -// TODO: support updating tags externally -// TODO: support char array as tag creators (like comma) -// TODO: add notes to Docs about the Android fix for onKeyPress +const removeIcon = require('./assets/xSmall.png'); -type ChipType = string | boolean | any; -export type ChipsInputChipProps = ChipProps & {invalid?: boolean} +export enum ChipsInputChangeReason { + Added = 'added', + Removed = 'removed' +} -export type ChipsInputProps = TypographyModifiers & TextFieldProps & { - /** - * DEPRECATED: use chips instead. list of tags. can be string boolean or custom object when implementing getLabel - */ - tags?: ChipType[]; - /** - * list of tags. can be string boolean or custom object when implementing getLabel - */ - chips?: ChipsInputChipProps[]; - /** - * Style your chips - */ - defaultChipProps?: ChipsInputChipProps; - /** - * callback for extracting the label out of the tag item - */ - getLabel?: (tag: ChipType) => any; - /** - * DEPRECATED: use chips instead. callback for custom rendering tag item - */ - renderTag?: (tag: ChipType, index: number, shouldMarkTag: boolean, label: string) => React.ReactElement; - /** - * callback for onChangeTags event - */ - onChangeTags?: () => void; - /** - * DEPRECATED: use chips instead. callback for creating new tag out of input value (good for composing tag object) - */ - onCreateTag?: (value: any) => void; - /** - * DEPRECATED: use chips instead. callback for when pressing a tag in the following format (tagIndex, markedTagIndex) => {...} - */ - onTagPress?: (index: number, toRemove?: number) => void; - /** - * validation message error appears when tag isn't validate - */ - validationErrorMessage?: string; - /** - * if true, tags *removal* Ux won't be available - */ - disableTagRemoval?: boolean; - /** - * if true, tags *adding* Ux (i.e. by 'submitting' the input text) won't be available - */ - disableTagAdding?: boolean; - /** - * custom styling for the component container - */ - containerStyle?: ViewStyle; +export type ChipsInputChipProps = ChipProps & {invalid?: boolean}; + +export type ChipsInputProps = Omit & { /** - * custom styling for the tag item + * Chip items to render in the input */ - tagStyle?: ViewStyle; + chips?: ChipsInputChipProps[]; /** - * custom styling for the text input + * A default set of chip props to pass to all chips */ - inputStyle?: RNTextInputProps['style']; + defaultChipProps?: ChipProps; /** - * should hide input underline + * A default set of chip props to pass to all invalid chips */ - hideUnderline?: boolean; + invalidChipProps?: ChipProps; /** - * Maximum numbers of chips - */ - maxLength?: number; - /** - * Chips with maxHeigh is inside a scrollView - */ - scrollViewProps?: ScrollViewProps; - /** - * Chips inside a ScrollView + * Change callback for when chips changed (either added or removed) */ - maxHeight?: number; + onChange?: (chips: ChipsInputChipProps[], changeReason: ChipsInputChangeReason, updatedChip: ChipProps) => void; /** - * Custom element before the chips, for example 'search' icon, 'To:' label etc' + * Maximum chips */ - leftElement?: JSX.Element | JSX.Element[]; - - value?: any; - - selectionColor?: string | number; -} - -type State = { - value: any; - chips: Array; - chipIndexToRemove?: number; - initialChips?: Array; - isFocused: boolean; -} - -const GUTTER_SPACING = 8; - -type OwnProps = ChipsInputProps & BaseComponentInjectedProps; - -/** - * @description: Chips input component - * @modifiers: Typography - * @gif: https://github.com/wix/react-native-ui-lib/blob/master/demo/showcase/ChipsInput/ChipsInput.gif?raw=true - * @example: https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/ChipsInputScreen.tsx - * @extends: TextField - */ -class ChipsInput extends Component { - static displayName = 'ChipsInput'; - - static onChangeTagsActions = { - ADDED: 'added', - REMOVED: 'removed' - }; - - input = React.createRef(); - scrollRef = React.createRef(); - - constructor(props: OwnProps) { - super(props); - - this.state = { - value: props.value, - chips: _.cloneDeep(props.tags || props.chips) || [], - chipIndexToRemove: undefined, - initialChips: props.tags || props.chips, - isFocused: this.input.current?.isFocused() || false - }; - - LogService.componentDeprecationWarn({oldComponent: 'ChipsInput', newComponent: 'Incubator.ChipsInput'}); - } - - componentDidMount() { - if (Constants.isAndroid) { - const textInputHandle = findNodeHandle(this.input.current); - if (textInputHandle && NativeModules.TextInputDelKeyHandler) { - NativeModules.TextInputDelKeyHandler.register(textInputHandle); - } - } - } - - static getDerivedStateFromProps(nextProps: Readonly, prevState: State) { - const {tags, chips} = nextProps; - if (tags && tags !== prevState.initialChips || chips && chips !== prevState.initialChips) { - return { - initialChips: nextProps.tags || nextProps.chips, - chips: nextProps.tags || nextProps.chips - }; - } - return null; - } - - addTag = () => { - const {onCreateTag, disableTagAdding, maxLength, chips: chipsProps} = this.props; - const {value, chips} = this.state; - - if (this.scrollRef?.current?.scrollToEnd) { - this.scrollRef?.current?.scrollToEnd(); - } - - if (disableTagAdding) { - return; - } - if (_.isNil(value) || _.isEmpty(value.trim())) { - return; - } - - if (maxLength && this.state.chips.length >= maxLength) { - this.setState({value: ''}); - return; - } - - const newChip = _.isFunction(onCreateTag) ? onCreateTag(value) : chipsProps ? {label: value} : value; - const newChips = [...chips, newChip]; - - this.setState({ - value: '', - chips: newChips - }); - - _.invoke(this.props, 'onChangeTags', newChips, ChipsInput.onChangeTagsActions.ADDED, newChip); - this.clear(); - } - - removeMarkedTag() { - const {chips, chipIndexToRemove} = this.state; - - if (!_.isUndefined(chipIndexToRemove)) { - const removedTag = chips[chipIndexToRemove]; - - chips.splice(chipIndexToRemove, 1); - this.setState({ - chips, - chipIndexToRemove: undefined - }); - - _.invoke(this.props, 'onChangeTags', chips, ChipsInput.onChangeTagsActions.REMOVED, removedTag); - } - } - - markTagIndex = (chipIndex: number) => { - this.setState({chipIndexToRemove: chipIndex}); - } - - onChangeText = _.debounce((value) => { - this.setState({value, chipIndexToRemove: undefined}); - _.invoke(this.props, 'onChangeText', value); - }, 0); - - onTagPress(index: number) { - const {onTagPress} = this.props; - const {chipIndexToRemove} = this.state; - - // custom press handler - if (onTagPress) { - onTagPress(index, chipIndexToRemove); - return; - } - - // default press handler - if (chipIndexToRemove === index) { - this.removeMarkedTag(); - } else { - this.markTagIndex(index); - } - } - - isLastTagMarked() { - const {chips, chipIndexToRemove} = this.state; - const tagsCount = _.size(chips); - const isLastTagMarked = chipIndexToRemove === tagsCount - 1; - - return isLastTagMarked; - } - - removeTag = () => { - const {value, chips, chipIndexToRemove} = this.state; - const tagsCount = _.size(chips); - const hasNoValue = _.isEmpty(value); - const hasTags = tagsCount > 0; - - const {disableTagRemoval} = this.props; - if (disableTagRemoval) { - return; - } - - if (hasNoValue && hasTags && _.isUndefined(chipIndexToRemove)) { - this.setState({ - chipIndexToRemove: tagsCount - 1 - }); - } else if (!_.isUndefined(chipIndexToRemove)) { - this.removeMarkedTag(); - } - } - - onKeyPress = (event: NativeSyntheticEvent) => { - _.invoke(this.props, 'onKeyPress', event); + maxChips?: number; +}; - const keyCode = _.get(event, 'nativeEvent.key'); +const ChipsInput = forwardRef((props: ChipsInputProps, refToForward: React.Ref) => { + const fieldRef = useCombinedRefs(refToForward); + const { + chips = [], + defaultChipProps, + invalidChipProps, + leadingAccessory, + onChange, + fieldStyle, + maxChips, + ...others + } = props; + const [markedForRemoval, setMarkedForRemoval] = useState(undefined); + const fieldValue = useRef(others.value); + + const addChip = useCallback(() => { + const reachedMaximum = maxChips && chips?.length >= maxChips; + if (fieldValue.current && !reachedMaximum) { + const newChip = {label: fieldValue.current}; + setMarkedForRemoval(undefined); + // @ts-expect-error + fieldRef.current.clear(); + fieldValue.current = ''; + /* NOTE: Delay change event to give clear field time to complete and avoid a flickering */ + setTimeout(() => { + onChange?.([...chips, newChip], ChipsInputChangeReason.Added, newChip); + }, 0); + } + }, [onChange, chips, maxChips]); + + const removeMarkedChip = useCallback(() => { + if (!isUndefined(markedForRemoval)) { + const removedChip = chips?.splice(markedForRemoval, 1); + onChange?.([...chips], ChipsInputChangeReason.Removed, removedChip?.[0]); + setMarkedForRemoval(undefined); + } + }, [chips, markedForRemoval, onChange]); + + const onChipPress = useCallback(({customValue: index}: {customValue: number}) => { + const selectedChip = chips[index]; + selectedChip?.onPress?.(); + + setMarkedForRemoval(index); + }, + [chips]); + + const onChangeText = useCallback((value: string) => { + fieldValue.current = value; + props.onChangeText?.(value); + + if (!isUndefined(markedForRemoval)) { + setMarkedForRemoval(undefined); + } + }, + [props.onChangeText, markedForRemoval]); + + const onKeyPress = useCallback((event: NativeSyntheticEvent) => { + props.onKeyPress?.(event); + const keyCode = event?.nativeEvent?.key; const pressedBackspace = keyCode === Constants.backspaceKey; - if (pressedBackspace) { - this.removeTag(); - } - } - - getLabel = (item: ChipType) => { - const {getLabel} = this.props; - - if (getLabel) { - return getLabel(item); - } - if (_.isString(item)) { - return item; - } - return _.get(item, 'label'); - } - - onFocus = () => { - this.setState({isFocused: true}); - } - - onBlur = () => { - this.setState({isFocused: false}); - } - - renderLabel(tag: ChipType, shouldMarkTag: boolean) { - const {typography} = this.props.modifiers; - const label = this.getLabel(tag); - - return ( - - {shouldMarkTag && ( - ) - } - - {!tag.invalid && shouldMarkTag ? 'Remove' : label} - - - ); - } - - renderTag = (tag: ChipType, index: number) => { - const {tagStyle, renderTag} = this.props; - const {chipIndexToRemove} = this.state; - const shouldMarkTag = chipIndexToRemove === index; - const markedTagStyle = tag.invalid ? styles.invalidMarkedTag : styles.tagMarked; - const defaultTagStyle = tag.invalid ? styles.invalidTag : styles.tag; - - if (_.isFunction(renderTag)) { - return renderTag(tag, index, shouldMarkTag, this.getLabel(tag)); - } - - return ( - - {this.renderLabel(tag, shouldMarkTag)} - - ); - } - - renderTagWrapper = (tag: ChipType, index: number) => { - return ( - this.onTagPress(index)} - accessibilityHint={!this.props.disableTagRemoval ? 'tap twice for remove tag mode' : undefined} - > - {this.renderTag(tag, index)} - - ); - } - - renderNewChip = () => { - const {defaultChipProps} = this.props; - const {chipIndexToRemove, chips} = this.state; - const disabled = isDisabled(this.props); - - return _.map(chips, (chip, index) => { - const selected = chipIndexToRemove === index; - const dismissColor = getChipDismissColor(chip, selected, defaultChipProps); - return ( - - this.onTagPress(index)} - onDismiss={selected ? () => this.onTagPress(index) : undefined} - dismissColor={dismissColor} - dismissIcon={Assets.icons.xSmall} - dismissIconStyle={styles.dismissIconStyle} - /> - - ); - }); - } - - renderTitleText = () => { - const {title, defaultChipProps} = this.props; - const color = this.state.isFocused ? getValidationBasedColor(this.state.chips, defaultChipProps) : Colors.grey30; - return title && ( - {title} - ); - }; - - renderChips = () => { - const {disableTagRemoval, chips: chipsProps} = this.props; - const {chips} = this.state; - const renderFunction = disableTagRemoval ? this.renderTag : this.renderTagWrapper; - - if (chipsProps) { - return this.renderNewChip(); - } else { - // The old way of creating the 'Chip' internally - return _.map(chips, (tag, index) => { - return ( - - {renderFunction(tag, index)} - - ); - }); - } - } - - renderCharCounter() { - const {maxLength} = this.props; - const counter = this.state.chips.length; - - - if (maxLength) { - const color = getCounterTextColor(this.state.chips, this.props); - const counterText = getCounterText(counter, maxLength); - - return ( - - {counterText} - - ); + if (pressedBackspace && !fieldValue.current && chips.length > 0) { + if (isUndefined(markedForRemoval) || markedForRemoval !== chips.length - 1) { + setMarkedForRemoval(chips.length - 1); + } else { + removeMarkedChip(); + } } - } - - renderUnderline = () => { - const {isFocused, chips} = this.state; - const {defaultChipProps} = this.props; - const color = getValidationBasedColor(chips, defaultChipProps); - return ; - } - - renderTextInput() { - const {inputStyle, selectionColor, title, ...others} = this.props; - const {value} = this.state; - const isLastTagMarked = this.isLastTagMarked(); - - return ( - - - - ); - } - - renderChipsContainer = () => { - const {maxHeight, scrollViewProps} = this.props; - const Container = maxHeight ? ScrollView : View; - return ( - - {this.renderChips()} - {this.renderTextInput()} - - ); - } - - render() { - const {containerStyle, hideUnderline, validationErrorMessage, leftElement, maxHeight, chips} = this.props; - const {chipIndexToRemove} = this.state; + }, + [chips, props.onKeyPress, markedForRemoval, removeMarkedChip]); + const chipList = useMemo(() => { return ( - - {!!chips && this.renderTitleText()} - - {leftElement} - {this.renderChipsContainer()} - - {!hideUnderline && this.renderUnderline()} - {this.renderCharCounter()} - {validationErrorMessage ? - ( - - - {validationErrorMessage} - - - ) : null} - + <> + {leadingAccessory} + {map(chips, (chip, index) => { + const isMarkedForRemoval = index === markedForRemoval; + return ( + + ); + })} + ); - } - - blur() { - this.input.current?.blur(); - } - - focus() { - this.input.current?.focus(); - } - - clear() { - this.input.current?.clear(); - } -} - -export {ChipsInput}; // For tests -export default asBaseComponent(ChipsInput); - - -const basicTagStyle = { - borderRadius: BorderRadiuses.br100, - paddingVertical: 4.5, - paddingHorizontal: 12, - marginRight: GUTTER_SPACING, - marginVertical: GUTTER_SPACING / 2 -}; + }, [chips, leadingAccessory, defaultChipProps, removeMarkedChip, markedForRemoval]); + + return ( + + ); +}); const styles = StyleSheet.create({ - withUnderline: { - borderBottomWidth: StyleSheet.hairlineWidth, - borderColor: ThemeManager.dividerColor - }, - tagsList: { - minHeight: 38, - backgroundColor: 'transparent', - flexDirection: 'row', + fieldStyle: { flexWrap: 'wrap' }, - tagListContainer: { - backgroundColor: 'transparent', - flexDirection: 'row', - flexWrap: 'nowrap' - }, - inputWrapper: { - flexGrow: 1, - minWidth: 120, - backgroundColor: 'transparent', - justifyContent: 'center' - }, - tag: { - borderWidth: 0, - paddingVertical: 5, - backgroundColor: Colors.$backgroundPrimaryHeavy - }, - invalidTag: { - borderWidth: 1, - borderColor: Colors.red30, - backgroundColor: 'transparent' - }, - basicTagStyle: { - ...basicTagStyle - }, - invalidMarkedTag: { - borderColor: Colors.red10 - }, - tagMarked: { - backgroundColor: Colors.grey10 - }, - dismissIconStyle: { - width: 10, - height: 10, - marginRight: Spacings.s1 - }, - removeIcon: { - tintColor: Colors.white, - width: 10, - height: 10, - marginRight: 6 - }, - invalidTagRemoveIcon: { - tintColor: Colors.red10 - }, - tagLabel: { - ...Typography.text80, - color: Colors.white - }, - errorMessage: { - ...Typography.text80, - color: Colors.red30 - }, - errorMessageWhileMarked: { - color: Colors.red10 - }, - label: { - marginTop: Spacings.s1, - alignSelf: 'flex-end', - height: Typography.text80?.lineHeight, - ...Typography.text80 - }, - alignTextCenter: { - textAlignVertical: 'center' + input: { + flexGrow: undefined } }); +// @ts-expect-error +ChipsInput.changeReasons = { + ADDED: 'added', + REMOVED: 'removed' +}; + +export default ChipsInput; diff --git a/src/incubator/ChipsInput/chipsInput.api.json b/src/incubator/ChipsInput/chipsInput.api.json deleted file mode 100644 index 2b72227313..0000000000 --- a/src/incubator/ChipsInput/chipsInput.api.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "ChipsInput", - "category": "incubator", - "description": "A chips input", - "extends": ["Incubator/TextField"], - "modifiers": ["margin", "color", "typography"], - "example": "https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/incubatorScreens/IncubatorChipsInputScreen.tsx", - "images": [], - "props": [ - {"name": "chips", "type": "ChipProps[]", "description": "List of chips to render"}, - { - "name": "defaultChipProps", - "type": "ChipProps", - "description": "Default set of props to pass by default to all chips" - }, - { - "name": "onChange", - "type": "(newChips, changeReason, updatedChip) => void", - "description": "Callback for chips change (adding or removing chip)" - }, - {"name": "maxChips", "type": "number", "description": "The maximum chips to allow adding"} - ], - "snippet": [ - "" - ] -} diff --git a/src/incubator/ChipsInput/index.tsx b/src/incubator/ChipsInput/index.tsx deleted file mode 100644 index 99e928fb1f..0000000000 --- a/src/incubator/ChipsInput/index.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, {useCallback, useMemo, useRef, useState, forwardRef} from 'react'; -import {StyleSheet, NativeSyntheticEvent, TextInputKeyPressEventData} from 'react-native'; -import {isUndefined, map} from 'lodash'; -import {Constants} from '../../commons/new'; -import {useCombinedRefs} from '../../hooks'; -import TextField, {TextFieldProps} from '../TextField'; -import Chip, {ChipProps} from '../../components/chip'; - -const removeIcon = require('./assets/xSmall.png'); - -export enum ChipsInputChangeReason { - Added = 'added', - Removed = 'removed' -} - -export type ChipsInputChipProps = ChipProps & {invalid?: boolean}; - -export type ChipsInputProps = Omit & { - /** - * Chip items to render in the input - */ - chips?: ChipsInputChipProps[]; - /** - * A default set of chip props to pass to all chips - */ - defaultChipProps?: ChipProps; - /** - * A default set of chip props to pass to all invalid chips - */ - invalidChipProps?: ChipProps; - /** - * Change callback for when chips changed (either added or removed) - */ - onChange?: (chips: ChipsInputChipProps[], changeReason: ChipsInputChangeReason, updatedChip: ChipProps) => void; - /** - * Maximum chips - */ - maxChips?: number; -}; - -const ChipsInput = forwardRef((props: ChipsInputProps, refToForward: React.Ref) => { - const fieldRef = useCombinedRefs(refToForward); - const { - chips = [], - defaultChipProps, - invalidChipProps, - leadingAccessory, - onChange, - fieldStyle, - maxChips, - ...others - } = props; - const [markedForRemoval, setMarkedForRemoval] = useState(undefined); - const fieldValue = useRef(others.value); - - const addChip = useCallback(() => { - const reachedMaximum = maxChips && chips?.length >= maxChips; - if (fieldValue.current && !reachedMaximum) { - const newChip = {label: fieldValue.current}; - setMarkedForRemoval(undefined); - // @ts-expect-error - fieldRef.current.clear(); - fieldValue.current = ''; - /* NOTE: Delay change event to give clear field time to complete and avoid a flickering */ - setTimeout(() => { - onChange?.([...chips, newChip], ChipsInputChangeReason.Added, newChip); - }, 0); - } - }, [onChange, chips, maxChips]); - - const removeMarkedChip = useCallback(() => { - if (!isUndefined(markedForRemoval)) { - const removedChip = chips?.splice(markedForRemoval, 1); - onChange?.([...chips], ChipsInputChangeReason.Removed, removedChip?.[0]); - setMarkedForRemoval(undefined); - } - }, [chips, markedForRemoval, onChange]); - - const onChipPress = useCallback(({customValue: index}: {customValue: number}) => { - const selectedChip = chips[index]; - selectedChip?.onPress?.(); - - setMarkedForRemoval(index); - }, - [chips]); - - const onChangeText = useCallback((value: string) => { - fieldValue.current = value; - props.onChangeText?.(value); - - if (!isUndefined(markedForRemoval)) { - setMarkedForRemoval(undefined); - } - }, - [props.onChangeText, markedForRemoval]); - - const onKeyPress = useCallback((event: NativeSyntheticEvent) => { - props.onKeyPress?.(event); - const keyCode = event?.nativeEvent?.key; - const pressedBackspace = keyCode === Constants.backspaceKey; - - if (pressedBackspace && !fieldValue.current && chips.length > 0) { - if (isUndefined(markedForRemoval) || markedForRemoval !== chips.length - 1) { - setMarkedForRemoval(chips.length - 1); - } else { - removeMarkedChip(); - } - } - }, - [chips, props.onKeyPress, markedForRemoval, removeMarkedChip]); - - const chipList = useMemo(() => { - return ( - <> - {leadingAccessory} - {map(chips, (chip, index) => { - const isMarkedForRemoval = index === markedForRemoval; - return ( - - ); - })} - - ); - }, [chips, leadingAccessory, defaultChipProps, removeMarkedChip, markedForRemoval]); - - return ( - - ); -}); - -const styles = StyleSheet.create({ - fieldStyle: { - flexWrap: 'wrap' - }, - input: { - flexGrow: undefined - } -}); -// @ts-expect-error -ChipsInput.changeReasons = { - ADDED: 'added', - REMOVED: 'removed' -}; - -export default ChipsInput; diff --git a/src/incubator/index.ts b/src/incubator/index.ts index 5da84aa4ed..eba6b095c8 100644 --- a/src/incubator/index.ts +++ b/src/incubator/index.ts @@ -1,5 +1,5 @@ // export {default as Calendar} from './Calendar'; -export {default as ChipsInput, ChipsInputProps, ChipsInputChangeReason, ChipsInputChipProps} from './ChipsInput'; +export {default as ChipsInput, ChipsInputProps, ChipsInputChangeReason, ChipsInputChipProps} from '../components/chipsInput'; export {default as ExpandableOverlay} from './expandableOverlay'; // @ts-ignore export {default as TextField, TextFieldProps, FieldContextType} from './TextField';