fix(forms): unify option disabledness

This commit is contained in:
ldm0
2026-09-23 08:50:55 +08:00
parent ee930faa6b
commit 54bc64c191
13 changed files with 246 additions and 138 deletions
@@ -3087,7 +3087,6 @@ html/semantics/forms/form-submission-target/rel-input-target.html
html/semantics/forms/textfieldselection/selection.html
html/semantics/forms/the-input-element/show-picker-cross-origin-iframe.html
html/semantics/forms/the-select-element/customizable-select/base-appearance-inheritance.html
html/semantics/forms/the-select-element/customizable-select/option-disabled-optgroup.html
html/semantics/forms/the-select-element/customizable-select/picker-icon-animation.html
html/semantics/forms/the-select-element/customizable-select/select-base-appearance-computed-style.html
html/semantics/forms/the-select-element/customizable-select/select-value-selectedOption.html
@@ -6442,6 +6442,7 @@ html/semantics/forms/the-select-element/common-HTMLOptionsCollection.html
html/semantics/forms/the-select-element/customizable-select/nested-select-crash.html
html/semantics/forms/the-select-element/customizable-select/option-color-inheritance.html
html/semantics/forms/the-select-element/customizable-select/option-disabled-invalid-nesting.html
html/semantics/forms/the-select-element/customizable-select/option-disabled-optgroup.html
html/semantics/forms/the-select-element/customizable-select/option-form-ancestor-select.html
html/semantics/forms/the-select-element/customizable-select/option-list.html
html/semantics/forms/the-select-element/customizable-select/select-highlight-crash.html
+4 -1
View File
@@ -34,7 +34,10 @@ pub use numeric::{
parse_html_floating_point_prefix, parse_input_numeric_value, progress_element_values,
step_input_value,
};
pub use option::{OptionNearestSelectStep, OptionNearestSelectTraversal};
pub use option::{
OptionDisabledAncestorStep, OptionNearestSelectStep, OptionNearestSelectTraversal,
option_disabled_ancestor_step,
};
pub use text::{
normalize_custom_validation_message, normalize_form_submission_newlines,
parse_non_negative_integer_prefix, parse_non_negative_length_attribute,
+26
View File
@@ -7,6 +7,28 @@ pub enum OptionNearestSelectStep {
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionDisabledAncestorStep {
Continue,
Disabled(bool),
}
pub fn option_disabled_ancestor_step(
namespace: &str,
local_name: &str,
has_disabled_attribute: bool,
) -> OptionDisabledAncestorStep {
if namespace != HTML_NAMESPACE {
return OptionDisabledAncestorStep::Continue;
}
match local_name {
"select" | "hr" | "datalist" | "option" => OptionDisabledAncestorStep::Disabled(false),
"optgroup" => OptionDisabledAncestorStep::Disabled(has_disabled_attribute),
_ => OptionDisabledAncestorStep::Continue,
}
}
/// State for the HTML "option element nearest ancestor select" algorithm.
///
/// Tree owners perform the actual ancestor walk and feed element names into
@@ -18,6 +40,10 @@ pub struct OptionNearestSelectTraversal {
}
impl OptionNearestSelectTraversal {
pub fn starting_at_optgroup() -> Self {
Self { saw_optgroup: true }
}
pub fn visit_ancestor(&mut self, namespace: &str, local_name: &str) -> OptionNearestSelectStep {
if namespace != HTML_NAMESPACE {
return OptionNearestSelectStep::Continue;
+8 -20
View File
@@ -166,6 +166,14 @@ impl DomHost {
self.dom.option_nearest_ancestor_select(handle)
}
pub fn optgroup_nearest_ancestor_select(&self, handle: DomHandle) -> Option<DomHandle> {
self.dom.optgroup_nearest_ancestor_select(handle)
}
pub fn option_is_disabled(&self, handle: DomHandle) -> bool {
self.dom.option_is_disabled(handle)
}
pub fn radio_group_members(&self, handle: DomHandle) -> Vec<DomHandle> {
let Some(element) = self.node(handle).and_then(Node::as_element) else {
return Vec::new();
@@ -230,26 +238,6 @@ impl DomHost {
.into_iter()
.collect()
}
fn option_is_disabled(&self, handle: DomHandle) -> bool {
let mut current = Some(handle);
while let Some(candidate) = current {
let Some(element) = self.node(candidate).and_then(Node::as_element) else {
current = self.parent_node(candidate);
continue;
};
if matches!(element.local_name(), "option" | "optgroup")
&& element.has_attribute("disabled")
{
return true;
}
if element.is_html_select() {
return false;
}
current = self.parent_node(candidate);
}
false
}
}
fn select_display_size(select: &Element) -> i32 {
+15 -1
View File
@@ -1560,11 +1560,13 @@ mod tests {
assert!(host.append_child(datalist, datalist_option));
let optgroup = host.create_element("optgroup");
let optgroup_div = host.create_element("div");
let optgroup_option = host.create_element("option");
let nested_optgroup = host.create_element("optgroup");
let nested_optgroup_option = host.create_element("option");
assert!(host.append_child(child_select, optgroup));
assert!(host.append_child(optgroup, optgroup_option));
assert!(host.append_child(optgroup, optgroup_div));
assert!(host.append_child(optgroup_div, optgroup_option));
assert!(host.append_child(optgroup, nested_optgroup));
assert!(host.append_child(nested_optgroup, nested_optgroup_option));
@@ -1587,6 +1589,18 @@ mod tests {
] {
assert_eq!(host.option_nearest_ancestor_select(option), None);
}
assert!(host.set_attribute(optgroup, "disabled", ""));
assert!(host.option_is_disabled(optgroup_option));
for option in [
normal_option,
nested_option,
hr_option,
datalist_option,
nested_optgroup_option,
] {
assert!(!host.option_is_disabled(option));
}
}
#[test]
+59 -23
View File
@@ -2,7 +2,8 @@ use super::NativeDom;
use super::element::Element;
use super::node::{NativeNodeId, Node};
use crate::forms::{
OptionNearestSelectStep, OptionNearestSelectTraversal, parse_non_negative_integer_prefix,
OptionDisabledAncestorStep, OptionNearestSelectStep, OptionNearestSelectTraversal,
option_disabled_ancestor_step, parse_non_negative_integer_prefix,
};
impl NativeDom {
@@ -73,8 +74,33 @@ impl NativeDom {
return None;
}
let mut traversal = OptionNearestSelectTraversal::default();
let mut current = self.parent_node(option_id);
self.nearest_ancestor_select(option_id, OptionNearestSelectTraversal::default())
}
pub fn optgroup_nearest_ancestor_select(
&self,
optgroup_id: NativeNodeId,
) -> Option<NativeNodeId> {
if !self
.node(optgroup_id)
.and_then(Node::as_element)
.is_some_and(|element| element.is_html_element("optgroup"))
{
return None;
}
self.nearest_ancestor_select(
optgroup_id,
OptionNearestSelectTraversal::starting_at_optgroup(),
)
}
fn nearest_ancestor_select(
&self,
element_id: NativeNodeId,
mut traversal: OptionNearestSelectTraversal,
) -> Option<NativeNodeId> {
let mut current = self.parent_node(element_id);
while let Some(parent) = current {
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
@@ -90,6 +116,36 @@ impl NativeDom {
None
}
pub fn option_is_disabled(&self, option_id: NativeNodeId) -> bool {
let Some(option) = self.node(option_id).and_then(Node::as_element) else {
return false;
};
if !option.is_html_option() {
return false;
}
if option.has_attribute("disabled") {
return true;
}
let mut current = self.parent_node(option_id);
while let Some(parent) = current {
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
continue;
};
match option_disabled_ancestor_step(
element.namespace(),
element.local_name(),
element.has_attribute("disabled"),
) {
OptionDisabledAncestorStep::Continue => {}
OptionDisabledAncestorStep::Disabled(disabled) => return disabled,
}
current = self.parent_node(parent);
}
false
}
pub fn select_selected_option_elements(&self, select_id: NativeNodeId) -> Vec<NativeNodeId> {
let options = self.select_option_elements(select_id);
let Some(select) = self.node(select_id).and_then(Node::as_element) else {
@@ -208,26 +264,6 @@ impl NativeDom {
}
out
}
fn option_is_disabled(&self, option_id: NativeNodeId) -> bool {
let mut current = Some(option_id);
while let Some(candidate) = current {
let Some(element) = self.node(candidate).and_then(Node::as_element) else {
current = self.parent_node(candidate);
continue;
};
if matches!(element.local_name(), "option" | "optgroup")
&& element.has_attribute("disabled")
{
return true;
}
if element.is_html_select() {
return false;
}
current = self.parent_node(candidate);
}
false
}
}
fn select_display_size(select: &Element) -> i32 {
@@ -1,7 +1,10 @@
use super::storage::{form_data_entries, form_data_is_object, push_form_data_entry};
use super::*;
use crate::custom_elements::is_form_associated_custom_element_handle;
use crate::dom::{forms::InputType, native::Node};
use crate::dom::{
forms::{InputType, OptionDisabledAncestorStep, option_disabled_ancestor_step},
native::Node,
};
use crate::native_bridge::{
element::{
element_attribute_for_object, element_internals_form_value_for_target,
@@ -504,18 +507,21 @@ fn control_has_datalist_ancestor<'s>(
}
fn option_is_disabled(scope: &mut v8::PinScope<'_, '_>, option: v8::Local<'_, v8::Object>) -> bool {
let mut current = Some(option);
if object_bool_property(scope, option, "disabled").unwrap_or(false) {
return true;
}
let mut current = object_property_as_object(scope, option, "parentElement");
while let Some(element) = current {
let tag = object_string_property_defined(scope, element, "tagName")
.map(|tag| tag.to_ascii_lowercase())
.unwrap_or_default();
if matches!(tag.as_str(), "option" | "optgroup")
&& object_bool_property(scope, element, "disabled").unwrap_or(false)
{
return true;
}
if tag == "select" {
return false;
let namespace =
object_string_property_defined(scope, element, "namespaceURI").unwrap_or_default();
let local_name =
object_string_property_defined(scope, element, "localName").unwrap_or_default();
let has_disabled_attribute =
object_bool_property(scope, element, "disabled").unwrap_or(false);
match option_disabled_ancestor_step(&namespace, &local_name, has_disabled_attribute) {
OptionDisabledAncestorStep::Continue => {}
OptionDisabledAncestorStep::Disabled(disabled) => return disabled,
}
current = object_property_as_object(scope, element, "parentElement");
}
@@ -125,7 +125,7 @@ pub(crate) fn form_control_is_effectively_disabled(
{
return true;
}
if option_is_disabled_by_optgroup(runtime, handle) {
if runtime.dom_host().option_is_disabled(handle) {
return true;
}
@@ -161,33 +161,6 @@ fn disabled_attribute_applies_to_control(
))
}
fn option_is_disabled_by_optgroup(runtime: &JsContextHost, handle: DomHandle) -> bool {
if !runtime
.dom_host()
.node(handle)
.and_then(Node::as_element)
.is_some_and(|element| element.is_html_element("option"))
{
return false;
}
let mut current = runtime.dom_host().parent_node(handle);
while let Some(parent) = current {
let Some(parent_element) = runtime.dom_host().node(parent).and_then(Node::as_element)
else {
current = runtime.dom_host().parent_node(parent);
continue;
};
match parent_element.local_name() {
"optgroup" => return parent_element.has_attribute("disabled"),
"select" | "hr" | "datalist" | "option" => return false,
_ => {}
}
current = runtime.dom_host().parent_node(parent);
}
false
}
fn control_is_in_first_legend(
runtime: &JsContextHost,
control: DomHandle,
@@ -150,28 +150,5 @@ pub(in crate::script_vm) fn current_selection_state(
}
pub(in crate::script_vm) fn option_is_disabled(runtime: &JsContextHost, handle: DomHandle) -> bool {
if runtime
.dom_host()
.node(handle)
.and_then(Node::as_element)
.is_some_and(|element| element.has_attribute("disabled"))
{
return true;
}
let mut current = runtime.dom_host().parent_node(handle);
while let Some(parent) = current {
let Some(parent_element) = runtime.dom_host().node(parent).and_then(Node::as_element)
else {
current = runtime.dom_host().parent_node(parent);
continue;
};
match parent_element.local_name() {
"optgroup" => return parent_element.has_attribute("disabled"),
"select" | "hr" | "datalist" | "option" => return false,
_ => {}
}
current = runtime.dom_host().parent_node(parent);
}
false
runtime.dom_host().option_is_disabled(handle)
}
@@ -267,6 +267,42 @@ fn has_pseudo_class_invalidation_updates_derived_form_states() {
);
}
#[test]
fn optgroup_disabled_invalidation_updates_nested_option_computed_style() {
let mut vm = new_storage_test_vm("https://nested-option-disabled-style.test/");
let result = vm
.eval(
r#"
(() => {
const root = document.documentElement || document.appendChild(document.createElement('html'));
const head = document.head || root.appendChild(document.createElement('head'));
const body = document.body || root.appendChild(document.createElement('body'));
const style = document.createElement('style');
style.textContent = 'option { color: black; } option:disabled { color: gray; }';
head.append(style);
const select = document.createElement('select');
const optgroup = document.createElement('optgroup');
const div = document.createElement('div');
const option = document.createElement('option');
div.append(option);
optgroup.append(div);
select.append(optgroup);
body.append(select);
const computed = getComputedStyle(option);
const before = computed.color;
optgroup.disabled = true;
return [before, computed.color, option.matches(':disabled')].join('|');
})()
"#,
)
.expect("nested option disabled style should evaluate");
assert_eq!(result, "rgb(0, 0, 0)|rgb(128, 128, 128)|true");
}
#[test]
fn focus_selector_invalidation_preserves_unrelated_cache_entries() {
let mut vm = new_storage_test_vm("https://focus-style-cache-targeted.test/");
+64
View File
@@ -1931,6 +1931,70 @@ mod tests {
assert!(!engine.matches_host(&host, option, ":disabled").unwrap());
}
#[test]
fn dom_api_selectors_option_disabledness_respects_association_boundaries() {
let url = url::Url::parse("https://example.test/").unwrap();
let mut host = DomHost::from_dom(NativeDom::new_html(url));
host.reset_html_document_shell();
let body = host.document_body_handle().unwrap();
let engine = QueryEngine;
let select = host.create_element("select");
let optgroup = host.create_element("optgroup");
let div = host.create_element("div");
let option = host.create_element("option");
assert!(host.append_child(body, select));
assert!(host.append_child(select, optgroup));
assert!(host.append_child(optgroup, div));
assert!(host.append_child(div, option));
assert!(host.set_attribute(optgroup, "disabled", ""));
assert!(engine.matches_host(&host, option, ":disabled").unwrap());
let disabled_select = host.create_element("select");
let valid_optgroup = host.create_element("optgroup");
let valid_option = host.create_element("option");
let nested_optgroup = host.create_element("optgroup");
let nested_optgroup_option = host.create_element("option");
let parent_option = host.create_element("option");
let nested_option = host.create_element("option");
let hr = host.create_element("hr");
let hr_option = host.create_element("option");
let datalist = host.create_element("datalist");
let datalist_option = host.create_element("option");
assert!(host.set_attribute(disabled_select, "disabled", ""));
assert!(host.append_child(body, disabled_select));
assert!(host.append_child(disabled_select, valid_optgroup));
assert!(host.append_child(valid_optgroup, valid_option));
assert!(host.append_child(valid_optgroup, nested_optgroup));
assert!(host.append_child(nested_optgroup, nested_optgroup_option));
assert!(host.append_child(disabled_select, parent_option));
assert!(host.append_child(parent_option, nested_option));
assert!(host.append_child(disabled_select, hr));
assert!(host.append_child(hr, hr_option));
assert!(host.append_child(disabled_select, datalist));
assert!(host.append_child(datalist, datalist_option));
assert!(
engine
.matches_host(&host, valid_optgroup, ":disabled")
.unwrap()
);
assert!(
engine
.matches_host(&host, valid_option, ":disabled")
.unwrap()
);
for handle in [
nested_optgroup,
nested_optgroup_option,
nested_option,
hr_option,
datalist_option,
] {
assert!(!engine.matches_host(&host, handle, ":disabled").unwrap());
}
}
#[test]
fn dom_api_selectors_disabled_fieldset_disables_option_descendants() {
let url = url::Url::parse("https://example.test/").unwrap();
+13 -28
View File
@@ -239,27 +239,17 @@ impl<'a> QueryElement<'a> {
if self.element().has_attribute("disabled") {
return true;
}
if self.element().local_name() == "option"
&& self.parent_element().is_some_and(|(_, parent)| {
parent.local_name() == "optgroup" && parent.has_attribute("disabled")
})
{
if self.element().local_name() == "option" && self.host.option_is_disabled(self.handle) {
return true;
}
if matches!(self.element().local_name(), "option" | "optgroup")
&& self.disabled_select_ancestor().is_some()
&& self.disabled_associated_select().is_some()
{
return true;
}
self.disabled_fieldset_ancestor().is_some()
}
pub(super) fn parent_element(self) -> Option<(NodeId, &'a Element)> {
let parent = self.node().parent_node()?;
let element = self.host.node(parent)?.as_element()?;
Some((parent, element))
}
pub(super) fn disabled_fieldset_ancestor(self) -> Option<NodeId> {
let mut current = self.node().parent_node();
while let Some(parent) = current {
@@ -275,22 +265,17 @@ impl<'a> QueryElement<'a> {
None
}
pub(super) fn disabled_select_ancestor(self) -> Option<NodeId> {
let mut current = self.node().parent_node();
while let Some(parent) = current {
let Some(element) = self.host.node(parent).and_then(Node::as_element) else {
current = self.host.node(parent).and_then(Node::parent_node);
continue;
};
match element.local_name() {
"select" if element.has_attribute("disabled") => return Some(parent),
"select" | "option" => return None,
"optgroup" if self.element().local_name() == "optgroup" => return None,
_ => {}
}
current = self.host.node(parent).and_then(Node::parent_node);
}
None
pub(super) fn disabled_associated_select(self) -> Option<NodeId> {
let select = match self.element().local_name() {
"option" => self.host.option_nearest_ancestor_select(self.handle),
"optgroup" => self.host.optgroup_nearest_ancestor_select(self.handle),
_ => None,
}?;
self.host
.node(select)
.and_then(Node::as_element)
.is_some_and(|element| element.has_attribute("disabled"))
.then_some(select)
}
pub(super) fn is_inside_first_legend_child(self, fieldset: NodeId) -> bool {