From 528901bee61ecd4975f24602a4809fa3ee2abee9 Mon Sep 17 00:00:00 2001
From: Adrian Chaves
Date: Fri, 21 Aug 2026 14:06:31 +0200
Subject: [PATCH] Align :enabled/:disabled with the HTML Living Standard
---
cssselect/xpath.py | 57 ++++++++++++++++++++---------------------
tests/test_cssselect.py | 24 ++++++++++-------
2 files changed, 43 insertions(+), 38 deletions(-)
diff --git a/cssselect/xpath.py b/cssselect/xpath.py
index 8ef5d7c..ab6694d 100644
--- a/cssselect/xpath.py
+++ b/cssselect/xpath.py
@@ -872,10 +872,10 @@ class HTMLTranslator(GenericTranslator):
Has a more useful implementation of some pseudo-classes based on
HTML-specific element names and attribute names, as described in
- the `HTML5 specification`_. It assumes no-quirks mode.
+ the `HTML specification`_. It assumes no-quirks mode.
The API is the same as :class:`GenericTranslator`.
- .. _HTML5 specification: http://www.w3.org/TR/html5/links.html#selectors
+ .. _HTML specification: https://html.spec.whatwg.org/multipage/semantics-other.html#pseudo-classes
:param xhtml:
If false (the default), element names and attribute names
@@ -925,62 +925,67 @@ def xpath_link_pseudo(self, xpath: XPathExpr) -> XPathExpr:
# Links are never visited, the implementation for :visited is the same
# as in GenericTranslator
+ # An element is a descendant of a disabled fieldset ancestor unless it is
+ # a descendant of that fieldset's own first legend element child. lxml's
+ # XPath evaluator has no generate-id() or current(), so the exemption is
+ # tested by checking whether any ancestor legend is also the first legend
+ # child of a disabled ancestor fieldset; with several nested disabled
+ # fieldsets this can exempt an element that a fully spec-accurate
+ # algorithm would still treat as disabled.
+ _disabled_by_fieldset = (
+ "ancestor::fieldset[@disabled] and not("
+ "count(ancestor::legend | ancestor::fieldset[@disabled]/legend[1]) < "
+ "count(ancestor::legend) + count(ancestor::fieldset[@disabled]/legend[1])"
+ ")"
+ )
+
def xpath_disabled_pseudo(self, xpath: XPathExpr) -> XPathExpr:
- # http://www.w3.org/TR/html5/section-index.html#attributes-1
+ # https://html.spec.whatwg.org/multipage/semantics-other.html#concept-element-disabled
return xpath.add_condition(
- """
+ f"""
(
@disabled and
(
- (name(.) = 'input' and @type != 'hidden') or
+ name(.) = 'input' or
name(.) = 'button' or
name(.) = 'select' or
name(.) = 'textarea' or
- name(.) = 'command' or
name(.) = 'fieldset' or
name(.) = 'optgroup' or
name(.) = 'option'
)
) or (
(
- (name(.) = 'input' and @type != 'hidden') or
+ name(.) = 'input' or
name(.) = 'button' or
name(.) = 'select' or
name(.) = 'textarea'
)
- and ancestor::fieldset[@disabled]
+ and {self._disabled_by_fieldset}
+ ) or (
+ name(.) = 'option' and ancestor::optgroup[@disabled]
)
"""
)
- # FIXME: in the second half, add "and is not a descendant of that
- # fieldset element's first legend element child, if any."
def xpath_enabled_pseudo(self, xpath: XPathExpr) -> XPathExpr:
- # http://www.w3.org/TR/html5/section-index.html#attributes-1
+ # https://html.spec.whatwg.org/multipage/semantics-other.html#concept-element-disabled
return xpath.add_condition(
- """
+ f"""
(
- @href and (
- name(.) = 'a' or
- name(.) = 'link' or
- name(.) = 'area'
- )
- ) or (
(
- name(.) = 'command' or
name(.) = 'fieldset' or
name(.) = 'optgroup'
)
and not(@disabled)
) or (
(
- (name(.) = 'input' and @type != 'hidden') or
+ name(.) = 'input' or
name(.) = 'button' or
name(.) = 'select' or
- name(.) = 'textarea' or
- name(.) = 'keygen'
+ name(.) = 'textarea'
)
- and not (@disabled or ancestor::fieldset[@disabled])
+ and not(@disabled) and not({self._disabled_by_fieldset})
) or (
name(.) = 'option' and not(
@disabled or ancestor::optgroup[@disabled]
@@ -988,9 +993,3 @@ def xpath_enabled_pseudo(self, xpath: XPathExpr) -> XPathExpr:
)
"""
)
- # FIXME: ... or "li elements that are children of menu elements,
- # and that have a child element that defines a command, if the first
- # such element's Disabled State facet is false (not disabled)".
- # FIXME: after ancestor::fieldset[@disabled], add "and is not a
- # descendant of that fieldset element's first legend element child,
- # if any."
diff --git a/tests/test_cssselect.py b/tests/test_cssselect.py
index 8e1b2a7..ad38586 100644
--- a/tests/test_cssselect.py
+++ b/tests/test_cssselect.py
@@ -1393,7 +1393,11 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]:
assert pcss("span:only-child") == ["foobar-span"]
assert pcss("li div:only-child") == ["li-div"]
- assert pcss("div *:only-child") == ["li-div", "foobar-span"]
+ assert pcss("div *:only-child") == [
+ "li-div",
+ "checkbox-legend-enabled",
+ "foobar-span",
+ ]
# The root element has no siblings, so it matches :only-child
# (just like :first-child and :last-child)
assert pcss("html:only-child") == ["html"]
@@ -1444,7 +1448,8 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]:
"first-li",
"li-div",
"p-b",
- "checkbox-fieldset-disabled",
+ "legend",
+ "checkbox-legend-enabled",
"area-href",
]
assert pcss("a[href]") == ["tag-anchor", "nofollow-anchor"]
@@ -1536,19 +1541,19 @@ def pcss(main: str, *selectors: str, **kwargs: bool) -> list[str]:
]
assert pcss(":visited", html_only=True) == []
assert pcss(":enabled", html_only=True) == [
- "link-href",
- "tag-anchor",
- "nofollow-anchor",
"checkbox-unchecked",
"text-checked",
+ "hidden-unchecked",
"checkbox-checked",
- "area-href",
+ "checkbox-legend-enabled",
]
assert pcss(":disabled", html_only=True) == [
"checkbox-disabled",
+ "hidden-disabled",
"checkbox-disabled-checked",
"fieldset",
"checkbox-fieldset-disabled",
+ "hidden-fieldset-disabled",
]
assert pcss(":checked", html_only=True) == [
"checkbox-checked",
@@ -1727,14 +1732,15 @@ def count(selector: str) -> int:
-
-
+
+