diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 3067a7828..b796a90d8 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -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 diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index a14af4c85..4e3ad6c4d 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -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 diff --git a/moli-dom/src/forms/mod.rs b/moli-dom/src/forms/mod.rs index d2f027344..2c26ab096 100644 --- a/moli-dom/src/forms/mod.rs +++ b/moli-dom/src/forms/mod.rs @@ -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, diff --git a/moli-dom/src/forms/option.rs b/moli-dom/src/forms/option.rs index e626d490b..7129c4022 100644 --- a/moli-dom/src/forms/option.rs +++ b/moli-dom/src/forms/option.rs @@ -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; diff --git a/moli-dom/src/native/host/collections/form.rs b/moli-dom/src/native/host/collections/form.rs index 10feaa45c..48038ecdd 100644 --- a/moli-dom/src/native/host/collections/form.rs +++ b/moli-dom/src/native/host/collections/form.rs @@ -166,6 +166,14 @@ impl DomHost { self.dom.option_nearest_ancestor_select(handle) } + pub fn optgroup_nearest_ancestor_select(&self, handle: DomHandle) -> Option { + 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 { 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 { diff --git a/moli-dom/src/native/mod.rs b/moli-dom/src/native/mod.rs index da89579f2..70ff08e43 100644 --- a/moli-dom/src/native/mod.rs +++ b/moli-dom/src/native/mod.rs @@ -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] diff --git a/moli-dom/src/native/queries.rs b/moli-dom/src/native/queries.rs index 49dc9f0b7..70eb8eec6 100644 --- a/moli-dom/src/native/queries.rs +++ b/moli-dom/src/native/queries.rs @@ -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 { + 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 { + 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 { 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 { diff --git a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs index 5cd4637c9..89028c5f6 100644 --- a/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs +++ b/moli-renderer-v8/src/context_bootstrap/form_data_runtime/serialize.rs @@ -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"); } diff --git a/moli-renderer-v8/src/native_bridge/element/forms/owner.rs b/moli-renderer-v8/src/native_bridge/element/forms/owner.rs index 2a5870653..3127f84bd 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/owner.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/owner.rs @@ -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, diff --git a/moli-renderer-v8/src/script_vm/inspector/input_state.rs b/moli-renderer-v8/src/script_vm/inspector/input_state.rs index 9f16232bd..a4474ccf1 100644 --- a/moli-renderer-v8/src/script_vm/inspector/input_state.rs +++ b/moli-renderer-v8/src/script_vm/inspector/input_state.rs @@ -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) } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/style_invalidation.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/style_invalidation.rs index 584c7fe70..b6ff8a298 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/style_invalidation.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/style_invalidation.rs @@ -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/"); diff --git a/moli-selector/src/lib.rs b/moli-selector/src/lib.rs index d74cb1003..ca3784e51 100644 --- a/moli-selector/src/lib.rs +++ b/moli-selector/src/lib.rs @@ -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(); diff --git a/moli-selector/src/stylo/query/pseudo.rs b/moli-selector/src/stylo/query/pseudo.rs index 8581b69f7..dba3cd832 100644 --- a/moli-selector/src/stylo/query/pseudo.rs +++ b/moli-selector/src/stylo/query/pseudo.rs @@ -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 { 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 { - 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 { + 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 {