Repository: Textualize/textual

Issue:
Make `COMPONENT_CLASSES` inheritable
Much like `DEFAULT_CSS` and similar things, look at the idea of making `COMPONENT_CLASSES` inheritable. At the moment, if you inherit from a widget that has a `COMPONENT_CLASSES`, and you set your own in your derived widget, you'll erase those of the parent. Right now the best option is to combine the sets. For example:

```python
class ThreeDimensionalTree(Tree[Anaglyph]):
    """Tree, but more so."""

    COMPONENT_CLASSES: ClassVar[set[str]] = Tree.COMPONENT_CLASSES | {
        "anaglyph--behind-screen",
        "anaglyph--in-front-of-screen",
    }
```

This will likely also need the addition of a `inherit_component_classes` to control if the inheriting should happen or not.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 954f3b7d0..53dabe816 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Added read-only public access to the children of a `TreeNode` via `TreeNode.children` https://github.com/Textualize/textual/issues/1398
 - Added `Tree.get_node_by_id` to allow getting a node by its ID https://github.com/Textualize/textual/pull/1535
 - Added a `Tree.NodeHighlighted` message, giving a `on_tree_node_highlighted` event handler https://github.com/Textualize/textual/issues/1400
+- Added a `inherit_component_classes` subclassing parameter to control whether or not component classes are inherited from base classes https://github.com/Textualize/textual/issues/1399
 - Added `diagnose` as a `textual` command https://github.com/Textualize/textual/issues/1542
 
 ### Changed
@@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Added Widget._refresh_scroll to avoid expensive layout when scrolling https://github.com/Textualize/textual/pull/1524
 - `events.Paste` now bubbles https://github.com/Textualize/textual/issues/1434
 - Clock color in the `Header` widget now matches the header color https://github.com/Textualize/textual/issues/1459
+- `COMPONENT_CLASSES` are now inherited from base classes https://github.com/Textualize/textual/issues/1399
 - Watch methods may now take no parameters
 - Added `compute` parameter to reactive
 
diff --git a/src/textual/app.py b/src/textual/app.py
index e704c11fb..b0f2e8c63 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -1405,7 +1405,7 @@ class App(Generic[ReturnType], DOMNode):
         try:
             if self.css_path:
                 self.stylesheet.read_all(self.css_path)
-            for path, css, tie_breaker in self.get_default_css():
+            for path, css, tie_breaker in self._get_default_css():
                 self.stylesheet.add_source(
                     css, path=path, is_default_css=True, tie_breaker=tie_breaker
                 )
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index 56ea05dc0..ad59ac00c 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -407,7 +407,7 @@ class Stylesheet:
         self.replace_rules(node, node_rules, animate=animate)
 
         node._component_styles.clear()
-        for component in node.COMPONENT_CLASSES:
+        for component in node._get_component_classes():
             virtual_node = DOMNode(classes=component)
             virtual_node._attach(node)
             self.apply(virtual_node, animate=False)
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 8d375998e..4bc55421c 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -99,6 +99,9 @@ class DOMNode(MessagePump):
     # True if this node inherits the CSS from the base class.
     _inherit_css: ClassVar[bool] = True
 
+    # True if this node inherits the component classes from the base class.
+    _inherit_component_classes: ClassVar[bool] = True
+
     # True to inherit bindings from base class
     _inherit_bindings: ClassVar[bool] = True
 
@@ -163,7 +166,10 @@ class DOMNode(MessagePump):
         self.refresh()
 
     def __init_subclass__(
-        cls, inherit_css: bool = True, inherit_bindings: bool = True
+        cls,
+        inherit_css: bool = True,
+        inherit_bindings: bool = True,
+        inherit_component_classes: bool = True,
     ) -> None:
         super().__init_subclass__()
 
@@ -179,6 +185,7 @@ class DOMNode(MessagePump):
 
         cls._inherit_css = inherit_css
         cls._inherit_bindings = inherit_bindings
+        cls._inherit_component_classes = inherit_component_classes
         css_type_names: set[str] = set()
         for base in cls._css_bases(cls):
             css_type_names.add(base.__name__)
@@ -269,9 +276,12 @@ class DOMNode(MessagePump):
         if self._classes:
             yield "classes", " ".join(self._classes)
 
-    def get_default_css(self) -> list[tuple[str, str, int]]:
+    def _get_default_css(self) -> list[tuple[str, str, int]]:
         """Gets the CSS for this class and inherited from bases.
 
+        Default CSS is inherited from base classes, unless `inherit_css` is set to
+        `False` when subclassing.
+
         Returns:
             list[tuple[str, str]]: a list of tuples containing (PATH, SOURCE) for this
                 and inherited from base classes.
@@ -293,6 +303,24 @@ class DOMNode(MessagePump):
 
         return css_stack
 
+    def _get_component_classes(self) -> set[str]:
+        """Gets the component classes for this class and inherited from bases.
+
+        Component classes are inherited from base classes, unless
+        `inherit_component_classes` is set to `False` when subclassing.
+
+        Returns:
+            set[str]: a set with all the component classes available.
+        """
+
+        component_classes: set[str] = set()
+        for base in self._node_bases:
+            component_classes.update(base.__dict__.get("COMPONENT_CLASSES", set()))
+            if not base.__dict__.get("_inherit_component_classes", True):
+                break
+
+        return component_classes
+
     @property
     def parent(self) -> DOMNode | None:
         """DOMNode | None: The parent node."""
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 5a1a00e0f..37c6bcc25 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -695,7 +695,7 @@ class Widget(DOMNode):
             app (App): App instance.
         """
         # Parse the Widget's CSS
-        for path, css, tie_breaker in self.get_default_css():
+        for path, css, tie_breaker in self._get_default_css():
             self.app.stylesheet.add_source(
                 css, path=path, is_default_css=True, tie_breaker=tie_breaker
             )


Test patch:
diff --git a/tests/test_dom.py b/tests/test_dom.py
index c1b5221c0..ded834300 100644
--- a/tests/test_dom.py
+++ b/tests/test_dom.py
@@ -47,6 +47,7 @@ def test_validate():
 
 def test_inherited_bindings():
     """Test if binding merging is done correctly when (not) inheriting bindings."""
+
     class A(DOMNode):
         BINDINGS = [("a", "a", "a")]
 
@@ -78,29 +79,34 @@ def test_inherited_bindings():
     assert list(e._bindings.keys.keys()) == ["e"]
 
 
-def test_get_default_css():
+def test__get_default_css():
     class A(DOMNode):
         pass
+
     class B(A):
         pass
+
     class C(B):
         DEFAULT_CSS = "C"
+
     class D(C):
         pass
+
     class E(D):
         DEFAULT_CSS = "E"
+
     node = DOMNode()
-    node_css = node.get_default_css()
+    node_css = node._get_default_css()
     a = A()
-    a_css = a.get_default_css()
+    a_css = a._get_default_css()
     b = B()
-    b_css = b.get_default_css()
+    b_css = b._get_default_css()
     c = C()
-    c_css = c.get_default_css()
+    c_css = c._get_default_css()
     d = D()
-    d_css = d.get_default_css()
+    d_css = d._get_default_css()
     e = E()
-    e_css = e.get_default_css()
+    e_css = e._get_default_css()
 
     # Descendants that don't assign to DEFAULT_CSS don't add new CSS to the stack.
     assert len(node_css) == len(a_css) == len(b_css) == 0
@@ -115,6 +121,51 @@ def test_get_default_css():
     assert e_css[1][1:] == ("C", -2)
 
 
+def test_component_classes_inheritance():
+    """Test if component classes are inherited properly."""
+
+    class A(DOMNode):
+        COMPONENT_CLASSES = {"a-1", "a-2"}
+
+    class B(A, inherit_component_classes=False):
+        COMPONENT_CLASSES = {"b-1"}
+
+    class C(B):
+        COMPONENT_CLASSES = {"c-1", "c-2"}
+
+    class D(C):
+        pass
+
+    class E(D):
+        COMPONENT_CLASSES = {"e-1"}
+
+    class F(E, inherit_component_classes=False):
+        COMPONENT_CLASSES = {"f-1"}
+
+    node = DOMNode()
+    node_cc = node._get_component_classes()
+    a = A()
+    a_cc = a._get_component_classes()
+    b = B()
+    b_cc = b._get_component_classes()
+    c = C()
+    c_cc = c._get_component_classes()
+    d = D()
+    d_cc = d._get_component_classes()
+    e = E()
+    e_cc = e._get_component_classes()
+    f = F()
+    f_cc = f._get_component_classes()
+
+    assert node_cc == set()
+    assert a_cc == {"a-1", "a-2"}
+    assert b_cc == {"b-1"}
+    assert c_cc == {"b-1", "c-1", "c-2"}
+    assert d_cc == c_cc
+    assert e_cc == {"b-1", "c-1", "c-2", "e-1"}
+    assert f_cc == {"f-1"}
+
+
 @pytest.fixture
 def search():
     """