index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. import {
  2. useImperativeHandle,
  3. forwardRef,
  4. useState,
  5. useRef
  6. } from "react";
  7. import {
  8. TextInput as NativeTextInput,
  9. TouchableOpacity,
  10. Keyboard,
  11. Platform,
  12. View
  13. } from "react-native";
  14. import type ITextInputProps from "./type";
  15. import {
  16. type NumericInputInstance,
  17. type INumericInputRef,
  18. type NumericInputType
  19. } from "./type";
  20. import stylesheet, {
  21. getNumericInputType,
  22. useStyles
  23. } from "./stylesheet";
  24. import {
  25. NCoreUIKitLocalize,
  26. NCoreUIKitTheme
  27. } from "../../core/hooks";
  28. import {
  29. type RefForwardingComponent
  30. } from "../../types";
  31. import type ITextProps from "../text/type";
  32. import {
  33. BadgeQuestionMark as BadgeQuestionMarkIcon,
  34. BadgeAlert as BadgeAlertIcon,
  35. BadgeCheck as BadgeCheckIcon,
  36. BadgeInfo as BadgeInfoIcon,
  37. EyeClosed as EyeClosedIcon,
  38. CircleX as CircleXIcon,
  39. BadgeX as BadgeXIcon,
  40. type LucideIcon,
  41. Eye as EyeIcon
  42. } from "lucide-react-native";
  43. import Text from "../text";
  44. const NumericInputTypeIcon: Record<Exclude<NumericInputType, "default">, LucideIcon> = {
  45. "question": BadgeQuestionMarkIcon,
  46. "success": BadgeCheckIcon,
  47. "warning": BadgeAlertIcon,
  48. "info": BadgeInfoIcon,
  49. "danger": BadgeXIcon
  50. };
  51. const NumericInput: RefForwardingComponent<INumericInputRef, ITextInputProps> = ({
  52. isAutoKeyboardDismissOnBlur = true,
  53. rightIcon: RightIconComponentProp,
  54. hintTextIcon: HintTextIconProp,
  55. spreadBehaviour = "baseline",
  56. isShowHideTextButton = true,
  57. isWorkWithDebouncer = false,
  58. isShowHintTextIcon = false,
  59. icon: IconComponentProp,
  60. hintTextContainerStyle,
  61. isCleanEnabled = false,
  62. contentContainerStyle,
  63. subTitle = "Optional",
  64. onFocus: onFocusProp,
  65. isRequired = false,
  66. isDisabled = false,
  67. onBlur: onBlurProp,
  68. hideTextIconStyle,
  69. variant = "text",
  70. type = "default",
  71. rightIconOnPress,
  72. customLocalize,
  73. rightIconStyle,
  74. isShowSubTitle,
  75. cleanIconStyle,
  76. onChangeText,
  77. initialValue,
  78. customTheme,
  79. placeholder,
  80. iconOnPress,
  81. isOptional,
  82. validation,
  83. inputStyle,
  84. iconStyle,
  85. hintText,
  86. style,
  87. title,
  88. ...props
  89. }, ref) => {
  90. const {
  91. inlineSpaces,
  92. typography,
  93. radiuses,
  94. borders,
  95. spaces,
  96. colors
  97. } = NCoreUIKitTheme.useContext(customTheme);
  98. const {
  99. localize
  100. } = NCoreUIKitLocalize.useContext(customLocalize);
  101. const currentType = getNumericInputType({
  102. type
  103. });
  104. const inputDebouncer = useRef<ReturnType<typeof setTimeout>>(null);
  105. const inputRef = useRef<NumericInputInstance | null>(null);
  106. const styleType = type === "default" ? "neutral" : type === "question" ? "neutral" : type === "danger" ? "error" : type;
  107. const [
  108. value,
  109. setValue
  110. ] = useState(initialValue ? initialValue : "");
  111. const [
  112. hideValue,
  113. setHideValue
  114. ] = useState(true);
  115. const [
  116. isFocused,
  117. setIsFocused
  118. ] = useState(false);
  119. const [
  120. selection,
  121. setSelection
  122. ] = useState({
  123. start: 0,
  124. end: 0
  125. });
  126. const {
  127. hideTextIconContainer: hideTextIconContainerDynamicStyle,
  128. titleContainer: titleContainerDynamicStyle,
  129. hintTextIcon: hintTextIconDynamicStyle,
  130. cleanButton: cleanButtonDynamicStyle,
  131. rightIcon: rightIconDynamicStyle,
  132. container: containerDynamicStyle,
  133. hintText: hintTextDynamicStyle,
  134. required: requiredDynamicStyle,
  135. subTitle: subTitleDynamicStyle,
  136. content: contentDynamicStyle,
  137. overlay: overlayDynamicStyle,
  138. title: titleDynamicStyle,
  139. input: inputDynamicStyle,
  140. icon: iconDynamicStyle
  141. } = useStyles({
  142. icon: IconComponentProp ? true : false,
  143. spreadBehaviour,
  144. inlineSpaces,
  145. currentType,
  146. isDisabled,
  147. typography,
  148. isFocused,
  149. radiuses,
  150. borders,
  151. spaces,
  152. colors,
  153. title,
  154. type
  155. });
  156. useImperativeHandle(
  157. ref,
  158. () => ({
  159. updateValue,
  160. cleanText,
  161. focus,
  162. blur
  163. }),
  164. []
  165. );
  166. const titleProps: ITextProps = {
  167. color: currentType.titleColor,
  168. variant: "bodyLargeSize"
  169. };
  170. const iconProps: NCoreUIKit.IconCallbackProps = {
  171. size: Number(typography.labelLargeSize.fontSize) + 6,
  172. color: currentType.iconColor
  173. };
  174. if(isDisabled) {
  175. iconProps.color = "disabled";
  176. }
  177. const blur = () => {
  178. inputRef.current?.blur();
  179. };
  180. const focus = () => {
  181. inputRef.current?.focus();
  182. };
  183. const cleanText = () => {
  184. setValue("");
  185. inputRef.current?.clear();
  186. if(onChangeText) {
  187. onChangeText("");
  188. }
  189. };
  190. const updateValue = (text: string) => {
  191. setValue(text);
  192. inputRef.current?.setNativeProps({
  193. text: text
  194. });
  195. };
  196. const onFocus = () => {
  197. setIsFocused(true);
  198. if(onFocusProp) onFocusProp();
  199. };
  200. const onBlur = () => {
  201. setIsFocused(false);
  202. inputRef.current?.blur();
  203. if(isAutoKeyboardDismissOnBlur) {
  204. Keyboard.dismiss();
  205. }
  206. if(onBlurProp) onBlurProp();
  207. };
  208. const toggleValueVisibility = () => {
  209. setHideValue(prevState => !prevState);
  210. };
  211. const handleTextChange = (text: string) => {
  212. if (
  213. props.maxLength &&
  214. selection.start >= value.length &&
  215. text.length > props.maxLength &&
  216. text.length > value.length
  217. ) {
  218. inputRef.current?.setNativeProps({
  219. value: value
  220. });
  221. return;
  222. }
  223. const cleanedText = text.replace(/\n/g, "").replace(/[^0-9]/g, "");
  224. const newValue = props.maxLength ? cleanedText.slice(0, props.maxLength) : cleanedText;
  225. if(text === "") {
  226. setValue("");
  227. if(onChangeText) onChangeText("");
  228. return;
  229. }
  230. const isValid = validation ? validation(newValue, (text) => {
  231. if (onChangeText) onChangeText(newValue);
  232. setValue(text);
  233. }) : true;
  234. if (!isValid) {
  235. setSelection({
  236. start: selection.start,
  237. end: selection.end
  238. });
  239. return;
  240. }
  241. const isDeleting = text.length < value.length;
  242. let nextPosition = selection.start;
  243. if (isDeleting) {
  244. nextPosition = Math.max(0, selection.start - 1);
  245. } else {
  246. nextPosition = Math.min(newValue.length, selection.start + 1);
  247. }
  248. setValue(newValue);
  249. if (onChangeText) onChangeText(newValue);
  250. setSelection({
  251. start: nextPosition,
  252. end: nextPosition
  253. });
  254. };
  255. const renderCleanButton = () => {
  256. if(isDisabled) {
  257. return null;
  258. }
  259. if(variant !== "text") {
  260. return null;
  261. }
  262. if(!isCleanEnabled || !value.length) {
  263. return null;
  264. }
  265. return <TouchableOpacity
  266. style={[
  267. cleanIconStyle,
  268. stylesheet.cleanButton,
  269. cleanButtonDynamicStyle
  270. ]}
  271. onPress={() => {
  272. if(inputRef.current) inputRef.current.clear();
  273. if(onChangeText) {
  274. onChangeText("");
  275. }
  276. setValue("");
  277. }}
  278. >
  279. <CircleXIcon
  280. color={colors.content.icon[currentType.iconColor]}
  281. size={20}
  282. />
  283. </TouchableOpacity>;
  284. };
  285. const renderIcon = () => {
  286. if (!IconComponentProp) {
  287. return null;
  288. }
  289. return <TouchableOpacity
  290. onPress={iconOnPress}
  291. style={[
  292. iconStyle,
  293. stylesheet.icon,
  294. iconDynamicStyle
  295. ]}
  296. >
  297. <IconComponentProp
  298. color={iconProps.color}
  299. size={iconProps.size}
  300. />
  301. </TouchableOpacity>;
  302. };
  303. const renderRightIcon = () => {
  304. if (!RightIconComponentProp) {
  305. return null;
  306. }
  307. if(isShowHideTextButton && variant === "hidden") {
  308. return null;
  309. }
  310. if(isCleanEnabled && value.length > 0 && variant === "text") {
  311. return null;
  312. }
  313. return <TouchableOpacity
  314. onPress={rightIconOnPress}
  315. style={[
  316. rightIconStyle,
  317. stylesheet.rightIcon,
  318. rightIconDynamicStyle
  319. ]}
  320. >
  321. <RightIconComponentProp
  322. color={iconProps.color}
  323. size={iconProps.size}
  324. />
  325. </TouchableOpacity>;
  326. };
  327. const renderHideTextIcon = () => {
  328. if(!isShowHideTextButton) {
  329. return null;
  330. }
  331. if (variant !== "hidden") {
  332. return null;
  333. }
  334. return <TouchableOpacity
  335. onPress={toggleValueVisibility}
  336. style={[
  337. hideTextIconStyle,
  338. stylesheet.hideTextIconContainer,
  339. hideTextIconContainerDynamicStyle
  340. ]}
  341. >
  342. {
  343. hideValue ?
  344. <EyeIcon
  345. color={colors.content.icon[iconProps.color]}
  346. size={20}
  347. />
  348. :
  349. <EyeClosedIcon
  350. color={colors.content.icon[iconProps.color]}
  351. size={20}
  352. />
  353. }
  354. </TouchableOpacity>;
  355. };
  356. const renderHintIcon = () => {
  357. if(!isShowHintTextIcon) {
  358. return null;
  359. }
  360. if(HintTextIconProp) {
  361. return <HintTextIconProp
  362. color={isDisabled ? "disabled" : currentType.hintTextIconColor}
  363. size={20}
  364. style={[
  365. stylesheet.hintTextIcon,
  366. hintTextIconDynamicStyle
  367. ]}
  368. />;
  369. }
  370. const CurrentHintIcon = NumericInputTypeIcon[type === "default" ? "question" : type];
  371. let hintIconColor = colors.content.icon[currentType.hintTextIconColor];
  372. if(isDisabled) {
  373. hintIconColor = colors.system.state.content.disabled[styleType];
  374. }
  375. return <CurrentHintIcon
  376. color={hintIconColor}
  377. size={20}
  378. style={[
  379. stylesheet.hintTextIcon,
  380. hintTextIconDynamicStyle
  381. ]}
  382. />;
  383. };
  384. const renderHintText = () => {
  385. if (!hintText) {
  386. return null;
  387. }
  388. return <View
  389. style={[
  390. hintTextContainerStyle,
  391. stylesheet.hintText,
  392. hintTextDynamicStyle
  393. ]}
  394. >
  395. {renderHintIcon()}
  396. <Text
  397. customColor={isDisabled ? colors.system.state.content.disabled[styleType] : undefined}
  398. color={currentType.hintTextColor}
  399. variant="labelSmallSize"
  400. >
  401. {hintText}
  402. </Text>
  403. </View>;
  404. };
  405. const renderRequired = () => {
  406. if(!isRequired) {
  407. return null;
  408. }
  409. return <Text
  410. color="danger"
  411. style={[
  412. stylesheet.required,
  413. requiredDynamicStyle
  414. ]}
  415. >
  416. *
  417. </Text>;
  418. };
  419. const renderSubtitle = () => {
  420. if(!isShowSubTitle && !isOptional) {
  421. return null;
  422. }
  423. return <Text
  424. variant="labelLargeSize"
  425. color={titleProps.color}
  426. style={[
  427. stylesheet.subTitle,
  428. subTitleDynamicStyle
  429. ]}
  430. >
  431. ( {isOptional ? localize("is-optional") : subTitle} )
  432. </Text>;
  433. };
  434. const renderTitle = () => {
  435. if (!title) {
  436. return null;
  437. }
  438. return <View
  439. style={[
  440. stylesheet.titleContainer,
  441. titleContainerDynamicStyle
  442. ]}
  443. >
  444. {renderRequired()}
  445. <Text
  446. {...titleProps}
  447. variant={titleProps.variant}
  448. color={titleProps.color}
  449. style={[
  450. stylesheet.title,
  451. titleDynamicStyle
  452. ]}
  453. >
  454. {title}
  455. </Text>
  456. {renderSubtitle()}
  457. </View>;
  458. };
  459. const renderInput = () => {
  460. return <NativeTextInput
  461. {...props}
  462. placeholderTextColor={colors.content.text[currentType.placeholderColor]}
  463. onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
  464. secureTextEntry={variant === "hidden" && hideValue}
  465. underlineColorAndroid="rgba(255,255,255,0)"
  466. onChangeText={(text) => {
  467. if(isWorkWithDebouncer) {
  468. if(inputDebouncer.current) {
  469. clearTimeout(inputDebouncer.current);
  470. inputDebouncer.current = null;
  471. }
  472. inputDebouncer.current = setTimeout(() => {{
  473. handleTextChange(text);
  474. }}, 550);
  475. } else {
  476. handleTextChange(text);
  477. }
  478. }}
  479. placeholder={placeholder}
  480. allowFontScaling={false}
  481. editable={!isDisabled}
  482. maxLength={undefined}
  483. selection={selection}
  484. multiline={false}
  485. onFocus={onFocus}
  486. onBlur={onBlur}
  487. value={value}
  488. ref={inputRef}
  489. style={[
  490. inputStyle,
  491. stylesheet.input,
  492. inputDynamicStyle
  493. ]}
  494. />;
  495. };
  496. const renderOverlay = () => {
  497. return <View
  498. style={[
  499. stylesheet.overlay,
  500. overlayDynamicStyle
  501. ]}
  502. />;
  503. };
  504. return <TouchableOpacity
  505. disabled={isDisabled}
  506. style={[
  507. style,
  508. stylesheet.container,
  509. containerDynamicStyle
  510. ]}
  511. onPress={() => {
  512. if(!isDisabled) inputRef.current?.focus();
  513. }}
  514. {...Platform.select({
  515. web: {
  516. dataSet: {
  517. disablePressableDownEffect: "true"
  518. }
  519. },
  520. default: {}
  521. })}
  522. >
  523. {renderTitle()}
  524. <View
  525. style={[
  526. contentContainerStyle,
  527. stylesheet.content,
  528. contentDynamicStyle
  529. ]}
  530. >
  531. {renderIcon()}
  532. {renderInput()}
  533. {renderCleanButton()}
  534. {renderHideTextIcon()}
  535. {renderRightIcon()}
  536. {renderOverlay()}
  537. </View>
  538. {renderHintText()}
  539. </TouchableOpacity>;
  540. };
  541. export default forwardRef(NumericInput);