Repository: Textualize/textual

Issue:
Validation on blur
Currently validation is done on input change and submit. I think we also need to do it on blur.

Consider a password check. I find it super irritating when validation shows an error before I'm done typing.

It should probably also be configrable. So the dev can decide when the validation happens.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8666691fd..35e4c63ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 ### Added
 
+- `Input` is now validated when focus moves out of it https://github.com/Textualize/textual/pull/3193
+- Attribute `Input.validate_on` (and `__init__` parameter of the same name) to customise when validation occurs https://github.com/Textualize/textual/pull/3193
 - Screen-specific (sub-)title attributes https://github.com/Textualize/textual/pull/3199:
   - `Screen.TITLE`
   - `Screen.SUB_TITLE`
diff --git a/docs/widgets/input.md b/docs/widgets/input.md
index e1f6191e5..455861a39 100644
--- a/docs/widgets/input.md
+++ b/docs/widgets/input.md
@@ -26,7 +26,14 @@ The example below shows how you might create a simple form using two `Input` wid
 
 You can supply one or more *[validators][textual.validation.Validator]* to the `Input` widget to validate the value.
 
-When the value changes or the `Input` is submitted, all the supplied validators will run.
+All the supplied validators will run when the value changes, the `Input` is submitted, or focus moves _out_ of the `Input`.
+The values `"changed"`, `"submitted"`, and `"blur"`, can be passed as an iterable to the `Input` parameter `validate_on` to request that validation occur only on the respective mesages.
+(See [`InputValidationOn`][textual.widgets._input.InputValidationOn] and [`Input.validate_on`][textual.widgets.Input.validate_on].)
+For example, the code below creates an `Input` widget that only gets validated when the value is submitted explicitly:
+
+```python
+input = Input(validate_on=["submitted"])
+```
 
 Validation is considered to have failed if *any* of the validators fail.
 
diff --git a/src/textual/types.py b/src/textual/types.py
index 57466874a..0bb237f94 100644
--- a/src/textual/types.py
+++ b/src/textual/types.py
@@ -9,6 +9,7 @@ from ._types import CallbackType, MessageTarget, WatchCallbackType
 from .actions import ActionParseResult
 from .css.styles import RenderStyles
 from .widgets._data_table import CursorType
+from .widgets._input import InputValidationOn
 
 __all__ = [
     "ActionParseResult",
@@ -18,6 +19,7 @@ __all__ = [
     "CSSPathType",
     "CursorType",
     "EasingFunction",
+    "InputValidationOn",
     "MessageTarget",
     "NoActiveAppError",
     "RenderStyles",
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index 24596d557..b92161e50 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -9,6 +9,7 @@ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
 from rich.highlighter import Highlighter
 from rich.segment import Segment
 from rich.text import Text
+from typing_extensions import Literal
 
 from .. import events
 from .._segment_tools import line_crop
@@ -21,6 +22,11 @@ from ..suggester import Suggester, SuggestionReady
 from ..validation import ValidationResult, Validator
 from ..widget import Widget
 
+InputValidationOn = Literal["blur", "changed", "submitted"]
+"""Possible messages that trigger input validation."""
+_POSSIBLE_VALIDATE_ON_VALUES = {"blur", "changed", "submitted"}
+"""Set literal with the legal values for the type `InputValidationOn`."""
+
 
 class _InputRenderable:
     """Render the input content."""
@@ -221,6 +227,7 @@ class Input(Widget, can_focus=True):
         *,
         suggester: Suggester | None = None,
         validators: Validator | Iterable[Validator] | None = None,
+        validate_on: Iterable[InputValidationOn] | None = None,
         name: str | None = None,
         id: str | None = None,
         classes: str | None = None,
@@ -236,6 +243,9 @@ class Input(Widget, can_focus=True):
             suggester: [`Suggester`][textual.suggester.Suggester] associated with this
                 input instance.
             validators: An iterable of validators that the Input value will be checked against.
+            validate_on: Zero or more of the values "blur", "changed", and "submitted",
+                which determine when to do input validation. The default is to do
+                validation for all messages.
             name: Optional name for the input widget.
             id: Optional ID for the widget.
             classes: Optional initial classes for the widget.
@@ -254,7 +264,25 @@ class Input(Widget, can_focus=True):
         elif validators is None:
             self.validators = []
         else:
-            self.validators = list(validators) or []
+            self.validators = list(validators)
+
+        self.validate_on = (
+            set(validate_on) & _POSSIBLE_VALIDATE_ON_VALUES
+            if validate_on is not None
+            else _POSSIBLE_VALIDATE_ON_VALUES
+        )
+        """Set with event names to do input validation on.
+
+        Validation can only be performed on blur, on input changes and on input submission.
+
+        Example:
+            This creates an `Input` widget that only gets validated when the value
+            is submitted explicitly:
+
+            ```py
+            input = Input(validate_on=["submitted"])
+            ```
+        """
 
     def _position_to_cell(self, position: int) -> int:
         """Convert an index within the value to cell position."""
@@ -306,8 +334,9 @@ class Input(Widget, can_focus=True):
         if self.styles.auto_dimensions:
             self.refresh(layout=True)
 
-        validation_result = self.validate(value)
-
+        validation_result = (
+            self.validate(value) if "changed" in self.validate_on else None
+        )
         self.post_message(self.Changed(self, value, validation_result))
 
     def validate(self, value: str) -> ValidationResult | None:
@@ -389,6 +418,8 @@ class Input(Widget, can_focus=True):
 
     def _on_blur(self, _: Blur) -> None:
         self.blink_timer.pause()
+        if "blur" in self.validate_on:
+            self.validate(self.value)
 
     def _on_focus(self, _: Focus) -> None:
         self.cursor_position = len(self.value)
@@ -577,7 +608,9 @@ class Input(Widget, can_focus=True):
     async def action_submit(self) -> None:
         """Handle a submit action.
 
-        Normally triggered by the user pressing Enter. This will also run any validators.
+        Normally triggered by the user pressing Enter. This may also run any validators.
         """
-        validation_result = self.validate(self.value)
+        validation_result = (
+            self.validate(self.value) if "submitted" in self.validate_on else None
+        )
         self.post_message(self.Submitted(self, self.value, validation_result))


Test patch:
diff --git a/tests/input/test_input_validation.py b/tests/input/test_input_validation.py
index b02b2e657..cfbdf3292 100644
--- a/tests/input/test_input_validation.py
+++ b/tests/input/test_input_validation.py
@@ -1,18 +1,23 @@
+import pytest
+
 from textual import on
 from textual.app import App, ComposeResult
+from textual.events import Blur
 from textual.validation import Number, ValidationResult
 from textual.widgets import Input
 
 
 class InputApp(App):
-    def __init__(self):
+    def __init__(self, validate_on=None):
         super().__init__()
         self.messages = []
         self.validator = Number(minimum=1, maximum=5)
+        self.validate_on = validate_on
 
     def compose(self) -> ComposeResult:
         yield Input(
             validators=self.validator,
+            validate_on=self.validate_on,
         )
 
     @on(Input.Changed)
@@ -77,3 +82,126 @@ async def test_input_submitted_message_validation_success():
         await pilot.pause()
         assert len(app.messages) == 2
         assert app.messages[1].validation_result == ValidationResult.success()
+
+
+async def test_on_blur_triggers_validation():
+    app = InputApp()
+    async with app.run_test() as pilot:
+        input = app.query_one(Input)
+        input.focus()
+        input.value = "3"
+        input.remove_class("-valid")
+        app.set_focus(None)
+        await pilot.pause()
+        assert input.has_class("-valid")
+
+
+@pytest.mark.parametrize(
+    "validate_on",
+    [
+        set(),
+        {"blur"},
+        {"submitted"},
+        {"blur", "submitted"},
+        {"fried", "garbage"},
+    ],
+)
+async def test_validation_on_changed_should_not_happen(validate_on):
+    app = InputApp(validate_on)
+    async with app.run_test() as pilot:
+        # sanity checks
+        assert len(app.messages) == 0
+        input = app.query_one(Input)
+        assert not input.has_class("-valid")
+        assert not input.has_class("-invalid")
+
+        input.value = "3"
+        await pilot.pause()
+        assert len(app.messages) == 1
+        assert app.messages[-1].validation_result is None
+        assert not input.has_class("-valid")
+        assert not input.has_class("-invalid")
+
+
+@pytest.mark.parametrize(
+    "validate_on",
+    [
+        set(),
+        {"blur"},
+        {"changed"},
+        {"blur", "changed"},
+        {"fried", "garbage"},
+    ],
+)
+async def test_validation_on_submitted_should_not_happen(validate_on):
+    app = InputApp(validate_on)
+    async with app.run_test() as pilot:
+        # sanity checks
+        assert len(app.messages) == 0
+        input = app.query_one(Input)
+        assert not input.has_class("-valid")
+        assert not input.has_class("-invalid")
+
+        await input.action_submit()
+        await pilot.pause()
+        assert len(app.messages) == 1
+        assert app.messages[-1].validation_result is None
+        assert not input.has_class("-valid")
+        assert not input.has_class("-invalid")
+
+
+@pytest.mark.parametrize(
+    "validate_on",
+    [
+        set(),
+        {"submitted"},
+        {"changed"},
+        {"submitted", "changed"},
+        {"fried", "garbage"},
+    ],
+)
+async def test_validation_on_blur_should_not_happen_unless_specified(validate_on):
+    app = InputApp(validate_on)
+    async with app.run_test() as pilot:
+        # sanity checks
+        input = app.query_one(Input)
+        assert not input.has_class("-valid")
+        assert not input.has_class("-invalid")
+
+        input.focus()
+        await pilot.pause()
+        app.set_focus(None)
+        await pilot.pause()
+        assert not input.has_class("-valid")
+        assert not input.has_class("-invalid")
+
+
+async def test_none_validate_on_means_all_validations_happen():
+    app = InputApp(None)
+    async with app.run_test() as pilot:
+        assert len(app.messages) == 0  # sanity checks
+        input = app.query_one(Input)
+        assert not input.has_class("-valid")
+        assert not input.has_class("-invalid")
+
+        input.value = "3"
+        await pilot.pause()
+        assert len(app.messages) == 1
+        assert app.messages[-1].validation_result is not None
+        assert input.has_class("-valid")
+
+        input.remove_class("-valid")
+
+        await input.action_submit()
+        await pilot.pause()
+        assert len(app.messages) == 2
+        assert app.messages[-1].validation_result is not None
+        assert input.has_class("-valid")
+
+        input.remove_class("-valid")
+
+        input.focus()
+        await pilot.pause()
+        app.set_focus(None)
+        await pilot.pause()
+        assert input.has_class("-valid")