Skip to main content

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