script/dom/html/
htmldetailselement.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::cell::{Cell, Ref};
6use std::collections::HashMap;
7use std::collections::hash_map::Entry;
8
9use dom_struct::dom_struct;
10use html5ever::{LocalName, Prefix, QualName, local_name, ns};
11use js::rust::HandleObject;
12use script_bindings::domstring::DOMString;
13
14use crate::dom::attr::Attr;
15use crate::dom::bindings::cell::DomRefCell;
16use crate::dom::bindings::codegen::Bindings::HTMLDetailsElementBinding::HTMLDetailsElementMethods;
17use crate::dom::bindings::codegen::Bindings::HTMLSlotElementBinding::HTMLSlotElement_Binding::HTMLSlotElementMethods;
18use crate::dom::bindings::codegen::Bindings::NodeBinding::GetRootNodeOptions;
19use crate::dom::bindings::codegen::Bindings::NodeBinding::Node_Binding::NodeMethods;
20use crate::dom::bindings::codegen::UnionTypes::ElementOrText;
21use crate::dom::bindings::inheritance::Castable;
22use crate::dom::bindings::refcounted::Trusted;
23use crate::dom::bindings::reflector::DomGlobal;
24use crate::dom::bindings::root::{Dom, DomRoot};
25use crate::dom::document::Document;
26use crate::dom::element::{AttributeMutation, CustomElementCreationMode, Element, ElementCreator};
27use crate::dom::event::{Event, EventBubbles, EventCancelable};
28use crate::dom::eventtarget::EventTarget;
29use crate::dom::html::htmlelement::HTMLElement;
30use crate::dom::html::htmlslotelement::HTMLSlotElement;
31use crate::dom::node::{
32    BindContext, ChildrenMutation, IsShadowTree, Node, NodeDamage, NodeTraits, ShadowIncluding,
33    UnbindContext,
34};
35use crate::dom::text::Text;
36use crate::dom::toggleevent::ToggleEvent;
37use crate::dom::virtualmethods::VirtualMethods;
38use crate::script_runtime::CanGc;
39
40/// The summary that should be presented if no `<summary>` element is present
41const DEFAULT_SUMMARY: &str = "Details";
42
43/// Holds handles to all slots in the UA shadow tree
44///
45/// The composition of the tree is described in
46/// <https://html.spec.whatwg.org/multipage/#the-details-and-summary-elements>
47#[derive(Clone, JSTraceable, MallocSizeOf)]
48#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
49struct ShadowTree {
50    summary: Dom<HTMLSlotElement>,
51    descendants: Dom<HTMLSlotElement>,
52    /// The summary that is displayed if no other summary exists
53    implicit_summary: Dom<HTMLElement>,
54}
55
56#[dom_struct]
57pub(crate) struct HTMLDetailsElement {
58    htmlelement: HTMLElement,
59    toggle_counter: Cell<u32>,
60
61    /// Represents the UA widget for the details element
62    shadow_tree: DomRefCell<Option<ShadowTree>>,
63}
64
65/// Tracks all [details name groups](https://html.spec.whatwg.org/multipage/#details-name-group)
66/// within a tree.
67#[derive(Clone, Default, JSTraceable, MallocSizeOf)]
68#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
69pub(crate) struct DetailsNameGroups {
70    /// Map from `name` attribute to a list of details elements.
71    pub(crate) groups: HashMap<DOMString, Vec<Dom<HTMLDetailsElement>>>,
72}
73
74/// Describes how to proceed in case two details elements in the same
75/// [details name groups](https://html.spec.whatwg.org/multipage/#details-name-group) are
76/// open at the same time
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78enum ExclusivityConflictResolution {
79    CloseThisElement,
80    CloseExistingOpenElement,
81}
82
83impl DetailsNameGroups {
84    fn register_details_element(&mut self, details_element: &HTMLDetailsElement) {
85        let name = details_element.Name();
86        if name.is_empty() {
87            return;
88        }
89
90        debug!("Registering details element with name={name:?}");
91        let details_elements_with_the_same_name = self.groups.entry(name).or_default();
92
93        // The spec tells us to keep the list in tree order, but that's not actually necessary.
94        details_elements_with_the_same_name.push(Dom::from_ref(details_element));
95    }
96
97    fn unregister_details_element(
98        &mut self,
99        name: DOMString,
100        details_element: &HTMLDetailsElement,
101    ) {
102        if name.is_empty() {
103            return;
104        }
105
106        debug!("Unregistering details element with name={name:?}");
107        let Entry::Occupied(mut entry) = self.groups.entry(name) else {
108            panic!("details element is not registered");
109        };
110        entry
111            .get_mut()
112            .retain(|group_member| details_element != &**group_member);
113    }
114
115    /// Returns an iterator over all members with the given name, except for `details`.
116    fn group_members_for(
117        &self,
118        name: &DOMString,
119        details: &HTMLDetailsElement,
120    ) -> impl Iterator<Item = DomRoot<HTMLDetailsElement>> {
121        self.groups
122            .get(name)
123            .map(|members| members.iter())
124            .expect("No details element with the given name was registered for the tree")
125            .filter(move |member| **member != details)
126            .map(|member| member.as_rooted())
127    }
128}
129
130impl HTMLDetailsElement {
131    fn new_inherited(
132        local_name: LocalName,
133        prefix: Option<Prefix>,
134        document: &Document,
135    ) -> HTMLDetailsElement {
136        HTMLDetailsElement {
137            htmlelement: HTMLElement::new_inherited(local_name, prefix, document),
138            toggle_counter: Cell::new(0),
139            shadow_tree: Default::default(),
140        }
141    }
142
143    #[cfg_attr(crown, allow(crown::unrooted_must_root))]
144    pub(crate) fn new(
145        local_name: LocalName,
146        prefix: Option<Prefix>,
147        document: &Document,
148        proto: Option<HandleObject>,
149        can_gc: CanGc,
150    ) -> DomRoot<HTMLDetailsElement> {
151        Node::reflect_node_with_proto(
152            Box::new(HTMLDetailsElement::new_inherited(
153                local_name, prefix, document,
154            )),
155            document,
156            proto,
157            can_gc,
158        )
159    }
160
161    pub(crate) fn toggle(&self) {
162        self.SetOpen(!self.Open());
163    }
164
165    fn shadow_tree(&self, can_gc: CanGc) -> Ref<'_, ShadowTree> {
166        if !self.upcast::<Element>().is_shadow_host() {
167            self.create_shadow_tree(can_gc);
168        }
169
170        Ref::filter_map(self.shadow_tree.borrow(), Option::as_ref)
171            .ok()
172            .expect("UA shadow tree was not created")
173    }
174
175    fn create_shadow_tree(&self, can_gc: CanGc) {
176        let document = self.owner_document();
177        // TODO(stevennovaryo): Reimplement details styling so that it would not
178        //                      mess the cascading and require some reparsing.
179        let root = self
180            .upcast::<Element>()
181            .attach_ua_shadow_root(false, can_gc);
182
183        let summary = Element::create(
184            QualName::new(None, ns!(html), local_name!("slot")),
185            None,
186            &document,
187            ElementCreator::ScriptCreated,
188            CustomElementCreationMode::Asynchronous,
189            None,
190            can_gc,
191        );
192        let summary = DomRoot::downcast::<HTMLSlotElement>(summary).unwrap();
193        root.upcast::<Node>()
194            .AppendChild(summary.upcast::<Node>(), can_gc)
195            .unwrap();
196
197        let fallback_summary = Element::create(
198            QualName::new(None, ns!(html), local_name!("summary")),
199            None,
200            &document,
201            ElementCreator::ScriptCreated,
202            CustomElementCreationMode::Asynchronous,
203            None,
204            can_gc,
205        );
206        let fallback_summary = DomRoot::downcast::<HTMLElement>(fallback_summary).unwrap();
207        fallback_summary
208            .upcast::<Node>()
209            .set_text_content_for_element(Some(DEFAULT_SUMMARY.into()), can_gc);
210        summary
211            .upcast::<Node>()
212            .AppendChild(fallback_summary.upcast::<Node>(), can_gc)
213            .unwrap();
214
215        let descendants = Element::create(
216            QualName::new(None, ns!(html), local_name!("slot")),
217            None,
218            &document,
219            ElementCreator::ScriptCreated,
220            CustomElementCreationMode::Asynchronous,
221            None,
222            can_gc,
223        );
224        let descendants = DomRoot::downcast::<HTMLSlotElement>(descendants).unwrap();
225        root.upcast::<Node>()
226            .AppendChild(descendants.upcast::<Node>(), can_gc)
227            .unwrap();
228
229        let _ = self.shadow_tree.borrow_mut().insert(ShadowTree {
230            summary: summary.as_traced(),
231            descendants: descendants.as_traced(),
232            implicit_summary: fallback_summary.as_traced(),
233        });
234        self.upcast::<Node>()
235            .dirty(crate::dom::node::NodeDamage::Other);
236    }
237
238    pub(crate) fn find_corresponding_summary_element(&self) -> Option<DomRoot<HTMLElement>> {
239        self.upcast::<Node>()
240            .children()
241            .filter_map(DomRoot::downcast::<HTMLElement>)
242            .find(|html_element| {
243                html_element.upcast::<Element>().local_name() == &local_name!("summary")
244            })
245    }
246
247    fn update_shadow_tree_contents(&self, can_gc: CanGc) {
248        let shadow_tree = self.shadow_tree(can_gc);
249
250        if let Some(summary) = self.find_corresponding_summary_element() {
251            shadow_tree
252                .summary
253                .Assign(vec![ElementOrText::Element(DomRoot::upcast(summary))]);
254        }
255
256        let mut slottable_children = vec![];
257        for child in self.upcast::<Node>().children() {
258            if let Some(element) = child.downcast::<Element>() {
259                if element.local_name() == &local_name!("summary") {
260                    continue;
261                }
262
263                slottable_children.push(ElementOrText::Element(DomRoot::from_ref(element)));
264            }
265
266            if let Some(text) = child.downcast::<Text>() {
267                slottable_children.push(ElementOrText::Text(DomRoot::from_ref(text)));
268            }
269        }
270        shadow_tree.descendants.Assign(slottable_children);
271    }
272
273    fn update_shadow_tree_styles(&self, can_gc: CanGc) {
274        let shadow_tree = self.shadow_tree(can_gc);
275
276        let value = if self.Open() {
277            "display: block;"
278        } else {
279            // TODO: This should be "display: block; content-visibility: hidden;",
280            // but servo does not support content-visibility yet
281            "display: none;"
282        };
283        shadow_tree
284            .descendants
285            .upcast::<Element>()
286            .set_string_attribute(&local_name!("style"), value.into(), can_gc);
287
288        // Manually update the list item style of the implicit summary element.
289        // Unlike the other summaries, this summary is in the shadow tree and
290        // can't be styled with UA sheets
291        let implicit_summary_list_item_style = if self.Open() {
292            "disclosure-open"
293        } else {
294            "disclosure-closed"
295        };
296        let implicit_summary_style = format!(
297            "display: list-item;
298            counter-increment: list-item 0;
299            list-style: {implicit_summary_list_item_style} inside;"
300        );
301        shadow_tree
302            .implicit_summary
303            .upcast::<Element>()
304            .set_string_attribute(&local_name!("style"), implicit_summary_style.into(), can_gc);
305    }
306
307    /// <https://html.spec.whatwg.org/multipage/#ensure-details-exclusivity-by-closing-the-given-element-if-needed>
308    /// <https://html.spec.whatwg.org/multipage/#ensure-details-exclusivity-by-closing-other-elements-if-needed>
309    fn ensure_details_exclusivity(
310        &self,
311        conflict_resolution_behaviour: ExclusivityConflictResolution,
312    ) {
313        // NOTE: This method implements two spec algorithms that are very similar to each other, distinguished by the
314        // `conflict_resolution_behaviour` argument. Steps that are different between the two are annotated with two
315        // spec comments.
316
317        // Step 1. Assert: element has an open attribute.
318        // Step 1. If element does not have an open attribute, then return.
319        if !self.Open() {
320            if conflict_resolution_behaviour ==
321                ExclusivityConflictResolution::CloseExistingOpenElement
322            {
323                unreachable!()
324            } else {
325                return;
326            }
327        }
328
329        // Step 2. If element does not have a name attribute, or its name attribute
330        // is the empty string, then return.
331        let name = self.Name();
332        if name.is_empty() {
333            return;
334        }
335
336        // Step 3. Let groupMembers be a list of elements, containing all elements in element's
337        // details name group except for element, in tree order.
338        // Step 4. For each element otherElement of groupMembers:
339        //     Step 4.1 If the open attribute is set on otherElement, then:
340
341        // NOTE: We implement an optimization that allows us to easily find details group members when the
342        // root of the tree is a document or shadow root, which is why this looks a bit more complicated.
343        let other_open_member = if let Some(shadow_root) = self.containing_shadow_root() {
344            shadow_root
345                .details_name_groups()
346                .group_members_for(&name, self)
347                .find(|group_member| group_member.Open())
348        } else if self.upcast::<Node>().is_in_a_document_tree() {
349            self.owner_document()
350                .details_name_groups()
351                .group_members_for(&name, self)
352                .find(|group_member| group_member.Open())
353        } else {
354            // This is the slow case, which is hopefully not too common.
355            self.upcast::<Node>()
356                .GetRootNode(&GetRootNodeOptions::empty())
357                .traverse_preorder(ShadowIncluding::No)
358                .flat_map(DomRoot::downcast::<HTMLDetailsElement>)
359                .filter(|details_element| {
360                    details_element
361                        .upcast::<Element>()
362                        .get_string_attribute(&local_name!("name")) ==
363                        name
364                })
365                .filter(|group_member| &**group_member != self)
366                .find(|group_member| group_member.Open())
367        };
368
369        if let Some(other_open_member) = other_open_member {
370            // Step 4.1.1 Assert: otherElement is the only element in groupMembers that has the open attribute set.
371            // Step 4.1.2 Remove the open attribute on otherElement.
372            // Step 4.1.3 Break.
373            //
374            // Step 4.1.1 Remove the open attribute on element.
375            // Step 4.1.2 Break.
376            // NOTE: We don't bother to assert here and don't need to "break" since we're not in a loop.
377            match conflict_resolution_behaviour {
378                ExclusivityConflictResolution::CloseThisElement => self.SetOpen(false),
379                ExclusivityConflictResolution::CloseExistingOpenElement => {
380                    other_open_member.SetOpen(false)
381                },
382            }
383        }
384    }
385}
386
387impl HTMLDetailsElementMethods<crate::DomTypeHolder> for HTMLDetailsElement {
388    // https://html.spec.whatwg.org/multipage/#dom-details-name
389    make_getter!(Name, "name");
390
391    // https://html.spec.whatwg.org/multipage/#dom-details-name
392    make_atomic_setter!(SetName, "name");
393
394    // https://html.spec.whatwg.org/multipage/#dom-details-open
395    make_bool_getter!(Open, "open");
396
397    // https://html.spec.whatwg.org/multipage/#dom-details-open
398    make_bool_setter!(SetOpen, "open");
399}
400
401impl VirtualMethods for HTMLDetailsElement {
402    fn super_type(&self) -> Option<&dyn VirtualMethods> {
403        Some(self.upcast::<HTMLElement>() as &dyn VirtualMethods)
404    }
405
406    /// <https://html.spec.whatwg.org/multipage/#the-details-element:concept-element-attributes-change-ext>
407    fn attribute_mutated(&self, attr: &Attr, mutation: AttributeMutation, can_gc: CanGc) {
408        self.super_type()
409            .unwrap()
410            .attribute_mutated(attr, mutation, can_gc);
411
412        // Step 1. If namespace is not null, then return.
413        if *attr.namespace() != ns!() {
414            return;
415        }
416
417        // Step 2. If localName is name, then ensure details exclusivity by closing the given element if needed
418        // given element.
419        if attr.local_name() == &local_name!("name") {
420            let old_name: Option<DOMString> = match mutation {
421                AttributeMutation::Set(old, _) => old.map(|value| value.to_string().into()),
422                AttributeMutation::Removed => Some(attr.value().to_string().into()),
423            };
424
425            if let Some(shadow_root) = self.containing_shadow_root() {
426                if let Some(old_name) = old_name {
427                    shadow_root
428                        .details_name_groups()
429                        .unregister_details_element(old_name, self);
430                }
431                if matches!(mutation, AttributeMutation::Set(..)) {
432                    shadow_root
433                        .details_name_groups()
434                        .register_details_element(self);
435                }
436            } else if self.upcast::<Node>().is_in_a_document_tree() {
437                let document = self.owner_document();
438                if let Some(old_name) = old_name {
439                    document
440                        .details_name_groups()
441                        .unregister_details_element(old_name, self);
442                }
443                if matches!(mutation, AttributeMutation::Set(..)) {
444                    document
445                        .details_name_groups()
446                        .register_details_element(self);
447                }
448            }
449
450            self.ensure_details_exclusivity(ExclusivityConflictResolution::CloseThisElement);
451        }
452        // Step 3. If localName is open, then:
453        else if attr.local_name() == &local_name!("open") {
454            self.update_shadow_tree_styles(can_gc);
455
456            let counter = self.toggle_counter.get().wrapping_add(1);
457            self.toggle_counter.set(counter);
458            let (old_state, new_state) = if self.Open() {
459                ("closed", "open")
460            } else {
461                ("open", "closed")
462            };
463
464            let this = Trusted::new(self);
465            self.owner_global()
466                .task_manager()
467                .dom_manipulation_task_source()
468                .queue(task!(details_notification_task_steps: move || {
469                    let this = this.root();
470                    if counter == this.toggle_counter.get() {
471                        let event = ToggleEvent::new(
472                            this.global().as_window(),
473                            atom!("toggle"),
474                            EventBubbles::DoesNotBubble,
475                            EventCancelable::NotCancelable,
476                            DOMString::from(old_state),
477                            DOMString::from(new_state),
478                            None,
479                            CanGc::note(),
480                        );
481                        let event = event.upcast::<Event>();
482                        event.fire(this.upcast::<EventTarget>(), CanGc::note());
483                    }
484                }));
485            self.upcast::<Node>().dirty(NodeDamage::Other);
486
487            // Step 3.2. If oldValue is null and value is not null, then ensure details exclusivity
488            // by closing other elements if needed given element.
489            let was_previously_closed = match mutation {
490                AttributeMutation::Set(old, _) => old.is_none(),
491                AttributeMutation::Removed => false,
492            };
493            if was_previously_closed && self.Open() {
494                self.ensure_details_exclusivity(
495                    ExclusivityConflictResolution::CloseExistingOpenElement,
496                );
497            }
498        }
499    }
500
501    fn children_changed(&self, mutation: &ChildrenMutation, can_gc: CanGc) {
502        self.super_type()
503            .unwrap()
504            .children_changed(mutation, can_gc);
505
506        self.update_shadow_tree_contents(can_gc);
507    }
508
509    /// <https://html.spec.whatwg.org/multipage/#the-details-element:html-element-insertion-steps>
510    fn bind_to_tree(&self, context: &BindContext, can_gc: CanGc) {
511        self.super_type().unwrap().bind_to_tree(context, can_gc);
512
513        self.update_shadow_tree_contents(can_gc);
514        self.update_shadow_tree_styles(can_gc);
515
516        if context.tree_is_in_a_document_tree {
517            // If this is true then we can't have been in a document tree previously, so
518            // we register ourselves.
519            self.owner_document()
520                .details_name_groups()
521                .register_details_element(self);
522        }
523
524        let was_already_in_shadow_tree = context.is_shadow_tree == IsShadowTree::Yes;
525        if !was_already_in_shadow_tree {
526            if let Some(shadow_root) = self.containing_shadow_root() {
527                shadow_root
528                    .details_name_groups()
529                    .register_details_element(self);
530            }
531        }
532
533        // Step 1. Ensure details exclusivity by closing the given element if needed given insertedNode.
534        self.ensure_details_exclusivity(ExclusivityConflictResolution::CloseThisElement);
535    }
536
537    fn unbind_from_tree(&self, context: &UnbindContext, can_gc: CanGc) {
538        self.super_type().unwrap().unbind_from_tree(context, can_gc);
539
540        if context.tree_is_in_a_document_tree && !self.upcast::<Node>().is_in_a_document_tree() {
541            self.owner_document()
542                .details_name_groups()
543                .unregister_details_element(self.Name(), self);
544        }
545
546        if !self.upcast::<Node>().is_in_a_shadow_tree() {
547            if let Some(old_shadow_root) = self.containing_shadow_root() {
548                // If we used to be in a shadow root, but aren't anymore, then unregister this details
549                // element.
550                old_shadow_root
551                    .details_name_groups()
552                    .unregister_details_element(self.Name(), self);
553            }
554        }
555    }
556}