Repository: Textualize/textual

Issue:
`TextArea` still uses `Escape` to move focus on
While "out of the box" `TextArea` now uses <kbd>Tab</kbd> to shift focus, it also still uses <kbd>Escape</kbd>, ideally it would not when in this mode. Pressing <kbd>Escape</kbd> to exit a modal dialog would be common, so having <kbd>Escape</kbd> do something different given one particular widget type would be surprising for the user.

For example, this dialog uses <kbd>Escape</kbd> as the quick exit key, but instead if moves focus if you happen to press it while in the `TextArea`:

![Screenshot 2024-02-02 at 21 18 15](https://github.com/Textualize/textual/assets/28237/7b1e295e-5908-4afe-94e2-847b20e3f851)


Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32facd86a..916101fc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,12 +5,7 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
-## [0.48.2] - 2024-02-02
-
-### Fixed
-
-- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104
-- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101
+## Unreleased
 
 ### Added
 
@@ -20,6 +15,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075
 - Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075
 
+### Fixed
+
+- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110
+
+## [0.48.2] - 2024-02-02
+
+### Fixed
+
+- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104
+- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101
+
 ### Changed
 
 - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881
diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md
index bb0f50810..57fdc719f 100644
--- a/docs/widgets/text_area.md
+++ b/docs/widgets/text_area.md
@@ -283,11 +283,13 @@ This immediately updates the appearance of the `TextArea`:
 ```{.textual path="docs/examples/widgets/text_area_custom_theme.py" columns="42" lines="8"}
 ```
 
-### Tab behaviour
+### Tab and Escape behaviour
 
 Pressing the ++tab++ key will shift focus to the next widget in your application by default.
 This matches how other widgets work in Textual.
+
 To have ++tab++ insert a `\t` character, set the `tab_behaviour` attribute to the string value `"indent"`.
+While in this mode, you can shift focus by pressing the ++escape++ key.
 
 ### Indentation
 
diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py
index 6c8abd101..a263ab6d3 100644
--- a/src/textual/widgets/_text_area.py
+++ b/src/textual/widgets/_text_area.py
@@ -153,7 +153,6 @@ TextArea:light .text-area--cursor {
     """
 
     BINDINGS = [
-        Binding("escape", "screen.focus_next", "Shift Focus", show=False),
         # Cursor movement
         Binding("up", "cursor_up", "cursor up", show=False),
         Binding("down", "cursor_down", "cursor down", show=False),
@@ -213,7 +212,6 @@ TextArea:light .text-area--cursor {
     """
     | Key(s)                 | Description                                  |
     | :-                     | :-                                           |
-    | escape                 | Focus on the next item.                      |
     | up                     | Move the cursor up.                          |
     | down                   | Move the cursor down.                        |
     | left                   | Move the cursor left.                        |
@@ -1213,6 +1211,11 @@ TextArea:light .text-area--cursor {
             "enter": "\n",
         }
         if self.tab_behaviour == "indent":
+            if key == "escape":
+                event.stop()
+                event.prevent_default()
+                self.screen.focus_next()
+                return
             if self.indent_type == "tabs":
                 insert_values["tab"] = "\t"
             else:


Test patch:
diff --git a/tests/text_area/test_escape_binding.py b/tests/text_area/test_escape_binding.py
new file mode 100644
index 000000000..bc644d308
--- /dev/null
+++ b/tests/text_area/test_escape_binding.py
@@ -0,0 +1,55 @@
+from textual.app import App, ComposeResult
+from textual.screen import ModalScreen
+from textual.widgets import Button, TextArea
+
+
+class TextAreaDialog(ModalScreen):
+    BINDINGS = [("escape", "dismiss")]
+
+    def compose(self) -> ComposeResult:
+        yield TextArea(
+            tab_behaviour="focus",  # the default
+        )
+        yield Button("Submit")
+
+
+class TextAreaDialogApp(App):
+    def on_mount(self) -> None:
+        self.push_screen(TextAreaDialog())
+
+
+async def test_escape_key_when_tab_behaviour_is_focus():
+    """Regression test for https://github.com/Textualize/textual/issues/4110
+
+    When the `tab_behaviour` of TextArea is the default to shift focus,
+    pressing <Escape> should not shift focus but instead skip and allow any
+    parent bindings to run.
+    """
+
+    app = TextAreaDialogApp()
+    async with app.run_test() as pilot:
+        # Sanity check
+        assert isinstance(pilot.app.screen, TextAreaDialog)
+        assert isinstance(pilot.app.focused, TextArea)
+
+        # Pressing escape should dismiss the dialog screen, not focus the button
+        await pilot.press("escape")
+        assert not isinstance(pilot.app.screen, TextAreaDialog)
+
+
+async def test_escape_key_when_tab_behaviour_is_indent():
+    """When the `tab_behaviour` of TextArea is indent rather than switch focus,
+    pressing <Escape> should instead shift focus.
+    """
+
+    app = TextAreaDialogApp()
+    async with app.run_test() as pilot:
+        # Sanity check
+        assert isinstance(pilot.app.screen, TextAreaDialog)
+        assert isinstance(pilot.app.focused, TextArea)
+
+        pilot.app.query_one(TextArea).tab_behaviour = "indent"
+        # Pressing escape should focus the button, not dismiss the dialog screen
+        await pilot.press("escape")
+        assert isinstance(pilot.app.screen, TextAreaDialog)
+        assert isinstance(pilot.app.focused, Button)