rdom-tui 0.5.0

Terminal rendering layer for rdom-core — flexbox layout, TUI styles, key/mouse events. Use rdom-core directly for headless DOM manipulation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
//! `<form>` submission + reset infrastructure.
//!
//! ## Contract (from MDN)
//!
//! - **Submit triggers**:
//!   - Click on `<input type="submit">`, `<input type="image">`,
//!     `<button type="submit">`,
//!     or `<button>` without a `type` attribute (HTML default for
//!     buttons in a form is "submit").
//!   - **Implicit submission** (HTML §4.10.21.2): Enter in a
//!     single-line text input fires a `click` at the form's *default
//!     button* — the first submit button it owns, in tree order — which then
//!     submits with that button as the submitter; a disabled default
//!     button makes Enter do nothing. A form with no submit button
//!     submits (submitter `None`) only when the focused input is its
//!     one field that blocks implicit submission.
//! - **Form owner** (HTML §4.10.17.3, `Dom::form_owner`): a control's
//!   form is the `<form>` its `form="id"` attribute names, else its
//!   nearest ancestor `<form>`; a `form` attribute naming no form means
//!   no owner. Submission, reset, [`elements`], [`collect`] and implicit
//!   submission all work on the controls a form *owns*, in tree order
//!   of the whole document — controls outside the form that name it
//!   included.
//! - The `submit` event carries `EventDetail::Submit(SubmitDetail)`
//!   (`Dom::submit_detail`): `submitter` (`SubmitEvent.submitter`) and
//!   the effective `action` / `method` / `enctype` / `target` /
//!   `no_validate` — the submitter's `formaction` / `formmethod` /
//!   `formenctype` / `formtarget` / `formnovalidate` over the form's
//!   attributes (§4.10.19.6). rdom has no navigation; the handler
//!   decides what submitting means. A handler passes the submitter to
//!   [`collect_with_submitter`] so the entry list holds the submitter's
//!   name / value, as `new FormData(form, submitter)`.
//! - **Reset triggers**: click on `<input type="reset">` or
//!   `<button type="reset">`.
//! - Submit fires the `submit` event on the `<form>` element —
//!   bubbling, **cancelable**. `preventDefault()` blocks the
//!   default action (a TUI app's submit handler usually
//!   `preventDefault`s and reads form data via [`collect`]).
//! - Reset fires the `reset` event on the form, also cancelable.
//!   Unless canceled, every control goes back to its default:
//!   `defaultValue` (text controls, ranges), `defaultChecked`
//!   (checkboxes, radios), `defaultSelected` (`<select>` options).
//! - An effective method of `dialog` (the form's `method` or the
//!   submitter's `formmethod`) closes the form's nearest ancestor
//!   `<dialog>` with the submitter's `value` unless the submit was
//!   canceled.
//!
//! - **Constraint validation** (HTML §4.10.21.3 step 6.3): every
//!   submission path interactively validates the form first unless the
//!   form has `novalidate` or the submitter `formnovalidate`; an invalid
//!   control blocks the `submit` event (`validation`).
//!
//! ## v1 deliberate simplifications
//!
//! - No `formdata` event (would require a `FormData` shim).

use rdom_core::{FormMethod, InputTypeState, ListenerOptions, NodeId};

use crate::{TuiDom, TuiEvent};

/// Install the form default actions. Two root-level listeners:
/// click (submit / reset trigger), keydown (implicit Enter
/// submit on single-text-input forms).
pub fn install(dom: &mut TuiDom) {
    let root = dom.root();

    dom.add_event_listener(root, "click", ListenerOptions::default(), move |ctx| {
        if ctx.event.default_prevented() {
            return;
        }
        let Some(target) = ctx.event.target else {
            return;
        };
        let Some(button) = closest_form_button(ctx.dom, target) else {
            return;
        };
        if ctx.dom.is_actually_disabled(button) {
            return;
        }
        let Some(form) = ctx.dom.form_owner(button) else {
            return;
        };
        match button_action(ctx.dom, button) {
            ButtonAction::Submit => {
                submit(ctx.dom, form, Some(button));
            }
            ButtonAction::Reset => {
                if fire_reset(ctx.dom, form) {
                    reset_controls(ctx.dom, form);
                }
            }
            ButtonAction::Button => {} // no default action
        }
    })
    .expect("form click listener install");

    // Implicit submission (HTML §4.10.21.2), from Enter in a
    // single-line text-family input.
    dom.add_event_listener(root, "keydown", ListenerOptions::default(), move |ctx| {
        if ctx.event.default_prevented() {
            return;
        }
        let Some(focused) = ctx.dom.focused() else {
            return;
        };
        let Some(key) = ctx.event.detail.as_keyboard() else {
            return;
        };
        let no_mods = !key.modifiers.ctrl
            && !key.modifiers.shift
            && !key.modifiers.alt
            && !key.modifiers.meta;
        if !no_mods || key.key != "Enter" {
            return;
        }
        if !crate::node::is_text_input(ctx.dom, focused) {
            return;
        }
        let Some(form) = ctx.dom.form_owner(focused) else {
            return;
        };
        if let Some(default) = default_button(ctx.dom, form) {
            // The default button's activation behavior does the
            // submitting (and the `method="dialog"` close) through the
            // click listener above, with it as the submitter. A
            // disabled default button blocks implicit submission.
            if !ctx.dom.is_actually_disabled(default) {
                use crate::accessors::TuiAccessorsMut;
                ctx.dom.node_mut(default).click();
            }
            return;
        }
        // No submit button: submit from the form itself, only when
        // the focused input is the one field that blocks implicit
        // submission.
        if count_text_inputs(ctx.dom, form) != 1 {
            return;
        }
        submit(ctx.dom, form, None);
    })
    .expect("form implicit-enter submit listener install");
}

/// The form's entry list with no submitter — `new FormData(form)`:
/// every form-controlled element's `(name, value)` pair, and no button
/// entry at all. A `submit` handler that wants the clicked button's
/// entry calls [`collect_with_submitter`] instead.
///
/// Rules (v1):
/// - Only elements with a non-empty `name` attribute participate.
/// - Actually disabled controls are skipped: own `disabled`, or inside a
///   `<fieldset disabled>` outside its first `<legend>` (HTML
///   §4.10.18.5, `Dom::is_actually_disabled`).
/// - `<input type="checkbox">` / `<input type="radio">` only
///   contribute when `checked` (matches HTML form-encoding).
/// - Checkbox value defaults to `"on"` when no `value` attribute
///   is set (HTML rule).
/// - Text-family inputs and `<textarea>` contribute their current
///   text content.
/// - `<select>` contributes one pair per selected option that is not
///   disabled — by its own attribute or a disabled `<optgroup>`
///   (HTML §4.10.21.4).
/// - Buttons (`<button>`, `<input type=submit|reset|button>`) are not
///   collected; see [`collect_with_submitter`] for the submitter.
/// - `<input type=hidden>` contributes its `value` attribute.
pub fn collect(dom: &TuiDom, form: NodeId) -> Vec<(String, String)> {
    collect_with_submitter(dom, form, None)
}

/// The entry list for a submission by `submitter` — HTML §4.10.21.4,
/// `new FormData(form, submitter)`. As [`collect`], plus one entry for
/// the submitter, in tree order, when it is a submit button (`<button>`
/// with a missing / invalid / `submit` type, `<input type=submit>`) that
/// has a non-empty `name` and is not disabled; its value is its `value`
/// attribute, or `""`. An image button submitter contributes `name.x` /
/// `name.y` (`x` / `y` without a name) instead, both `"0"`. Any other
/// button never contributes.
///
/// A `submit` handler passes the event's submitter:
/// `ctx.event.detail.as_submit().and_then(|s| s.submitter)`.
pub fn collect_with_submitter(
    dom: &TuiDom,
    form: NodeId,
    submitter: Option<NodeId>,
) -> Vec<(String, String)> {
    let mut out = Vec::new();
    for id in dom.form_listed_elements(form) {
        collect_entry(dom, id, submitter, &mut out);
    }
    out
}

// ── Internals ──────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ButtonAction {
    Submit,
    Reset,
    Button,
}

/// HTML §4.10.6: a `<button>` is a submit button unless its `type` is
/// `reset` or `button` — the missing and the invalid value default are
/// both Submit (`Dom::is_submit_button`). `<input type=submit/reset/button>`
/// uses its type state. Keywords match ASCII case-insensitively
/// (§2.3.3). Anything else is `Button` (no default action).
fn button_action(dom: &TuiDom, id: NodeId) -> ButtonAction {
    if dom.is_submit_button(id) {
        return ButtonAction::Submit;
    }
    let reset = match dom.node(id).tag_name() {
        Some("button") => dom
            .node(id)
            .get_attribute("type")
            .is_some_and(|t| t.eq_ignore_ascii_case("reset")),
        _ => dom.input_type_state(id) == Some(InputTypeState::Reset),
    };
    if reset {
        ButtonAction::Reset
    } else {
        ButtonAction::Button
    }
}

/// The form's default button (HTML §4.10.21.2): the first submit button
/// in tree order whose form owner is `form`, disabled or not.
fn default_button(dom: &TuiDom, form: NodeId) -> Option<NodeId> {
    dom.form_listed_elements(form)
        .into_iter()
        .find(|&id| dom.is_submit_button(id))
}

/// Walk up from `id` (inclusive) to the nearest `<button>` or
/// button-like `<input>`. Returns `None` if no button is on the
/// click target's path.
fn closest_form_button(dom: &TuiDom, id: NodeId) -> Option<NodeId> {
    let mut cur = Some(id);
    while let Some(n) = cur {
        if super::button::is_button_like(dom, n) {
            return Some(n);
        }
        cur = dom.node(n).parent_node().map(|p| p.id());
    }
    None
}

/// The form's controls — HTML `form.elements` (§4.10.3): every listed
/// element (`<button>`, `<fieldset>`, `<input>`, `<object>`, `<output>`,
/// `<select>`, `<textarea>`) whose form owner is `form`, in tree order
/// of the whole document, so a control outside the form that names it
/// with `form="id"` is included and one inside that names another form
/// is not (`Dom::form_listed_elements`).
///
/// Public for [`crate::accessors::TuiAccessors::form_elements`]
/// (step 31).
pub fn elements(dom: &TuiDom, form: NodeId) -> Vec<NodeId> {
    dom.form_listed_elements(form)
}

/// What one run of the form submission algorithm did.
///
/// Returned by [`crate::accessors::TuiAccessorsMut::form_request_submit`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubmitOutcome {
    /// The `submit` event fired and no listener canceled it; the form's
    /// default action ran (a method-`dialog` form closed its dialog).
    Submitted,
    /// The `submit` event fired and a listener called
    /// `preventDefault()`; nothing else happened.
    Canceled,
    /// Interactive validation found invalid controls: `invalid` fired
    /// at each, the first uncanceled one was focused, and no `submit`
    /// fired.
    Invalid,
    /// The node is not a `<form>`: nothing happened (the accessors'
    /// wrong-tag no-op).
    NotAForm,
    /// The form was already firing submission events — this call came
    /// from an `invalid` or `submit` listener of a submission of the
    /// same form (HTML §4.10.21.3 step 6.1): nothing happened.
    AlreadySubmitting,
    /// The form cannot navigate because it is not connected (HTML
    /// §4.10.21.3 steps 1 and 6.9). Either nothing happened (it was
    /// disconnected before the call) or the `submit` event fired
    /// uncanceled and one of its listeners disconnected the form, so the
    /// default action (the method-`dialog` close) did not run.
    Disconnected,
}

/// The form submission algorithm (HTML §4.10.21.3) from `submitter` —
/// the one path click activation, implicit submission and
/// `requestSubmit()` share:
///
/// 1. a form that cannot navigate — one that is not connected — does
///    nothing → [`SubmitOutcome::Disconnected`] (step 1);
/// 2. a form already firing submission events — this call comes from
///    one of its own `invalid` / `submit` listeners — does nothing →
///    [`SubmitOutcome::AlreadySubmitting`] (step 6.1); otherwise the
///    flag is set until the `submit` event returns (steps 6.2 / 6.7),
///    and restored even when a listener panics;
/// 3. unless the no-validate state is set (the form's `novalidate`, the
///    submit button's `formnovalidate` — `SubmitDetail::no_validate`),
///    interactively validate the form (`validation`): an invalid owned
///    control fires `invalid`, the first uncanceled one is focused, and
///    the submission stops → [`SubmitOutcome::Invalid`];
/// 4. fire a bubbling, cancelable `submit` at `form` carrying
///    `EventDetail::Submit(Dom::submit_detail(form, submitter))`;
/// 5. canceled → [`SubmitOutcome::Canceled`];
/// 6. a listener disconnected the form → [`SubmitOutcome::Disconnected`]
///    (step 6.9);
/// 7. otherwise, when the effective method is `dialog` (the form's
///    `method`, or the submit button's `formmethod`), close the form's
///    nearest ancestor `<dialog>` with the submitter's `value` (`""`
///    without a submitter) as its `returnValue`.
///
/// `submitter` is the clicked submit button, the default button on
/// implicit submission, or `None` for an implicit submission from a
/// form with no submit button and for `requestSubmit()` without one.
/// rdom has no navigation: the `submit` handler decides what submitting
/// means. The other early returns of step 1 / 2 (a document that is not
/// fully active, a form constructing its entry list for `formdata`) have
/// no rdom counterpart.
pub(crate) fn submit(dom: &mut TuiDom, form: NodeId, submitter: Option<NodeId>) -> SubmitOutcome {
    if !dom.node(form).is_connected() {
        return SubmitOutcome::Disconnected;
    }
    let (method, canceled) = {
        let Some(firing) = FiringSubmissionEvents::enter(&mut *dom, form) else {
            return SubmitOutcome::AlreadySubmitting;
        };
        let dom = &mut *firing.dom;
        let detail = dom.submit_detail(form, submitter);
        if !detail.no_validate
            && !crate::runtime::builtins::validation::interactively_validate(dom, form)
        {
            return SubmitOutcome::Invalid;
        }
        let method = detail.method;
        let mut ev = TuiEvent::new("submit");
        ev.event.detail = rdom_core::EventDetail::Submit(Box::new(detail));
        // Validation passed without firing `invalid` (no listener ran
        // since the connected check), so the form is live here; a dead
        // target would be a form that cannot navigate.
        if !crate::tui_event::dispatch_to_live(dom, form, &mut ev) {
            return SubmitOutcome::Disconnected;
        }
        (method, ev.event.default_prevented())
    };
    if canceled {
        return SubmitOutcome::Canceled;
    }
    if !dom.node(form).is_connected() {
        return SubmitOutcome::Disconnected;
    }
    if method == FormMethod::Dialog
        && let Some(dialog) = crate::runtime::builtins::dialog::enclosing_dialog(dom, form)
    {
        let rv = submitter
            .and_then(|b| dom.node(b).get_attribute("value"))
            .unwrap_or("")
            .to_string();
        crate::runtime::builtins::dialog::close(dom, dialog, &rv);
    }
    SubmitOutcome::Submitted
}

/// The form's "firing submission events" flag held set for a scope:
/// [`Self::enter`] sets it (or reports it already set), `Drop` clears
/// it — on unwind too, so a panicking `invalid` / `submit` listener
/// cannot leave the form unable to submit again. The guard owns the
/// `&mut TuiDom` borrow; the algorithm reaches the tree through it.
struct FiringSubmissionEvents<'a> {
    dom: &'a mut TuiDom,
    form: NodeId,
}

impl<'a> FiringSubmissionEvents<'a> {
    /// `None` when `form` is already firing submission events.
    fn enter(dom: &'a mut TuiDom, form: NodeId) -> Option<Self> {
        let already = {
            let mut node = dom.node_mut(form);
            let ext = node.ext_mut()?;
            std::mem::replace(&mut ext.form_state.get_mut().firing_submission_events, true)
        };
        // Not `then_some`: it would build (and drop) a guard for the
        // already-set case, clearing the outer submission's flag.
        if already {
            None
        } else {
            Some(Self { dom, form })
        }
    }
}

impl Drop for FiringSubmissionEvents<'_> {
    fn drop(&mut self) {
        // A listener may have dropped the form node itself.
        if !self.dom.contains(self.form) {
            return;
        }
        let mut node = self.dom.node_mut(self.form);
        if let Some(state) = node.ext_mut().and_then(|e| e.form_state.existing_mut()) {
            state.firing_submission_events = false;
        }
    }
}

/// `form.requestSubmit(submitter)` (HTML §4.10.3): a `submitter` must
/// be a submit button (`DomError::Type` otherwise) whose form
/// owner is `form` (`DomError::NotFound` otherwise); then the shared
/// [`submit`] path runs with it. `form` must be a `<form>`.
pub(crate) fn request_submit(
    dom: &mut TuiDom,
    form: NodeId,
    submitter: Option<NodeId>,
) -> rdom_core::Result<SubmitOutcome> {
    if let Some(s) = submitter {
        if !dom.is_submit_button(s) {
            return Err(rdom_core::DomError::Type(
                "requestSubmit: the submitter is not a submit button",
            ));
        }
        if dom.form_owner(s) != Some(form) {
            return Err(rdom_core::DomError::NotFound);
        }
    }
    Ok(submit(dom, form, submitter))
}

/// HTML §4.10.21.5 reset algorithm: every control whose form owner is
/// `form` goes back to its default — text controls and ranges to `defaultValue`,
/// checkboxes and radios to `defaultChecked` (`FORM-DEFAULTS-1`), a
/// `<select>`'s options to `defaultSelected` (`P6G-FORM-RESET-1`). No
/// `input` / `change` events fire, as on the web.
fn reset_controls(dom: &mut TuiDom, form: NodeId) {
    for id in dom.form_listed_elements(form) {
        match dom.node(id).tag_name() {
            Some("input") if crate::runtime::builtins::toggle::is_toggle(dom, id) => {
                crate::runtime::builtins::toggle::reset_to_default(dom, id);
            }
            Some("input") | Some("textarea") => {
                crate::runtime::builtins::input::reset_to_default(dom, id);
            }
            Some("select") => {
                crate::runtime::builtins::select::reset_to_default(dom, id);
            }
            _ => {}
        }
    }
}

/// Fire a cancelable `reset` at `form`; returns whether the controls
/// should be reset — the event went uncanceled at a live form (a form
/// that is no longer a node has nothing to reset).
fn fire_reset(dom: &mut TuiDom, form: NodeId) -> bool {
    let mut ev = TuiEvent::new("reset");
    crate::tui_event::dispatch_to_live(dom, form, &mut ev) && !ev.event.default_prevented()
}

/// The form's fields that block implicit submission (HTML §4.10.21.2):
/// its owned single-line text inputs.
fn count_text_inputs(dom: &TuiDom, form: NodeId) -> usize {
    dom.form_listed_elements(form)
        .into_iter()
        .filter(|&id| crate::node::is_text_input(dom, id))
        .count()
}

/// The entries one owned control contributes to the entry list.
fn collect_entry(
    dom: &TuiDom,
    id: NodeId,
    submitter: Option<NodeId>,
    out: &mut Vec<(String, String)>,
) {
    use InputTypeState as T;
    let node = dom.node(id);
    // HTML §4.10.21.4 step 5.5: an image button contributes only as the
    // submitter, as `name.x` / `name.y` (plain `x` / `y` without a
    // name) holding its selected coordinate, never its `value`. rdom has
    // no image to measure the activation point against, so the
    // coordinate is (0, 0) — what HTML gives a keyboard or scripted
    // activation (DIVERGENCES).
    if dom.input_type_state(id) == Some(T::Image) {
        if submitter == Some(id) && !dom.is_actually_disabled(id) {
            let prefix = match node.get_attribute("name") {
                Some(name) if !name.is_empty() => format!("{name}."),
                _ => String::new(),
            };
            out.push((format!("{prefix}x"), "0".to_string()));
            out.push((format!("{prefix}y"), "0".to_string()));
        }
        return;
    }
    if !dom.is_actually_disabled(id) {
        let name = node.get_attribute("name").unwrap_or("").to_string();
        if !name.is_empty() {
            // The clippy::collapsible_match suggestion here is unsafe:
            // collapsing `if node.has_attribute("checked")` into a guard
            // would let unchecked checkboxes/radios fall through to the
            // generic `(Some("input"), _)` arm below and submit their
            // text value — matching the browser, which excludes
            // unchecked checkbox values from form submission, requires
            // the explicit no-op on miss.
            #[allow(clippy::collapsible_match, clippy::collapsible_if)]
            match (node.tag_name(), dom.input_type_state(id)) {
                (_, Some(T::Checkbox | T::Radio)) => {
                    if node.has_attribute("checked") {
                        let value = node.get_attribute("value").unwrap_or("on").to_string();
                        out.push((name, value));
                    }
                }
                // HTML §4.10.21.4: a button contributes only as the
                // submitter, and only a submit button can be one. Its
                // value is the `value` attribute or `""` (value mode
                // default) — not the "Submit" default label browsers
                // send (DIVERGENCES).
                // (`Image` returned above.)
                _ if super::button::is_button_like(dom, id) => {
                    if submitter == Some(id) && button_action(dom, id) == ButtonAction::Submit {
                        let value = node.get_attribute("value").unwrap_or("").to_string();
                        out.push((name, value));
                    }
                }
                (_, Some(T::Hidden)) => {
                    if let Some(value) = node.get_attribute("value") {
                        out.push((name, value.to_string()));
                    }
                }
                (Some("input"), _) => {
                    out.push((name, crate::runtime::builtins::input::value(dom, id)));
                }
                (Some("textarea"), _) => {
                    out.push((name, crate::node::child_text(dom, id)));
                }
                (Some("select"), _) => {
                    // HTML §4.10.21.4: one (name, value) entry per
                    // option that is selected and not disabled (own
                    // attribute or disabled `<optgroup>` parent). A
                    // multi-select yields several entries; a single-
                    // select at most one.
                    use crate::runtime::builtins::select;
                    let selected = select::selected_options(dom, id);
                    for opt in selected
                        .into_iter()
                        .filter(|&o| !select::option_disabled(dom, o))
                    {
                        let value = crate::runtime::builtins::select::option_value(dom, opt);
                        out.push((name.clone(), value));
                    }
                }
                _ => {}
            }
        }
    }
}

#[cfg(test)]
mod tests;