Repository: streamlit/streamlit
Pull request: TextArea and TextInput max_chars
**Issue:** This PR fixes #613 

**Description:** Adding max_chars arg to text area and text input

---

**Contribution License Agreement**

By submiting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.


Problem statement:
Specify max number of characters in text area
### Problem

Right now, there is no limit to specify number of characters in text area. This is very risky and an overkill if users use it to process too large textual data.

### Solution

It would be great to give users the option to specify the max number of characters allowed in text area. It should be up to the user to specify number of characters though.

Hints from issue discussion:
How about a `max_chars` kwarg? Default `==None` meaning infinite.
I think this should be fine. I hope the documentation will be updated accordingly once this is implemented.

Thanks a million @treuille for creating such an amazing framework. 

Sorry to bother you guys. Any progress on this one? I am trying to make an app available that uses spaCy, but increased number of words in the text area basically kills it. I want to limit to something like 2000 characters for example.
Hi @mzeidhassan we are prioritizing it on our backlog but in the meantime you could do something like this:

```python
import streamlit as st

text = st.text_area("Your text:")

if len(text) > 10:
    st.error("You can't use more than 10 characters")
else:
    st.success(text)

```

Commit message: e2e

Patch to review:
diff --git a/e2e/scripts/st_text_area.py b/e2e/scripts/st_text_area.py
index 665d302648ff..e0f0426933fa 100644
--- a/e2e/scripts/st_text_area.py
+++ b/e2e/scripts/st_text_area.py
@@ -25,3 +25,6 @@
 
 i4 = st.text_area("text area 4", None)
 st.write('value 4: "', i4, '"')
+
+i5 = st.text_area("text area 5", max_chars=10)
+st.write('value 5: "', i5, '"')
diff --git a/e2e/specs/st_text_area.spec.ts b/e2e/specs/st_text_area.spec.ts
index 652c01ef0e04..ee526b9e8fe0 100644
--- a/e2e/specs/st_text_area.spec.ts
+++ b/e2e/specs/st_text_area.spec.ts
@@ -26,7 +26,7 @@ describe("st.text_area", () => {
   });
 
   it("shows widget correctly", () => {
-    cy.get(".stTextArea").should("have.length", 4);
+    cy.get(".stTextArea").should("have.length", 5);
 
     cy.get(".stTextArea").each((el, idx) => {
       return cy.wrap(el).matchImageSnapshot("text_area" + idx);
@@ -39,21 +39,23 @@ describe("st.text_area", () => {
       'value 1: "  "' +
         'value 2: " default text "' +
         'value 3: " 1234 "' +
-        'value 4: " None "'
+        'value 4: " None "' +
+        'value 5: "  "'
     );
   });
 
   it("sets value correctly when user types", () => {
     cy.get(".stTextArea textarea")
       .first()
-      .type("test area{enter}");
+      .type("test area{ctrl}{enter}");
 
     cy.get(".stMarkdown").should(
       "have.text",
-      'value 1: "  "' +
+      'value 1: " test area "' +
         'value 2: " default text "' +
         'value 3: " 1234 "' +
-        'value 4: " None "'
+        'value 4: " None "' +
+        'value 5: "  "'
     );
   });
 
@@ -67,7 +69,8 @@ describe("st.text_area", () => {
       'value 1: " test area "' +
         'value 2: " default text "' +
         'value 3: " 1234 "' +
-        'value 4: " None "'
+        'value 4: " None "' +
+        'value 5: "  "'
     );
   });
 
@@ -81,7 +84,8 @@ describe("st.text_area", () => {
       'value 1: " test area "' +
         'value 2: " default text "' +
         'value 3: " 1234 "' +
-        'value 4: " None "'
+        'value 4: " None "' +
+        'value 5: "  "'
     );
   });
 
@@ -96,7 +100,24 @@ describe("st.text_area", () => {
       'value 1: " test area "' +
         'value 2: " default text "' +
         'value 3: " 1234 "' +
-        'value 4: " None "'
+        'value 4: " None "' +
+        'value 5: "  "'
+    );
+  });
+
+  it("sets value correctly with max_chars enabled", () => {
+    cy.get(".stTextArea textarea")
+      .last()
+      .type("test area! this shouldn't be returned")
+      .blur();
+
+    cy.get(".stMarkdown").should(
+      "have.text",
+      'value 1: "  "' +
+        'value 2: " default text "' +
+        'value 3: " 1234 "' +
+        'value 4: " None "' +
+        'value 5: " test area! "'
     );
   });
 });
diff --git a/frontend/src/components/shared/InputInstructions/InputInstructions.test.tsx b/frontend/src/components/shared/InputInstructions/InputInstructions.test.tsx
new file mode 100644
index 000000000000..c77b07fc84a7
--- /dev/null
+++ b/frontend/src/components/shared/InputInstructions/InputInstructions.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2018-2020 Streamlit Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react"
+import { shallow } from "enzyme"
+
+import InputInstructions, { Props } from "./InputInstructions"
+
+const getProps = (props: Partial<Props> = {}): Props => ({
+  dirty: true,
+  value: "asd",
+  ...props,
+})
+
+describe("InputInstructions", () => {
+  const props = getProps()
+  const wrapper = shallow(<InputInstructions {...props} />)
+
+  it("renders without crashing", () => {
+    expect(wrapper.text()).toBeDefined()
+  })
+
+  it("should show Enter instructions", () => {
+    expect(wrapper.text()).toBe("Press Enter to apply")
+  })
+
+  describe("Multiline type", () => {
+    const props = getProps({
+      type: "multiline",
+    })
+    const wrapper = shallow(<InputInstructions {...props} />)
+
+    it("should show Ctrl+Enter instructions", () => {
+      expect(wrapper.text()).toBe("Press Ctrl+Enter to apply")
+    })
+
+    it("show ⌘+Enter instructions", () => {
+      Object.defineProperty(navigator, "platform", {
+        value: "MacIntel",
+        writable: true,
+      })
+
+      const props = getProps({
+        type: "multiline",
+      })
+      const wrapper = shallow(<InputInstructions {...props} />)
+
+      expect(wrapper.text()).toBe("Press ⌘+Enter to apply")
+    })
+
+    it("should show instructions for max length", () => {
+      const props = getProps({
+        type: "multiline",
+        maxLength: 3,
+      })
+      const wrapper = shallow(<InputInstructions {...props} />)
+
+      expect(wrapper.text()).toBe("Press ⌘+Enter to apply•3/3")
+    })
+  })
+
+  it("should show instructions for max length", () => {
+    const props = getProps({
+      maxLength: 3,
+    })
+    const wrapper = shallow(<InputInstructions {...props} />)
+
+    expect(wrapper.text()).toBe("Press Enter to apply•3/3")
+  })
+})
diff --git a/frontend/src/components/shared/InputInstructions/InputInstructions.tsx b/frontend/src/components/shared/InputInstructions/InputInstructions.tsx
new file mode 100644
index 000000000000..c247ea2db092
--- /dev/null
+++ b/frontend/src/components/shared/InputInstructions/InputInstructions.tsx
@@ -0,0 +1,60 @@
+import React, { ReactElement } from "react"
+import { isFromMac } from "lib/utils"
+import classNames from "classnames"
+
+import "./style.scss"
+
+export interface Props {
+  dirty: boolean
+  value: string
+  maxLength?: number
+  className?: string
+  type?: "multiline" | "single"
+}
+
+const InputInstructions = ({
+  dirty,
+  value,
+  maxLength,
+  className,
+  type = "single",
+}: Props): ReactElement => {
+  const containerClassName = classNames("instructions", className)
+  let message
+
+  if (type === "multiline") {
+    if (isFromMac()) {
+      message = "Press ⌘+Enter to apply"
+    }
+
+    if (!isFromMac()) {
+      message = "Press Ctrl+Enter to apply"
+    }
+  } else {
+    message = "Press Enter to apply"
+  }
+
+  if (dirty && maxLength && value.length > 0) {
+    return (
+      <div className={containerClassName}>
+        <span className="message">{message}</span>
+        <span className="separator">•</span>
+        <span
+          className={classNames("counter", {
+            blink: value.length >= maxLength,
+          })}
+        >
+          {value.length}/{maxLength}
+        </span>
+      </div>
+    )
+  }
+
+  if (dirty) {
+    return <div className={containerClassName}>{message}</div>
+  }
+
+  return <></>
+}
+
+export default InputInstructions
diff --git a/frontend/src/components/shared/InputInstructions/style.scss b/frontend/src/components/shared/InputInstructions/style.scss
new file mode 100644
index 000000000000..3e890dc6c2af
--- /dev/null
+++ b/frontend/src/components/shared/InputInstructions/style.scss
@@ -0,0 +1,22 @@
+@import "src/assets/css/variables";
+
+.instructions {
+  .separator {
+    margin: 0 5px 0 5px;
+  }
+
+  .counter {
+    &.blink {
+      color: $red;
+      animation-name: blinker;
+      animation-duration: 0.5s;
+      animation-iteration-count: 5;
+    }
+  }
+}
+
+@keyframes blinker {
+  50% {
+    opacity: 0;
+  }
+}
diff --git a/frontend/src/components/widgets/NumberInput/NumberInput.scss b/frontend/src/components/widgets/NumberInput/NumberInput.scss
index 0bcbb356c9a4..07c439322950 100644
--- a/frontend/src/components/widgets/NumberInput/NumberInput.scss
+++ b/frontend/src/components/widgets/NumberInput/NumberInput.scss
@@ -82,7 +82,7 @@
     }
   }
 
-  .instructions {
+  .input-instructions {
     margin-right: 5px;
     right: $controls-width * 2;
   }
diff --git a/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx b/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
index f5df1320c32c..ebb7d9a1ab87 100644
--- a/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
+++ b/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
@@ -283,15 +283,4 @@ describe("NumberInput widget", () => {
       expect(preventDefault).toHaveBeenCalled()
     })
   })
-
-  it("should show a message when it's dirty", () => {
-    const props = getIntProps()
-    const wrapper = shallow(<NumberInput {...props} />)
-
-    wrapper.setState({
-      dirty: true,
-    })
-
-    expect(wrapper.find("div.instructions").length).toBe(1)
-  })
 })
diff --git a/frontend/src/components/widgets/NumberInput/NumberInput.tsx b/frontend/src/components/widgets/NumberInput/NumberInput.tsx
index ff5c4990791f..3f005d770c1d 100644
--- a/frontend/src/components/widgets/NumberInput/NumberInput.tsx
+++ b/frontend/src/components/widgets/NumberInput/NumberInput.tsx
@@ -15,15 +15,16 @@
  * limitations under the License.
  */
 
-import { NumberInput as NumberInputProto } from "autogen/proto"
 import React from "react"
 import { sprintf } from "sprintf-js"
-import { Input as UIInput } from "baseui/input"
+import { logWarning } from "lib/log"
 import { Map as ImmutableMap } from "immutable"
+import { NumberInput as NumberInputProto } from "autogen/proto"
 import { WidgetStateManager, Source } from "lib/WidgetStateManager"
-import { logWarning } from "lib/log"
 
 import Icon from "components/shared/Icon"
+import { Input as UIInput } from "baseui/input"
+import InputInstructions from "components/shared/InputInstructions/InputInstructions"
 
 import "./NumberInput.scss"
 
@@ -281,7 +282,11 @@ class NumberInput extends React.PureComponent<Props, State> {
             </button>
           </div>
         </div>
-        {dirty && <div className="instructions">Press Enter to apply</div>}
+        <InputInstructions
+          dirty={dirty}
+          value={formattedValue}
+          className="input-instructions"
+        />
       </div>
     )
   }
diff --git a/frontend/src/components/widgets/TextArea/TextArea.test.tsx b/frontend/src/components/widgets/TextArea/TextArea.test.tsx
index 4474753670ba..5a60189a19bb 100644
--- a/frontend/src/components/widgets/TextArea/TextArea.test.tsx
+++ b/frontend/src/components/widgets/TextArea/TextArea.test.tsx
@@ -84,19 +84,6 @@ describe("TextArea widget", () => {
     expect(wrapper.find(UITextArea).prop("disabled")).toBe(props.disabled)
   })
 
-  it("should show Ctrl+Enter instructions", () => {
-    // @ts-ignore
-    wrapper.find(UITextArea).prop("onChange")({
-      target: {
-        value: "testing",
-      },
-    } as React.ChangeEvent<HTMLTextAreaElement>)
-
-    expect(wrapper.find("div.instructions").text()).toBe(
-      "Press Ctrl+Enter to apply"
-    )
-  })
-
   it("should set widget value on blur", () => {
     const props = getProps()
     const wrapper = shallow(<TextArea {...props} />)
@@ -157,6 +144,32 @@ describe("TextArea widget", () => {
     expect(resize).toBe("vertical")
   })
 
+  it("should limit the length if max_chars is passed", () => {
+    const props = getProps({
+      height: 500,
+      maxChars: 10,
+    })
+    const wrapper = shallow(<TextArea {...props} />)
+
+    // @ts-ignore
+    wrapper.find(UITextArea).prop("onChange")({
+      target: {
+        value: "0123456789",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UITextArea).prop("value")).toBe("0123456789")
+
+    // @ts-ignore
+    wrapper.find(UITextArea).prop("onChange")({
+      target: {
+        value: "0123456789a",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UITextArea).prop("value")).toBe("0123456789")
+  })
+
   describe("On mac it should", () => {
     Object.defineProperty(navigator, "platform", {
       value: "MacIntel",
@@ -166,19 +179,6 @@ describe("TextArea widget", () => {
     const props = getProps()
     const wrapper = shallow(<TextArea {...props} />)
 
-    it("show ⌘+Enter instructions", () => {
-      // @ts-ignore
-      wrapper.find(UITextArea).prop("onChange")({
-        target: {
-          value: "testing",
-        },
-      } as React.ChangeEvent<HTMLTextAreaElement>)
-
-      expect(wrapper.find("div.instructions").text()).toBe(
-        "Press ⌘+Enter to apply"
-      )
-    })
-
     it("should set widget value when ⌘+enter is pressed", () => {
       // @ts-ignore
       wrapper.find(UITextArea).prop("onChange")({
diff --git a/frontend/src/components/widgets/TextArea/TextArea.tsx b/frontend/src/components/widgets/TextArea/TextArea.tsx
index 3649fd66bfa5..c034aae4019a 100644
--- a/frontend/src/components/widgets/TextArea/TextArea.tsx
+++ b/frontend/src/components/widgets/TextArea/TextArea.tsx
@@ -20,6 +20,7 @@ import { Map as ImmutableMap } from "immutable"
 import { WidgetStateManager, Source } from "lib/WidgetStateManager"
 
 import { Textarea as UITextArea } from "baseui/textarea"
+import InputInstructions from "components/shared/InputInstructions/InputInstructions"
 
 export interface Props {
   disabled: boolean
@@ -51,8 +52,6 @@ class TextArea extends React.PureComponent<Props, State> {
     this.setWidgetValue({ fromUi: false })
   }
 
-  private isFromMac = /Mac/i.test(navigator.platform)
-
   private setWidgetValue = (source: Source): void => {
     const widgetId: string = this.props.element.get("id")
     this.props.widgetMgr.setStringValue(widgetId, this.state.value, source)
@@ -66,10 +65,17 @@ class TextArea extends React.PureComponent<Props, State> {
   }
 
   private onChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
-    this.setState({
-      dirty: true,
-      value: e.target.value,
-    })
+    const { value } = e.target
+    const { element } = this.props
+
+    const maxChars = element.get("maxChars")
+
+    if (!maxChars || value.length <= maxChars) {
+      this.setState({
+        dirty: true,
+        value,
+      })
+    }
   }
 
   isEnterKeyPressed = (
@@ -100,6 +106,7 @@ class TextArea extends React.PureComponent<Props, State> {
     const style = { width }
     const label = element.get("label")
     const height = element.get("height")
+    const maxChars = element.get("maxChars")
 
     return (
       <div className="Widget stTextArea" style={style}>
@@ -120,13 +127,12 @@ class TextArea extends React.PureComponent<Props, State> {
             },
           }}
         />
-        {dirty && !this.isFromMac && (
-          <div className="instructions">Press Ctrl+Enter to apply</div>
-        )}
-
-        {dirty && this.isFromMac && (
-          <div className="instructions">Press ⌘+Enter to apply</div>
-        )}
+        <InputInstructions
+          dirty={dirty}
+          value={value}
+          maxLength={maxChars}
+          type={"multiline"}
+        />
       </div>
     )
   }
diff --git a/frontend/src/components/widgets/TextInput/TextInput.test.tsx b/frontend/src/components/widgets/TextInput/TextInput.test.tsx
index 9f75e46d2b8f..8d716af559e5 100644
--- a/frontend/src/components/widgets/TextInput/TextInput.test.tsx
+++ b/frontend/src/components/widgets/TextInput/TextInput.test.tsx
@@ -89,19 +89,6 @@ describe("TextInput widget", () => {
     expect(wrapper.find(UIInput).prop("disabled")).toBe(props.disabled)
   })
 
-  it("should show Enter instructions", () => {
-    // @ts-ignore
-    wrapper.find(UIInput).prop("onChange")({
-      target: {
-        value: "testing",
-      },
-    } as React.ChangeEvent<HTMLTextAreaElement>)
-
-    expect(wrapper.find("div.instructions").text()).toBe(
-      "Press Enter to apply"
-    )
-  })
-
   it("should set widget value on blur", () => {
     const props = getProps()
     const wrapper = shallow(<TextInput {...props} />)
@@ -161,4 +148,29 @@ describe("TextInput widget", () => {
 
     expect(props.widgetMgr.setStringValue).toHaveBeenCalledTimes(1)
   })
+
+  it("should limit the length if max_chars is passed", () => {
+    const props = getProps({
+      maxChars: 10,
+    })
+    const wrapper = shallow(<TextInput {...props} />)
+
+    // @ts-ignore
+    wrapper.find(UIInput).prop("onChange")({
+      target: {
+        value: "0123456789",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UIInput).prop("value")).toBe("0123456789")
+
+    // @ts-ignore
+    wrapper.find(UIInput).prop("onChange")({
+      target: {
+        value: "0123456789a",
+      },
+    } as EventTarget)
+
+    expect(wrapper.find(UIInput).prop("value")).toBe("0123456789")
+  })
 })
diff --git a/frontend/src/components/widgets/TextInput/TextInput.tsx b/frontend/src/components/widgets/TextInput/TextInput.tsx
index 5d7fef01db24..f81eef35afe6 100644
--- a/frontend/src/components/widgets/TextInput/TextInput.tsx
+++ b/frontend/src/components/widgets/TextInput/TextInput.tsx
@@ -18,8 +18,9 @@
 import React from "react"
 import { Input as UIInput } from "baseui/input"
 import { Map as ImmutableMap } from "immutable"
-import { WidgetStateManager, Source } from "lib/WidgetStateManager"
 import { TextInput as TextInputProto } from "autogen/proto"
+import { WidgetStateManager, Source } from "lib/WidgetStateManager"
+import InputInstructions from "components/shared/InputInstructions/InputInstructions"
 
 export interface Props {
   disabled: boolean
@@ -64,10 +65,17 @@ class TextInput extends React.PureComponent<Props, State> {
   }
 
   private onChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
-    this.setState({
-      dirty: true,
-      value: e.target.value,
-    })
+    const { value } = e.target
+    const { element } = this.props
+
+    const maxChars = element.get("maxChars")
+
+    if (!maxChars || value.length <= maxChars) {
+      this.setState({
+        dirty: true,
+        value,
+      })
+    }
   }
 
   private onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
@@ -83,23 +91,25 @@ class TextInput extends React.PureComponent<Props, State> {
   }
 
   public render = (): React.ReactNode => {
-    const label: string = this.props.element.get("label")
-    const style = { width: this.props.width }
+    const { dirty, value } = this.state
+    const { element, width, disabled } = this.props
+
+    const label: string = element.get("label")
+    const maxChars = element.get("maxChars")
+    const style = { width }
 
     return (
       <div className="Widget row-widget stTextInput" style={style}>
         <label>{label}</label>
         <UIInput
-          value={this.state.value}
+          value={value}
           onBlur={this.onBlur}
           onChange={this.onChange}
           onKeyPress={this.onKeyPress}
-          disabled={this.props.disabled}
+          disabled={disabled}
           type={this.getTypeString()}
         />
-        {this.state.dirty ? (
-          <div className="instructions">Press Enter to apply</div>
-        ) : null}
+        <InputInstructions dirty={dirty} value={value} maxLength={maxChars} />
       </div>
     )
   }
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index 471b60f93fc9..102d4308f799 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -118,3 +118,10 @@ export function flattenElements(
 export function timeout(ms: number): Promise<void> {
   return new Promise(resolve => setTimeout(resolve, ms))
 }
+
+/**
+ * Tests if the app is running from a Mac
+ */
+export function isFromMac(): boolean {
+  return /Mac/i.test(navigator.platform)
+}
diff --git a/lib/streamlit/DeltaGenerator.py b/lib/streamlit/DeltaGenerator.py
index 59986ec5dc57..7ca6f482562f 100644
--- a/lib/streamlit/DeltaGenerator.py
+++ b/lib/streamlit/DeltaGenerator.py
@@ -1723,7 +1723,7 @@ def _check_and_convert_to_indices(options, default_values):
             if not isinstance(default_values, list):
                 # This if is done before others because calling if not x (done
                 # right below) when x is of type pd.Series() or np.array() throws a
-                # ValueError exception.                
+                # ValueError exception.
                 if is_type(default_values, "numpy.ndarray") or is_type(
                     default_values, "pandas.core.series.Series"
                 ):
@@ -2214,7 +2214,7 @@ def beta_color_picker(self, element, label, value=None, key=None):
         return str(current_value)
 
     @_with_element
-    def text_input(self, element, label, value="", key=None, type="default"):
+    def text_input(self, element, label, value="", max_chars=None, key=None, type="default"):
         """Display a single-line text input widget.
 
         Parameters
@@ -2224,6 +2224,8 @@ def text_input(self, element, label, value="", key=None, type="default"):
         value : any
             The text value of this widget when it first renders. This will be
             cast to str internally.
+        max_chars : int or None
+            Max number of characters allowed in text input.
         key : str
             An optional string to use as the unique key for the widget.
             If this is omitted, a key will be generated for the widget
@@ -2247,6 +2249,10 @@ def text_input(self, element, label, value="", key=None, type="default"):
         """
         element.text_input.label = label
         element.text_input.default = str(value)
+
+        if max_chars is not None:
+            element.text_input.max_chars = max_chars
+
         if type == "default":
             element.text_input.type = TextInput.DEFAULT
         elif type == "password":
@@ -2262,7 +2268,7 @@ def text_input(self, element, label, value="", key=None, type="default"):
         return str(current_value)
 
     @_with_element
-    def text_area(self, element, label, value="", height=None, key=None):
+    def text_area(self, element, label, value="", height=None, max_chars=None, key=None):
         """Display a multi-line text input widget.
 
         Parameters
@@ -2275,6 +2281,8 @@ def text_area(self, element, label, value="", height=None, key=None):
         height : int or None
             Desired height of the UI element expressed in pixels. If None, a
             default height is used.
+        max_chars : int or None
+            Maximum number of characters allowed in text area.
         key : str
             An optional string to use as the unique key for the widget.
             If this is omitted, a key will be generated for the widget
@@ -2301,9 +2309,12 @@ def text_area(self, element, label, value="", height=None, key=None):
         element.text_area.label = label
         element.text_area.default = str(value)
 
-        if  height is not None:
+        if height is not None:
             element.text_area.height = height
 
+        if max_chars is not None:
+            element.text_area.max_chars = max_chars
+
         ui_value = _get_widget_ui_value("text_area", element, user_key=key)
         current_value = ui_value if ui_value is not None else value
         return str(current_value)
diff --git a/proto/streamlit/proto/TextArea.proto b/proto/streamlit/proto/TextArea.proto
index c3895a4406c0..488d0109cced 100644
--- a/proto/streamlit/proto/TextArea.proto
+++ b/proto/streamlit/proto/TextArea.proto
@@ -21,4 +21,5 @@ message TextArea {
   string label = 2;
   string default = 3;
   uint32 height = 4;
+  uint32 max_chars = 5;
 }
diff --git a/proto/streamlit/proto/TextInput.proto b/proto/streamlit/proto/TextInput.proto
index e1446b8b7dcc..4ad67dc0cf10 100644
--- a/proto/streamlit/proto/TextInput.proto
+++ b/proto/streamlit/proto/TextInput.proto
@@ -29,4 +29,5 @@ message TextInput {
   string label = 2;
   string default = 3;
   Type type = 4;
+  uint32 max_chars = 5;
 }


Code review comments:
diff_hunk: @@ -0,0 +1,60 @@
+import React, { ReactElement } from "react"
+import { isFromMac } from "lib/utils"
+import classNames from "classnames"
+
+import "./style.scss"
+
+export interface Props {
+  dirty: boolean
+  value: string
+  maxLength?: number
+  className?: string
+  type?: "multiline" | "single"
+}
+
+const InputInstructions = ({
+  dirty,
+  value,
+  maxLength,
+  className,
+  type = "single",
+}: Props): ReactElement => {
+  const containerClassName = classNames("instructions", className)
+  let message
+
+  if (type === "multiline") {
+    if (isFromMac()) {
+      message = "Press ⌘+Enter to apply"
+    }
+
+    if (!isFromMac()) {
+      message = "Press Ctrl+Enter to apply"
+    }
+  } else {
+    message = "Press Enter to apply"
+  }
+
+  if (dirty && maxLength && value.length > 0) {
path: frontend/src/components/shared/InputInstructions/InputInstructions.tsx
text: @user1:
I think the logic is a little off. We should show always the fraction "x/y" when there's a `maxLength`.

So the code should be more like:

```
messages = []

if (dirty) {
  messages.push(
    <span className="message">{message}</span>
  )
}

if (maxLength != null) {
  messages.push(
    <span
      className={classNames("counter", {
        blink: value.length >= maxLength,
      })}
    >
      {value.length}/{maxLength}
    </span>
  )
}

return (
  <div ...>
    { messages }
  </div>
)
```

diff_hunk: @@ -0,0 +1,60 @@
+import React, { ReactElement } from "react"
+import { isFromMac } from "lib/utils"
+import classNames from "classnames"
+
+import "./style.scss"
+
+export interface Props {
+  dirty: boolean
+  value: string
+  maxLength?: number
+  className?: string
+  type?: "multiline" | "single"
+}
+
+const InputInstructions = ({
+  dirty,
+  value,
+  maxLength,
+  className,
+  type = "single",
+}: Props): ReactElement => {
+  const containerClassName = classNames("instructions", className)
+  let message
+
+  if (type === "multiline") {
+    if (isFromMac()) {
+      message = "Press ⌘+Enter to apply"
+    }
+
+    if (!isFromMac()) {
+      message = "Press Ctrl+Enter to apply"
+    }
+  } else {
+    message = "Press Enter to apply"
+  }
+
+  if (dirty && maxLength && value.length > 0) {
+    return (
+      <div className={containerClassName}>
+        <span className="message">{message}</span>
+        <span className="separator">•</span>
path: frontend/src/components/shared/InputInstructions/InputInstructions.tsx
text: @user1:
This can be done more cleanly with CSS.

```
.message:not(:first)::before {
  content: "•";
}
```

@author:
If I use this approach, the blink animation will affect the separator as well, is that ok for you?

@author:
Solved using color instead of opacity :)

diff_hunk: @@ -0,0 +1,60 @@
+import React, { ReactElement } from "react"
+import { isFromMac } from "lib/utils"
+import classNames from "classnames"
+
+import "./style.scss"
+
+export interface Props {
+  dirty: boolean
+  value: string
+  maxLength?: number
+  className?: string
+  type?: "multiline" | "single"
+}
+
+const InputInstructions = ({
+  dirty,
+  value,
+  maxLength,
+  className,
+  type = "single",
+}: Props): ReactElement => {
+  const containerClassName = classNames("instructions", className)
+  let message
+
+  if (type === "multiline") {
+    if (isFromMac()) {
+      message = "Press ⌘+Enter to apply"
+    }
+
+    if (!isFromMac()) {
path: frontend/src/components/shared/InputInstructions/InputInstructions.tsx
text: @user1:
Nit: should be `else` instead