Skip to main content

lumen_primitives/
state_style.rs

1//! State-driven text-color / opacity / box-shadow / background swaps.
2//!
3//! The CSS parser routes `:hover` / `:focus` / `:active` / `:disabled`
4//! declarations for `text-color`, `opacity`, and `box-shadow` (plus
5//! `bg` for `:disabled`) into a [`StateVisuals`] component (one
6//! [`StatePatch`] per state); this module's [`apply_state_visuals`]
7//! system swaps the live [`TextStyle`] / [`Opacity`] /
8//! [`Visuals::shadows`] / [`Visuals::fill`] values as the interaction
9//! markers come and go, holding them in
10//! [`StyleOverrides`](lumen_core::style_overrides::StyleOverrides) and
11//! letting go onto the element's own styles when every state ends. Swaps
12//! snap (no tween), matching CSS state rules without a `transition` -
13//! exactly like the border swap in [`crate::hover::apply_state_borders`].
14//!
15//! `:disabled` reacts to the
16//! [`Disabled`](lumen_core::components::Disabled) marker at runtime
17//! (Wave 3: `bind-disabled` can add / remove it live). Statically
18//! disabled markup without a binding takes its `:disabled` styles as its
19//! own computed style (`lumen_scene::computed`); the patch here drives
20//! every dynamic case.
21//! [`eject_interaction_on_disable`] strips `Hovered` / `Pressed` /
22//! focus the moment an entity becomes disabled (moving keyboard focus
23//! to the next focusable) so no interaction state lingers on a
24//! greyed-out widget.
25
26use bevy_ecs::prelude::*;
27use lumen_core::components::{
28    Color, Disabled, DocumentOrder, DropHovered, Fill, Opacity, ShadowSpec, TextStyle, Visuals,
29};
30use lumen_core::input::FocusVisible;
31use lumen_core::prelude::*;
32use lumen_core::style_overrides::{Live, StyleOverrides, StyleOwner, StyleValues};
33
34/// One state's property patch. Only authored fields swap; the rest keep
35/// the idle value.
36#[derive(Clone, Debug, Default, PartialEq)]
37pub struct StatePatch {
38    /// `:state { text-color: ... }`.
39    pub text_color: Option<Color>,
40    /// `:state { opacity: ... }`.
41    pub opacity: Option<f32>,
42    /// `:state { box-shadow: ... }` (full stack replacement).
43    pub shadows: Option<Vec<ShadowSpec>>,
44    /// `:state { bg: ... }` (solid fill replacement). Routed for
45    /// `:disabled` today - hover / press backgrounds keep going through
46    /// the tweened [`crate::hover::Interaction`] path.
47    pub bg: Option<Color>,
48}
49
50impl StatePatch {
51    /// `true` when no field is authored.
52    pub fn is_empty(&self) -> bool {
53        self.text_color.is_none()
54            && self.opacity.is_none()
55            && self.shadows.is_none()
56            && self.bg.is_none()
57    }
58}
59
60/// Author-set per-state style patches. Pressed wins over focused wins
61/// over hovered on a per-field basis (mirrors the cascade order skins
62/// author these rules in); disabled stamps over everything.
63#[derive(Component, Clone, Debug, Default, PartialEq)]
64#[require(StyleOverrides)]
65pub struct StateVisuals {
66    /// `:hover` patch.
67    pub hover: StatePatch,
68    /// `:focus` patch (any focus source).
69    pub focus: StatePatch,
70    /// `:focus-visible` patch - applies only while the `FocusVisible`
71    /// marker is present (keyboard-driven focus). Wins over
72    /// [`Self::focus`] per field.
73    pub focus_visible: StatePatch,
74    /// `:active` patch.
75    pub active: StatePatch,
76    /// `:drag-over` patch - applies while the entity carries the
77    /// [`DropHovered`] marker (an acceptable in-app drag is hovering this
78    /// drop target). HTML5 `dragover` parity.
79    pub drag_over: StatePatch,
80    /// `:disabled` patch - applies while the entity carries the
81    /// [`Disabled`] marker. Wins over every other state (a disabled
82    /// widget has no live interaction states anyway; see
83    /// [`eject_interaction_on_disable`]).
84    pub disabled: StatePatch,
85}
86
87impl StateVisuals {
88    /// `true` when every state patch is empty (component not needed).
89    pub fn is_empty(&self) -> bool {
90        self.hover.is_empty()
91            && self.focus.is_empty()
92            && self.focus_visible.is_empty()
93            && self.active.is_empty()
94            && self.drag_over.is_empty()
95            && self.disabled.is_empty()
96    }
97}
98
99/// Marks an element whose state patch [`apply_state_visuals`] holds, and
100/// names its layer in [`StyleOverrides`].
101#[derive(Component, Clone, Copy, Debug, Default)]
102pub struct StatePatched;
103
104impl StyleOwner for StatePatched {
105    const RANK: u8 = 30;
106}
107
108/// Plugin: registers [`eject_interaction_on_disable`] then
109/// [`apply_state_visuals`].
110pub struct StateStylePlugin;
111
112impl Plugin for StateStylePlugin {
113    fn build(self, app: &mut App) {
114        // Ejection runs first (with a sync point in between) so the
115        // `:disabled` swap on the disable tick lands on the true idle
116        // style, not a half-faded hover tint.
117        app.add_systems(
118            TickStage::Systems,
119            eject_interaction_on_disable.before(apply_state_visuals),
120        );
121        // `.after(dispatch_clicks)` (=> after `hit_test` too): flush the
122        // Hovered / Pressed marker commands before the swap so `:hover`
123        // / `:active` / `:disabled` styling reflects this tick's input.
124        // No-op when `lumen-input` isn't installed.
125        app.add_systems(
126            TickStage::Systems,
127            apply_state_visuals.after(lumen_input::dispatch_clicks),
128        );
129    }
130}
131
132/// Swap text color / opacity / shadows / background per interaction
133/// state, and let them go when every state ends.
134///
135/// The swapped values are held in [`StyleOverrides`], so a restyle while a
136/// state is on keeps them, and letting go lands on what the element's
137/// styles say by then. Only the fields some active state authors are held;
138/// the rest stay the element's own.
139///
140/// Every component this system attaches goes through `try_insert`. The
141/// entity under the pointer is the one most likely to leave the tree on this
142/// very tick: a click on a nav link runs the handler that swaps the page,
143/// and the `<if>` reconciler despawns the outgoing tree in this same stage
144/// with nothing ordering it against this system. A plain `insert` against an
145/// element the swap already took away fails the whole command buffer rather
146/// than just itself.
147#[allow(clippy::type_complexity)]
148pub fn apply_state_visuals(
149    mut commands: Commands,
150    pointer: Option<Res<PointerState>>,
151    mut active: Query<
152        (
153            Entity,
154            &StateVisuals,
155            Option<&mut TextStyle>,
156            Option<&mut Visuals>,
157            Option<&Opacity>,
158            &mut StyleOverrides,
159            Has<StatePatched>,
160            Option<&Hovered>,
161            Option<&Focused>,
162            Option<&lumen_core::input::FocusVisible>,
163            Option<&Pressed>,
164            Has<Disabled>,
165            Has<DropHovered>,
166        ),
167        Or<(
168            With<Hovered>,
169            With<Focused>,
170            With<Pressed>,
171            With<Disabled>,
172            With<DropHovered>,
173        )>,
174    >,
175    mut idle: Query<
176        (
177            Entity,
178            Option<&mut TextStyle>,
179            Option<&mut Visuals>,
180            Option<&Opacity>,
181            &mut StyleOverrides,
182        ),
183        (
184            With<StatePatched>,
185            With<StateVisuals>,
186            Without<Hovered>,
187            Without<Focused>,
188            Without<Pressed>,
189            Without<Disabled>,
190            Without<DropHovered>,
191        ),
192    >,
193    mut orphaned: Query<
194        (
195            Entity,
196            Option<&mut TextStyle>,
197            Option<&mut Visuals>,
198            Option<&Opacity>,
199            &mut StyleOverrides,
200        ),
201        (With<StatePatched>, Without<StateVisuals>),
202    >,
203) {
204    let primary_down = pointer.map(|p| p.primary_down).unwrap_or(false);
205    for (
206        entity,
207        sv,
208        text,
209        vis,
210        opacity,
211        mut overrides,
212        patched,
213        hovered,
214        focused,
215        focus_visible,
216        pressed,
217        disabled,
218        drag_over,
219    ) in &mut active
220    {
221        // `:active` mirrors the press-tint contract (spec section 0 rule 3):
222        // a pointer press dragged off the captured widget presents
223        // un-pressed even though `Pressed` (the capture marker) stays.
224        // Keyboard presses (pointer up) present pressed without hover.
225        let active_on = pressed.is_some() && (hovered.is_some() || !primary_down);
226        // Field-wise merge: hover < focus < focus-visible < active <
227        // disabled (disabled stamps last).
228        let mut patch = StatePatch::default();
229        for (marker_on, p) in [
230            (hovered.is_some(), &sv.hover),
231            (focused.is_some(), &sv.focus),
232            (
233                focused.is_some() && focus_visible.is_some(),
234                &sv.focus_visible,
235            ),
236            (active_on, &sv.active),
237            (drag_over, &sv.drag_over),
238            (disabled, &sv.disabled),
239        ] {
240            if !marker_on {
241                continue;
242            }
243            if p.text_color.is_some() {
244                patch.text_color = p.text_color;
245            }
246            if p.opacity.is_some() {
247                patch.opacity = p.opacity;
248            }
249            if p.shadows.is_some() {
250                patch.shadows = p.shadows.clone();
251            }
252            if p.bg.is_some() {
253                patch.bg = p.bg;
254            }
255        }
256        let values = StyleValues {
257            text_color: patch.text_color.filter(|_| text.is_some()),
258            opacity: patch.opacity.map(Some),
259            shadows: patch.shadows.filter(|_| vis.is_some()),
260            fill: patch
261                .bg
262                .filter(|_| vis.is_some())
263                .map(|c| Some(Fill::Solid(c))),
264            ..StyleValues::default()
265        };
266        let out = if values.fields().is_empty() {
267            // Nothing to apply (an active marker whose patches are all
268            // authored for other states - e.g. `Disabled` present but only
269            // `:hover` styled).
270            if !patched {
271                continue;
272            }
273            commands.entity(entity).remove::<StatePatched>();
274            overrides.release::<StatePatched>()
275        } else {
276            if overrides.held_by::<StatePatched>() == Some(&values) {
277                continue;
278            }
279            if !patched {
280                commands.entity(entity).try_insert(StatePatched);
281            }
282            let live = Live {
283                style: None,
284                visuals: vis.as_deref(),
285                text: text.as_deref(),
286                opacity,
287            };
288            overrides.hold::<StatePatched>(values, &live)
289        };
290        write_out(&mut commands, entity, &out, text, vis, opacity);
291    }
292    // Idle, or a restyle took the state patches away.
293    for (entity, text, vis, opacity, mut overrides) in idle.iter_mut().chain(orphaned.iter_mut()) {
294        let out = overrides.release::<StatePatched>();
295        write_out(&mut commands, entity, &out, text, vis, opacity);
296        commands.entity(entity).remove::<StatePatched>();
297    }
298}
299
300/// Write `out` onto the element's text, paint and opacity.
301fn write_out(
302    commands: &mut Commands,
303    entity: Entity,
304    out: &StyleValues,
305    text: Option<Mut<TextStyle>>,
306    vis: Option<Mut<Visuals>>,
307    opacity: Option<&Opacity>,
308) {
309    if let Some(mut t) = text {
310        out.write_text(&mut t);
311    }
312    if let Some(mut v) = vis {
313        out.write_visuals(&mut v);
314    }
315    match out.opacity_change(opacity) {
316        Some(Some(o)) => {
317            commands.entity(entity).try_insert(o);
318        }
319        Some(None) => {
320            commands.entity(entity).remove::<Opacity>();
321        }
322        None => {}
323    }
324}
325
326/// The moment an entity gains [`Disabled`] (spec section 0: runtime
327/// enable/disable via `bind-disabled`), strip every live interaction
328/// state from it:
329///
330/// * `Hovered` / `Pressed` are removed (with their transient tween
331///   components) - the hover tint / press tint end immediately, and a
332///   pending release will not click (`dispatch_clicks` requires
333///   `Pressed`). The tinted fill is let go of at once, so the
334///   `:disabled` swap lands on the true idle style.
335/// * Keyboard focus moves to the next focusable in tab order (same
336///   `(TabIndex, DocumentOrder)` ordering `cycle_focus_on_tab` uses,
337///   wrapping; hidden and disabled entities skipped), or clears when
338///   the disabled entity was the only focusable. The `FocusVisible`
339///   marker travels with the focus if it was present.
340#[allow(clippy::type_complexity, clippy::too_many_arguments)]
341pub fn eject_interaction_on_disable(
342    mut commands: Commands,
343    newly_disabled: Query<Entity, Added<Disabled>>,
344    mut tracker: ResMut<FocusTracker>,
345    mut tinted: Query<(&mut Visuals, &mut StyleOverrides)>,
346    focus_visibles: Query<(), With<FocusVisible>>,
347    focusables: Query<(Entity, &TabIndex, Option<&DocumentOrder>), Without<Disabled>>,
348    tab_info: Query<(&TabIndex, Option<&DocumentOrder>)>,
349    parents: Query<&ChildOf>,
350    visibles: Query<&lumen_core::components::Visible>,
351    styles: Query<&lumen_core::components::Style>,
352) {
353    for entity in &newly_disabled {
354        if let Ok((mut vis, mut overrides)) = tinted.get_mut(entity) {
355            overrides
356                .release::<crate::hover::HoverTween>()
357                .write_visuals(&mut vis);
358            overrides
359                .release::<crate::hover::PressTween>()
360                .write_visuals(&mut vis);
361        }
362        commands.entity(entity).remove::<(
363            Hovered,
364            Pressed,
365            Focused,
366            FocusVisible,
367            crate::hover::HoverTween,
368            crate::hover::PressTween,
369        )>();
370
371        if tracker.0 != Some(entity) {
372            continue;
373        }
374        // Focus ejection: pick the next focusable after the disabled
375        // entity in (TabIndex, DocumentOrder, Entity) order, wrapping.
376        let mut sorted: Vec<(i32, u32, Entity)> = focusables
377            .iter()
378            .filter(|(e, t, _)| t.0 >= 0 && !hidden_via_ancestors(*e, &parents, &visibles, &styles))
379            .map(|(e, t, doc)| (t.0, doc.map(|d| d.0).unwrap_or(u32::MAX), e))
380            .collect();
381        sorted.sort();
382        let next = if sorted.is_empty() {
383            None
384        } else {
385            let own_key = tab_info
386                .get(entity)
387                .map(|(t, doc)| (t.0, doc.map(|d| d.0).unwrap_or(u32::MAX), entity))
388                .unwrap_or((i32::MIN, 0, entity));
389            sorted
390                .iter()
391                .find(|k| **k > own_key)
392                .or_else(|| sorted.first())
393                .copied()
394        };
395        let had_visible = focus_visibles.contains(entity);
396        match next {
397            Some((_, _, next_e)) => {
398                if had_visible {
399                    commands.entity(next_e).insert((Focused, FocusVisible));
400                } else {
401                    commands.entity(next_e).insert(Focused);
402                }
403                tracker.0 = Some(next_e);
404            }
405            None => {
406                tracker.0 = None;
407            }
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn shadow(blur: f32) -> ShadowSpec {
417        ShadowSpec {
418            offset_x: 0.0,
419            offset_y: 1.0,
420            blur,
421            spread: 0.0,
422            color: Color::rgb(0.0, 0.0, 0.0),
423            inner: false,
424        }
425    }
426
427    fn run(world: &mut World) {
428        let mut schedule = bevy_ecs::schedule::Schedule::default();
429        schedule.add_systems(apply_state_visuals);
430        schedule.run(world);
431        // Two passes: the first tick captures the base + queues the
432        // command-applied writes.
433        let mut schedule2 = bevy_ecs::schedule::Schedule::default();
434        schedule2.add_systems(apply_state_visuals);
435        schedule2.run(world);
436    }
437
438    /// Hover swaps text color + shadows in; un-hover restores; pressed
439    /// wins over hover per field.
440    #[test]
441    fn state_text_color_opacity_shadow_swap_and_restore() {
442        let mut world = World::new();
443        let red = Color::rgb(1.0, 0.0, 0.0);
444        let green = Color::rgb(0.0, 1.0, 0.0);
445        let idle = Color::rgb(0.5, 0.5, 0.5);
446        let e = world
447            .spawn((
448                StateVisuals {
449                    hover: StatePatch {
450                        text_color: Some(red),
451                        opacity: Some(0.8),
452                        shadows: Some(vec![shadow(4.0)]),
453                        ..Default::default()
454                    },
455                    active: StatePatch {
456                        text_color: Some(green),
457                        ..Default::default()
458                    },
459                    ..Default::default()
460                },
461                TextStyle {
462                    color: idle,
463                    ..Default::default()
464                },
465                Visuals::default(),
466            ))
467            .id();
468
469        // Hover -> patch applied.
470        world.entity_mut(e).insert(Hovered);
471        run(&mut world);
472        assert_eq!(world.get::<TextStyle>(e).unwrap().color, red);
473        assert_eq!(world.get::<Opacity>(e).map(|o| o.0), Some(0.8));
474        assert_eq!(world.get::<Visuals>(e).unwrap().shadows, vec![shadow(4.0)]);
475
476        // Hover + press -> active text color wins; hover opacity stays.
477        world.entity_mut(e).insert(Pressed);
478        run(&mut world);
479        assert_eq!(world.get::<TextStyle>(e).unwrap().color, green);
480        assert_eq!(world.get::<Opacity>(e).map(|o| o.0), Some(0.8));
481
482        // All states end -> baseline restored, capture removed.
483        world.entity_mut(e).remove::<Hovered>();
484        world.entity_mut(e).remove::<Pressed>();
485        run(&mut world);
486        assert_eq!(world.get::<TextStyle>(e).unwrap().color, idle);
487        assert!(world.get::<Opacity>(e).is_none(), "idle Opacity was absent");
488        assert!(world.get::<Visuals>(e).unwrap().shadows.is_empty());
489        assert!(world.get::<StatePatched>(e).is_none());
490    }
491
492    /// Focus ring: an `input:focus { box-shadow: ... }` rule (routed into
493    /// `StateVisuals.focus.shadows`) swaps a real ring in when the input
494    /// gains `Focused` and restores the idle stack on blur. The ring
495    /// color/width comes entirely from the authored CSS shadow - no
496    /// hardcoded Rust color - so a skin can drive it from a
497    /// `--lumen-focus-ring` token. This is deliverable J's "focus ring
498    /// driven by `:focus` tokens, not a hardcoded color".
499    #[test]
500    fn focus_ring_box_shadow_swaps_on_focus_and_restores_on_blur() {
501        let mut world = World::new();
502        let ring = ShadowSpec {
503            offset_x: 0.0,
504            offset_y: 0.0,
505            blur: 0.0,
506            spread: 2.0,
507            // Token-sourced color - the CSS parser fills this from
508            // `var(--lumen-focus-ring)`; the test just proves the swap.
509            color: Color::rgba(0.2, 0.5, 1.0, 0.9),
510            inner: false,
511        };
512        let e = world
513            .spawn((
514                StateVisuals {
515                    focus: StatePatch {
516                        shadows: Some(vec![ring]),
517                        ..Default::default()
518                    },
519                    ..Default::default()
520                },
521                TextInput::default(),
522                Visuals::default(),
523            ))
524            .id();
525
526        // Focus -> ring shadow applied.
527        world.entity_mut(e).insert(Focused);
528        run(&mut world);
529        assert_eq!(
530            world.get::<Visuals>(e).unwrap().shadows,
531            vec![ring],
532            ":focus ring must swap in on Focused"
533        );
534
535        // Blur -> idle (empty) shadow stack restored.
536        world.entity_mut(e).remove::<Focused>();
537        run(&mut world);
538        assert!(
539            world.get::<Visuals>(e).unwrap().shadows.is_empty(),
540            "focus ring must clear on blur"
541        );
542    }
543
544    /// `:disabled` swaps bg + opacity in when the `Disabled` marker
545    /// lands at runtime, and restores the exact idle values when the
546    /// marker is removed (bind-disabled re-enable).
547    #[test]
548    fn disabled_patch_swaps_on_marker_add_and_restores_on_remove() {
549        let mut world = World::new();
550        let idle_bg = Color::rgb(0.1, 0.4, 0.9);
551        let disabled_bg = Color::rgb(0.3, 0.3, 0.3);
552        let e = world
553            .spawn((
554                StateVisuals {
555                    disabled: StatePatch {
556                        opacity: Some(0.5),
557                        bg: Some(disabled_bg),
558                        ..Default::default()
559                    },
560                    ..Default::default()
561                },
562                Visuals {
563                    fill: Some(Fill::Solid(idle_bg)),
564                    ..Default::default()
565                },
566            ))
567            .id();
568
569        // Disable -> bg + opacity swap in.
570        world.entity_mut(e).insert(Disabled);
571        run(&mut world);
572        assert_eq!(
573            world.get::<Visuals>(e).unwrap().fill,
574            Some(Fill::Solid(disabled_bg)),
575            ":disabled bg applied on marker add"
576        );
577        assert_eq!(world.get::<Opacity>(e).map(|o| o.0), Some(0.5));
578
579        // Re-enable -> exact idle restored, capture removed.
580        world.entity_mut(e).remove::<Disabled>();
581        run(&mut world);
582        assert_eq!(
583            world.get::<Visuals>(e).unwrap().fill,
584            Some(Fill::Solid(idle_bg)),
585            "idle bg restored on marker remove"
586        );
587        assert!(world.get::<Opacity>(e).is_none());
588        assert!(world.get::<StatePatched>(e).is_none());
589    }
590
591    /// Disabled wins over hover/press patches when markers coexist for
592    /// a tick (before ejection strips them).
593    #[test]
594    fn disabled_patch_wins_over_hover() {
595        let mut world = World::new();
596        let red = Color::rgb(1.0, 0.0, 0.0);
597        let grey = Color::rgb(0.4, 0.4, 0.4);
598        let e = world
599            .spawn((
600                StateVisuals {
601                    hover: StatePatch {
602                        text_color: Some(red),
603                        ..Default::default()
604                    },
605                    disabled: StatePatch {
606                        text_color: Some(grey),
607                        ..Default::default()
608                    },
609                    ..Default::default()
610                },
611                TextStyle::default(),
612            ))
613            .id();
614        world.entity_mut(e).insert((Hovered, Disabled));
615        run(&mut world);
616        assert_eq!(world.get::<TextStyle>(e).unwrap().color, grey);
617    }
618}
619
620#[cfg(test)]
621mod eject_tests {
622    //! `eject_interaction_on_disable` - becoming disabled strips
623    //! `Hovered` / `Pressed` / focus and moves keyboard focus to the
624    //! next focusable in tab order.
625    use super::*;
626
627    fn run(world: &mut World) {
628        let mut schedule = bevy_ecs::schedule::Schedule::default();
629        schedule.add_systems(eject_interaction_on_disable);
630        schedule.run(world);
631    }
632
633    #[test]
634    fn disable_strips_hover_press_and_moves_focus_forward() {
635        let mut world = World::new();
636        let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
637        let b = world.spawn((TabIndex(0), DocumentOrder(1))).id();
638        let c = world.spawn((TabIndex(0), DocumentOrder(2))).id();
639        world.insert_resource(FocusTracker(Some(b)));
640        world
641            .entity_mut(b)
642            .insert((Hovered, Pressed, Focused, FocusVisible, Disabled));
643
644        run(&mut world);
645        assert!(world.get::<Hovered>(b).is_none(), "hover stripped");
646        assert!(world.get::<Pressed>(b).is_none(), "press stripped");
647        assert!(world.get::<Focused>(b).is_none(), "focus stripped");
648        assert_eq!(
649            world.resource::<FocusTracker>().0,
650            Some(c),
651            "focus moved to the next focusable in document order"
652        );
653        assert!(world.get::<Focused>(c).is_some());
654        assert!(
655            world.get::<FocusVisible>(c).is_some(),
656            "keyboard-visible focus travels with the ejection"
657        );
658        let _ = a;
659    }
660
661    #[test]
662    fn disable_of_last_focusable_wraps_to_first() {
663        let mut world = World::new();
664        let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
665        let b = world.spawn((TabIndex(0), DocumentOrder(1))).id();
666        world.insert_resource(FocusTracker(Some(b)));
667        world.entity_mut(b).insert((Focused, Disabled));
668        run(&mut world);
669        assert_eq!(world.resource::<FocusTracker>().0, Some(a), "wraps");
670    }
671
672    #[test]
673    fn disable_of_only_focusable_clears_focus() {
674        let mut world = World::new();
675        let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
676        world.insert_resource(FocusTracker(Some(a)));
677        world.entity_mut(a).insert((Focused, Disabled));
678        run(&mut world);
679        assert_eq!(world.resource::<FocusTracker>().0, None);
680    }
681
682    #[test]
683    fn disable_without_focus_leaves_tracker_alone() {
684        let mut world = World::new();
685        let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
686        let b = world.spawn((TabIndex(0), DocumentOrder(1), Hovered)).id();
687        world.insert_resource(FocusTracker(Some(a)));
688        world.entity_mut(b).insert(Disabled);
689        run(&mut world);
690        assert_eq!(world.resource::<FocusTracker>().0, Some(a));
691        assert!(world.get::<Hovered>(b).is_none());
692    }
693}