Repository: Textualize/textual

Issue:
The `disabled` parameter for a `TabPane` should disable a tab
Consider this code:

```python
from textual.app import App, ComposeResult
from textual.widgets import TabbedContent, TabPane, Label

class DisableTabApp(App[None]):

    def compose(self) -> ComposeResult:
        with TabbedContent(id="top-level"):
            with TabPane("One", id="one", disabled=True):
                yield Label("This is tab one")
            with TabPane("Two", id="two"):
                yield Label("This is tab two")

if __name__ == "__main__":
    DisableTabApp().run()
```

Note the `disabled=True` on `TabPane`. Widgets in Textual will go into disabled mode when `disabled` is set to `True` when creating them. It would be reasonable for a developer to expect this to happen here too but currently it doesn't.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f30d0dc33..6626a0b4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 ## Unreleased
 
+### Added
+
+- Ability to enable/disable tabs via the reactive `disabled` in tab panes https://github.com/Textualize/textual/pull/3152
 ### Fixed
 
 - Could not hide/show/disable/enable tabs in nested `TabbedContent` https://github.com/Textualize/textual/pull/3150
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index 7dc62a537..3dafb5579 100644
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from asyncio import gather
+from dataclasses import dataclass
 from itertools import zip_longest
 from typing import Generator
 
@@ -26,14 +27,15 @@ __all__ = [
 class ContentTab(Tab):
     """A Tab with an associated content id."""
 
-    def __init__(self, label: Text, content_id: str):
+    def __init__(self, label: Text, content_id: str, disabled: bool = False):
         """Initialize a ContentTab.
 
         Args:
             label: The label to be displayed within the tab.
             content_id: The id of the content associated with the tab.
+            disabled: Is the tab disabled?
         """
-        super().__init__(label, id=content_id)
+        super().__init__(label, id=content_id, disabled=disabled)
 
 
 class TabPane(Widget):
@@ -49,6 +51,30 @@ class TabPane(Widget):
     }
     """
 
+    @dataclass
+    class TabPaneMessage(Message):
+        """Base class for `TabPane` messages."""
+
+        tab_pane: TabPane
+        """The `TabPane` that is he object of this message."""
+
+        @property
+        def control(self) -> TabPane:
+            """The tab pane that is the object of this message.
+
+            This is an alias for the attribute `tab_pane` and is used by the
+            [`on`][textual.on] decorator.
+            """
+            return self.tab_pane
+
+    @dataclass
+    class Disabled(TabPaneMessage):
+        """Sent when a tab pane is disabled via its reactive `disabled`."""
+
+    @dataclass
+    class Enabled(TabPaneMessage):
+        """Sent when a tab pane is enabled via its reactive `disabled`."""
+
     def __init__(
         self,
         title: TextType,
@@ -73,6 +99,10 @@ class TabPane(Widget):
             *children, name=name, id=id, classes=classes, disabled=disabled
         )
 
+    def _watch_disabled(self, disabled: bool) -> None:
+        """Notify the parent `TabbedContent` that a tab pane was enabled/disabled."""
+        self.post_message(self.Disabled(self) if disabled else self.Enabled(self))
+
 
 class AwaitTabbedContent:
     """An awaitable returned by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs."""
@@ -235,7 +265,8 @@ class TabbedContent(Widget):
         ]
         # Get a tab for each pane
         tabs = [
-            ContentTab(content._title, content.id or "") for content in pane_content
+            ContentTab(content._title, content.id or "", disabled=content.disabled)
+            for content in pane_content
         ]
         # Yield the tabs
         yield Tabs(*tabs, active=self._initial or None)
@@ -381,9 +412,22 @@ class TabbedContent(Widget):
         event.stop()
         tab_id = event.tab.id or ""
         try:
-            self.get_child_by_type(ContentSwitcher).get_child_by_id(
-                tab_id, expect_type=TabPane
-            ).disabled = True
+            with self.prevent(TabPane.Disabled):
+                self.get_child_by_type(ContentSwitcher).get_child_by_id(
+                    tab_id, expect_type=TabPane
+                ).disabled = True
+        except NoMatches:
+            return
+
+    def _on_tab_pane_disabled(self, event: TabPane.Disabled) -> None:
+        """Disable the corresponding tab."""
+        event.stop()
+        tab_pane_id = event.tab_pane.id or ""
+        try:
+            with self.prevent(Tab.Disabled):
+                self.get_child_by_type(Tabs).query_one(
+                    f"Tab#{tab_pane_id}"
+                ).disabled = True
         except NoMatches:
             return
 
@@ -392,9 +436,22 @@ class TabbedContent(Widget):
         event.stop()
         tab_id = event.tab.id or ""
         try:
-            self.get_child_by_type(ContentSwitcher).get_child_by_id(
-                tab_id, expect_type=TabPane
-            ).disabled = False
+            with self.prevent(TabPane.Enabled):
+                self.get_child_by_type(ContentSwitcher).get_child_by_id(
+                    tab_id, expect_type=TabPane
+                ).disabled = False
+        except NoMatches:
+            return
+
+    def _on_tab_pane_enabled(self, event: TabPane.Enabled) -> None:
+        """Enable the corresponding tab."""
+        event.stop()
+        tab_pane_id = event.tab_pane.id or ""
+        try:
+            with self.prevent(Tab.Enabled):
+                self.get_child_by_type(Tabs).query_one(
+                    f"Tab#{tab_pane_id}"
+                ).disabled = False
         except NoMatches:
             return
 
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
index d98759ce7..ca0343364 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -148,6 +148,7 @@ class Tab(Static):
         *,
         id: str | None = None,
         classes: str | None = None,
+        disabled: bool = False,
     ) -> None:
         """Initialise a Tab.
 
@@ -155,9 +156,10 @@ class Tab(Static):
             label: The label to use in the tab.
             id: Optional ID for the widget.
             classes: Space separated list of class names.
+            disabled: Whether the tab is disabled or not.
         """
         self.label = Text.from_markup(label) if isinstance(label, str) else label
-        super().__init__(id=id, classes=classes)
+        super().__init__(id=id, classes=classes, disabled=disabled)
         self.update(label)
 
     @property


Test patch:
diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py
index a4ef4fa76..f0b029a7f 100644
--- a/tests/test_tabbed_content.py
+++ b/tests/test_tabbed_content.py
@@ -475,6 +475,38 @@ async def test_disabling_via_tabbed_content():
         assert app.query_one(Tabs).active == "tab-1"
 
 
+async def test_disabling_via_tab_pane():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+                yield Label("tab-2")
+
+        def on_mount(self) -> None:
+            self.query_one("TabPane#tab-2").disabled = True
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        await pilot.pause()
+        await pilot.click("Tab#tab-2")
+        assert app.query_one(Tabs).active == "tab-1"
+
+
+async def test_creating_disabled_tab():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                with TabPane("first"):
+                    yield Label("hello")
+                with TabPane("second", disabled=True):
+                    yield Label("world")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        await pilot.click("Tab#tab-2")
+        assert app.query_one(Tabs).active == "tab-1"
+
+
 async def test_navigation_around_disabled_tabs():
     class TabbedApp(App[None]):
         def compose(self) -> ComposeResult:
@@ -546,6 +578,29 @@ async def test_reenabling_via_tabbed_content():
         assert app.query_one(Tabs).active == "tab-2"
 
 
+async def test_reenabling_via_tab_pane():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+                yield Label("tab-2")
+
+        def on_mount(self) -> None:
+            self.query_one("TabPane#tab-2").disabled = True
+
+        def reenable(self) -> None:
+            self.query_one("TabPane#tab-2").disabled = False
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        await pilot.pause()
+        await pilot.click("Tab#tab-2")
+        assert app.query_one(Tabs).active == "tab-1"
+        app.reenable()
+        await pilot.click("Tab#tab-2")
+        assert app.query_one(Tabs).active == "tab-2"
+
+
 async def test_disabling_unknown_tab():
     class TabbedApp(App[None]):
         def compose(self) -> ComposeResult: