Repository: Textualize/textual

Issue:
TooManyMatches error with nested TabbedContent
I'm running 0.34 - was actually really looking forward to the ability to hide and show tabs programmatically, fortuitous timing for the thing I'm working on.

However, I've run into a number of issues that I think are all inter-related, potentially due to some nested elements on the DOM having the same id. Allow me to explain with the minor issues first before the showstopper that caused me to write this ticket.

First symptom is that I am unable to `query_one()` with the expected type of `TabPane` as it gets type of `ContentTab` and so throws a `WrongType` error. `ContentTab` is protected so can't/shouldn't directly import.
The second symptom is that removing the `expect_type=` param from `query_one` now begins to throw a `TooManyMatches` error. I initially got around this by using `query().first`. 
I concede that maybe `TabPanes` shouldn't be used directly? Maybe this needs clarification in the documentation if so. Reason for me attempting to use this was as a way to hide tabs prior to 0.34.

Anyway, switching to using `TabbedContent().hide_tab("tab-id")` worked in testing a simple scenario but fails when there is more than one tab AND there is a nested `TabbedContent`. There may be other conditions for this as well. As you can see from the example below, everything has a unique id.

### Example Code
```
from textual.app import App, ComposeResult
from textual.widgets import Label, TabbedContent, TabPane


class ExampleApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    #tabbed-content {
        height: 1fr;
    }
    #another-tabbed-content {
        height: 1fr;
    }
    """

    def compose(self) -> ComposeResult:
        with TabbedContent(id="tabbed-content"):
            with TabPane("Tab Pane 1", id="first-tab"):
                yield Label("foo")
            with TabPane("Tab Pane 2", id="second-tab"):
                yield Label("bar")
            with TabPane("Tab Pane 3", id="third-tab"):
                with TabbedContent(id="another-tabbed-content"):
                    with TabPane("Inner Pane 1", id="inner-tab-1"):
                        yield Label("fizz")
                    with TabPane("Inner Pane 2", id="inner-tab-2"):
                        yield Label("bang")

    def on_mount(self):
        # tab_pane = self.query_one("#first-tab", expect_type=TabPane)  # WrongType error: unexpected type of ContentTab
        # tab_pane = self.query_one("#first-tab")  # TooManyMatches error
        tabber = self.query_one("#tabbed-content", expect_type=TabbedContent)
        tabber.hide_tab("second-tab")  # TooManyMatches: Call to only_one resulted in more than one matched node


if __name__ == "__main__":
    app = ExampleApp()
    app.run()

```
### Error Trace
```
│ textual\widgets\_tabbed_content.py:431 in hide_tab                           │
│                                                                                                                                                          │
│   428 │   │   │   Tabs.TabError: If there are any issues with the request.                     ╭────────────────── locals ───────────────────╮           │
│   429 │   │   """                                                                              │   self = TabbedContent(id='tabbed-content') │           │
│   430 │   │                                                                                    │ tab_id = 'second-tab'                       │           │
│ ❱ 431 │   │   self.query_one(Tabs).hide(tab_id)                                                ╰─────────────────────────────────────────────╯           │
│   432 │                                                                                                                                                  │
│   433 │   def show_tab(self, tab_id: str) -> None:                                                                                                       │
│   434 │   │   """Shows the tab with the given ID.                                                                                                        │
```
TooManyMatches error with nested TabbedContent
I'm running 0.34 - was actually really looking forward to the ability to hide and show tabs programmatically, fortuitous timing for the thing I'm working on.

However, I've run into a number of issues that I think are all inter-related, potentially due to some nested elements on the DOM having the same id. Allow me to explain with the minor issues first before the showstopper that caused me to write this ticket.

First symptom is that I am unable to `query_one()` with the expected type of `TabPane` as it gets type of `ContentTab` and so throws a `WrongType` error. `ContentTab` is protected so can't/shouldn't directly import.
The second symptom is that removing the `expect_type=` param from `query_one` now begins to throw a `TooManyMatches` error. I initially got around this by using `query().first`. 
I concede that maybe `TabPanes` shouldn't be used directly? Maybe this needs clarification in the documentation if so. Reason for me attempting to use this was as a way to hide tabs prior to 0.34.

Anyway, switching to using `TabbedContent().hide_tab("tab-id")` worked in testing a simple scenario but fails when there is more than one tab AND there is a nested `TabbedContent`. There may be other conditions for this as well. As you can see from the example below, everything has a unique id.

### Example Code
```
from textual.app import App, ComposeResult
from textual.widgets import Label, TabbedContent, TabPane


class ExampleApp(App):
    CSS = """
    Screen {
        align: center middle;
    }
    #tabbed-content {
        height: 1fr;
    }
    #another-tabbed-content {
        height: 1fr;
    }
    """

    def compose(self) -> ComposeResult:
        with TabbedContent(id="tabbed-content"):
            with TabPane("Tab Pane 1", id="first-tab"):
                yield Label("foo")
            with TabPane("Tab Pane 2", id="second-tab"):
                yield Label("bar")
            with TabPane("Tab Pane 3", id="third-tab"):
                with TabbedContent(id="another-tabbed-content"):
                    with TabPane("Inner Pane 1", id="inner-tab-1"):
                        yield Label("fizz")
                    with TabPane("Inner Pane 2", id="inner-tab-2"):
                        yield Label("bang")

    def on_mount(self):
        # tab_pane = self.query_one("#first-tab", expect_type=TabPane)  # WrongType error: unexpected type of ContentTab
        # tab_pane = self.query_one("#first-tab")  # TooManyMatches error
        tabber = self.query_one("#tabbed-content", expect_type=TabbedContent)
        tabber.hide_tab("second-tab")  # TooManyMatches: Call to only_one resulted in more than one matched node


if __name__ == "__main__":
    app = ExampleApp()
    app.run()

```
### Error Trace
```
│ textual\widgets\_tabbed_content.py:431 in hide_tab                           │
│                                                                                                                                                          │
│   428 │   │   │   Tabs.TabError: If there are any issues with the request.                     ╭────────────────── locals ───────────────────╮           │
│   429 │   │   """                                                                              │   self = TabbedContent(id='tabbed-content') │           │
│   430 │   │                                                                                    │ tab_id = 'second-tab'                       │           │
│ ❱ 431 │   │   self.query_one(Tabs).hide(tab_id)                                                ╰─────────────────────────────────────────────╯           │
│   432 │                                                                                                                                                  │
│   433 │   def show_tab(self, tab_id: str) -> None:                                                                                                       │
│   434 │   │   """Shows the tab with the given ID.                                                                                                        │
```


Discussion:
Good spot. The code that is enabling/disabling and showing/hiding the tabs is using `query_one` when it should be using `get_child_by_type` or `get_child_by_id`.
Hey @klott, sorry for the mess. I distinctly remember thinking about the need to handle nested tabs but looks like I didn't do it... Let me fix this for you. 🔧 
Good spot. The code that is enabling/disabling and showing/hiding the tabs is using `query_one` when it should be using `get_child_by_type` or `get_child_by_id`.
Hey @klott, sorry for the mess. I distinctly remember thinking about the need to handle nested tabs but looks like I didn't do it... Let me fix this for you. 🔧 

Resolving patch:
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
--- a/src/textual/widgets/_tabbed_content.py
+++ b/src/textual/widgets/_tabbed_content.py
@@ -379,18 +379,22 @@ def tab_count(self) -> int:
     def _on_tabs_tab_disabled(self, event: Tabs.TabDisabled) -> None:
         """Disable the corresponding tab pane."""
         event.stop()
-        tab_id = event.tab.id
+        tab_id = event.tab.id or ""
         try:
-            self.query_one(f"TabPane#{tab_id}").disabled = True
+            self.get_child_by_type(ContentSwitcher).get_child_by_id(
+                tab_id, expect_type=TabPane
+            ).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
+        tab_id = event.tab.id or ""
         try:
-            self.query_one(f"TabPane#{tab_id}").disabled = False
+            self.get_child_by_type(ContentSwitcher).get_child_by_id(
+                tab_id, expect_type=TabPane
+            ).disabled = False
         except NoMatches:
             return
 
@@ -404,7 +408,7 @@ def disable_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).disable(tab_id)
+        self.get_child_by_type(Tabs).disable(tab_id)
 
     def enable_tab(self, tab_id: str) -> None:
         """Enables the tab with the given ID.
@@ -416,7 +420,7 @@ def enable_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).enable(tab_id)
+        self.get_child_by_type(Tabs).enable(tab_id)
 
     def hide_tab(self, tab_id: str) -> None:
         """Hides the tab with the given ID.
@@ -428,7 +432,7 @@ def hide_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).hide(tab_id)
+        self.get_child_by_type(Tabs).hide(tab_id)
 
     def show_tab(self, tab_id: str) -> None:
         """Shows the tab with the given ID.
@@ -440,4 +444,4 @@ def show_tab(self, tab_id: str) -> None:
             Tabs.TabError: If there are any issues with the request.
         """
 
-        self.query_one(Tabs).show(tab_id)
+        self.get_child_by_type(Tabs).show(tab_id)


Test patch:
diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py
--- a/tests/test_tabbed_content.py
+++ b/tests/test_tabbed_content.py
@@ -696,3 +696,55 @@ def compose(self) -> ComposeResult:
         tabbed_content.show_tab(tab_id)
         await pilot.pause()
         assert tabbed_content.active == tab_id
+
+
+async def test_disabling_nested_tabs():
+    """Regression test for https://github.com/Textualize/textual/issues/3145."""
+
+    class TabbedApp(App):
+        def compose(self) -> ComposeResult:
+            with TabbedContent(id="tabbed-content"):
+                with TabPane("Tab Pane 1"):
+                    yield Label("foo")
+                with TabPane("Tab Pane 2"):
+                    yield Label("bar")
+                with TabPane("Tab Pane 3"):
+                    with TabbedContent():
+                        with TabPane("Inner Pane 1"):
+                            yield Label("fizz")
+                        with TabPane("Inner Pane 2"):
+                            yield Label("bang")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabber = app.query_one("#tabbed-content", expect_type=TabbedContent)
+        tabber.disable_tab("tab-1")
+        await pilot.pause()
+        tabber.enable_tab("tab-1")
+        await pilot.pause()
+
+
+async def test_hiding_nested_tabs():
+    """Regression test for https://github.com/Textualize/textual/issues/3145."""
+
+    class TabbedApp(App):
+        def compose(self) -> ComposeResult:
+            with TabbedContent(id="tabbed-content"):
+                with TabPane("Tab Pane 1"):
+                    yield Label("foo")
+                with TabPane("Tab Pane 2"):
+                    yield Label("bar")
+                with TabPane("Tab Pane 3"):
+                    with TabbedContent():
+                        with TabPane("Inner Pane 1"):
+                            yield Label("fizz")
+                        with TabPane("Inner Pane 2"):
+                            yield Label("bang")
+
+    app = TabbedApp()
+    async with app.run_test() as pilot:
+        tabber = app.query_one("#tabbed-content", expect_type=TabbedContent)
+        tabber.hide_tab("tab-1")
+        await pilot.pause()
+        tabber.show_tab("tab-1")
+        await pilot.pause()