mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #3193 from Textualize/input-blur-validation
Customisable input validation (& validation on blur events)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user