Repository: Textualize/textual
Pull request: Tab hide disable
This fixes #3088.

Adds API to enable/disable/show/hide tabs.
 - A `Tab` widget can be disabled directly via the reactive `.disabled`, which then fires a `Tab.Disabled` message, which is turned into a `Tabs.Disabled` message.
 - Preferably, and in line with the show/hide API, one can use the methods on `Tabs` or `TabbedContent`.

Problem statement:
Add the ability to hide and disable tabs
This one comes up from time to time and we should probably add it. This will affect both `Tabs` and `TabbedContent`. The ability to either:

- Disable a tab (so it's still visible but the tab shows disabled and, if you can select it at all, the content (in the case of `TabbedContent`) of the pane is all disabled).
- Hide a tab (and so it can't be seen at all, but can be made visible again; in the case of `TabbedContent` the related pane would be invisible and the widgets within it unreachable).

Hints from issue discussion:

Commit message: Add more tests for tab enabling/disabling/showing/hiding.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4911c22c1c..cdd4b7f829 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 ## Unreleased
 
+### Added
+
+- Methods `TabbedContent.disable_tab` and `TabbedContent.enable_tab` https://github.com/Textualize/textual/pull/3112
+- Methods `Tabs.disable` and `Tabs.enable` https://github.com/Textualize/textual/pull/3112
+- Messages `Tab.Disabled`, `Tab.Enabled`, `Tabs.TabDisabled` and `Tabs.Enabled` https://github.com/Textualize/textual/pull/3112
+- Methods `TabbedContent.hide_tab` and `TabbedContent.show_tab` https://github.com/Textualize/textual/pull/3112
+- Methods `Tabs.hide` and `Tabs.show` https://github.com/Textualize/textual/pull/3112
+- Messages `Tabs.TabHidden` and `Tabs.TabShown` https://github.com/Textualize/textual/pull/3112
+
 ### Changed
 
 - grid-columns and grid-rows now accept an `auto` token to detect the optimal size https://github.com/Textualize/textual/pull/3107
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index 53a1b6afa8..e3d8d20c28 100644
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -375,3 +375,69 @@ def _watch_active(self, active: str) -> None:
     def tab_count(self) -> int:
         """Total number of tabs."""
         return self.get_child_by_type(Tabs).tab_count
+
+    def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
+        """Disable the corresponding tab pane."""
+        event.stop()
+        tab_id = event.tab.id
+        try:
+            self.query_one(f"TabPane#{tab_id}").disabled = True
+        except NoMatches:
+            return
+
+    def _on_tabs_tab_enabled(self, event: Tabs.TabEnabled) -> None:
+        """Enable the corresponding tab pane."""
+        event.stop()
+        tab_id = event.tab.id
+        try:
+            self.query_one(f"TabPane#{tab_id}").disabled = False
+        except NoMatches:
+            return
+
+    def disable_tab(self, tab_id: str) -> None:
+        """Disables the tab with the given ID.
+
+        Args:
+            tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to disable.
+
+        Raises:
+            Tabs.TabError: If there are any issues with the request.
+        """
+
+        self.query_one(Tabs).disable(tab_id)
+
+    def enable_tab(self, tab_id: str) -> None:
+        """Enables the tab with the given ID.
+
+        Args:
+            tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to enable.
+
+        Raises:
+            Tabs.TabError: If there are any issues with the request.
+        """
+
+        self.query_one(Tabs).enable(tab_id)
+
+    def hide_tab(self, tab_id: str) -> None:
+        """Hides the tab with the given ID.
+
+        Args:
+            tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to hide.
+
+        Raises:
+            Tabs.TabError: If there are any issues with the request.
+        """
+
+        self.query_one(Tabs).hide(tab_id)
+
+    def show_tab(self, tab_id: str) -> None:
+        """Shows the tab with the given ID.
+
+        Args:
+            tab_id: The ID of the [`TabPane`][textual.widgets.TabPane] to show.
+
+        Raises:
+            Tabs.TabError: If there are any issues with the request.
+        """
+
+        self.query_one(Tabs).show(tab_id)
diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py
index 46fecf72bc..5c65786786 100644
--- a/src/textual/widgets/_tabs.py
+++ b/src/textual/widgets/_tabs.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from dataclasses import dataclass
 from typing import ClassVar
 
 import rich.repr
@@ -104,17 +105,35 @@ class Tab(Static):
     Tab.-active:hover {
         color: $text;
     }
+    Tab:disabled {
+        color: $text-disabled;
+        text-opacity: 50%;
+    }
+    Tab.-hidden {
+        display: none;
+    }
     """
 
+    @dataclass
     class Clicked(Message):
         """A tab was clicked."""
 
         tab: Tab
         """The tab that was clicked."""
 
-        def __init__(self, tab: Tab) -> None:
-            self.tab = tab
-            super().__init__()
+    @dataclass
+    class Disabled(Message):
+        """A tab was disabled."""
+
+        tab: Tab
+        """The tab that was disabled."""
+
+    @dataclass
+    class Enabled(Message):
+        """A tab was enabled."""
+
+        tab: Tab
+        """The tab that was enabled."""
 
     def __init__(
         self,
@@ -143,6 +162,10 @@ def _on_click(self):
         """Inform the message that the tab was clicked."""
         self.post_message(self.Clicked(self))
 
+    def _watch_disabled(self, disabled: bool) -> None:
+        """Notify the parent `Tabs` that a tab was enabled/disabled."""
+        self.post_message(self.Disabled(self) if disabled else self.Enabled(self))
+
 
 class Tabs(Widget, can_focus=True):
     """A row of tabs."""
@@ -184,8 +207,8 @@ class Tabs(Widget, can_focus=True):
     class TabError(Exception):
         """Exception raised when there is an error relating to tabs."""
 
-    class TabActivated(Message):
-        """Sent when a new tab is activated."""
+    class TabMessage(Message):
+        """Parent class for all messages that have to do with a specific tab."""
 
         ALLOW_SELECTOR_MATCH = {"tab"}
         """Additional message attributes that can be used with the [`on` decorator][textual.on]."""
@@ -195,20 +218,20 @@ def __init__(self, tabs: Tabs, tab: Tab) -> None:
 
             Args:
                 tabs: The Tabs widget.
-                tab: The tab that was activated.
+                tab: The tab that is the object of this message.
             """
             self.tabs: Tabs = tabs
             """The tabs widget containing the tab."""
             self.tab: Tab = tab
-            """The tab that was activated."""
+            """The tab that is the object of this message."""
             super().__init__()
 
         @property
         def control(self) -> Tabs:
-            """The tabs widget containing the tab that was activated.
+            """The tabs widget containing the tab that is the object of this message.
 
-            This is an alias for [`TabActivated.tabs`][textual.widgets.Tabs.TabActivated.tabs]
-            which is used by the [`on`][textual.on] decorator.
+            This is an alias for the attribute `tabs` and is used by the
+            [`on`][textual.on] decorator.
             """
             return self.tabs
 
@@ -216,6 +239,21 @@ def __rich_repr__(self) -> rich.repr.Result:
             yield self.tabs
             yield self.tab
 
+    class TabActivated(TabMessage):
+        """Sent when a new tab is activated."""
+
+    class TabDisabled(TabMessage):
+        """Sent when a tab is disabled."""
+
+    class TabEnabled(TabMessage):
+        """Sent when a tab is enabled."""
+
+    class TabHidden(TabMessage):
+        """Sent when a tab is hidden."""
+
+    class TabShown(TabMessage):
+        """Sent when a tab is shown."""
+
     class Cleared(Message):
         """Sent when there are no active tabs."""
 
@@ -299,10 +337,24 @@ def tab_count(self) -> int:
         """Total number of tabs."""
         return len(self.query("#tabs-list > Tab"))
 
+    @property
+    def _potentially_active_tabs(self) -> list[Tab]:
+        """List of all tabs that could be active.
+
+        This list is comprised of all tabs that are shown and enabled,
+        plus the active tab in case it is disabled.
+        """
+        return [
+            tab
+            for tab in self.query("#tabs-list > Tab").results(Tab)
+            if ((not tab.disabled or tab is self.active_tab) and tab.display)
+        ]
+
     @property
     def _next_active(self) -> Tab | None:
         """Next tab to make active if the active tab is removed."""
-        tabs = list(self.query("#tabs-list > Tab").results(Tab))
+        active_tab = self.active_tab
+        tabs = self._potentially_active_tabs
         if self.active_tab is None:
             return None
         try:
@@ -412,7 +464,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitRemove:
         """Remove a tab.
 
         Args:
-            tab_or_id: The Tab's id.
+            tab_or_id: The Tab to remove or its id.
 
         Returns:
             An awaitable object that waits for the tab to be removed.
@@ -590,10 +642,117 @@ def _move_tab(self, direction: int) -> None:
         active_tab = self.active_tab
         if active_tab is None:
             return
-        tabs = list(self.query(Tab))
+        tabs = self._potentially_active_tabs
         if not tabs:
             return
         tab_count = len(tabs)
         new_tab_index = (tabs.index(active_tab) + direction) % tab_count
         self.active = tabs[new_tab_index].id or ""
         self._scroll_active_tab()
+
+    def _on_tab_disabled(self, event: Tab.Disabled) -> None:
+        """Re-post the disabled message."""
+        event.stop()
+        self.post_message(self.TabDisabled(self, event.tab))
+
+    def _on_tab_enabled(self, event: Tab.Enabled) -> None:
+        """Re-post the enabled message."""
+        event.stop()
+        self.post_message(self.TabEnabled(self, event.tab))
+
+    def disable(self, tab_id: str) -> Tab:
+        """Disable the indicated tab.
+
+        Args:
+            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable.
+
+        Returns:
+            The [`Tab`][textual.widgets.Tab] that was targeted.
+
+        Raises:
+            TabError: If there are any issues with the request.
+        """
+
+        try:
+            tab_to_disable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
+        except NoMatches:
+            raise self.TabError(
+                f"There is no tab with ID {tab_id!r} to disable."
+            ) from None
+
+        tab_to_disable.disabled = True
+        return tab_to_disable
+
+    def enable(self, tab_id: str) -> Tab:
+        """Enable the indicated tab.
+
+        Args:
+            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable.
+
+        Returns:
+            The [`Tab`][textual.widgets.Tab] that was targeted.
+
+        Raises:
+            TabError: If there are any issues with the request.
+        """
+
+        try:
+            tab_to_enable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
+        except NoMatches:
+            raise self.TabError(
+                f"There is no tab with ID {tab_id!r} to enable."
+            ) from None
+
+        tab_to_enable.disabled = False
+        return tab_to_enable
+
+    def hide(self, tab_id: str) -> Tab:
+        """Hide the indicated tab.
+
+        Args:
+            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide.
+
+        Returns:
+            The [`Tab`][textual.widgets.Tab] that was targeted.
+
+        Raises:
+            TabError: If there are any issues with the request.
+        """
+
+        try:
+            tab_to_hide = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
+        except NoMatches:
+            raise self.TabError(f"There is no tab with ID {tab_id!r} to hide.")
+
+        if tab_to_hide.has_class("-active"):
+            next_tab = self._next_active
+            self.active = next_tab.id or "" if next_tab else ""
+        tab_to_hide.add_class("-hidden")
+        self.post_message(self.TabHidden(self, tab_to_hide))
+        self.call_after_refresh(self._highlight_active)
+        return tab_to_hide
+
+    def show(self, tab_id: str) -> Tab:
+        """Show the indicated tab.
+
+        Args:
+            tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show.
+
+        Returns:
+            The [`Tab`][textual.widgets.Tab] that was targeted.
+
+        Raises:
+            TabError: If there are any issues with the request.
+        """
+
+        try:
+            tab_to_show = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
+        except NoMatches:
+            raise self.TabError(f"There is no tab with ID {tab_id!r} to show.")
+
+        tab_to_show.remove_class("-hidden")
+        self.post_message(self.TabShown(self, tab_to_show))
+        if not self.active:
+            self._activate_tab(tab_to_show)
+        self.call_after_refresh(self._highlight_active)
+        return tab_to_show
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index e2c939ae7a..f68d2917b0 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -27317,6 +27317,169 @@
   
   '''
 # ---
+# name: test_tabbed_content_with_modified_tabs
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-3132124347-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-3132124347-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-3132124347-r1 { fill: #c5c8c6 }
+  .terminal-3132124347-r2 { fill: #484848;font-weight: bold }
+  .terminal-3132124347-r3 { fill: #484848 }
+  .terminal-3132124347-r4 { fill: #737373 }
+  .terminal-3132124347-r5 { fill: #474747 }
+  .terminal-3132124347-r6 { fill: #0178d4 }
+  .terminal-3132124347-r7 { fill: #a32327 }
+  .terminal-3132124347-r8 { fill: #f09d9e;font-weight: bold }
+  .terminal-3132124347-r9 { fill: #810000 }
+  .terminal-3132124347-r10 { fill: #e1e1e1 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-3132124347-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-3132124347-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-3132124347-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-3132124347-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">FiddleWithTabsApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-3132124347-clip-terminal)">
+      <rect fill="#1e1e1e" x="0" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="97.6" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="195.2" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="292.8" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="390.4" y="1.5" width="585.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="25.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="85.4" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="97.6" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="122" y="25.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="183" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="195.2" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="219.6" y="25.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="280.6" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="292.8" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="317.2" y="25.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="378.2" y="25.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="390.4" y="25.9" width="585.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="12.2" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="24.4" y="50.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="85.4" y="50.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="97.6" y="50.3" width="878.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#931316" x="24.4" y="99.1" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="219.6" y="99.1" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="951.6" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#931316" x="24.4" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#931316" x="73.2" y="123.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#931316" x="170.8" y="123.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="219.6" y="123.5" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="951.6" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#931316" x="24.4" y="147.9" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="219.6" y="147.9" width="732" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="951.6" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-3132124347-matrix">
+      <text class="terminal-3132124347-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-3132124347-line-0)">
+  </text><text class="terminal-3132124347-r2" x="24.4" y="44.4" textLength="61" clip-path="url(#terminal-3132124347-line-1)">Tab&#160;1</text><text class="terminal-3132124347-r3" x="122" y="44.4" textLength="61" clip-path="url(#terminal-3132124347-line-1)">Tab&#160;2</text><text class="terminal-3132124347-r4" x="219.6" y="44.4" textLength="61" clip-path="url(#terminal-3132124347-line-1)">Tab&#160;4</text><text class="terminal-3132124347-r4" x="317.2" y="44.4" textLength="61" clip-path="url(#terminal-3132124347-line-1)">Tab&#160;5</text><text class="terminal-3132124347-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-3132124347-line-1)">
+  </text><text class="terminal-3132124347-r5" x="0" y="68.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-2)">━</text><text class="terminal-3132124347-r5" x="12.2" y="68.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-2)">╸</text><text class="terminal-3132124347-r6" x="24.4" y="68.8" textLength="61" clip-path="url(#terminal-3132124347-line-2)">━━━━━</text><text class="terminal-3132124347-r5" x="85.4" y="68.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-2)">╺</text><text class="terminal-3132124347-r5" x="97.6" y="68.8" textLength="878.4" clip-path="url(#terminal-3132124347-line-2)">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</text><text class="terminal-3132124347-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-2)">
+  </text><text class="terminal-3132124347-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-3132124347-line-3)">
+  </text><text class="terminal-3132124347-r7" x="24.4" y="117.6" textLength="195.2" clip-path="url(#terminal-3132124347-line-4)">▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔</text><text class="terminal-3132124347-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-3132124347-line-4)">
+  </text><text class="terminal-3132124347-r8" x="73.2" y="142" textLength="97.6" clip-path="url(#terminal-3132124347-line-5)">&#160;Button&#160;</text><text class="terminal-3132124347-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-3132124347-line-5)">
+  </text><text class="terminal-3132124347-r9" x="24.4" y="166.4" textLength="195.2" clip-path="url(#terminal-3132124347-line-6)">▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁</text><text class="terminal-3132124347-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-3132124347-line-6)">
+  </text><text class="terminal-3132124347-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-7)">
+  </text><text class="terminal-3132124347-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-3132124347-line-8)">
+  </text><text class="terminal-3132124347-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-3132124347-line-9)">
+  </text><text class="terminal-3132124347-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-3132124347-line-10)">
+  </text><text class="terminal-3132124347-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-3132124347-line-11)">
+  </text><text class="terminal-3132124347-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-12)">
+  </text><text class="terminal-3132124347-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-3132124347-line-13)">
+  </text><text class="terminal-3132124347-r1" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-3132124347-line-14)">
+  </text><text class="terminal-3132124347-r1" x="976" y="386" textLength="12.2" clip-path="url(#terminal-3132124347-line-15)">
+  </text><text class="terminal-3132124347-r1" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-3132124347-line-16)">
+  </text><text class="terminal-3132124347-r1" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-17)">
+  </text><text class="terminal-3132124347-r1" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-3132124347-line-18)">
+  </text><text class="terminal-3132124347-r1" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-3132124347-line-19)">
+  </text><text class="terminal-3132124347-r1" x="976" y="508" textLength="12.2" clip-path="url(#terminal-3132124347-line-20)">
+  </text><text class="terminal-3132124347-r1" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-3132124347-line-21)">
+  </text><text class="terminal-3132124347-r1" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-3132124347-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_table_markup
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/snapshot_apps/modified_tabs.py b/tests/snapshot_tests/snapshot_apps/modified_tabs.py
new file mode 100644
index 0000000000..48bb66d567
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/modified_tabs.py
@@ -0,0 +1,27 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Button, TabbedContent
+
+
+class FiddleWithTabsApp(App[None]):
+    CSS = """
+    TabPane:disabled {
+        background: red;
+    }
+    """
+
+    def compose(self) -> ComposeResult:
+        with TabbedContent():
+            yield Button()
+            yield Button()
+            yield Button()
+            yield Button()
+            yield Button()
+
+    def on_mount(self) -> None:
+        self.query_one(TabbedContent).disable_tab(f"tab-1")
+        self.query_one(TabbedContent).disable_tab(f"tab-2")
+        self.query_one(TabbedContent).hide_tab(f"tab-3")
+
+
+if __name__ == "__main__":
+    FiddleWithTabsApp().run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index d82c91d782..5ad871c740 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -222,6 +222,11 @@ def test_tabbed_content(snap_compare):
     assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py")
 
 
+def test_tabbed_content_with_modified_tabs(snap_compare):
+    # Tabs enabled and hidden.
+    assert snap_compare(SNAPSHOT_APPS_DIR / "modified_tabs.py")
+
+
 def test_option_list_strings(snap_compare):
     assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_strings.py")
 
diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py
index 882dd11523..5eec2abcfb 100644
--- a/tests/test_tabbed_content.py
+++ b/tests/test_tabbed_content.py
@@ -427,3 +427,272 @@ def on_tabbed_content_cleared(self) -> None:
         assert tabbed_content.tab_count == 0
         assert tabbed_content.active == ""
         assert pilot.app.cleared == 1
+
+
+async def test_disabling_does_not_deactivate_tab():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+
+        def on_mount(self) -> None:
+            self.query_one("Tab#tab-1").disabled = True
+
+    app = TabbedApp()
+    async with app.run_test():
+        assert app.query_one(Tabs).active == "tab-1"
+
+
+async def test_disabled_tab_cannot_be_clicked():
+    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("Tab#tab-2").disabled = True
+
+    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_disabling_via_tabbed_content():
+    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(TabbedContent).disable_tab("tab-2")
+
+    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:
+            with TabbedContent():
+                yield Label("tab-1")
+                yield Label("tab-2")
+                yield Label("tab-3")
+                yield Label("tab-4")
+
+        def on_mount(self) -> None:
+            self.query_one("Tab#tab-1").disabled = True
+            self.query_one("Tab#tab-3").disabled = True
+
+    app = TabbedApp()
+    async with app.run_test():
+        tabs = app.query_one(Tabs)
+        assert tabs.active == "tab-1"
+        tabs.action_next_tab()
+        assert tabs.active == "tab-2"
+        tabs.action_next_tab()
+        assert tabs.active == "tab-4"
+        tabs.action_next_tab()
+        assert tabs.active == "tab-2"
+        tabs.action_previous_tab()
+        assert tabs.active == "tab-4"
+
+
+async def test_reenabling_tab():
+    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("Tab#tab-2").disabled = True
+
+        def reenable(self) -> None:
+            app.query_one("Tab#tab-2").disabled = False
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        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_reenabling_via_tabbed_content():
+    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(TabbedContent).disable_tab("tab-2")
+
+        def reenable(self) -> None:
+            self.query_one(TabbedContent).enable_tab("tab-2")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        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:
+            with TabbedContent():
+                yield Label("tab-1")
+
+    app = TabbedApp()
+    async with app.run_test():
+        with pytest.raises(Tabs.TabError):
+            app.query_one(TabbedContent).disable_tab("foo")
+
+
+async def test_enabling_unknown_tab():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+
+    app = TabbedApp()
+    async with app.run_test():
+        with pytest.raises(Tabs.TabError):
+            app.query_one(TabbedContent).enable_tab("foo")
+
+
+async def test_hide_unknown_tab():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+
+    app = TabbedApp()
+    async with app.run_test():
+        with pytest.raises(Tabs.TabError):
+            app.query_one(TabbedContent).hide_tab("foo")
+
+
+async def test_show_unknown_tab():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+
+    app = TabbedApp()
+    async with app.run_test():
+        with pytest.raises(Tabs.TabError):
+            app.query_one(TabbedContent).show_tab("foo")
+
+
+async def test_hide_show_messages():
+    hide_msg = False
+    show_msg = False
+
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+
+        def on_tabs_tab_hidden(self) -> None:
+            nonlocal hide_msg
+            hide_msg = True
+
+        def on_tabs_tab_shown(self) -> None:
+            nonlocal show_msg
+            show_msg = True
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        app.query_one(TabbedContent).hide_tab("tab-1")
+        await pilot.pause()
+        assert hide_msg
+        app.query_one(TabbedContent).show_tab("tab-1")
+        await pilot.pause()
+        assert show_msg
+
+
+async def test_hide_last_tab_means_no_tab_active():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabbed_content = app.query_one(TabbedContent)
+        tabbed_content.hide_tab("tab-1")
+        await pilot.pause()
+        assert not tabbed_content.active
+
+
+async def test_hiding_tabs_moves_active_to_next_tab():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+                yield Label("tab-2")
+                yield Label("tab-3")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabbed_content = app.query_one(TabbedContent)
+        tabbed_content.hide_tab("tab-1")
+        await pilot.pause()
+        assert tabbed_content.active == "tab-2"
+        tabbed_content.hide_tab("tab-2")
+        await pilot.pause()
+        assert tabbed_content.active == "tab-3"
+
+
+async def test_showing_tabs_does_not_change_active_tab():
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+                yield Label("tab-2")
+                yield Label("tab-3")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabbed_content = app.query_one(TabbedContent)
+        tabbed_content.hide_tab("tab-1")
+        tabbed_content.hide_tab("tab-2")
+        await pilot.pause()
+        # sanity check
+        assert tabbed_content.active == "tab-3"
+
+        tabbed_content.show_tab("tab-1")
+        tabbed_content.show_tab("tab-2")
+        assert tabbed_content.active == "tab-3"
+
+
+@pytest.mark.parametrize("tab_id", ["tab-1", "tab-2"])
+async def test_showing_first_tab_activates_tab(tab_id: str):
+    class TabbedApp(App[None]):
+        def compose(self) -> ComposeResult:
+            with TabbedContent():
+                yield Label("tab-1")
+                yield Label("tab-2")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabbed_content = app.query_one(TabbedContent)
+        tabbed_content.hide_tab("tab-1")
+        tabbed_content.hide_tab("tab-2")
+        await pilot.pause()
+        # sanity check
+        assert not tabbed_content.active
+
+        tabbed_content.show_tab(tab_id)
+        await pilot.pause()
+        assert tabbed_content.active == tab_id


Code review comments:
diff_hunk: @@ -104,17 +105,35 @@ class Tab(Static):
     Tab.-active:hover {
         color: $text;
     }
+    Tab:disabled {
+        color: $text-disabled;
+        text-opacity: 50%;
+    }
+    Tab.-hidden {
+        display: none;
+    }
     """
 
+    @dataclass
     class Clicked(Message):
         """A tab was clicked."""
 
         tab: Tab
         """The tab that was clicked."""
 
-        def __init__(self, tab: Tab) -> None:
-            self.tab = tab
-            super().__init__()
+    @dataclass
+    class Disabled(Message):
path: src/textual/widgets/_tabs.py
text: @user2:
I think you may need to add a `control` property to allow these to be use with the `@user1` decorator. Can you check please.

@author:
That'd be right. But `Tab.Disabled` + `Tab.Enabled` are kind of internal, caught by `Tabs`, and re-posted as `Tabs.TabDisabled` and `Tabs.TabEnabled`, mimicking the way `Tab.Clicked` & `Tabs.TabActivated` work.

Do you think it's worth adding the controls to `Tab.Disabled` and `Tab.Enabled`?
(Do you want me to do the same for `Tab.Clicked`..?)

@user2:
Only the messages visible to the dev need it.