From 9b598dfbfb4ef96545fe913b4d78fad8929ab434 Mon Sep 17 00:00:00 2001 From: Matt Anderson Date: Sat, 12 Feb 2022 01:25:17 +1030 Subject: [PATCH] Converted project to functional components. Allowed forwarding ref from parent for uses with libraries like React Hook Form. Added props for wrapperClass and inputClass. Upgraded react and typescript. --- .prettierrc | 7 ++ package-lock.json | 80 +++++++-------- package.json | 10 +- src/components/ContentEditable.tsx | 118 +++++++++------------- src/components/Tag.tsx | 67 ++++++------- src/index.tsx | 153 +++++++++++++---------------- src/utils/functions.tsx | 2 +- 7 files changed, 195 insertions(+), 242 deletions(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..334e1fc --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "printWidth": 120, + "trailingComma": "none", + "arrowParens": "avoid" +} diff --git a/package-lock.json b/package-lock.json index 326740f..cf3ef78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -964,9 +964,9 @@ "dev": true }, "@types/prop-types": { - "version": "15.7.3", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", - "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", "dev": true }, "@types/q": { @@ -976,24 +976,31 @@ "dev": true }, "@types/react": { - "version": "16.9.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.15.tgz", - "integrity": "sha512-WsmM1b6xQn1tG3X2Hx4F3bZwc2E82pJXt5OPs2YJgg71IzvUoKOSSSYOvLXYCg1ttipM+UuA4Lj3sfvqjVxyZw==", + "version": "17.0.39", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.39.tgz", + "integrity": "sha512-UVavlfAxDd/AgAacMa60Azl7ygyQNRwC/DsHZmKgNvPmRR5p70AJ5Q9EAmL2NWOJmeV+vVUI4IAP7GZrN8h8Ug==", "dev": true, "requires": { "@types/prop-types": "*", - "csstype": "^2.2.0" + "@types/scheduler": "*", + "csstype": "^3.0.2" } }, "@types/react-dom": { - "version": "16.9.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.4.tgz", - "integrity": "sha512-fya9xteU/n90tda0s+FtN5Ym4tbgxpq/hb/Af24dvs6uYnYn+fspaxw5USlw0R8apDNwxsqumdRoCoKitckQqw==", + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", + "integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==", "dev": true, "requires": { "@types/react": "*" } }, + "@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", + "dev": true + }, "abab": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", @@ -2319,9 +2326,9 @@ } }, "csstype": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz", - "integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", + "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==", "dev": true }, "dashdash": { @@ -5751,17 +5758,6 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, - "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.8.1" - } - }, "psl": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.5.0.tgz", @@ -5880,34 +5876,26 @@ "dev": true }, "react": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", - "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", "dev": true, "requires": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2" + "object-assign": "^4.1.1" } }, "react-dom": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", - "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.18.0" + "scheduler": "^0.20.2" } }, - "react-is": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", - "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==", - "dev": true - }, "readable-stream": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", @@ -6240,9 +6228,9 @@ } }, "scheduler": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.18.0.tgz", - "integrity": "sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", "dev": true, "requires": { "loose-envify": "^1.1.0", @@ -7052,9 +7040,9 @@ "dev": true }, "typescript": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.3.tgz", - "integrity": "sha512-Mcr/Qk7hXqFBXMN7p7Lusj1ktCBydylfQM/FZCk5glCNQJrCUKPkMHdo9R0MTFWsC/4kPFvDS0fDPvukfCkFsw==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index 00bf97e..b11d148 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,16 @@ "author": "pathof.dev", "license": "MIT", "devDependencies": { - "@types/react": "^16.8.25", - "@types/react-dom": "^16.8.5", + "@types/react": "^17.0.39", + "@types/react-dom": "^17.0.11", "parcel": "^1.12.3", - "react": "^16.8.6", - "react-dom": "^16.8.6", + "react": "^17.0.2", + "react-dom": "^17.0.2", "rollup": "^1.19.4", "sass": "^1.2.3", "tslint": "^5.18.0", "tslint-language-service": "^0.9.9", - "typescript": "^3.5.3", + "typescript": "^4.5.5", "uglify-js": "^3.6.0" } } diff --git a/src/components/ContentEditable.tsx b/src/components/ContentEditable.tsx index da09dbd..d6c884a 100644 --- a/src/components/ContentEditable.tsx +++ b/src/components/ContentEditable.tsx @@ -1,33 +1,26 @@ -import React from "react"; -import {safeHtmlString} from "../utils/functions"; +import React, { useEffect, useRef, useState } from "react"; +import { safeHtmlString } from "../utils/functions"; interface Props { value: string; className: string; - innerEditableRef: React.RefObject; - inputRef: React.RefObject; change: (value: string) => void; remove: () => void; validator?: (value: string) => boolean; removeOnBackspace?: boolean; } -export class ContentEditable extends React.Component { +const ContentEditable = React.forwardRef(({ value, className, change, remove, validator, removeOnBackspace }, ref) => { - // Track focus state of editable tag - focused: boolean = false; + const innerEditableRef = useRef() as React.MutableRefObject; + const [removed, setRemoved] = useState(false); + const [preFocusedValue, setPreFocusedValue] = useState(""); - // Track if element has been removed from DOM - removed: boolean = false; + useEffect(() => { + setPreFocusedValue(getValue()); + }, []); - // Save value before input is focused / user starts typing - preFocusedValue: string = ""; - - componentDidMount() { - this.preFocusedValue = this.getValue(); - } - - onPaste = (e: React.ClipboardEvent) => { + const onPaste = (e: React.ClipboardEvent) => { // Cancel paste event e.preventDefault(); @@ -40,93 +33,80 @@ export class ContentEditable extends React.Component { } - onFocus = () => { - this.preFocusedValue = this.getValue(); - this.focused = true; + // When we focus on the div, get it's text value. + const onFocus = () => { + setPreFocusedValue(getValue()); } - onBlur = () => { - - this.focused = false; - const ref = this.props.innerEditableRef.current; - const { validator, change } = this.props; - - if (!this.removed && ref) { + const onBlur = () => { + const ref = innerEditableRef.current; + if (!removed && ref) { // On blur, if no content in tag, remove it if (ref.innerText === "") { - this.props.remove(); + remove(); return; } // Validate input if needed if (validator) { - const valid = validator(this.getValue()); + const valid = validator(getValue()); // If invalidate, switch ref back to pre focused value if (!valid) { - ref.innerText = this.preFocusedValue; + ref.innerText = preFocusedValue; return; } } - change(ref.innerText); - } - } - onKeyDown = (e: React.KeyboardEvent) => { + const onKeyDown = (e: React.KeyboardEvent) => { // On enter, focus main tag input - if (e.keyCode === 13) { + const key = e.key || e.keyCode; + if (key === 13 || key === 188 || key === 'Enter' || key === ',') { e.preventDefault(); - this.focusInputRef(); + focusInputRef(); return; } // On backspace, if no content in ref, remove tag and focus main tag input - const { removeOnBackspace } = this.props; - const value = this.getValue(); - if (removeOnBackspace && e.keyCode === 8 && value === "") { - this.removed = true; - this.props.remove(); - this.focusInputRef(); + const value = getValue(); + if (removeOnBackspace && (key === 8 || key === 'Backspace') && value === "") { + setRemoved(true); + remove(); + focusInputRef(); return; } - } - getValue = () => { - const ref = this.getRef(); + const getValue = () => { + const ref = innerEditableRef.current; return ref ? ref.innerText : ""; } - getRef = () => { - return this.props.innerEditableRef.current; - } - focusInputRef = () => { - const { inputRef } = this.props; - if (inputRef && inputRef.current) { - inputRef.current.focus(); + const focusInputRef = () => { + if (ref && typeof ref !== 'function' && ref?.current) { + ref?.current?.focus(); } } - render() { - const { value, className, innerEditableRef } = this.props; - return ( -
- ); - } - -} + return ( +
+ ) + +}); + +export default ContentEditable; diff --git a/src/components/Tag.tsx b/src/components/Tag.tsx index 0d8a290..61f7220 100644 --- a/src/components/Tag.tsx +++ b/src/components/Tag.tsx @@ -1,51 +1,42 @@ import React from "react"; -import {classSelectors} from "../utils/selectors"; -import {ContentEditable} from "./ContentEditable"; +import { classSelectors } from "../utils/selectors"; +import ContentEditable from "./ContentEditable"; interface Props { value: string; index: number; editable: boolean; readOnly: boolean; - inputRef: React.RefObject; update: (i: number, value: string) => void; remove: (i: number) => void; validator?: (val: string) => boolean; removeOnBackspace?: boolean; } -export class Tag extends React.Component { - - innerEditableRef: React.RefObject = React.createRef(); - - remove = () => this.props.remove(this.props.index); - - render() { - - const { value, index, editable, inputRef, validator, update, readOnly, removeOnBackspace } = this.props; - - const tagRemoveClass = !readOnly ? - classSelectors.tagRemove : `${classSelectors.tagRemove} ${classSelectors.tagRemoveReadOnly}`; - - return ( -
- {!editable &&
{value}
} - {editable && ( - update(index, newValue)} - remove={this.remove} - validator={validator} - removeOnBackspace={removeOnBackspace} - /> - )} -
-
- ); - - } - -} +const Tag = React.forwardRef(({ value, index, editable, readOnly, update, remove, validator, removeOnBackspace }, ref) => { + + const removeTag = () => remove(index); + + const tagRemoveClass = !readOnly ? + classSelectors.tagRemove : `${classSelectors.tagRemove} ${classSelectors.tagRemoveReadOnly}`; + + return ( +
+ {!editable &&
{value}
} + {editable && ( + update(index, newValue)} + remove={removeTag} + validator={validator} + removeOnBackspace={removeOnBackspace} + /> + )} +
+
+ ); +}); + +export default Tag; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 0bf6bce..6c8859d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,6 @@ -import React from "react"; -import {Tag} from "./components/Tag"; -import {classSelectors} from "./utils/selectors"; +import React, { useRef, useState } from "react"; +import Tag from "./components/Tag"; +import { classSelectors } from "./utils/selectors"; type Tags = string[]; @@ -13,30 +13,26 @@ export interface ReactTagInputProps { editable?: boolean; readOnly?: boolean; removeOnBackspace?: boolean; + wrapperClass?: string; + inputClass?: string; + [x: string]: any; // Allow for any additional props } -interface State { - input: string; -} - -export default class ReactTagInput extends React.Component { +const ReactTagInput = React.forwardRef(({ tags, onChange, placeholder, maxTags, validator, editable, readOnly, removeOnBackspace, wrapperClass, inputClass, ...rest }, ref) => { - state = { input: "" }; + const [input, setInput] = useState(""); // Ref for input element - inputRef: React.RefObject = React.createRef(); + const inputRef = useRef() as React.MutableRefObject || ref; - onInputChange = (e: React.ChangeEvent) => { - this.setState({ input: e.target.value }); + const onInputChange = (e: React.ChangeEvent) => { + setInput(e.target.value); } - onInputKeyDown = (e: React.KeyboardEvent) => { - - const { input } = this.state; - const { validator, removeOnBackspace } = this.props; - + const onInputKeyDown = (e: React.KeyboardEvent) => { // On enter - if (e.keyCode === 13) { + const key = e.key || e.keyCode; + if (key === 13 || key === 188 || key === 'Enter' || key === ',') { // Prevent form submission if tag input is nested in
e.preventDefault(); @@ -51,11 +47,10 @@ export default class ReactTagInput extends React.Component { - const tags = [ ...this.props.tags ]; - if (!tags.includes(value)) { - tags.push(value); - this.props.onChange(tags); + const addTag = (value: string) => { + const newTags = [...tags]; + if (!newTags.includes(value)) { + newTags.push(value); + onChange(newTags); } - this.setState({ input: "" }); + setInput(''); } - removeTag = (i: number) => { - const tags = [ ...this.props.tags ]; - tags.splice(i, 1); - this.props.onChange(tags); + const removeTag = (i: number) => { + const newTags = [...tags]; + newTags.splice(i, 1); + onChange(newTags); } - updateTag = (i: number, value: string) => { - const tags = [...this.props.tags]; - const numOccurencesOfValue = tags.reduce((prev, currentValue, index) => prev + (currentValue === value && index !== i ? 1 : 0) , 0); + const updateTag = (i: number, value: string) => { + const newTags = [...tags]; + const numOccurencesOfValue = newTags.reduce((prev, currentValue, index) => prev + (currentValue === value && index !== i ? 1 : 0), 0); if (numOccurencesOfValue > 0) { - tags.splice(i, 1); + newTags.splice(i, 1); } else { - tags[i] = value; + newTags[i] = value; } - this.props.onChange(tags); + onChange(newTags); } - render() { - - const { input } = this.state; - - const { tags, placeholder, maxTags, editable, readOnly, validator, removeOnBackspace } = this.props; - - const maxTagsReached = maxTags !== undefined ? tags.length >= maxTags : false; - - const isEditable = readOnly ? false : (editable || false); - - const showInput = !readOnly && !maxTagsReached; - - return ( -
- {tags.map((tag, i) => ( - - ))} - {showInput && - - } -
- ); - - } + const maxTagsReached = maxTags !== undefined ? tags.length >= maxTags : false; + + const isEditable = readOnly ? false : (editable || false); + + const showInput = !readOnly && !maxTagsReached; + + return ( +
+ {tags.map((tag, i) => ( + + ))} + {showInput && + + } +
+ ); +}); -} +export default ReactTagInput; diff --git a/src/utils/functions.tsx b/src/utils/functions.tsx index 0b14b27..c62f159 100644 --- a/src/utils/functions.tsx +++ b/src/utils/functions.tsx @@ -15,7 +15,7 @@ const htmlEntityMap = { "=": "=", }; export function escapeHtml(value: string) { - return String(value).replace(/[&<>"'`=\/]/g, (s) => { + return String(value).replace(/[&<>"'`=/]/g, (s) => { // @ts-ignore return htmlEntityMap[s]; });