Skip to main content

blitz_dom/
select.rs

1//! The `<select>` element's list of options and its selectedness.
2//!
3//! There was no notion of selectedness anywhere in the engine: an `<option>`
4//! was a `display: none` node with a `selected` attribute nobody read, so a
5//! select could be measured and pressed but could not say what it offered or
6//! what was chosen. A QA harness driving worktables.dev's schema designer had
7//! nothing to assert against and nothing to change.
8
9use crate::node::SelectData;
10use crate::traversal::TreeTraverser;
11use crate::{BaseDocument, local_name};
12use blitz_traits::node_id::NodeId;
13
14impl BaseDocument {
15    /// A select's *list of options*: every descendant `<option>` in tree order.
16    ///
17    /// A subtree walk rather than a scan of the direct children, because
18    /// `<optgroup>` nests them one level down and the flattened order is what
19    /// `selectedIndex` counts.
20    ///
21    /// Recomputed on every call rather than cached on the select. The cached
22    /// form would have to be invalidated from every mutation path that can add
23    /// or remove an option, and the walk is over a handful of nodes.
24    pub fn select_options(&self, select_id: NodeId) -> Vec<NodeId> {
25        if !self
26            .get_node(select_id)
27            .is_some_and(|node| node.data.is_element_with_tag_name(&local_name!("select")))
28        {
29            return Vec::new();
30        }
31        TreeTraverser::new_with_root(self, select_id)
32            .filter(|id| {
33                self.get_node(*id)
34                    .is_some_and(|node| node.data.is_element_with_tag_name(&local_name!("option")))
35            })
36            .collect()
37    }
38
39    /// Whether the option is disabled, either in itself or through the
40    /// `<optgroup>` containing it. A disabled option cannot be picked and is
41    /// skipped by the arrow keys.
42    pub fn option_is_disabled(&self, option_id: NodeId) -> bool {
43        let mut current = Some(option_id);
44        while let Some(id) = current {
45            let Some(node) = self.get_node(id) else {
46                return false;
47            };
48            let Some(el) = node.data.downcast_element() else {
49                return false;
50            };
51            if el.attr(local_name!("disabled")).is_some() {
52                return true;
53            }
54            if el.name.local == local_name!("select") {
55                // The select's own `disabled` is the control's, not the
56                // option's; stop before inheriting it.
57                return false;
58            }
59            current = node.parent;
60        }
61        false
62    }
63
64    /// An option's label: the `label` attribute if it has one, otherwise its
65    /// stripped-and-collapsed text. This is what the option shows and what the
66    /// accessibility tree reports as its name.
67    pub fn option_label(&self, option_id: NodeId) -> String {
68        let Some(node) = self.get_node(option_id) else {
69            return String::new();
70        };
71        if let Some(label) = node.data.downcast_element().and_then(|el| {
72            el.attr(local_name!("label"))
73                .filter(|label| !label.is_empty())
74        }) {
75            return label.to_string();
76        }
77        collapse_whitespace(&node.text_content())
78    }
79
80    /// An option's submission value: the `value` attribute if present,
81    /// otherwise its label. An empty `value=""` is a value, not an absence,
82    /// which is why this tests for the attribute rather than for a non-empty
83    /// string.
84    pub fn option_value(&self, option_id: NodeId) -> String {
85        let Some(node) = self.get_node(option_id) else {
86            return String::new();
87        };
88        if let Some(value) = node
89            .data
90            .downcast_element()
91            .and_then(|el| el.attr(local_name!("value")))
92        {
93            return value.to_string();
94        }
95        self.option_label(option_id)
96    }
97
98    /// The selectedness a freshly constructed select should start with.
99    ///
100    /// The `selected` content attribute seeds it. A single select then has to
101    /// end up with exactly one option selected if it has any enabled ones at
102    /// all: HTML's *ask for a reset* step picks the last option carrying the
103    /// attribute, or the first non-disabled option when none does. Without that
104    /// last part a plain `<select>` with no `selected` anywhere would report an
105    /// empty value, which is not what it submits or displays.
106    pub fn initial_select_data(&self, select_id: NodeId) -> SelectData {
107        let options = self.select_options(select_id);
108        let mut selected: Vec<bool> = options
109            .iter()
110            .map(|id| {
111                self.get_node(*id)
112                    .and_then(|node| node.data.downcast_element())
113                    .is_some_and(|el| el.attr(local_name!("selected")).is_some())
114            })
115            .collect();
116
117        let multiple = self
118            .get_node(select_id)
119            .and_then(|node| node.data.downcast_element())
120            .is_some_and(|el| el.attr(local_name!("multiple")).is_some());
121
122        if !multiple {
123            let last = selected.iter().rposition(|s| *s);
124            let chosen = last.or_else(|| {
125                options
126                    .iter()
127                    .position(|id| !self.option_is_disabled(*id))
128                    .filter(|_| self.select_display_size(select_id) <= 1)
129            });
130            for (i, entry) in selected.iter_mut().enumerate() {
131                *entry = Some(i) == chosen;
132            }
133        }
134
135        SelectData::new(selected)
136    }
137
138    /// The number of rows the select shows: `size`, or one for a drop-down and
139    /// four for a `multiple` list box, which is what browsers settled on.
140    ///
141    /// Only the "is this a drop-down" question is asked of it here: a list box
142    /// (`size` greater than one) does *not* auto-select its first option, while
143    /// a drop-down must, because a drop-down always displays something.
144    pub fn select_display_size(&self, select_id: NodeId) -> u32 {
145        let Some(el) = self
146            .get_node(select_id)
147            .and_then(|node| node.data.downcast_element())
148        else {
149            return 1;
150        };
151        el.attr(local_name!("size"))
152            .and_then(|size| size.parse::<u32>().ok())
153            .filter(|rows| *rows >= 1)
154            .unwrap_or(if el.attr(local_name!("multiple")).is_some() {
155                4
156            } else {
157                1
158            })
159    }
160
161    /// The node id of the select's currently selected option, if any.
162    pub fn select_selected_option(&self, select_id: NodeId) -> Option<NodeId> {
163        let data = self
164            .get_node(select_id)?
165            .data
166            .downcast_element()?
167            .select_data()?;
168        let index = data.selected_index()?;
169        self.select_options(select_id).get(index).copied()
170    }
171
172    /// A select's value: the value of its first selected option, or the empty
173    /// string when nothing is selected. This is `HTMLSelectElement.value`.
174    pub fn select_value(&self, select_id: NodeId) -> String {
175        self.select_selected_option(select_id)
176            .map(|option_id| self.option_value(option_id))
177            .unwrap_or_default()
178    }
179
180    /// The label of the select's selected option, which is what the control
181    /// displays and what the accessibility tree reports as its value.
182    pub fn select_label(&self, select_id: NodeId) -> String {
183        self.select_selected_option(select_id)
184            .map(|option_id| self.option_label(option_id))
185            .unwrap_or_default()
186    }
187
188    /// `selectedIndex`, or `None` when nothing is selected.
189    pub fn select_selected_index(&self, select_id: NodeId) -> Option<usize> {
190        self.get_node(select_id)?
191            .data
192            .downcast_element()?
193            .select_data()?
194            .selected_index()
195    }
196
197    /// Whether the option is selected, according to the parent select's live
198    /// state when it has any and the `selected` content attribute before
199    /// construction has run.
200    pub fn option_is_selected(&self, option_id: NodeId) -> bool {
201        match self.option_owner_select(option_id) {
202            Some(select_id) => {
203                let index = self
204                    .select_options(select_id)
205                    .iter()
206                    .position(|id| *id == option_id);
207                match (index, self.select_live_data(select_id)) {
208                    (Some(index), Some(data)) => data.is_selected(index),
209                    _ => self.option_has_selected_attr(option_id),
210                }
211            }
212            None => self.option_has_selected_attr(option_id),
213        }
214    }
215
216    /// The `<select>` an option belongs to, walking out through any
217    /// `<optgroup>`.
218    pub fn option_owner_select(&self, option_id: NodeId) -> Option<NodeId> {
219        let mut current = self.get_node(option_id)?.parent;
220        while let Some(id) = current {
221            let node = self.get_node(id)?;
222            if node.data.is_element_with_tag_name(&local_name!("select")) {
223                return Some(id);
224            }
225            current = node.parent;
226        }
227        None
228    }
229
230    fn select_live_data(&self, select_id: NodeId) -> Option<&SelectData> {
231        self.get_node(select_id)?
232            .data
233            .downcast_element()?
234            .select_data()
235    }
236
237    fn option_has_selected_attr(&self, option_id: NodeId) -> bool {
238        self.get_node(option_id)
239            .and_then(|node| node.data.downcast_element())
240            .is_some_and(|el| el.attr(local_name!("selected")).is_some())
241    }
242
243    /// Select the option at `index`, clearing the others on a single select.
244    /// Returns whether the selection actually changed, so a caller can decide
245    /// whether an `input` event is owed.
246    ///
247    /// Selecting a disabled option is refused rather than ignored, because the
248    /// keyboard handler steps over disabled options and a script that asks for
249    /// one directly should not end up with a value the control would never
250    /// submit.
251    pub fn set_select_selected_index(&mut self, select_id: NodeId, index: usize) -> bool {
252        let options = self.select_options(select_id);
253        if options
254            .get(index)
255            .is_none_or(|id| self.option_is_disabled(*id))
256        {
257            return false;
258        }
259        let multiple = self
260            .get_node(select_id)
261            .and_then(|node| node.data.downcast_element())
262            .is_some_and(|el| el.attr(local_name!("multiple")).is_some());
263        let option_count = options.len();
264
265        let Some(data) = self
266            .get_node_mut(select_id)
267            .and_then(|node| node.data.downcast_element_mut())
268            .and_then(|el| el.select_data_mut())
269        else {
270            return false;
271        };
272        // Construction sizes this, but a script can append an <option> and set
273        // it selected before the next resolve has run, at which point the entry
274        // does not exist yet and the write would be silently dropped.
275        data.resize(option_count);
276        if multiple {
277            data.set_selected(index, true)
278        } else {
279            data.select_only(index)
280        }
281    }
282
283    /// The index the arrow keys move to from the current selection, skipping
284    /// disabled options. `None` when there is nowhere to go.
285    pub fn select_index_step(&self, select_id: NodeId, forwards: bool) -> Option<usize> {
286        let options = self.select_options(select_id);
287        if options.is_empty() {
288            return None;
289        }
290        let current = self.select_selected_index(select_id);
291        let mut index = match current {
292            Some(current) => current as isize,
293            // With nothing selected, the first press lands on the end the key
294            // points away from rather than moving off nowhere.
295            None => {
296                return options
297                    .iter()
298                    .enumerate()
299                    .filter(|(_, id)| !self.option_is_disabled(**id))
300                    .map(|(i, _)| i)
301                    .next_back()
302                    .filter(|_| !forwards)
303                    .or_else(|| {
304                        options
305                            .iter()
306                            .position(|id| !self.option_is_disabled(*id))
307                            .filter(|_| forwards)
308                    });
309            }
310        };
311        loop {
312            index += if forwards { 1 } else { -1 };
313            if index < 0 || index as usize >= options.len() {
314                return None;
315            }
316            if !self.option_is_disabled(options[index as usize]) {
317                return Some(index as usize);
318            }
319        }
320    }
321
322    /// The first or last selectable option, for Home and End.
323    pub fn select_index_edge(&self, select_id: NodeId, last: bool) -> Option<usize> {
324        let options = self.select_options(select_id);
325        let mut enabled = options
326            .iter()
327            .enumerate()
328            .filter(|(_, id)| !self.option_is_disabled(**id))
329            .map(|(i, _)| i);
330        if last {
331            enabled.next_back()
332        } else {
333            enabled.next()
334        }
335    }
336}
337
338/// Strip and collapse the way an option's label is rendered. Authors indent
339/// their markup, so the raw text of `<option>\n  France\n</option>` is neither
340/// what is shown nor what is submitted.
341fn collapse_whitespace(text: &str) -> String {
342    let mut out = String::with_capacity(text.len());
343    for word in text.split_whitespace() {
344        if !out.is_empty() {
345            out.push(' ');
346        }
347        out.push_str(word);
348    }
349    out
350}