Skip to main content

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