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)



Discussion:
Yeah I realised this on Thursday. Since we don't have dynamic key bindings I think we'll have to just manage the escape press in "on_key" instead of as a binding.
You can raise `SkipAction` from an action, which allow bindings from parents to run. If that helps.

Resolving patch:
diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py
--- a/src/textual/widgets/_text_area.py
+++ b/src/textual/widgets/_text_area.py
@@ -153,7 +153,6 @@ class TextArea(ScrollView, can_focus=True):
     """
 
     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 @@ class TextArea(ScrollView, can_focus=True):
     """
     | 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 @@ async def _on_key(self, event: events.Key) -> None:
             "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
--- /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)