Skip to content

Commit d19e242

Browse files
authored
Add maxLength prop
- Add maxLength prop - Add charsCounterClassName style prop - Update methods - Update docs
2 parents 890a767 + 6460cd4 commit d19e242

File tree

3 files changed

+102
-38
lines changed

3 files changed

+102
-38
lines changed

README.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,22 @@ export default App
5151

5252
> All props are optional, that's how you can **fully customize** it!
5353
54-
| Name | Optional | Type | Description |
55-
| ------------------------ | -------- | ------------------- | --------------------------------------------------------------------- |
56-
| containerClassName | ✔️ | `string` | Custom classes for the wrapper div |
57-
| contentEditableClassName | ✔️ | `string` | Custom classes for the input element |
58-
| placeholderClassName | ✔️ | `string` | Custom classes for the placeholder text |
59-
| placeholder | ✔️ | `string` | Input placeholder text |
60-
| disabled | ✔️ | `boolean` | Flag that disables the input element |
61-
| updatedContent | ✔️ | `string` | Text injected from parent element into the input as the current value |
62-
| onContentExternalUpdate | ✔️ | `(content) => void` | Method that emits the injected content by the `updatedContent` prop |
63-
| onChange | ✔️ | `(content) => void` | Method that emits the current content as a string |
64-
| onKeyUp | ✔️ | `(e) => void` | Method that emits the keyUp keyboard event |
65-
| onKeyDown | ✔️ | `(e) => void` | Method that emits the keyDown keyboard event |
66-
| onFocus | ✔️ | `(e) => void` | Method that emits the focus event |
67-
| onBlur | ✔️ | `(e) => void` | Method that emits the blur event |
54+
| Name | Optional | Type | Description |
55+
| ------------------------ | -------- | ------------------- | --------------------------------------------------------------------------- |
56+
| containerClassName | ✔️ | `string` | Custom classes for the wrapper div |
57+
| contentEditableClassName | ✔️ | `string` | Custom classes for the input element |
58+
| placeholderClassName | ✔️ | `string` | Custom classes for the placeholder text |
59+
| charsCounterClassName | ✔️ | `string` | Custom classes for the character counter text (if `maxLength` is specified) |
60+
| placeholder | ✔️ | `string` | Input placeholder text |
61+
| disabled | ✔️ | `boolean` | Flag that disables the input element |
62+
| maxLength | ✔️ | `number` | Indicates the maximum number of characters a user can enter |
63+
| updatedContent | ✔️ | `string` | Text injected from parent element into the input as the current value |
64+
| onContentExternalUpdate | ✔️ | `(content) => void` | Method that emits the injected content by the `updatedContent` prop |
65+
| onChange | ✔️ | `(content) => void` | Method that emits the current content as a string |
66+
| onKeyUp | ✔️ | `(e) => void` | Method that emits the keyUp keyboard event |
67+
| onKeyDown | ✔️ | `(e) => void` | Method that emits the keyDown keyboard event |
68+
| onFocus | ✔️ | `(e) => void` | Method that emits the focus event |
69+
| onBlur | ✔️ | `(e) => void` | Method that emits the blur event |
6870

6971
## Contribution
7072

lib/ContentEditable.tsx

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ interface ContentEditableProps {
44
containerClassName?: string
55
contentEditableClassName?: string
66
placeholderClassName?: string
7+
charsCounterClassName?: string
78
placeholder?: string
89
disabled?: boolean
910
updatedContent?: string
11+
maxLength?: number
1012
onChange?: (content: string) => void
1113
onKeyUp?: (e: React.KeyboardEvent) => void
1214
onKeyDown?: (e: React.KeyboardEvent) => void
@@ -15,13 +17,24 @@ interface ContentEditableProps {
1517
onContentExternalUpdate?: (content: string) => void
1618
}
1719

20+
// Helper function to check if content length is within maxLength
21+
const isContentWithinMaxLength = (
22+
content: string,
23+
maxLength?: number
24+
): boolean => {
25+
if (!maxLength) return true
26+
return content.length <= maxLength
27+
}
28+
1829
const ContentEditable: React.FC<ContentEditableProps> = ({
1930
containerClassName,
2031
contentEditableClassName,
2132
placeholderClassName,
33+
charsCounterClassName,
2234
placeholder,
2335
disabled,
2436
updatedContent,
37+
maxLength,
2538
onChange,
2639
onKeyUp,
2740
onKeyDown,
@@ -43,9 +56,11 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
4356
useEffect(() => {
4457
if (divRef.current) {
4558
divRef.current.style.height = "auto"
46-
if (onChange) onChange(content)
59+
if (onChange && isContentWithinMaxLength(content, maxLength)) {
60+
onChange(content)
61+
}
4762
}
48-
}, [content, onChange])
63+
}, [content, onChange, maxLength])
4964

5065
/**
5166
* Checks if the caret is on the last line of a contenteditable element
@@ -125,30 +140,40 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
125140
const clipboardData = e.clipboardData || (window as any).clipboardData
126141
const plainText = clipboardData.getData("text/plain")
127142

128-
// Get the current selection
143+
// Get the current selection and current content
129144
const sel: Selection | null = window.getSelection()
145+
const currentContent = divRef.current?.innerText || ""
146+
130147
if (sel && sel.rangeCount) {
131-
// Get the first range of the selection
132148
const range = sel.getRangeAt(0)
133-
134-
// Delete the contents of the range (this is the selected text)
135-
range.deleteContents()
136-
137-
// Create a new text node containing the pasted text
138-
const textNode = document.createTextNode(plainText)
139-
140-
// Insert the text node into the range, which will replace the selected text
141-
range.insertNode(textNode)
142-
143-
// Move the caret to the end of the new text
144-
range.setStartAfter(textNode)
145-
sel.removeAllRanges()
146-
sel.addRange(range)
147-
148-
setContent(divRef.current?.innerText ?? "")
149+
const selectedText = range.toString()
150+
151+
// Calculate how much text we can insert
152+
const availableSpace = maxLength
153+
? maxLength - (currentContent.length - selectedText.length)
154+
: plainText.length
155+
const truncatedText = plainText.slice(0, availableSpace)
156+
157+
if (truncatedText.length > 0) {
158+
range.deleteContents()
159+
const textNode = document.createTextNode(truncatedText)
160+
range.insertNode(textNode)
161+
range.setStartAfter(textNode)
162+
sel.removeAllRanges()
163+
sel.addRange(range)
164+
165+
setContent(divRef.current?.innerText ?? "")
166+
}
149167
} else {
150-
// If there's no selection, just insert the text at the current caret position
151-
insertTextAtCaret(plainText)
168+
// If there isn't a selection, check if we can insert at current position
169+
const availableSpace = maxLength
170+
? maxLength - currentContent.length
171+
: plainText.length
172+
const truncatedText = plainText.slice(0, availableSpace)
173+
174+
if (truncatedText.length > 0) {
175+
insertTextAtCaret(truncatedText)
176+
}
152177
}
153178
}
154179

@@ -205,6 +230,21 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
205230
}
206231
}
207232

233+
/**
234+
* Sets the caret (text cursor) position at the end of the specified contenteditable element
235+
* @param editableDiv - The HTMLElement representing the contenteditable div where the caret should be placed
236+
*/
237+
function setCaretAtTheEnd(editableDiv: HTMLElement) {
238+
const range = document.createRange()
239+
const sel = window.getSelection()
240+
if (editableDiv.lastChild && sel) {
241+
range.setStartAfter(editableDiv.lastChild)
242+
range.collapse(true)
243+
sel.removeAllRanges()
244+
sel.addRange(range)
245+
}
246+
}
247+
208248
/**
209249
* Retrieves the caret position within the contentEditable element
210250
* @param editableDiv - The contentEditable element
@@ -288,8 +328,18 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
288328
unicodeBidi: "plaintext",
289329
}}
290330
onInput={(e: React.FormEvent<HTMLDivElement>) => {
291-
if (disabled) return
292-
setContent(e.currentTarget.innerText)
331+
const currentContent = e.currentTarget.innerText
332+
if (
333+
disabled ||
334+
!isContentWithinMaxLength(currentContent, maxLength)
335+
) {
336+
if (divRef.current) {
337+
divRef.current.innerText = content
338+
setCaretAtTheEnd(divRef.current)
339+
}
340+
return
341+
}
342+
setContent(currentContent)
293343
}}
294344
onPaste={(e) => {
295345
if (disabled) return
@@ -323,6 +373,17 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
323373
{placeholder ?? ""}
324374
</span>
325375
)}
376+
{!!maxLength && (
377+
<span
378+
dir="auto"
379+
className={charsCounterClassName}
380+
style={{
381+
marginLeft: "1rem",
382+
}}
383+
>
384+
{`${content.length ?? 0}/${maxLength}`}
385+
</span>
386+
)}
326387
</div>
327388
)
328389
}

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const App = () => {
4545
placeholderClassName="input-placeholder"
4646
updatedContent={emptyContent}
4747
onChange={(content) => setContent(content)}
48+
maxLength={100}
4849
onFocus={() => {
4950
setIsFocused(true)
5051
setIsBlurred(false)

0 commit comments

Comments
 (0)