Skip to main content

azul_layout/managers/
focus_cursor.rs

1//! Focus and tab navigation management.
2//!
3//! Manages keyboard focus, tab navigation, and programmatic focus changes
4//! with a recursive event system for focus/blur callbacks (max depth: 5).
5
6use alloc::collections::BTreeMap;
7
8use azul_core::{
9    callbacks::{FocusTarget, FocusTargetPath},
10    dom::{DomId, DomNodeId, NodeId},
11    style::matches_html_element,
12    styled_dom::NodeHierarchyItemId,
13    window::UpdateFocusWarning,
14};
15
16use crate::window::DomLayoutResult;
17
18/// Information about a pending contenteditable focus that needs cursor initialization
19/// after layout is complete (W3C "flag and defer" pattern).
20///
21/// This is set during focus event handling and consumed after layout pass.
22#[derive(Copy, Debug, Clone, PartialEq, Eq)]
23pub struct PendingContentEditableFocus {
24    /// The DOM where the contenteditable element is
25    pub dom_id: DomId,
26    /// The contenteditable container node that received focus
27    pub container_node_id: NodeId,
28    /// The text node where the cursor should be placed (often a child of the container)
29    pub text_node_id: NodeId,
30}
31
32/// Manager for keyboard focus and tab navigation
33///
34/// Note: Text cursor management is now handled by the separate `CursorManager`.
35///
36/// The `FocusManager` only tracks which node has focus, while `CursorManager`
37/// tracks the cursor position within that node (if it's contenteditable).
38///
39/// ## W3C Focus/Selection Model
40///
41/// The W3C model maintains a strict separation between **keyboard focus** and **selection**:
42///
43/// 1. **Focus** lands on the contenteditable container (`document.activeElement`)
44/// 2. **Selection/Cursor** is placed in a descendant text node (`Selection.focusNode`)
45///
46/// This separation requires a "flag and defer" pattern:
47/// - During focus event: Set `cursor_needs_initialization = true`
48/// - After layout pass: Call `finalize_pending_focus_changes()` to actually initialize the cursor
49///
50/// This is necessary because cursor positioning requires text layout information,
51/// which isn't available during the focus event handling phase.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct FocusManager {
54    /// Currently focused node (if any)
55    pub focused_node: Option<DomNodeId>,
56    /// Pending focus request from callback
57    pub pending_focus_request: Option<FocusTarget>,
58    
59    // --- W3C "flag and defer" pattern fields ---
60    
61    /// Flag indicating that cursor initialization is pending (set during focus, consumed after layout)
62    pub cursor_needs_initialization: bool,
63    /// Information about the pending contenteditable focus
64    pub pending_contenteditable_focus: Option<PendingContentEditableFocus>,
65}
66
67impl Default for FocusManager {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl FocusManager {
74    /// Create a new focus manager
75    #[must_use] pub const fn new() -> Self {
76        Self {
77            focused_node: None,
78            pending_focus_request: None,
79            cursor_needs_initialization: false,
80            pending_contenteditable_focus: None,
81        }
82    }
83
84    /// Get the currently focused node
85    #[must_use] pub const fn get_focused_node(&self) -> Option<&DomNodeId> {
86        self.focused_node.as_ref()
87    }
88
89    /// Set the focused node directly (used by event system)
90    ///
91    /// Note: Cursor initialization/clearing is now handled by `CursorManager`.
92    /// The event system should check if the newly focused node is contenteditable
93    /// and call `CursorManager::initialize_cursor_at_end()` if needed.
94    pub const fn set_focused_node(&mut self, node: Option<DomNodeId>) {
95        self.focused_node = node;
96    }
97
98    /// Request a focus change (to be processed by event system)
99    pub fn request_focus_change(&mut self, target: FocusTarget) {
100        self.pending_focus_request = Some(target);
101    }
102
103    /// Take the pending focus request (one-shot)
104    pub const fn take_focus_request(&mut self) -> Option<FocusTarget> {
105        self.pending_focus_request.take()
106    }
107
108    /// Clear focus
109    pub const fn clear_focus(&mut self) {
110        self.focused_node = None;
111    }
112
113    /// Check if a specific node has focus
114    #[must_use] pub fn has_focus(&self, node: &DomNodeId) -> bool {
115        self.focused_node.as_ref() == Some(node)
116    }
117    
118    // --- W3C "flag and defer" pattern methods ---
119    
120    /// Mark that cursor initialization is needed for a contenteditable element.
121    ///
122    /// This is called during focus event handling. The actual cursor initialization
123    /// happens later in `finalize_pending_focus_changes()` after layout is complete.
124    ///
125    /// # W3C Conformance
126    ///
127    /// In the W3C model, when focus lands on a contenteditable element:
128    /// 1. The focus event fires on the container element
129    /// 2. The browser's editing engine modifies the Selection to place a caret
130    /// 3. The Selection's anchorNode/focusNode point to the child text node
131    ///
132    /// Since we need layout information to position the cursor, we defer step 2+3.
133    pub const fn set_pending_contenteditable_focus(
134        &mut self,
135        dom_id: DomId,
136        container_node_id: NodeId,
137        text_node_id: NodeId,
138    ) {
139        self.cursor_needs_initialization = true;
140        self.pending_contenteditable_focus = Some(PendingContentEditableFocus {
141            dom_id,
142            container_node_id,
143            text_node_id,
144        });
145    }
146    
147    /// Clear the pending contenteditable focus (when focus moves away or is cleared).
148    pub const fn clear_pending_contenteditable_focus(&mut self) {
149        self.cursor_needs_initialization = false;
150        self.pending_contenteditable_focus = None;
151    }
152    
153    /// Take the pending contenteditable focus (consumes the flag).
154    ///
155    /// Returns `Some(info)` if cursor initialization is pending, `None` otherwise.
156    /// After calling this, `cursor_needs_initialization` is set to `false`.
157    pub const fn take_pending_contenteditable_focus(&mut self) -> Option<PendingContentEditableFocus> {
158        if self.cursor_needs_initialization {
159            self.cursor_needs_initialization = false;
160            self.pending_contenteditable_focus.take()
161        } else {
162            None
163        }
164    }
165    
166    /// Check if cursor initialization is pending.
167    #[must_use] pub const fn needs_cursor_initialization(&self) -> bool {
168        self.cursor_needs_initialization
169    }
170
171}
172
173impl crate::managers::NodeIdRemap for FocusManager {
174    /// Remap the focused node AND the pending contenteditable focus.
175    ///
176    /// Focus on an unmounted node is CLEARED (not kept — the index now denotes a
177    /// different element).
178    fn remap_node_ids(&mut self, dom_id: DomId, map: &crate::managers::NodeIdMap) {
179        // 1. currently focused node
180        if let Some(focused) = self.focused_node {
181            if focused.dom == dom_id {
182                match focused
183                    .node
184                    .into_crate_internal()
185                    .and_then(|old| map.resolve(old))
186                {
187                    Some(new_id) => {
188                        self.focused_node = Some(DomNodeId {
189                            dom: dom_id,
190                            node: NodeHierarchyItemId::from_crate_internal(Some(new_id)),
191                        });
192                    }
193                    None => self.focused_node = None,
194                }
195            }
196        }
197
198        // 2. pending contenteditable focus (set during focus handling, consumed
199        //    after layout — a DOM rebuild can land in between).
200        if let Some(ref mut pending) = self.pending_contenteditable_focus {
201            if pending.dom_id != dom_id {
202                return;
203            }
204            if let (Some(container), Some(text)) = (
205                map.resolve(pending.container_node_id),
206                map.resolve(pending.text_node_id),
207            ) {
208                pending.container_node_id = container;
209                pending.text_node_id = text;
210            } else {
211                self.pending_contenteditable_focus = None;
212                self.cursor_needs_initialization = false;
213            }
214        }
215    }
216}
217
218/// MWA-C-focus_cursor: W3C sequential focus order over all DOMs.
219///
220/// Ordering: nodes with a positive `tabindex` (`TabIndex::OverrideInParent(n)`,
221/// n >= 1) come first, ascending by n (stable sort, so document order breaks
222/// ties); then all remaining keyboard-focusable nodes (`Auto`,
223/// `OverrideInParent(0)`, implicit focusables) in document order.
224/// `TabIndex::NoKeyboardFocus` (tabindex=-1) nodes stay focusable by click /
225/// API but are NEVER part of the Tab order. The previous linear `NodeId` walk
226/// both ignored positive-tabindex ordering and tabbed onto tabindex=-1 nodes.
227fn collect_tab_order(layout_results: &BTreeMap<DomId, DomLayoutResult>) -> Vec<DomNodeId> {
228    use azul_core::dom::TabIndex;
229    let mut positive: Vec<(u32, DomNodeId)> = Vec::new();
230    let mut auto: Vec<DomNodeId> = Vec::new();
231    for (dom_id, layout) in layout_results {
232        let node_data = layout.styled_dom.node_data.as_container();
233        for index in 0..node_data.len() {
234            let node_id = NodeId::new(index);
235            let Some(nd) = node_data.get(node_id) else {
236                continue;
237            };
238            if !nd.is_focusable() {
239                continue;
240            }
241            let dom_node = FocusSearchContext::make_dom_node_id(*dom_id, node_id);
242            match nd.get_tab_index() {
243                Some(TabIndex::NoKeyboardFocus) => {}
244                Some(TabIndex::OverrideInParent(n)) if n > 0 => positive.push((n, dom_node)),
245                _ => auto.push(dom_node),
246            }
247        }
248    }
249    order_tab_entries(positive, auto)
250}
251
252/// Pure merge of the two tab-order sections (split out for unit testing).
253fn order_tab_entries(
254    mut positive: Vec<(u32, DomNodeId)>,
255    auto: Vec<DomNodeId>,
256) -> Vec<DomNodeId> {
257    positive.sort_by_key(|(n, _)| *n); // stable: document order within equal n
258    positive.into_iter().map(|(_, id)| id).chain(auto).collect()
259}
260
261/// Document-order key for a node (DOM index, then arena index) — used to
262/// re-enter the tab order from a node that is not itself tab-focusable.
263fn doc_order_key(id: &DomNodeId) -> (usize, usize) {
264    (
265        id.dom.inner,
266        id.node.into_crate_internal().map_or(0, |n| n.index()),
267    )
268}
269
270/// Pick the next / previous entry in `order` relative to `current`.
271///
272/// If `current` is a tab stop, steps with wrap-around. If it is not (no focus
273/// yet, or focus sits on a tabindex=-1 / removed node), forward picks the
274/// first tab stop after it in document order (wrapping to the first entry),
275/// backward symmetrically.
276fn next_in_tab_order(
277    order: &[DomNodeId],
278    current: Option<DomNodeId>,
279    forward: bool,
280) -> Option<DomNodeId> {
281    if order.is_empty() {
282        return None;
283    }
284    let Some(cur) = current else {
285        return if forward {
286            order.first().copied()
287        } else {
288            order.last().copied()
289        };
290    };
291    if let Some(pos) = order.iter().position(|x| *x == cur) {
292        let len = order.len();
293        let next = if forward {
294            (pos + 1) % len
295        } else {
296            (pos + len - 1) % len
297        };
298        return Some(order[next]);
299    }
300    let cur_key = doc_order_key(&cur);
301    let candidate = if forward {
302        order
303            .iter()
304            .filter(|x| doc_order_key(x) > cur_key)
305            .min_by_key(|x| doc_order_key(x))
306    } else {
307        order
308            .iter()
309            .filter(|x| doc_order_key(x) < cur_key)
310            .max_by_key(|x| doc_order_key(x))
311    };
312    candidate.copied().or_else(|| {
313        if forward {
314            order.first().copied()
315        } else {
316            order.last().copied()
317        }
318    })
319}
320
321/// Context for focus-target resolution (`Path` / `Id` lookups).
322///
323/// MWA-C-focus_cursor: the old linear-walk machinery (`SearchDirection`,
324/// `search_focusable_node`, `get_*_start`) was replaced by the W3C tab order
325/// built in `collect_tab_order`; only the layout lookup helpers remain.
326struct FocusSearchContext<'a> {
327    /// Reference to all DOM layouts in the window
328    layout_results: &'a BTreeMap<DomId, DomLayoutResult>,
329}
330
331impl<'a> FocusSearchContext<'a> {
332    /// Create a new search context from layout results.
333    const fn new(layout_results: &'a BTreeMap<DomId, DomLayoutResult>) -> Self {
334        Self { layout_results }
335    }
336
337    /// Get the layout for a DOM ID, or return an error if invalid.
338    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
339    fn get_layout(&self, dom_id: &DomId) -> Result<&'a DomLayoutResult, UpdateFocusWarning> {
340        self.layout_results
341            .get(dom_id)
342            .ok_or_else(|| UpdateFocusWarning::FocusInvalidDomId(*dom_id))
343    }
344
345    /// Construct a `DomNodeId` from DOM and node IDs.
346    const fn make_dom_node_id(dom_id: DomId, node_id: NodeId) -> DomNodeId {
347        DomNodeId {
348            dom: dom_id,
349            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
350        }
351    }
352}
353
354/// Find the first focusable node matching a CSS path selector.
355///
356/// Iterates through all nodes in the DOM in document order (index 0..n),
357/// and returns the first node that:
358///
359/// 1. Matches the CSS path selector
360/// 2. Is focusable (has `tabindex` or is naturally focusable)
361///
362/// # Returns
363///
364/// * `Ok(Some(node))` - Found a matching focusable node
365/// * `Ok(None)` - No matching focusable node exists
366/// * `Err(_)` - CSS path could not be matched (malformed selector)
367#[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
368fn find_first_matching_focusable_node(
369    layout: &DomLayoutResult,
370    dom_id: &DomId,
371    css_path: &azul_css::css::CssPath,
372) -> Option<DomNodeId> {
373    let styled_dom = &layout.styled_dom;
374    let node_hierarchy = styled_dom.node_hierarchy.as_container();
375    let node_data = styled_dom.node_data.as_container();
376    let cascade_info = styled_dom.cascade_info.as_container();
377
378    // Iterate through all nodes in document order
379    let matching_node = (0..node_data.len())
380        .map(NodeId::new)
381        .filter(|&node_id| {
382            // Check if node matches the CSS path (no pseudo-selector requirement)
383            matches_html_element(
384                css_path,
385                node_id,
386                &node_hierarchy,
387                &node_data,
388                &cascade_info,
389                None, // No expected pseudo-selector ending like :hover/:focus
390            )
391        })
392        .find(|&node_id| {
393            // Among matching nodes, find first that is focusable
394            node_data[node_id].is_focusable()
395        });
396
397    matching_node.map(|node_id| DomNodeId {
398        dom: *dom_id,
399        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
400    })
401}
402
403/// Resolve a `FocusTarget` to an actual `DomNodeId`
404/// # Errors
405///
406/// Returns an `UpdateFocusWarning` if the focus target cannot be resolved.
407pub fn resolve_focus_target(
408    focus_target: &FocusTarget,
409    layout_results: &BTreeMap<DomId, DomLayoutResult>,
410    current_focus: Option<DomNodeId>,
411) -> Result<Option<DomNodeId>, UpdateFocusWarning> {
412    use azul_core::callbacks::FocusTarget::{Path, Id, Previous, Next, First, Last, NoFocus};
413
414    if layout_results.is_empty() {
415        return Ok(None);
416    }
417
418    let ctx = FocusSearchContext::new(layout_results);
419
420    match focus_target {
421        Path(FocusTargetPath { dom, css_path }) => {
422            let layout = ctx.get_layout(dom)?;
423            Ok(find_first_matching_focusable_node(layout, dom, css_path))
424        }
425
426        Id(dom_node_id) => {
427            let layout = ctx.get_layout(&dom_node_id.dom)?;
428            let is_valid = dom_node_id
429                .node
430                .into_crate_internal()
431                .is_some_and(|n| layout.styled_dom.node_data.as_container().get(n).is_some());
432
433            if is_valid {
434                Ok(Some(*dom_node_id))
435            } else {
436                Err(UpdateFocusWarning::FocusInvalidNodeId(
437                    dom_node_id.node,
438                ))
439            }
440        }
441
442        // MWA-C-focus_cursor: sequential navigation goes through the W3C tab
443        // order (positive tabindex ascending, then document order; -1
444        // excluded) instead of the old raw-NodeId walk.
445        Previous => Ok(next_in_tab_order(
446            &collect_tab_order(layout_results),
447            current_focus,
448            false,
449        )),
450
451        Next => Ok(next_in_tab_order(
452            &collect_tab_order(layout_results),
453            current_focus,
454            true,
455        )),
456
457        First => Ok(collect_tab_order(layout_results).first().copied()),
458
459        Last => Ok(collect_tab_order(layout_results).last().copied()),
460
461        NoFocus => Ok(None),
462    }
463}
464
465// Trait Implementations for Event Filtering
466
467impl azul_core::events::FocusManagerQuery for FocusManager {
468    fn get_focused_node_id(&self) -> Option<DomNodeId> {
469        self.focused_node
470    }
471}
472
473#[cfg(test)]
474mod tab_order_tests {
475    use super::*;
476
477    fn nid(dom: usize, node: usize) -> DomNodeId {
478        FocusSearchContext::make_dom_node_id(DomId { inner: dom }, NodeId::new(node))
479    }
480
481    #[test]
482    fn positive_tabindex_sorts_first_ascending_then_document_order() {
483        // Document order: n3 (tabindex=2), n5 (auto), n7 (tabindex=1), n9 (auto)
484        let order = order_tab_entries(
485            vec![(2, nid(0, 3)), (1, nid(0, 7))],
486            vec![nid(0, 5), nid(0, 9)],
487        );
488        assert_eq!(order, vec![nid(0, 7), nid(0, 3), nid(0, 5), nid(0, 9)]);
489    }
490
491    #[test]
492    fn equal_positive_tabindex_keeps_document_order() {
493        let order = order_tab_entries(vec![(1, nid(0, 2)), (1, nid(0, 8))], vec![]);
494        assert_eq!(order, vec![nid(0, 2), nid(0, 8)]);
495    }
496
497    #[test]
498    fn next_wraps_and_previous_wraps() {
499        let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
500        assert_eq!(next_in_tab_order(&order, Some(nid(0, 6)), true), Some(nid(0, 1)));
501        assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 6)));
502        assert_eq!(next_in_tab_order(&order, Some(nid(0, 4)), true), Some(nid(0, 6)));
503    }
504
505    #[test]
506    fn no_focus_starts_at_ends() {
507        let order = vec![nid(0, 1), nid(0, 4)];
508        assert_eq!(next_in_tab_order(&order, None, true), Some(nid(0, 1)));
509        assert_eq!(next_in_tab_order(&order, None, false), Some(nid(0, 4)));
510    }
511
512    #[test]
513    fn non_tab_stop_focus_reenters_in_document_order() {
514        // Focus sits on a tabindex=-1 node (0,5): Tab goes to the next tab
515        // stop in document order (0,6); Shift+Tab to the previous one (0,4).
516        let order = vec![nid(0, 1), nid(0, 4), nid(0, 6)];
517        assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), true), Some(nid(0, 6)));
518        assert_eq!(next_in_tab_order(&order, Some(nid(0, 5)), false), Some(nid(0, 4)));
519        // Past the last stop: wraps to first / last respectively.
520        assert_eq!(next_in_tab_order(&order, Some(nid(0, 9)), true), Some(nid(0, 1)));
521        assert_eq!(next_in_tab_order(&order, Some(nid(0, 0)), false), Some(nid(0, 6)));
522    }
523
524    #[test]
525    fn empty_order_yields_none() {
526        assert_eq!(next_in_tab_order(&[], Some(nid(0, 1)), true), None);
527        assert_eq!(next_in_tab_order(&[], None, false), None);
528    }
529}
530
531#[cfg(test)]
532mod autotest_generated {
533    use std::collections::HashMap;
534
535    use azul_core::{
536        dom::{Dom, NodeType, TabIndex},
537        geom::LogicalRect,
538        styled_dom::StyledDom,
539    };
540    use azul_css::css::{CssPath, CssPathSelector};
541
542    use super::*;
543    use crate::{
544        managers::{NodeIdMap, NodeIdRemap},
545        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
546    };
547
548    // ------------------------------------------------------------------
549    // Fixtures
550    // ------------------------------------------------------------------
551
552    fn dom(inner: usize) -> DomId {
553        DomId { inner }
554    }
555
556    fn nid(dom_idx: usize, node: usize) -> DomNodeId {
557        FocusSearchContext::make_dom_node_id(dom(dom_idx), NodeId::new(node))
558    }
559
560    /// A `DomNodeId` whose node slot is the "no node" sentinel (`inner == 0`).
561    fn null_nid(dom_idx: usize) -> DomNodeId {
562        DomNodeId {
563            dom: dom(dom_idx),
564            node: NodeHierarchyItemId::from_crate_internal(None),
565        }
566    }
567
568    /// `DomLayoutResult` with an empty layout tree — every function under test
569    /// here reads only `styled_dom`, so no real layout (and no font) is needed.
570    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
571        DomLayoutResult {
572            styled_dom,
573            layout_tree: LayoutTree {
574                nodes: Vec::new(),
575                warm: Vec::new(),
576                cold: Vec::new(),
577                root: 0,
578                dom_to_layout: BTreeMap::new(),
579                children_arena: Vec::new(),
580                children_offsets: Vec::new(),
581                subtree_needs_intrinsic: Vec::new(),
582            },
583            calculated_positions: Vec::new(),
584            viewport: LogicalRect::zero(),
585            display_list: DisplayList::default(),
586            scroll_ids: HashMap::new(),
587            scroll_id_to_node_id: HashMap::new(),
588        }
589    }
590
591    fn window(entries: Vec<(DomId, StyledDom)>) -> BTreeMap<DomId, DomLayoutResult> {
592        entries
593            .into_iter()
594            .map(|(id, sd)| (id, layout_result(sd)))
595            .collect()
596    }
597
598    /// Flat (pre-order) indices of [`tab_fixture`]:
599    ///
600    /// | idx | node                    | focusable | tab bucket        |
601    /// |-----|-------------------------|-----------|-------------------|
602    /// | 0   | body                    | no        | —                 |
603    /// | 1   | div (plain)             | no        | —                 |
604    /// | 2   | button                  | yes       | auto              |
605    /// | 3   | div `tabindex=2`        | yes       | positive (n=2)    |
606    /// | 4   | div `tabindex=-1`       | yes       | EXCLUDED          |
607    /// | 5   | div `tabindex=1`        | yes       | positive (n=1)    |
608    /// | 6   | div `tabindex=0`        | yes       | auto              |
609    /// | 7   | textarea                | yes       | auto              |
610    ///
611    /// => tab order `[5, 3, 2, 6, 7]`.
612    fn tab_fixture() -> StyledDom {
613        StyledDom::create_from_dom(
614            Dom::create_body()
615                .with_child(Dom::create_div())
616                .with_child(Dom::create_node(NodeType::Button))
617                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(2)))
618                .with_child(Dom::create_div().with_tab_index(TabIndex::NoKeyboardFocus))
619                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(1)))
620                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(0)))
621                .with_child(Dom::create_node(NodeType::TextArea)),
622        )
623    }
624
625    fn tab_order_of(fixture: StyledDom) -> Vec<DomNodeId> {
626        collect_tab_order(&window(vec![(dom(0), fixture)]))
627    }
628
629    fn class_path(class: &str) -> CssPath {
630        CssPath {
631            selectors: vec![CssPathSelector::Class(class.to_string().into())].into(),
632        }
633    }
634
635    // ==================================================================
636    // FocusManager — constructor / getters / predicates
637    // ==================================================================
638
639    #[test]
640    fn focus_manager_new_matches_default_and_is_fully_empty() {
641        let fm = FocusManager::new();
642        assert_eq!(fm, FocusManager::default());
643        assert_eq!(fm.get_focused_node(), None);
644        assert!(!fm.needs_cursor_initialization());
645        assert_eq!(fm.pending_focus_request, None);
646        assert_eq!(fm.pending_contenteditable_focus, None);
647        // A default instance must answer every query without panicking.
648        assert!(!fm.has_focus(&nid(0, 0)));
649        assert!(!fm.has_focus(&null_nid(0)));
650    }
651
652    #[test]
653    fn focus_manager_set_get_clear_focus_roundtrip() {
654        let mut fm = FocusManager::new();
655        fm.set_focused_node(Some(nid(0, 3)));
656        assert_eq!(fm.get_focused_node(), Some(&nid(0, 3)));
657        assert!(fm.has_focus(&nid(0, 3)));
658
659        // Explicitly setting `None` is equivalent to clearing.
660        fm.set_focused_node(None);
661        assert_eq!(fm.get_focused_node(), None);
662
663        fm.set_focused_node(Some(nid(0, 3)));
664        fm.clear_focus();
665        assert_eq!(fm.get_focused_node(), None);
666        assert!(!fm.has_focus(&nid(0, 3)));
667        // Clearing twice is idempotent, not a panic.
668        fm.clear_focus();
669        assert_eq!(fm.get_focused_node(), None);
670    }
671
672    #[test]
673    fn focus_manager_has_focus_discriminates_both_dom_and_node() {
674        let mut fm = FocusManager::new();
675        fm.set_focused_node(Some(nid(1, 4)));
676
677        assert!(fm.has_focus(&nid(1, 4)));
678        // Same node index, different DOM — must NOT be treated as focused.
679        assert!(!fm.has_focus(&nid(0, 4)));
680        assert!(!fm.has_focus(&nid(2, 4)));
681        // Same DOM, different node index.
682        assert!(!fm.has_focus(&nid(1, 3)));
683        assert!(!fm.has_focus(&nid(1, 5)));
684        // The "no node" sentinel must not alias node 0.
685        assert!(!fm.has_focus(&null_nid(1)));
686    }
687
688    #[test]
689    fn focus_manager_has_focus_on_null_node_sentinel_is_exact() {
690        // Focusing the sentinel itself: it matches only the sentinel, and in
691        // particular is NOT confused with real node index 0 (whose encoded
692        // `NodeHierarchyItemId` is 1, not 0).
693        let mut fm = FocusManager::new();
694        fm.set_focused_node(Some(null_nid(0)));
695        assert!(fm.has_focus(&null_nid(0)));
696        assert!(!fm.has_focus(&nid(0, 0)));
697        assert!(!fm.has_focus(&null_nid(1)));
698    }
699
700    #[test]
701    fn focus_manager_focus_survives_extreme_node_index() {
702        // `NodeHierarchyItemId` encodes `Some(n)` as `n + 1`, so `usize::MAX`
703        // itself would overflow the encoding. `usize::MAX - 1` is the largest
704        // representable node and must round-trip cleanly.
705        let extreme = nid(usize::MAX, usize::MAX - 1);
706        let mut fm = FocusManager::new();
707        fm.set_focused_node(Some(extreme));
708        assert!(fm.has_focus(&extreme));
709        assert_eq!(
710            fm.get_focused_node()
711                .and_then(|n| n.node.into_crate_internal())
712                .map(|n| n.index()),
713            Some(usize::MAX - 1)
714        );
715    }
716
717    // ==================================================================
718    // FocusManager — pending focus request (one-shot)
719    // ==================================================================
720
721    #[test]
722    fn focus_manager_take_focus_request_is_one_shot() {
723        let mut fm = FocusManager::new();
724        // Taking from a fresh manager yields None rather than panicking.
725        assert_eq!(fm.take_focus_request(), None);
726
727        fm.request_focus_change(FocusTarget::Next);
728        assert_eq!(fm.take_focus_request(), Some(FocusTarget::Next));
729        // Consumed — a second take must not replay the request.
730        assert_eq!(fm.take_focus_request(), None);
731        assert_eq!(fm.take_focus_request(), None);
732    }
733
734    #[test]
735    fn focus_manager_request_focus_change_overwrites_pending_request() {
736        // The field holds a single slot: a second request silently REPLACES the
737        // first (requests are not queued). Pin that, so a change to queueing
738        // semantics is a deliberate, visible break.
739        let mut fm = FocusManager::new();
740        fm.request_focus_change(FocusTarget::First);
741        fm.request_focus_change(FocusTarget::Last);
742        fm.request_focus_change(FocusTarget::NoFocus);
743        assert_eq!(fm.take_focus_request(), Some(FocusTarget::NoFocus));
744        assert_eq!(fm.take_focus_request(), None);
745    }
746
747    #[test]
748    fn focus_manager_request_focus_change_accepts_every_variant() {
749        let path = FocusTarget::Path(FocusTargetPath {
750            dom: dom(usize::MAX),
751            css_path: class_path("nonexistent"),
752        });
753        let targets = vec![
754            FocusTarget::Id(nid(0, 0)),
755            FocusTarget::Id(null_nid(usize::MAX)),
756            path,
757            FocusTarget::Previous,
758            FocusTarget::Next,
759            FocusTarget::First,
760            FocusTarget::Last,
761            FocusTarget::NoFocus,
762        ];
763        for t in targets {
764            let mut fm = FocusManager::new();
765            fm.request_focus_change(t.clone());
766            assert_eq!(fm.take_focus_request(), Some(t));
767        }
768    }
769
770    // ==================================================================
771    // FocusManager — W3C "flag and defer" contenteditable state
772    // ==================================================================
773
774    #[test]
775    fn focus_manager_pending_contenteditable_set_then_take_is_one_shot() {
776        let mut fm = FocusManager::new();
777        assert!(!fm.needs_cursor_initialization());
778        assert_eq!(fm.take_pending_contenteditable_focus(), None);
779
780        fm.set_pending_contenteditable_focus(dom(2), NodeId::new(7), NodeId::new(9));
781        assert!(fm.needs_cursor_initialization());
782
783        assert_eq!(
784            fm.take_pending_contenteditable_focus(),
785            Some(PendingContentEditableFocus {
786                dom_id: dom(2),
787                container_node_id: NodeId::new(7),
788                text_node_id: NodeId::new(9),
789            })
790        );
791        // Flag consumed; a second take must not replay the pending focus.
792        assert!(!fm.needs_cursor_initialization());
793        assert_eq!(fm.take_pending_contenteditable_focus(), None);
794    }
795
796    #[test]
797    fn focus_manager_set_pending_contenteditable_overwrites_and_accepts_extremes() {
798        let mut fm = FocusManager::new();
799        fm.set_pending_contenteditable_focus(dom(0), NodeId::new(1), NodeId::new(2));
800        // Extreme ids (and container == text, i.e. a degenerate self-reference)
801        // must be stored verbatim without panicking.
802        fm.set_pending_contenteditable_focus(
803            dom(usize::MAX),
804            NodeId::new(usize::MAX),
805            NodeId::new(usize::MAX),
806        );
807        assert_eq!(
808            fm.take_pending_contenteditable_focus(),
809            Some(PendingContentEditableFocus {
810                dom_id: dom(usize::MAX),
811                container_node_id: NodeId::new(usize::MAX),
812                text_node_id: NodeId::new(usize::MAX),
813            })
814        );
815    }
816
817    #[test]
818    fn focus_manager_clear_pending_contenteditable_clears_flag_and_value() {
819        let mut fm = FocusManager::new();
820        fm.set_pending_contenteditable_focus(dom(0), NodeId::new(1), NodeId::new(2));
821        fm.clear_pending_contenteditable_focus();
822
823        assert!(!fm.needs_cursor_initialization());
824        assert_eq!(fm.pending_contenteditable_focus, None);
825        assert_eq!(fm.take_pending_contenteditable_focus(), None);
826        // Clearing an already-clear manager is idempotent.
827        fm.clear_pending_contenteditable_focus();
828        assert!(!fm.needs_cursor_initialization());
829    }
830
831    #[test]
832    fn focus_manager_take_pending_without_flag_strands_the_value() {
833        // Both fields are `pub`, so the flag and the value can be desynced by a
834        // direct field write. `take_pending_contenteditable_focus` gates purely
835        // on the FLAG, so a value written without the flag is never handed out
836        // and is left stranded in the manager. Pin the (safe, non-panicking)
837        // behaviour: no cursor is initialised, and the stale value survives.
838        let mut fm = FocusManager::new();
839        fm.pending_contenteditable_focus = Some(PendingContentEditableFocus {
840            dom_id: dom(0),
841            container_node_id: NodeId::new(1),
842            text_node_id: NodeId::new(2),
843        });
844
845        assert!(!fm.needs_cursor_initialization());
846        assert_eq!(fm.take_pending_contenteditable_focus(), None);
847        assert!(fm.pending_contenteditable_focus.is_some());
848    }
849
850    #[test]
851    fn focus_manager_flag_without_value_take_returns_none_and_clears_flag() {
852        // The mirror-image desync: flag set, value absent. `take` must report
853        // "nothing to do" AND drop the flag, so the caller cannot spin on a
854        // permanently-pending initialisation.
855        let mut fm = FocusManager::new();
856        fm.cursor_needs_initialization = true;
857
858        assert!(fm.needs_cursor_initialization());
859        assert_eq!(fm.take_pending_contenteditable_focus(), None);
860        assert!(!fm.needs_cursor_initialization());
861    }
862
863    #[test]
864    fn focus_manager_clear_focus_does_not_clear_pending_cursor_state() {
865        // `clear_focus` touches ONLY `focused_node`: the deferred contenteditable
866        // cursor request deliberately survives it (callers must call
867        // `clear_pending_contenteditable_focus` themselves). Pin this, since a
868        // silent change would either leak a cursor into an unfocused node or
869        // drop a legitimate deferred cursor.
870        let mut fm = FocusManager::new();
871        fm.set_focused_node(Some(nid(0, 4)));
872        fm.set_pending_contenteditable_focus(dom(0), NodeId::new(4), NodeId::new(5));
873
874        fm.clear_focus();
875
876        assert_eq!(fm.get_focused_node(), None);
877        assert!(fm.needs_cursor_initialization());
878        assert!(fm.pending_contenteditable_focus.is_some());
879    }
880
881    // ==================================================================
882    // order_tab_entries / doc_order_key
883    // ==================================================================
884
885    #[test]
886    fn order_tab_entries_empty_inputs_yield_empty() {
887        assert_eq!(order_tab_entries(Vec::new(), Vec::new()), Vec::new());
888    }
889
890    #[test]
891    fn order_tab_entries_u32_max_sorts_after_smaller_positives() {
892        // No overflow / wrap: u32::MAX is just a very large key and must land
893        // last among the positives, never first.
894        let order = order_tab_entries(
895            vec![
896                (u32::MAX, nid(0, 1)),
897                (1, nid(0, 2)),
898                (u32::MAX - 1, nid(0, 3)),
899            ],
900            vec![nid(0, 4)],
901        );
902        assert_eq!(
903            order,
904            vec![nid(0, 2), nid(0, 3), nid(0, 1), nid(0, 4)],
905            "u32::MAX must sort last among positives, and all positives before auto"
906        );
907    }
908
909    #[test]
910    fn order_tab_entries_positive_always_precedes_auto() {
911        // Even the largest possible positive tabindex outranks every auto entry.
912        let order = order_tab_entries(vec![(u32::MAX, nid(9, 99))], vec![nid(0, 0), nid(0, 1)]);
913        assert_eq!(order[0], nid(9, 99));
914        assert_eq!(order.len(), 3);
915    }
916
917    #[test]
918    fn order_tab_entries_does_not_deduplicate() {
919        // Duplicates are preserved verbatim (the function is a pure merge, not a
920        // set builder) — a duplicated entry must not silently vanish.
921        let order = order_tab_entries(
922            vec![(1, nid(0, 1)), (1, nid(0, 1))],
923            vec![nid(0, 2), nid(0, 2)],
924        );
925        assert_eq!(order, vec![nid(0, 1), nid(0, 1), nid(0, 2), nid(0, 2)]);
926    }
927
928    #[test]
929    fn doc_order_key_null_node_collides_with_node_index_zero() {
930        // `doc_order_key` maps the "no node" sentinel to arena index 0, so it is
931        // indistinguishable from real node 0 within the same DOM. Pin the
932        // collision: `next_in_tab_order`'s re-entry search relies on this key,
933        // and a focus sitting on the sentinel therefore re-enters as if it sat
934        // on node 0.
935        assert_eq!(doc_order_key(&null_nid(0)), (0, 0));
936        assert_eq!(doc_order_key(&nid(0, 0)), (0, 0));
937        assert_eq!(doc_order_key(&null_nid(0)), doc_order_key(&nid(0, 0)));
938    }
939
940    #[test]
941    fn doc_order_key_is_dom_major_then_arena_index() {
942        assert_eq!(doc_order_key(&nid(3, 7)), (3, 7));
943        // DOM index dominates: a huge node index in DOM 0 still precedes node 0
944        // of DOM 1.
945        assert!(doc_order_key(&nid(0, usize::MAX - 1)) < doc_order_key(&nid(1, 0)));
946        assert_eq!(doc_order_key(&nid(usize::MAX, usize::MAX - 1)), (usize::MAX, usize::MAX - 1));
947    }
948
949    // ==================================================================
950    // next_in_tab_order
951    // ==================================================================
952
953    #[test]
954    fn next_in_tab_order_single_entry_wraps_onto_itself() {
955        // `(0 + 1) % 1 == 0` — must terminate on itself, not loop or panic.
956        let order = vec![nid(0, 1)];
957        assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), true), Some(nid(0, 1)));
958        assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 1)));
959    }
960
961    #[test]
962    fn next_in_tab_order_duplicate_entries_resolve_to_first_position() {
963        // `position()` finds the FIRST occurrence, so a duplicated tab stop makes
964        // the trailing copy unreachable by stepping. Pin it (a dedup in
965        // `collect_tab_order` would change this).
966        let order = vec![nid(0, 1), nid(0, 2), nid(0, 1)];
967        assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), true), Some(nid(0, 2)));
968        // Backward from index 0 wraps to the last element.
969        assert_eq!(next_in_tab_order(&order, Some(nid(0, 1)), false), Some(nid(0, 1)));
970    }
971
972    #[test]
973    fn next_in_tab_order_unknown_current_uses_cross_dom_document_order() {
974        // Current node lives in DOM 1; the tab order is split across DOM 0 and 2.
975        let order = vec![nid(0, 5), nid(2, 1)];
976        assert_eq!(next_in_tab_order(&order, Some(nid(1, 0)), true), Some(nid(2, 1)));
977        assert_eq!(next_in_tab_order(&order, Some(nid(1, 9)), false), Some(nid(0, 5)));
978    }
979
980    #[test]
981    fn next_in_tab_order_unknown_current_past_both_ends_wraps() {
982        let order = vec![nid(1, 2), nid(1, 4)];
983        // Nothing greater -> wrap to first.
984        assert_eq!(next_in_tab_order(&order, Some(nid(9, 9)), true), Some(nid(1, 2)));
985        // Nothing smaller -> wrap to last.
986        assert_eq!(next_in_tab_order(&order, Some(nid(0, 0)), false), Some(nid(1, 4)));
987    }
988
989    #[test]
990    fn next_in_tab_order_null_current_node_is_deterministic() {
991        // The sentinel keys as (dom, 0); it is not in the order, so the re-entry
992        // path runs. It must produce a stable answer, not panic.
993        let order = vec![nid(0, 1), nid(0, 3)];
994        assert_eq!(next_in_tab_order(&order, Some(null_nid(0)), true), Some(nid(0, 1)));
995        assert_eq!(next_in_tab_order(&order, Some(null_nid(0)), false), Some(nid(0, 3)));
996    }
997
998    #[test]
999    fn next_in_tab_order_empty_order_is_none_for_every_input() {
1000        assert_eq!(next_in_tab_order(&[], None, true), None);
1001        assert_eq!(next_in_tab_order(&[], None, false), None);
1002        assert_eq!(next_in_tab_order(&[], Some(null_nid(0)), true), None);
1003        assert_eq!(
1004            next_in_tab_order(&[], Some(nid(usize::MAX, usize::MAX - 1)), false),
1005            None
1006        );
1007    }
1008
1009    // ==================================================================
1010    // collect_tab_order
1011    // ==================================================================
1012
1013    #[test]
1014    fn collect_tab_order_empty_window_is_empty() {
1015        assert_eq!(collect_tab_order(&BTreeMap::new()), Vec::new());
1016    }
1017
1018    #[test]
1019    fn collect_tab_order_dom_without_focusables_is_empty() {
1020        let sd = StyledDom::create_from_dom(
1021            Dom::create_body()
1022                .with_child(Dom::create_div())
1023                .with_child(Dom::create_div()),
1024        );
1025        assert_eq!(tab_order_of(sd), Vec::new());
1026    }
1027
1028    #[test]
1029    fn collect_tab_order_positives_first_then_document_order_minus_one_excluded() {
1030        // See `tab_fixture` doc comment for the expected layout.
1031        assert_eq!(
1032            tab_order_of(tab_fixture()),
1033            vec![nid(0, 5), nid(0, 3), nid(0, 2), nid(0, 6), nid(0, 7)],
1034            "tabindex=1 then tabindex=2, then auto nodes in document order"
1035        );
1036    }
1037
1038    #[test]
1039    fn collect_tab_order_excludes_tabindex_minus_one_though_it_is_focusable() {
1040        // The tabindex=-1 node (index 4) is click/API focusable but must NEVER be
1041        // a tab stop.
1042        let order = tab_order_of(tab_fixture());
1043        assert!(!order.contains(&nid(0, 4)), "tabindex=-1 must not be a tab stop");
1044        // ...and the plain, non-focusable div is absent too.
1045        assert!(!order.contains(&nid(0, 1)));
1046        // ...while the body itself is never a tab stop.
1047        assert!(!order.contains(&nid(0, 0)));
1048    }
1049
1050    #[test]
1051    fn collect_tab_order_huge_tabindex_truncates_at_28_bits() {
1052        // `NodeFlags` packs the tabindex into 28 bits, so:
1053        //   * tabindex = u32::MAX  -> stored as 2^28-1  -> still POSITIVE
1054        //   * tabindex = 1 << 28   -> stored as 0       -> demoted to the AUTO
1055        //                                                  bucket (0 is not > 0)
1056        // The truncation is silent, so pin the observable ordering consequence.
1057        //
1058        // Document order: 1 = u32::MAX, 2 = 1<<28, 3 = tabindex 1, 4 = button.
1059        let sd = StyledDom::create_from_dom(
1060            Dom::create_body()
1061                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(u32::MAX)))
1062                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(1 << 28)))
1063                .with_child(Dom::create_div().with_tab_index(TabIndex::OverrideInParent(1)))
1064                .with_child(Dom::create_node(NodeType::Button)),
1065        );
1066
1067        assert_eq!(
1068            tab_order_of(sd),
1069            vec![nid(0, 3), nid(0, 1), nid(0, 2), nid(0, 4)],
1070            "u32::MAX stays positive (sorts after tabindex=1); 1<<28 truncates to 0 and \
1071             falls back into the auto bucket behind every positive"
1072        );
1073    }
1074
1075    #[test]
1076    fn collect_tab_order_tab_order_is_global_across_doms() {
1077        // A positive-tabindex node in DOM 1 must outrank an auto node in DOM 0:
1078        // the tab order is a single sequence over all DOMs, not per-DOM chunks.
1079        let order = collect_tab_order(&window(vec![
1080            (dom(0), tab_fixture()),
1081            (dom(1), tab_fixture()),
1082        ]));
1083
1084        assert_eq!(
1085            order,
1086            vec![
1087                // positives, ascending; ties broken by DOM then document order
1088                nid(0, 5),
1089                nid(1, 5),
1090                nid(0, 3),
1091                nid(1, 3),
1092                // autos, in DOM order then document order
1093                nid(0, 2),
1094                nid(0, 6),
1095                nid(0, 7),
1096                nid(1, 2),
1097                nid(1, 6),
1098                nid(1, 7),
1099            ]
1100        );
1101    }
1102
1103    // ==================================================================
1104    // FocusSearchContext
1105    // ==================================================================
1106
1107    #[test]
1108    fn focus_search_context_get_layout_hit_and_miss() {
1109        let results = window(vec![(dom(0), tab_fixture())]);
1110        let ctx = FocusSearchContext::new(&results);
1111
1112        // `DomLayoutResult` is not `PartialEq`, so compare on the error side only.
1113        assert!(ctx.get_layout(&dom(0)).is_ok());
1114        assert_eq!(
1115            ctx.get_layout(&dom(1)).err(),
1116            Some(UpdateFocusWarning::FocusInvalidDomId(dom(1)))
1117        );
1118        assert_eq!(
1119            ctx.get_layout(&dom(usize::MAX)).err(),
1120            Some(UpdateFocusWarning::FocusInvalidDomId(dom(usize::MAX)))
1121        );
1122    }
1123
1124    #[test]
1125    fn focus_search_context_new_on_empty_map_never_resolves() {
1126        let empty = BTreeMap::new();
1127        let ctx = FocusSearchContext::new(&empty);
1128        assert_eq!(
1129            ctx.get_layout(&dom(0)).err(),
1130            Some(UpdateFocusWarning::FocusInvalidDomId(dom(0)))
1131        );
1132    }
1133
1134    #[test]
1135    fn make_dom_node_id_round_trips_including_boundary_index() {
1136        // encode == decode for 0, a mid value, and the largest encodable index
1137        // (`usize::MAX` itself would overflow `NodeHierarchyItemId`'s n+1 encoding).
1138        for idx in [0usize, 1, 42, usize::MAX - 1] {
1139            let d = FocusSearchContext::make_dom_node_id(dom(7), NodeId::new(idx));
1140            assert_eq!(d.dom, dom(7));
1141            assert_eq!(d.node.into_crate_internal(), Some(NodeId::new(idx)));
1142            assert_eq!(doc_order_key(&d), (7, idx));
1143        }
1144    }
1145
1146    // ==================================================================
1147    // find_first_matching_focusable_node
1148    // ==================================================================
1149
1150    #[test]
1151    fn find_first_matching_skips_matching_but_unfocusable_nodes() {
1152        // Node 1 matches `.target` but is NOT focusable; node 2 matches AND is
1153        // focusable. The first *focusable* match must win.
1154        let sd = StyledDom::create_from_dom(
1155            Dom::create_body()
1156                .with_child(Dom::create_div().with_class("target".to_string().into()))
1157                .with_child(
1158                    Dom::create_div()
1159                        .with_class("target".to_string().into())
1160                        .with_tab_index(TabIndex::Auto),
1161                ),
1162        );
1163        let results = window(vec![(dom(0), sd)]);
1164        let layout = results.get(&dom(0)).unwrap();
1165
1166        assert_eq!(
1167            find_first_matching_focusable_node(layout, &dom(0), &class_path("target")),
1168            Some(nid(0, 2))
1169        );
1170    }
1171
1172    #[test]
1173    fn find_first_matching_empty_css_path_matches_nothing() {
1174        // A `CssPath` with zero selectors must not vacuously match every node.
1175        let results = window(vec![(dom(0), tab_fixture())]);
1176        let layout = results.get(&dom(0)).unwrap();
1177        let empty = CssPath {
1178            selectors: Vec::<CssPathSelector>::new().into(),
1179        };
1180
1181        assert_eq!(
1182            find_first_matching_focusable_node(layout, &dom(0), &empty),
1183            None
1184        );
1185    }
1186
1187    #[test]
1188    fn find_first_matching_unicode_class_matches_and_misses_cleanly() {
1189        // Non-ASCII / astral-plane class names must compare by exact string, with
1190        // no panic and no byte-index slicing surprises.
1191        let class = "クラス-día-🎯";
1192        let sd = StyledDom::create_from_dom(
1193            Dom::create_body().with_child(
1194                Dom::create_div()
1195                    .with_class(class.to_string().into())
1196                    .with_tab_index(TabIndex::Auto),
1197            ),
1198        );
1199        let results = window(vec![(dom(0), sd)]);
1200        let layout = results.get(&dom(0)).unwrap();
1201
1202        assert_eq!(
1203            find_first_matching_focusable_node(layout, &dom(0), &class_path(class)),
1204            Some(nid(0, 1))
1205        );
1206        // A near-miss (same prefix, different suffix) must NOT match.
1207        assert_eq!(
1208            find_first_matching_focusable_node(layout, &dom(0), &class_path("クラス-día-🎲")),
1209            None
1210        );
1211        // A huge class name that cannot exist in the DOM also just misses.
1212        let huge = "x".repeat(10_000);
1213        assert_eq!(
1214            find_first_matching_focusable_node(layout, &dom(0), &class_path(&huge)),
1215            None
1216        );
1217    }
1218
1219    // ==================================================================
1220    // resolve_focus_target
1221    // ==================================================================
1222
1223    #[test]
1224    fn resolve_focus_target_empty_window_short_circuits_every_variant() {
1225        // The `layout_results.is_empty()` guard runs BEFORE any validation, so
1226        // even a structurally invalid target resolves to `Ok(None)` — never Err,
1227        // never a panic.
1228        let empty = BTreeMap::new();
1229        let targets = vec![
1230            FocusTarget::Id(nid(usize::MAX, 0)),
1231            FocusTarget::Id(null_nid(0)),
1232            FocusTarget::Path(FocusTargetPath {
1233                dom: dom(usize::MAX),
1234                css_path: class_path("nope"),
1235            }),
1236            FocusTarget::Previous,
1237            FocusTarget::Next,
1238            FocusTarget::First,
1239            FocusTarget::Last,
1240            FocusTarget::NoFocus,
1241        ];
1242        for t in targets {
1243            assert_eq!(
1244                resolve_focus_target(&t, &empty, Some(nid(0, 1))),
1245                Ok(None),
1246                "empty window must short-circuit {t:?}"
1247            );
1248        }
1249    }
1250
1251    #[test]
1252    fn resolve_focus_target_id_rejects_unknown_dom() {
1253        let results = window(vec![(dom(0), tab_fixture())]);
1254        assert_eq!(
1255            resolve_focus_target(&FocusTarget::Id(nid(1, 2)), &results, None),
1256            Err(UpdateFocusWarning::FocusInvalidDomId(dom(1)))
1257        );
1258    }
1259
1260    #[test]
1261    fn resolve_focus_target_id_rejects_out_of_range_node() {
1262        // The fixture has 8 nodes (0..=7); anything past the end must be a
1263        // `FocusInvalidNodeId` error, not a panic and not a silent focus.
1264        let results = window(vec![(dom(0), tab_fixture())]);
1265        for idx in [8usize, 9, 1_000_000, usize::MAX - 1] {
1266            let target = nid(0, idx);
1267            assert_eq!(
1268                resolve_focus_target(&FocusTarget::Id(target), &results, None),
1269                Err(UpdateFocusWarning::FocusInvalidNodeId(target.node)),
1270                "node {idx} is out of range and must be rejected"
1271            );
1272        }
1273    }
1274
1275    #[test]
1276    fn resolve_focus_target_id_rejects_null_node_sentinel() {
1277        let results = window(vec![(dom(0), tab_fixture())]);
1278        let target = null_nid(0);
1279        assert_eq!(
1280            resolve_focus_target(&FocusTarget::Id(target), &results, None),
1281            Err(UpdateFocusWarning::FocusInvalidNodeId(target.node))
1282        );
1283    }
1284
1285    #[test]
1286    fn resolve_focus_target_id_accepts_valid_but_unfocusable_node() {
1287        // `Id` checks only that the node EXISTS — programmatic focus deliberately
1288        // bypasses the focusability check (unlike `Path` and the tab order).
1289        // Node 0 is the body and node 4 is tabindex=-1: both resolve.
1290        let results = window(vec![(dom(0), tab_fixture())]);
1291        assert_eq!(
1292            resolve_focus_target(&FocusTarget::Id(nid(0, 0)), &results, None),
1293            Ok(Some(nid(0, 0)))
1294        );
1295        assert_eq!(
1296            resolve_focus_target(&FocusTarget::Id(nid(0, 4)), &results, None),
1297            Ok(Some(nid(0, 4)))
1298        );
1299    }
1300
1301    #[test]
1302    fn resolve_focus_target_path_rejects_unknown_dom() {
1303        let results = window(vec![(dom(0), tab_fixture())]);
1304        let target = FocusTarget::Path(FocusTargetPath {
1305            dom: dom(3),
1306            css_path: class_path("target"),
1307        });
1308        assert_eq!(
1309            resolve_focus_target(&target, &results, None),
1310            Err(UpdateFocusWarning::FocusInvalidDomId(dom(3)))
1311        );
1312    }
1313
1314    #[test]
1315    fn resolve_focus_target_path_with_no_match_is_ok_none_not_err() {
1316        // NOTE: the doc comment on `find_first_matching_focusable_node` advertises
1317        // `Err(_)` for an unmatchable path, but the implementation returns
1318        // `Ok(None)`. Pin the IMPLEMENTED behaviour (a miss is not an error);
1319        // the doc comment is what is wrong here.
1320        let results = window(vec![(dom(0), tab_fixture())]);
1321        let target = FocusTarget::Path(FocusTargetPath {
1322            dom: dom(0),
1323            css_path: class_path("no-such-class"),
1324        });
1325        assert_eq!(resolve_focus_target(&target, &results, None), Ok(None));
1326    }
1327
1328    #[test]
1329    fn resolve_focus_target_no_focus_is_always_none() {
1330        let results = window(vec![(dom(0), tab_fixture())]);
1331        assert_eq!(
1332            resolve_focus_target(&FocusTarget::NoFocus, &results, Some(nid(0, 5))),
1333            Ok(None)
1334        );
1335    }
1336
1337    #[test]
1338    fn resolve_focus_target_first_and_last_are_the_tab_order_ends() {
1339        let results = window(vec![(dom(0), tab_fixture())]);
1340        // Tab order is [5, 3, 2, 6, 7].
1341        assert_eq!(
1342            resolve_focus_target(&FocusTarget::First, &results, None),
1343            Ok(Some(nid(0, 5))),
1344            "First must be the lowest positive tabindex, not document node 0"
1345        );
1346        assert_eq!(
1347            resolve_focus_target(&FocusTarget::Last, &results, None),
1348            Ok(Some(nid(0, 7)))
1349        );
1350        // `current_focus` must not influence First/Last.
1351        assert_eq!(
1352            resolve_focus_target(&FocusTarget::First, &results, Some(nid(0, 7))),
1353            Ok(Some(nid(0, 5)))
1354        );
1355    }
1356
1357    #[test]
1358    fn resolve_focus_target_first_and_last_on_unfocusable_dom_are_none() {
1359        let sd = StyledDom::create_from_dom(Dom::create_body().with_child(Dom::create_div()));
1360        let results = window(vec![(dom(0), sd)]);
1361        assert_eq!(resolve_focus_target(&FocusTarget::First, &results, None), Ok(None));
1362        assert_eq!(resolve_focus_target(&FocusTarget::Last, &results, None), Ok(None));
1363        assert_eq!(resolve_focus_target(&FocusTarget::Next, &results, None), Ok(None));
1364        assert_eq!(
1365            resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 0))),
1366            Ok(None)
1367        );
1368    }
1369
1370    #[test]
1371    fn resolve_focus_target_next_and_previous_wrap_around_the_tab_order() {
1372        let results = window(vec![(dom(0), tab_fixture())]);
1373        // Tab order [5, 3, 2, 6, 7]: stepping off either end wraps.
1374        assert_eq!(
1375            resolve_focus_target(&FocusTarget::Next, &results, Some(nid(0, 7))),
1376            Ok(Some(nid(0, 5)))
1377        );
1378        assert_eq!(
1379            resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 5))),
1380            Ok(Some(nid(0, 7)))
1381        );
1382        // ...and step normally in the middle.
1383        assert_eq!(
1384            resolve_focus_target(&FocusTarget::Next, &results, Some(nid(0, 3))),
1385            Ok(Some(nid(0, 2)))
1386        );
1387        assert_eq!(
1388            resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 2))),
1389            Ok(Some(nid(0, 3)))
1390        );
1391    }
1392
1393    #[test]
1394    fn resolve_focus_target_next_from_a_non_tab_stop_reenters_in_document_order() {
1395        let results = window(vec![(dom(0), tab_fixture())]);
1396        // Focus sits on the tabindex=-1 node (index 4), which is NOT in the tab
1397        // order. Shift+Tab must fall back to document order and land on node 3
1398        // (the nearest preceding tab stop by DOCUMENT position), NOT on the tab
1399        // order's neighbour of any element.
1400        assert_eq!(
1401            resolve_focus_target(&FocusTarget::Previous, &results, Some(nid(0, 4))),
1402            Ok(Some(nid(0, 3)))
1403        );
1404        // Focus on the plain, non-focusable div (index 1): Tab goes to the next
1405        // tab stop in DOCUMENT order (node 2, the button) — not to the tab
1406        // order's first entry (node 5).
1407        assert_eq!(
1408            resolve_focus_target(&FocusTarget::Next, &results, Some(nid(0, 1))),
1409            Ok(Some(nid(0, 2)))
1410        );
1411    }
1412
1413    #[test]
1414    fn resolve_focus_target_next_from_a_stale_removed_node_never_panics() {
1415        // Focus left over from a previous DOM whose node index no longer exists:
1416        // resolution must still yield a valid tab stop rather than panic.
1417        let results = window(vec![(dom(0), tab_fixture())]);
1418        let stale = nid(0, 9_999);
1419        assert_eq!(
1420            resolve_focus_target(&FocusTarget::Next, &results, Some(stale)),
1421            Ok(Some(nid(0, 5))),
1422            "no tab stop past node 9999 -> wrap to the first"
1423        );
1424        // A stale node in a DOM that isn't even mounted.
1425        let alien = nid(5, 1);
1426        assert_eq!(
1427            resolve_focus_target(&FocusTarget::Next, &results, Some(alien)),
1428            Ok(Some(nid(0, 5)))
1429        );
1430        assert_eq!(
1431            resolve_focus_target(&FocusTarget::Previous, &results, Some(alien)),
1432            Ok(Some(nid(0, 7)))
1433        );
1434    }
1435
1436    // ==================================================================
1437    // NodeIdRemap
1438    // ==================================================================
1439
1440    #[test]
1441    fn remap_rewrites_the_focused_node() {
1442        let mut fm = FocusManager::new();
1443        fm.set_focused_node(Some(nid(0, 5)));
1444        fm.remap_node_ids(
1445            dom(0),
1446            &NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(2))]),
1447        );
1448        assert_eq!(fm.get_focused_node(), Some(&nid(0, 2)));
1449    }
1450
1451    #[test]
1452    fn remap_clears_focus_on_an_unmounted_node() {
1453        // The node vanished from the rebuilt DOM: keeping the stale index would
1454        // silently focus a DIFFERENT element, so focus must be dropped.
1455        let mut fm = FocusManager::new();
1456        fm.set_focused_node(Some(nid(0, 5)));
1457        fm.remap_node_ids(
1458            dom(0),
1459            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(1))]),
1460        );
1461        assert_eq!(fm.get_focused_node(), None);
1462    }
1463
1464    #[test]
1465    fn remap_leaves_other_doms_untouched() {
1466        let mut fm = FocusManager::new();
1467        fm.set_focused_node(Some(nid(1, 5)));
1468        // Remapping DOM 0 must not disturb focus that lives in DOM 1.
1469        fm.remap_node_ids(
1470            dom(0),
1471            &NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(2))]),
1472        );
1473        assert_eq!(fm.get_focused_node(), Some(&nid(1, 5)));
1474    }
1475
1476    #[test]
1477    fn remap_rewrites_pending_contenteditable_focus() {
1478        let mut fm = FocusManager::new();
1479        fm.set_pending_contenteditable_focus(dom(0), NodeId::new(3), NodeId::new(4));
1480        fm.remap_node_ids(
1481            dom(0),
1482            &NodeIdMap::from_pairs([
1483                (NodeId::new(3), NodeId::new(10)),
1484                (NodeId::new(4), NodeId::new(11)),
1485            ]),
1486        );
1487        assert!(fm.needs_cursor_initialization());
1488        assert_eq!(
1489            fm.take_pending_contenteditable_focus(),
1490            Some(PendingContentEditableFocus {
1491                dom_id: dom(0),
1492                container_node_id: NodeId::new(10),
1493                text_node_id: NodeId::new(11),
1494            })
1495        );
1496    }
1497
1498    #[test]
1499    fn remap_partially_resolvable_pending_focus_drops_it_entirely() {
1500        // Container survives the rebuild but the text node does not (or vice
1501        // versa): keeping half of the pair would place a cursor in the wrong
1502        // node, so BOTH the value and the flag must be dropped.
1503        for pairs in [
1504            vec![(NodeId::new(3), NodeId::new(10))],  // text node unmapped
1505            vec![(NodeId::new(4), NodeId::new(11))],  // container unmapped
1506            vec![],                                   // neither survives
1507        ] {
1508            let mut fm = FocusManager::new();
1509            fm.set_pending_contenteditable_focus(dom(0), NodeId::new(3), NodeId::new(4));
1510            fm.remap_node_ids(dom(0), &NodeIdMap::from_pairs(pairs));
1511
1512            assert!(!fm.needs_cursor_initialization());
1513            assert_eq!(fm.pending_contenteditable_focus, None);
1514        }
1515    }
1516}