telar_ui_core/focus.rs
1//! Keyboard focus: which widget receives key events. A base primitive with no styling of its own — a
2//! focusable widget (e.g. [`crate::Input`]) requests focus on tap and consults it in `on_event`/`view`.
3//!
4//! Key events are broadcast to every widget (see `dispatch_container_event`), so focus is *self-filtering*:
5//! a widget handles a key only when [`is_focused`] holds for its id — there is no central router. Focus is
6//! a reactive signal, so a widget that reads [`current`]/[`is_focused`] inside its `view()` re-renders when
7//! focus moves (e.g. to show or hide its caret). State is per-surface (each surface owns its own focus via
8//! [`FocusContext`], activated by the runner), so focus never crosses windows; preserving focus across a
9//! hot-reload dylib swap is out of scope.
10
11use std::rc::Rc;
12
13use layout_core::NodeId;
14use platform_core::{Key, ModifiersState, NamedKey, NumericValue};
15use reactive_core::{RwSignal, signal};
16use rustc_hash::FxHashSet;
17
18/// An opaque focus identity, one per focusable widget. Allocate with [`next_id`].
19pub type FocusId = u64;
20
21/// What kind of widget a focusable is, as far as the keyboard is concerned.
22///
23/// The distinction exists for one question: whether the keys arriving now are *text*. Key events are
24/// broadcast, so an app-level shortcut handler and a focused field see the same press, and without this the
25/// `3` typed into a dimension field also fires the app's `3` shortcut.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum FocusKind {
28 /// Takes keys as commands: a button, a tab, a slider.
29 Widget,
30 /// Takes keys as text: a field, an editor.
31 TextEntry,
32}
33
34/// What a focusable *is*, for the reader that has to say it out loud — a separate question from [`FocusKind`],
35/// which asks what the widget does with a key.
36///
37/// Defined in `platform-core` because it is the vocabulary the UI and the platform share, the same way
38/// [`Key`] is. Re-exported here because this is where it is *authored*: a widget declares its role at the
39/// moment it declares itself focusable, and the two are one call.
40pub use platform_core::Role;
41
42/// A cheap, `Copy` handle to a focusable widget's identity, so a caller that has moved the widget into a
43/// container (and no longer holds a reference to it) can still drive its focus — e.g. autofocus a hosted
44/// editor when its tab activates. Obtain one from the widget (see [`crate::TextArea::focus_handle`]).
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct FocusHandle(FocusId);
47
48impl FocusHandle {
49 /// Gives focus to the handle's widget.
50 pub fn request(self) {
51 request(self.0);
52 }
53
54 /// Removes focus from the handle's widget, only if it currently holds it.
55 pub fn release(self) {
56 release(self.0);
57 }
58
59 /// Whether the handle's widget currently holds focus.
60 pub fn is_focused(self) -> bool {
61 is_focused(self.0)
62 }
63}
64
65/// Wraps a raw [`FocusId`] in a [`FocusHandle`]. A focusable widget hands out a handle to its own id.
66pub fn handle(id: FocusId) -> FocusHandle {
67 FocusHandle(id)
68}
69
70/// Identifies one registered [`Scope`], so a closing overlay can withdraw exactly its own.
71#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72pub struct ScopeId(u64);
73
74/// A region of the tree whose focusables are only reachable while it is showing.
75///
76/// Declared by whatever can hide content without taking it out of the tree — an [`Overlay`](crate::Overlay)
77/// kept mounted across a close, today. It names a *node*, not a set of ids, and that is the whole trick: an
78/// overlay's children are built before the overlay that will host them, so it never learns which focusables
79/// are its own. Ancestry answers instead.
80struct Scope {
81 id: ScopeId,
82 node: NodeId,
83 showing: Rc<dyn Fn() -> bool>,
84 /// Whether the scope holds focus in while it shows — a modal, as against a tooltip layer.
85 traps: bool,
86 reason: ScopeReason,
87}
88
89/// Why a scope's focusables are out of reach, which the keyboard does not care about and a screen reader does.
90///
91/// Tab treats the two the same — neither is a stop — but they are opposite things to say out loud. A control
92/// inside a closed dialog is *not there*; a disabled one is there and unavailable, and a reader that omitted
93/// it would leave the user wondering where the button went.
94#[derive(Clone, Copy, PartialEq, Eq, Debug)]
95pub enum ScopeReason {
96 NotShowing,
97 Disabled,
98}
99
100/// One entry in the tab order.
101struct Entry {
102 id: FocusId,
103 /// The widget's layout node, which is what makes reachability answerable. `None` for a focus id that
104 /// belongs to no widget (the dismiss stack takes one as a token).
105 node: Option<NodeId>,
106 role: Role,
107 /// Whether Tab stops here. `false` for a control that is driven some other way and still has to be
108 /// announced: the rows of a menu answer to arrow keys, and putting each one in the tab order would make
109 /// Tab walk a list the user opened precisely so as not to.
110 tabbable: bool,
111 /// A checked state, for the controls that have one. A closure and not a flag, for the same reason
112 /// "reachable" is one: a checkbox toggles without being rebuilt, and a reader asking a moment later has to
113 /// get the answer that is true then.
114 toggled: Option<Rc<dyn Fn() -> bool>>,
115 value: Option<Rc<dyn Fn() -> NumericValue>>,
116}
117
118/// Per-surface keyboard-focus state: the id allocator, the focused-widget signal, and the tab order.
119struct FocusState {
120 next_id: FocusId,
121 next_scope: u64,
122 focused: RwSignal<Option<FocusId>>,
123 // Whether the focus held now was taken by a pointer. A signal, not a plain flag: Tab onto the widget you just clicked moves it without moving `focused`, and a ring that missed that would be stale exactly when the keyboard took over.
124 pointer_focus: RwSignal<bool>,
125 // Registered focusables in tab order (registration order ≈ document order). Drives Tab/Shift-Tab.
126 order: Vec<Entry>,
127 // Regions that can hide their contents without unregistering them; consulted when stepping.
128 scopes: Vec<Scope>,
129 // The subset of `order` that takes keys as text. A set rather than a field on each entry because it is
130 // the minority and the only kind anyone asks about.
131 text_entries: FxHashSet<FocusId>,
132}
133
134impl FocusState {
135 fn new() -> Self {
136 Self {
137 next_id: 1,
138 next_scope: 1,
139 focused: signal(None),
140 pointer_focus: signal(false),
141 order: Vec::new(),
142 scopes: Vec::new(),
143 text_entries: FxHashSet::default(),
144 }
145 }
146}
147
148reactive_core::surface_local! {
149 /// Per-surface focus state. The runner activates each surface's [`FocusContext`] around its
150 /// build/event/frame, so focus never crosses windows.
151 slot FOCUS: FocusState = FocusState::new();
152 access with_focus, with_focus_ref;
153 context FocusContext, FocusGuard;
154}
155
156/// The active surface's focused-widget signal, cloned out of the slot so callers never hold the slot borrow
157/// across a `.set()` — its flush re-enters the slot when an effect reads [`current`].
158fn focused_signal() -> RwSignal<Option<FocusId>> {
159 with_focus_ref(|s| s.focused.clone())
160}
161
162/// Allocates a fresh focus id for a focusable widget.
163pub fn next_id() -> FocusId {
164 with_focus(|s| {
165 let id = s.next_id;
166 s.next_id += 1;
167 id
168 })
169}
170
171/// The currently focused widget, or `None`. Reactive: reading this inside a `view()` re-renders the
172/// caller when focus changes.
173pub fn current() -> Option<FocusId> {
174 focused_signal().get()
175}
176
177/// Whether `id` currently holds focus.
178pub fn is_focused(id: FocusId) -> bool {
179 current() == Some(id)
180}
181
182// The three commands below `peek` the signal they write, and it matters: a command is a thing an *effect* may
183// well issue ("while this row is the selected one, focus its field"), and a reactive read there would
184// subscribe that effect to the focus it sets — so the next focus change anywhere would re-run it and it would
185// take the focus straight back. Same rule, and the same bug, as `ScrollViewport::reveal`.
186
187/// Gives focus to `id` (a no-op if it already holds it).
188pub fn request(id: FocusId) {
189 set_pointer_focus(false);
190 let focused = focused_signal();
191 if focused.peek() != Some(id) {
192 focused.set(Some(id));
193 }
194}
195
196/// [`request`] for focus a *tap* is giving, which is the one case that should not draw a focus ring.
197///
198/// The distinction CSS spent years arriving at as `:focus-visible`. A ring on every click is noise — the user
199/// already knows where they clicked — and the ring drawn anyway is why so many stylesheets used to turn
200/// outlines off altogether, taking the keyboard's only cue with them. Focus taken any other way (Tab, or an
201/// application focusing something itself) shows it.
202pub fn request_from_pointer(id: FocusId) {
203 set_pointer_focus(true);
204 let focused = focused_signal();
205 if focused.peek() != Some(id) {
206 focused.set(Some(id));
207 }
208}
209
210/// Whether `id` holds focus *and* should show it. Reactive, like [`current`].
211pub fn is_focus_visible(id: FocusId) -> bool {
212 is_focused(id) && !pointer_focus_signal().get()
213}
214
215fn pointer_focus_signal() -> RwSignal<bool> {
216 with_focus_ref(|s| s.pointer_focus.clone())
217}
218
219fn set_pointer_focus(from_pointer: bool) {
220 let flag = pointer_focus_signal();
221 if flag.peek() != from_pointer {
222 flag.set(from_pointer);
223 }
224}
225
226/// Removes focus from `id`, but only if it currently holds it — so a widget blurring itself never steals
227/// focus away from another.
228pub fn release(id: FocusId) {
229 let focused = focused_signal();
230 if focused.peek() == Some(id) {
231 focused.set(None);
232 }
233}
234
235/// Clears focus entirely, whoever holds it.
236pub fn clear() {
237 let focused = focused_signal();
238 if focused.peek().is_some() {
239 focused.set(None);
240 }
241}
242
243/// Adds `id` to the tab order (at the end), if not already present, as a widget that says what it does with
244/// the keyboard — a text field registers as [`FocusKind::TextEntry`], which is what makes
245/// [`text_entry_focused`] answerable. A focusable widget calls this on creation; registration order is the
246/// traversal order.
247pub fn register_as(id: FocusId, kind: FocusKind) {
248 register_node(id, kind, None, default_role(kind), true);
249}
250
251/// [`register_as`] for a widget that can say which layout node it is, which is what lets Tab skip it while it
252/// is not on screen. Every focusable widget should use this; the node-less forms remain for a focus id that
253/// stands for something other than a widget.
254pub fn register_at(id: FocusId, kind: FocusKind, node: NodeId) {
255 register_node(id, kind, Some(node), default_role(kind), true);
256}
257
258/// [`register_at`] for a widget that is not simply "a thing you activate" — a checkbox, a tab, a slider. The
259/// role is what a screen reader says this is; see [`Role`].
260pub fn register_with_role(id: FocusId, kind: FocusKind, node: NodeId, role: Role) {
261 register_node(id, kind, Some(node), role, true);
262}
263
264/// Registers a control that is announced but is not a Tab stop, because something else drives it.
265///
266/// The rows of a menu are the case: they answer to arrow keys and type-ahead, and a reader that could not see
267/// them would be handed an open menu it could not describe — while a Tab order containing every row would
268/// walk the user through a list they opened in order to *avoid* walking it.
269pub fn register_presented(id: FocusId, node: NodeId, role: Role) {
270 register_node(id, FocusKind::Widget, Some(node), role, false);
271}
272
273/// What a widget is taken to be when it has not said: the reading that matches what the keyboard does with it.
274fn default_role(kind: FocusKind) -> Role {
275 match kind {
276 FocusKind::Widget => Role::Button,
277 FocusKind::TextEntry => Role::TextInput,
278 }
279}
280
281fn register_node(id: FocusId, kind: FocusKind, node: Option<NodeId>, role: Role, tabbable: bool) {
282 with_focus(|s| {
283 match s.order.iter_mut().find(|e| e.id == id) {
284 // Re-registering only ever adds knowledge: a widget that learns its node later keeps its place.
285 Some(existing) => {
286 existing.node = existing.node.or(node);
287 if role != Role::default() {
288 existing.role = role;
289 }
290 existing.tabbable &= tabbable;
291 }
292 None => s.order.push(Entry {
293 id,
294 node,
295 role,
296 tabbable,
297 toggled: None,
298 value: None,
299 }),
300 }
301 if kind == FocusKind::TextEntry {
302 s.text_entries.insert(id);
303 }
304 });
305}
306
307/// Declares a region whose focusables are only reachable while `showing` reads true, and — when `traps` — that
308/// holds focus inside itself while it is up.
309///
310/// The counterpart of the pointer barrier an overlay already puts up. Without it the tab order is a list built
311/// when widgets were *constructed*, which says nothing about what is on screen now: a dialog kept mounted
312/// across a close leaves its fields as Tab stops, and one that is open does not stop Tab walking out behind it.
313pub fn register_scope(node: NodeId, showing: impl Fn() -> bool + 'static, traps: bool) -> ScopeId {
314 register_scope_because(node, showing, traps, ScopeReason::NotShowing)
315}
316
317/// [`register_scope`] for a region that says *why* its focusables are out of reach. See [`ScopeReason`].
318pub fn register_scope_because(
319 node: NodeId,
320 showing: impl Fn() -> bool + 'static,
321 traps: bool,
322 reason: ScopeReason,
323) -> ScopeId {
324 with_focus(|s| {
325 let id = ScopeId(s.next_scope);
326 s.next_scope += 1;
327 s.scopes.push(Scope {
328 id,
329 node,
330 showing: Rc::new(showing),
331 traps,
332 reason,
333 });
334 id
335 })
336}
337
338/// Withdraws a scope registered with [`register_scope`].
339pub fn unregister_scope(id: ScopeId) {
340 with_focus(|s| s.scopes.retain(|scope| scope.id != id));
341}
342
343/// Removes `id` from the tab order and drops its focus if it held it. A focusable widget calls this on
344/// drop, so a destroyed widget never lingers in traversal or as the focused id.
345pub fn unregister(id: FocusId) {
346 with_focus(|s| {
347 s.order.retain(|e| e.id != id);
348 s.text_entries.remove(&id);
349 });
350 release(id);
351}
352
353/// Whether the focused widget takes keys as text. Reactive, like [`current`].
354///
355/// The guard an app-level shortcut table needs: without it, typing into a field also runs the shortcuts
356/// that share its letters. Prefer [`text_entry_takes_key`], which lets through the presses no editor wants.
357pub fn text_entry_focused() -> bool {
358 match current() {
359 Some(id) => with_focus_ref(|s| s.text_entries.contains(&id)),
360 None => false,
361 }
362}
363
364/// Whether a focused text entry would take this press as text — the guard for a global shortcut handler.
365///
366/// Narrower than [`text_entry_focused`] on purpose: a field claims the letters and the caret keys, and
367/// nothing else. `⌘S` still saves while the caret sits in a field, and so do the function keys, because no
368/// editor here does anything with them. The list mirrors what [`crate::Input`] and [`crate::TextArea`]
369/// actually consume, and their own tests hold it to that.
370pub fn text_entry_takes_key(key: &Key, modifiers: ModifiersState) -> bool {
371 text_entry_focused() && edits_text(key, modifiers)
372}
373
374fn edits_text(key: &Key, modifiers: ModifiersState) -> bool {
375 match key {
376 // A chord is a command, not text: the editors ignore it too.
377 Key::Char(_) if modifiers.is_ctrl || modifiers.is_meta => false,
378 Key::Char(c) => !c.is_control(),
379 Key::Named(named) => matches!(
380 named,
381 NamedKey::Space
382 | NamedKey::Backspace
383 | NamedKey::Delete
384 | NamedKey::ArrowLeft
385 | NamedKey::ArrowRight
386 | NamedKey::ArrowUp
387 | NamedKey::ArrowDown
388 | NamedKey::Home
389 | NamedKey::End
390 | NamedKey::Enter
391 | NamedKey::Escape
392 | NamedKey::Tab
393 ),
394 }
395}
396
397/// Moves focus to the next registered focusable in tab order (wrapping); with nothing focused, focuses
398/// the first. A no-op when nothing is registered.
399pub fn focus_next() {
400 step(1);
401}
402
403/// Like [`focus_next`] but backwards (Shift+Tab).
404pub fn focus_prev() {
405 step(-1);
406}
407
408/// A [`Scope`] as [`step`] reads it, once copied out from under the slot borrow: where it is, whether it is
409/// showing, and whether it holds focus inside itself.
410type ScopeView = (NodeId, Rc<dyn Fn() -> bool>, bool);
411
412/// Whether Tab should be able to land on a focusable at `node`, given the scopes registered right now.
413///
414/// Three ways to be out of reach, and they are genuinely different mechanisms rather than one seen from three
415/// angles — which is why a rule aimed at any single one of them leaves the others open:
416/// - out of layout flow, by its own `display:none` or an ancestor's, which leaves the rect it last had;
417/// - inside a region kept mounted while not showing, which leaves the rect *and* the layout intact;
418/// - outside the modal that is currently up, which is about nothing on the node itself.
419fn reachable(node: Option<NodeId>, scopes: &[ScopeView]) -> bool {
420 // A focus id that stands for no widget has no way to be off screen.
421 let Some(node) = node else { return true };
422 if layout_reactive::is_hidden(node) {
423 return false;
424 }
425 if scopes
426 .iter()
427 .any(|(scope, showing, _)| !showing() && layout_reactive::is_descendant_of(node, *scope))
428 {
429 return false;
430 }
431 // The topmost trapping scope that is up holds focus inside itself.
432 match scopes
433 .iter()
434 .rev()
435 .find(|(_, showing, traps)| *traps && showing())
436 {
437 Some((scope, _, _)) => layout_reactive::is_descendant_of(node, *scope),
438 None => true,
439 }
440}
441
442/// The tab order and the scopes, copied out from under the slot borrow — see [`step`] for why that matters.
443fn snapshot() -> (Vec<(FocusId, Option<NodeId>)>, Vec<ScopeView>) {
444 with_focus_ref(|s| {
445 let order: Vec<(FocusId, Option<NodeId>)> = s
446 .order
447 .iter()
448 .filter(|e| e.tabbable)
449 .map(|e| (e.id, e.node))
450 .collect();
451 let scopes: Vec<ScopeView> = s
452 .scopes
453 .iter()
454 .map(|sc| (sc.node, sc.showing.clone(), sc.traps))
455 .collect();
456 (order, scopes)
457 })
458}
459
460/// Declares that `id` carries a checked state, and how to read it now.
461///
462/// Separate from registering the control because the two are known at different moments: a box declares what
463/// it *is* as it is built, and what it is *bound to* when the caller hands it a signal.
464pub fn set_toggled(id: FocusId, state: impl Fn() -> bool + 'static) {
465 let state: Rc<dyn Fn() -> bool> = Rc::new(state);
466 with_focus(|s| {
467 if let Some(entry) = s.order.iter_mut().find(|e| e.id == id) {
468 entry.toggled = Some(state);
469 }
470 });
471}
472
473/// Declares that `id` carries a number, and how to read it now. The counterpart of [`set_toggled`] for the
474/// roles whose state is a value rather than a flag.
475pub fn set_value(id: FocusId, read: impl Fn() -> NumericValue + 'static) {
476 let read: Rc<dyn Fn() -> NumericValue> = Rc::new(read);
477 with_focus(|s| {
478 if let Some(entry) = s.order.iter_mut().find(|e| e.id == id) {
479 entry.value = Some(read);
480 }
481 });
482}
483
484/// One focusable as the accessibility layer sees it: where it is, what it is, and whether it is available.
485pub struct Exposed {
486 pub id: FocusId,
487 pub node: NodeId,
488 pub role: Role,
489 /// Available to be activated. `false` is *announced*, not hidden — see [`ScopeReason`].
490 pub enabled: bool,
491 /// Its checked state, for the controls that carry one.
492 pub toggled: Option<bool>,
493 /// Its numeric reading, for the controls that carry one.
494 pub value: Option<NumericValue>,
495}
496
497/// The focusables a screen reader should be told about, in tab order.
498///
499/// Deliberately the same [`reachable`] the keyboard walks, so the two can never disagree about what is on
500/// screen — with one distinction Tab has no use for: a control kept out of reach by being *disabled* is
501/// reported as present and unavailable, where one inside a closed dialog is not reported at all.
502pub fn exposed() -> Vec<Exposed> {
503 let (order, scopes) = with_focus_ref(|s| {
504 // The state closures come out with everything else and are called after the borrow drops: reading one
505 // can read a signal, and reading a signal can flush effects back through this very slot.
506 type Row = (
507 FocusId,
508 Option<NodeId>,
509 Role,
510 Option<Rc<dyn Fn() -> bool>>,
511 Option<Rc<dyn Fn() -> NumericValue>>,
512 );
513 let order: Vec<Row> = s
514 .order
515 .iter()
516 .map(|e| (e.id, e.node, e.role, e.toggled.clone(), e.value.clone()))
517 .collect();
518 let scopes: Vec<(NodeId, Rc<dyn Fn() -> bool>, bool, ScopeReason)> = s
519 .scopes
520 .iter()
521 .map(|sc| (sc.node, sc.showing.clone(), sc.traps, sc.reason))
522 .collect();
523 (order, scopes)
524 });
525 let hiding: Vec<ScopeView> = scopes
526 .iter()
527 .filter(|(_, _, _, reason)| *reason == ScopeReason::NotShowing)
528 .map(|(node, showing, traps, _)| (*node, showing.clone(), *traps))
529 .collect();
530
531 order
532 .into_iter()
533 .filter_map(|(id, node, role, toggled, value)| {
534 let node = node?;
535 reachable(Some(node), &hiding).then(|| Exposed {
536 id,
537 node,
538 role,
539 enabled: !scopes.iter().any(|(scope, showing, _, reason)| {
540 *reason == ScopeReason::Disabled
541 && !showing()
542 && layout_reactive::is_descendant_of(node, *scope)
543 }),
544 toggled: toggled.as_ref().map(|read| read()),
545 value: value.as_ref().map(|read| read()),
546 })
547 })
548 .collect()
549}
550
551/// Moves focus to the first reachable focusable inside `node`, reporting whether it found one.
552///
553/// What a dialog needs on open: the keyboard has to arrive somewhere inside it, or the user is left tabbing
554/// from wherever they were — which, now that a modal traps focus, means tabbing nowhere at all.
555pub fn focus_first_in(node: NodeId) -> bool {
556 let (order, scopes) = snapshot();
557 let found = order.into_iter().find(|(_, widget)| {
558 widget.is_some_and(|widget| layout_reactive::is_descendant_of(widget, node))
559 && reachable(*widget, &scopes)
560 });
561 match found {
562 Some((id, _)) => {
563 request(id);
564 true
565 }
566 None => false,
567 }
568}
569
570/// Whether `id` is still registered, so a caller restoring remembered focus does not aim at a widget that has
571/// since been dropped.
572pub fn is_registered(id: FocusId) -> bool {
573 with_focus_ref(|s| s.order.iter().any(|e| e.id == id))
574}
575
576fn step(dir: isize) {
577 // Snapshot, then release the slot borrow: `showing` is the author's closure and the reachability queries borrow the layout runtime, and neither may run under this one — nor may `request`, which flushes.
578 let (order, scopes) = snapshot();
579 let order: Vec<FocusId> = order
580 .into_iter()
581 .filter(|(_, node)| reachable(*node, &scopes))
582 .map(|(id, _)| id)
583 .collect();
584 if order.is_empty() {
585 return;
586 }
587 let n = order.len() as isize;
588 let next = match current().and_then(|c| order.iter().position(|&x| x == c)) {
589 Some(i) => order[((i as isize + dir).rem_euclid(n)) as usize],
590 None => {
591 if dir > 0 {
592 order[0]
593 } else {
594 order[order.len() - 1]
595 }
596 }
597 };
598 request(next);
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn request_release_and_ids_are_unique() {
607 clear();
608 let a = next_id();
609 let b = next_id();
610 assert_ne!(a, b, "ids must be unique");
611
612 assert!(!is_focused(a));
613 request(a);
614 assert!(is_focused(a) && current() == Some(a));
615
616 // Requesting b moves focus off a.
617 request(b);
618 assert!(is_focused(b) && !is_focused(a));
619
620 // Releasing a (which is not focused) leaves b focused.
621 release(a);
622 assert!(is_focused(b));
623
624 // Releasing the focused one clears it.
625 release(b);
626 assert!(current().is_none());
627 }
628
629 /// A control the application has disabled is not a Tab stop — in HTML a `disabled` element is skipped
630 /// outright, and a keyboard user made to walk through controls that do nothing is being told less about
631 /// the interface than a mouse user, who at least sees them dimmed.
632 ///
633 /// Rides the same scope mechanism a hidden overlay uses rather than a second one, which is what gives a
634 /// disabled *wrapper* the `fieldset` reading for the keyboard as well as for the pointer.
635 #[test]
636 fn tab_skips_a_disabled_box() {
637 use crate::context::{compute_layout, reset_layout_runtime};
638 use crate::{LayoutItem, StyledContainer};
639 use layout_core::{AvailableSpace, LayoutStyle};
640
641 reset_layout_runtime();
642 let base = next_id();
643 register_as(base, FocusKind::Widget);
644
645 let below = next_id();
646 let off = StyledContainer::new(
647 LayoutStyle::new().width(50.0).height(20.0),
648 |_r| renderer_core::RectStyle::default(),
649 vec![],
650 )
651 .unwrap()
652 .on_focus(|_| {})
653 .disabled(|| true);
654 let above = next_id();
655 compute_layout(
656 off.layout_node(),
657 AvailableSpace::Definite(50.0),
658 AvailableSpace::Definite(20.0),
659 )
660 .unwrap();
661
662 request(base);
663 focus_next();
664 let landed = current().expect("something took focus");
665 assert!(
666 !(landed > below && landed < above),
667 "Tab landed on a box the application had disabled"
668 );
669 }
670
671 /// The case that showed an overlay-shaped fix would only ever be half of one: `display:none` hides content
672 /// without any overlay involved, and left it in the tab order just the same. The two mechanisms leave
673 /// opposite traces — a hidden overlay keeps its children's rects and stops painting, a `display:none`
674 /// subtree collapses to zero and keeps its place in the walk — so neither a paint test nor a rect test
675 /// catches both. Ancestry does.
676 #[test]
677 fn tab_skips_a_focusable_taken_out_of_layout_flow() {
678 use crate::context::{compute_layout, reset_layout_runtime, set_display};
679 use crate::{LayoutItem, StyledContainer};
680 use layout_core::{AvailableSpace, LayoutStyle};
681
682 reset_layout_runtime();
683 let base = next_id();
684 register_as(base, FocusKind::Widget);
685
686 let below = next_id();
687 let hidden = StyledContainer::new(
688 LayoutStyle::new().width(50.0).height(20.0),
689 |_r| renderer_core::RectStyle::default(),
690 vec![],
691 )
692 .unwrap()
693 .on_focus(|_| {});
694 let node = hidden.layout_node();
695 let root = StyledContainer::new(
696 LayoutStyle::new().width(100.0).height(100.0),
697 |_r| renderer_core::RectStyle::default(),
698 vec![Box::new(hidden)],
699 )
700 .unwrap();
701 let above = next_id();
702
703 set_display(node, false);
704 compute_layout(
705 root.layout_node(),
706 AvailableSpace::Definite(100.0),
707 AvailableSpace::Definite(100.0),
708 )
709 .unwrap();
710
711 request(base);
712 focus_next();
713 let landed = current().expect("something took focus");
714 assert!(
715 !(landed > below && landed < above),
716 "Tab landed on a focusable that is out of layout flow"
717 );
718 }
719
720 #[test]
721 fn tab_order_steps_forward_and_back() {
722 // Register three contiguous ids at the end of the order and step within that block (robust to any
723 // ids other tests registered earlier on this thread).
724 let (a, b, c) = (next_id(), next_id(), next_id());
725 register_as(a, FocusKind::Widget);
726 register_as(b, FocusKind::Widget);
727 register_as(c, FocusKind::Widget);
728
729 request(a);
730 focus_next();
731 assert_eq!(current(), Some(b));
732 focus_next();
733 assert_eq!(current(), Some(c));
734 focus_prev();
735 assert_eq!(current(), Some(b));
736
737 // Unregistering the focused one drops focus and removes it from traversal.
738 unregister(b);
739 assert!(current().is_none());
740 unregister(a);
741 unregister(c);
742 }
743}