diff --git a/_release-content/migration-guides/focusable_component.md b/_release-content/migration-guides/focusable_component.md new file mode 100644 index 0000000000000..bc0e672cb4076 --- /dev/null +++ b/_release-content/migration-guides/focusable_component.md @@ -0,0 +1,27 @@ +--- +title: "Input focus is now represented by `Focusable`" +pull_requests: [] +--- + +The new `Focusable` component indicates that an entity may receive input focus, independently of +the navigation method used to reach it. When an explicit sequential-navigation order is not needed, +replace a default `TabIndex` with `Focusable`: + +```rust +// Before +TabIndex(0) + +// After +Focusable +``` + +`TabIndex` now only controls sequential navigation and automatically requires `Focusable`. +`TabIndex(-1)` therefore remains the way to make an entity focusable but exclude it from sequential +navigation. + +A `Focusable` entity inside a `TabGroup` participates in sequential navigation with an implicit +`TabIndex(0)` unless it has a negative `TabIndex`. Manual directional-navigation destinations must +also have `Focusable`; adding an edge to the `DirectionalNavigationMap` no longer makes its +destination focusable by itself. + +`DirectionalNavigation` SystemParam struct gained a second lifetime parameter. diff --git a/crates/bevy_feathers/src/controls/button.rs b/crates/bevy_feathers/src/controls/button.rs index bf248820ded71..e97f93e9a8036 100644 --- a/crates/bevy_feathers/src/controls/button.rs +++ b/crates/bevy_feathers/src/controls/button.rs @@ -9,7 +9,6 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs, system::{Commands, Query}, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_scene::prelude::*; @@ -104,7 +103,6 @@ impl FeathersButton { props.variant Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) FocusIndicator ThemeBackgroundColor(tokens::BUTTON_BG) InheritableThemeTextColor(tokens::BUTTON_TEXT) diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs index 8c0e96188a518..690c90b198e71 100644 --- a/crates/bevy_feathers/src/controls/checkbox.rs +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -11,7 +11,6 @@ use bevy_ecs::{ system::{Commands, Query}, template::FromTemplate, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_math::Rot2; use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; @@ -77,7 +76,6 @@ impl FeathersCheckbox { CheckboxFrame Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) InheritableThemeTextColor(tokens::CHECKBOX_TEXT) InheritableFont { font: fonts::REGULAR, diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index 980f07eb92320..ce4ed5686a795 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -10,7 +10,6 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs, system::Query, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_log::warn_once; use bevy_picking::{cursor::EntityCursor, PickingSystems}; use bevy_reflect::std_traits::ReflectDefault; @@ -246,7 +245,6 @@ impl FeathersColorSlider { SliderValue({props.value}) props.channel.range() EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) FocusIndicator Children [ // track diff --git a/crates/bevy_feathers/src/controls/color_swatch_grid.rs b/crates/bevy_feathers/src/controls/color_swatch_grid.rs index 422ff4090d239..75723a2ebd1e6 100644 --- a/crates/bevy_feathers/src/controls/color_swatch_grid.rs +++ b/crates/bevy_feathers/src/controls/color_swatch_grid.rs @@ -9,7 +9,6 @@ use bevy_ecs::{ reflect::ReflectComponent, system::{Commands, Query, SystemParam}, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_math::UVec2; use bevy_picking::{cursor::EntityCursor, Pickable}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; @@ -67,7 +66,6 @@ impl FeathersColorSwatchGrid { align_items: AlignItems::Stretch, } RadioGroup - TabIndex FocusIndicator on(swatch_grid_ready) on(swatch_grid_on_value_change) diff --git a/crates/bevy_feathers/src/controls/disclosure_toggle.rs b/crates/bevy_feathers/src/controls/disclosure_toggle.rs index 8db93e3d19cd0..3f45a70934126 100644 --- a/crates/bevy_feathers/src/controls/disclosure_toggle.rs +++ b/crates/bevy_feathers/src/controls/disclosure_toggle.rs @@ -8,7 +8,6 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs, system::{Commands, Query}, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_math::Rot2; use bevy_picking::{cursor::EntityCursor, PickingSystems}; use bevy_reflect::std_traits::ReflectDefault; @@ -51,7 +50,6 @@ impl FeathersDisclosureToggle { EntityCursor::System(SystemCursorIcon::Pointer) FocusIndicator InheritableThemeTextColor(tokens::BUTTON_TEXT) - TabIndex(0) Children [ @icon(icons::CHEVRON_RIGHT) ] diff --git a/crates/bevy_feathers/src/controls/listview.rs b/crates/bevy_feathers/src/controls/listview.rs index 1c65797584b6b..b4c07b551016e 100644 --- a/crates/bevy_feathers/src/controls/listview.rs +++ b/crates/bevy_feathers/src/controls/listview.rs @@ -12,9 +12,7 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs as _, system::{Commands, Query, Res}, }; -use bevy_input_focus::{ - tab_navigation::TabIndex, InputFocus, InputFocusSystems, InputFocusVisible, -}; +use bevy_input_focus::{InputFocus, InputFocusSystems, InputFocusVisible}; use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_scene::{bsn, bsn_list, Scene, SceneComponent, SceneList}; @@ -75,7 +73,6 @@ impl FeathersListView { ScrollbarGutter(px(14)) ListBox AccessibilityNode(accesskit::Node::new(Role::ListBox)) - TabIndex(0) Children [ // Inner part that scrolls ( diff --git a/crates/bevy_feathers/src/controls/menu.rs b/crates/bevy_feathers/src/controls/menu.rs index 93f1bb11dec49..95368aec0e2bb 100644 --- a/crates/bevy_feathers/src/controls/menu.rs +++ b/crates/bevy_feathers/src/controls/menu.rs @@ -41,8 +41,7 @@ use crate::{ tokens, }; use bevy_input_focus::{ - tab_navigation::{NavAction, TabIndex}, - FocusCause, InputFocus, InputFocusSystems, InputFocusVisible, + tab_navigation::NavAction, FocusCause, InputFocus, InputFocusSystems, InputFocusVisible, }; /// Top-level menu container. This wraps the menu button and provides an anchor for the popover. @@ -427,7 +426,6 @@ impl FeathersMenuItem { MenuItem Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) ThemeBackgroundColor(tokens::MENU_BG) // Same as menu InheritableThemeTextColor(tokens::MENUITEM_TEXT) InheritableFont { diff --git a/crates/bevy_feathers/src/controls/radio.rs b/crates/bevy_feathers/src/controls/radio.rs index b2b202f376201..6a9fddb3b83af 100644 --- a/crates/bevy_feathers/src/controls/radio.rs +++ b/crates/bevy_feathers/src/controls/radio.rs @@ -10,7 +10,7 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs, system::{Commands, Query}, }; -use bevy_input_focus::tab_navigation::TabIndex; +use bevy_input_focus::Focusable; use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_scene::prelude::*; @@ -75,7 +75,7 @@ impl FeathersRadio { RadioButton Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) + Focusable InheritableThemeTextColor(tokens::RADIO_TEXT) InheritableFont { font: fonts::REGULAR, diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index c12837bfc2826..479bf6d926c4f 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -13,7 +13,6 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs, system::{Commands, Query, Res}, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_scene::prelude::*; @@ -89,7 +88,6 @@ impl FeathersSlider { SliderValue({props.min}) SliderRange::new(props.min, props.max) EntityCursor::System(bevy_window::SystemCursorIcon::EwResize) - TabIndex(0) FocusIndicator InheritableThemeTextColor(tokens::SLIDER_TEXT) // Use a gradient to draw the moving bar diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index f7dca1262ba67..c50d9c698bed2 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -10,7 +10,6 @@ use bevy_ecs::{ system::{Commands, Query, Res}, template::template, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_picking::{cursor::EntityCursor, PickingSystems}; use bevy_reflect::std_traits::ReflectDefault; use bevy_reflect::Reflect; @@ -120,7 +119,6 @@ impl FeathersTextInput { TextLayout { linebreak: LineBreak::NoWrap, } - TabIndex(0) template(|ctx| { Ok(TextFont { font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), diff --git a/crates/bevy_feathers/src/controls/toggle_switch.rs b/crates/bevy_feathers/src/controls/toggle_switch.rs index c87f4e303f427..a46ef981a3224 100644 --- a/crates/bevy_feathers/src/controls/toggle_switch.rs +++ b/crates/bevy_feathers/src/controls/toggle_switch.rs @@ -12,7 +12,6 @@ use bevy_ecs::{ system::{Commands, Query}, world::Mut, }; -use bevy_input_focus::tab_navigation::TabIndex; use bevy_picking::{cursor::EntityCursor, hover::Hovered, PickingSystems}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_scene::prelude::*; @@ -58,7 +57,6 @@ impl FeathersToggleSwitch { AccessibilityNode(accesskit::Node::new(Role::Switch)) Hovered EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) FocusIndicator Children [( Node { diff --git a/crates/bevy_input_focus/src/directional_navigation.rs b/crates/bevy_input_focus/src/directional_navigation.rs index e63836ec6441e..35a226c41dfa3 100644 --- a/crates/bevy_input_focus/src/directional_navigation.rs +++ b/crates/bevy_input_focus/src/directional_navigation.rs @@ -43,6 +43,7 @@ //! //! Manually define your navigation using the [`DirectionalNavigationMap`], and use the //! [`DirectionalNavigation`] system parameter to navigate between components. +//! Destinations must have the [`Focusable`] component when navigation occurs. //! You can define navigation connections using methods like //! [`add_edge`](DirectionalNavigationMap::add_edge) and //! [`add_looping_edges`](DirectionalNavigationMap::add_looping_edges). @@ -56,7 +57,7 @@ //! - **Cross-layer navigation**: Connect elements across different UI layers or z-index levels //! - **Custom behavior**: Implement domain-specific navigation patterns (e.g., spreadsheet-style wrapping) -use crate::{navigator::find_best_candidate, FocusCause, InputFocus}; +use crate::{navigator::find_best_candidate, FocusCause, Focusable, InputFocus}; use bevy_app::prelude::*; use bevy_ecs::{ entity::{EntityHashMap, EntityHashSet}, @@ -393,14 +394,16 @@ impl DirectionalNavigationMap { /// A system parameter for navigating between focusable entities in a directional way. #[derive(SystemParam, Debug)] -pub struct DirectionalNavigation<'w> { +pub struct DirectionalNavigation<'w, 's> { /// The currently focused entity. pub focus: ResMut<'w, InputFocus>, /// The directional navigation map containing manually defined connections between entities. pub map: Res<'w, DirectionalNavigationMap>, + /// Entities which may receive input focus. + focusable: Query<'w, 's, (), With>, } -impl<'w> DirectionalNavigation<'w> { +impl DirectionalNavigation<'_, '_> { /// Navigates to the neighbor in a given direction from the current focus, if any. /// /// Returns the new focus if successful. @@ -423,8 +426,15 @@ impl<'w> DirectionalNavigation<'w> { direction, }), NavNeighbor::Set(new_focus) => { - self.focus.set(new_focus, FocusCause::Navigated); - Ok(new_focus) + if self.focusable.contains(new_focus) { + self.focus.set(new_focus, FocusCause::Navigated); + Ok(new_focus) + } else { + Err(DirectionalNavigationError::NoNeighborInDirection { + current_focus, + direction, + }) + } } } } else { @@ -436,8 +446,8 @@ impl<'w> DirectionalNavigation<'w> { /// An error that can occur when navigating between focusable entities using [directional navigation](crate::directional_navigation). #[derive(Debug, PartialEq, Clone, Error)] pub enum DirectionalNavigationError { - /// No focusable entity is currently set. - #[error("No focusable entity is currently set.")] + /// No entity currently has input focus. + #[error("No entity currently has input focus.")] NoFocus, /// No neighbor in the requested direction. #[error("No neighbor from {current_focus} in the {direction:?} direction.")] @@ -747,9 +757,9 @@ mod tests { #[test] fn manual_nav_with_system_param() { let mut world = World::new(); - let a = world.spawn_empty().id(); - let b = world.spawn_empty().id(); - let c = world.spawn_empty().id(); + let a = world.spawn(Focusable).id(); + let b = world.spawn(Focusable).id(); + let c = world.spawn(Focusable).id(); let mut map = DirectionalNavigationMap::default(); map.add_looping_edges(&[a, b, c], CompassOctant::East); @@ -779,6 +789,39 @@ mod tests { assert_eq!(world.resource::().get(), Some(a)); } + #[test] + fn manual_navigation_skips_non_focusable_destination() { + let mut world = World::new(); + let current = world.spawn_empty().id(); + let destination = world.spawn_empty().id(); + let mut map = DirectionalNavigationMap::default(); + map.add_edge(current, destination, CompassOctant::East); + world.insert_resource(map); + world.insert_resource(InputFocus::from_entity(current)); + + fn navigate_east( + mut nav: DirectionalNavigation, + ) -> Result { + nav.navigate(CompassOctant::East) + } + + assert_eq!( + world.run_system_once(navigate_east).unwrap(), + Err(DirectionalNavigationError::NoNeighborInDirection { + current_focus: current, + direction: CompassOctant::East, + }) + ); + assert_eq!(world.resource::().get(), Some(current)); + + world.entity_mut(destination).insert(Focusable); + assert_eq!( + world.run_system_once(navigate_east).unwrap(), + Ok(destination) + ); + assert_eq!(world.resource::().get(), Some(destination)); + } + #[test] fn test_auto_generate_navigation_edges() { let mut nav_map = DirectionalNavigationMap::default(); diff --git a/crates/bevy_input_focus/src/lib.rs b/crates/bevy_input_focus/src/lib.rs index 98d5e35c4b288..a59aae9e1c391 100644 --- a/crates/bevy_input_focus/src/lib.rs +++ b/crates/bevy_input_focus/src/lib.rs @@ -9,6 +9,7 @@ //! A UI-centric focus system for Bevy. //! //! This crate provides a system for managing input focus in Bevy applications, including: +//! * [`Focusable`], a component marking entities that may receive input focus. //! * [`InputFocus`], a resource for tracking which entity has input focus. //! * Methods for getting and setting input focus via [`InputFocus`] and [`IsFocusedHelper`]. //! * Events for when entities gain or lose focus: [`FocusGained`] and [`FocusLost`]. @@ -61,6 +62,15 @@ use core::fmt::Debug; #[cfg(feature = "bevy_reflect")] use bevy_reflect::{prelude::*, Reflect}; +/// Marks an entity as able to receive input focus. +#[derive(Component, Default, Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + feature = "bevy_reflect", + derive(Reflect), + reflect(Component, Default, Debug, Clone, PartialEq) +)] +pub struct Focusable; + /// Resource representing which entity has input focus, if any. Input events (other than pointer-like inputs) will be /// dispatched to the current focus entity, or to the primary window if no entity has focus. /// @@ -279,14 +289,9 @@ impl Traversal for WindowTraversal { /// Observer which clears input focus when an [`AcquireFocus`] request bubbles all the way up /// to a [`Window`] without finding a focusable target. /// -/// This is the generalized half of focus acquisition: it has no knowledge of any specific -/// focus-navigation scheme. It is what makes "click outside to unfocus" work even when no -/// navigation framework (e.g. [`tab_navigation`]) is installed — an [`AcquireFocus`] triggered -/// on a non-focusable entity bubbles via [`WindowTraversal`] until it reaches the window, where -/// this observer clears focus. Actually *focusing* a target is the responsibility of a -/// navigation module: for tab navigation, that is -/// [`acquire_focus_tab_index`](tab_navigation::acquire_focus_tab_index), which stops the request -/// (and focuses the target) before it can reach the window. +/// This makes "click outside to unfocus" work even when no navigation framework is installed: an +/// [`AcquireFocus`] triggered on a non-focusable entity bubbles via [`WindowTraversal`] until it +/// reaches the window, where this observer clears focus. /// /// Clearing focus relies on the request actually *reaching* the window via [`WindowTraversal`]. /// That traversal walks up [`ChildOf`] relationships, and — as a fallback — routes to @@ -311,6 +316,20 @@ pub fn on_window_acquire_focus_clear( } } +/// Observer which focuses the first [`Focusable`] target of an [`AcquireFocus`] request. +pub fn acquire_focus( + mut acquire_focus: On, + focusable: Query<(), (With, Without)>, + mut focus: ResMut, +) { + if focusable.contains(acquire_focus.focused_entity) { + acquire_focus.propagate(false); + if focus.get() != Some(acquire_focus.focused_entity) { + focus.set(acquire_focus.focused_entity, FocusCause::Navigated); + } + } +} + /// Plugin which sets up the core input focus system. /// /// This includes the[`InputFocus`] and [`InputFocusVisible`] resources, @@ -324,6 +343,7 @@ impl Plugin for InputFocusPlugin { app.add_systems(PostStartup, set_initial_focus) .init_resource::() .init_resource::() + .add_observer(acquire_focus) .add_observer(on_window_acquire_focus_clear) .add_systems( PostUpdate, @@ -843,4 +863,31 @@ mod tests { assert_eq!(app.world().resource::().get(), None); } + + #[test] + fn acquire_focus_focuses_focusable_without_navigation_plugin() { + let (mut app, window) = acquire_focus_app(); + let focusable = app.world_mut().spawn(Focusable).id(); + + app.world_mut().trigger(AcquireFocus { + focused_entity: focusable, + window, + }); + + assert_eq!(app.world().resource::().get(), Some(focusable)); + } + + #[test] + fn acquire_focus_on_child_focuses_focusable_parent() { + let (mut app, window) = acquire_focus_app(); + let parent = app.world_mut().spawn(Focusable).id(); + let child = app.world_mut().spawn(ChildOf(parent)).id(); + + app.world_mut().trigger(AcquireFocus { + focused_entity: child, + window, + }); + + assert_eq!(app.world().resource::().get(), Some(parent)); + } } diff --git a/crates/bevy_input_focus/src/pointer_focus.rs b/crates/bevy_input_focus/src/pointer_focus.rs index ba5d3bb593dd9..f59fb65208656 100644 --- a/crates/bevy_input_focus/src/pointer_focus.rs +++ b/crates/bevy_input_focus/src/pointer_focus.rs @@ -18,7 +18,7 @@ use bevy_ecs::{ use bevy_picking::events::PointerPress; use bevy_window::PrimaryWindow; -use crate::{tab_navigation::acquire_focus_tab_index, AcquireFocus, InputFocusVisible}; +use crate::{AcquireFocus, InputFocusVisible}; /// Observer which requests focus for a clicked entity. /// @@ -54,33 +54,16 @@ fn click_to_focus( /// Plugin which focuses (or blurs) entities in response to pointer clicks. /// /// On a pointer press this hides the focus indicator ([`InputFocusVisible`]) and triggers a -/// bubbling [`AcquireFocus`] on the clicked entity. That request is then resolved by the focus -/// observers this plugin installs: -/// - [`acquire_focus_tab_index`] focuses the target -/// if it carries a [`TabIndex`](crate::tab_navigation::TabIndex), stopping the request, or -/// - the generalized [`on_window_acquire_focus_clear`](crate::on_window_acquire_focus_clear) observer (installed by -/// [`InputFocusPlugin`](crate::InputFocusPlugin)) clears focus once the request bubbles up to the -/// window (this is "click outside to unfocus"). -/// -/// Registering `acquire_focus_tab_index` here is a **temporary bridge**: it lets pointer clicks -/// actually *acquire* focus on focusable targets even when [`TabNavigationPlugin`] is not installed -/// (previously that observer only existed inside `TabNavigationPlugin`, so clicking back into a -/// focusable element without tab navigation could only ever clear focus, never grant it). This -/// re-uses the [`TabIndex`](crate::tab_navigation::TabIndex) infrastructure until a dedicated, -/// navigation-agnostic pointer-focus target component (a `PointerFocusable` analog to `TabIndex`) -/// is designed in a future PR. See . -/// -/// It is safe to register `acquire_focus_tab_index` here even when [`TabNavigationPlugin`] also -/// registers it: `App::add_observer` does not deduplicate, so the observer runs twice per request, -/// but it is idempotent — it stops propagation and only mutates [`InputFocus`](crate::InputFocus) -/// when the focus target actually changes, so the second run is a no-op. +/// bubbling [`AcquireFocus`] on the clicked entity. +/// [`InputFocusPlugin`](crate::InputFocusPlugin) resolves that request by focusing the first +/// [`Focusable`](crate::Focusable) ancestor, or clearing focus when it reaches the window. /// /// Because that [`AcquireFocus`] event is shared across widgets and focus /// schemes, be deliberate when changing anything in this pathway — a change here can have /// engine-wide focus consequences. Individual widgets may also intercept the event and stop its /// propagation to implement custom behavior (e.g. the number-input scrubber focuses on pointer /// *release* rather than press). See the docs on [`on_window_acquire_focus_clear`](crate::on_window_acquire_focus_clear) and -/// [`acquire_focus_tab_index`]. +/// [`crate::acquire_focus`]. /// /// This is intentionally independent of any navigation scheme: it works with tab navigation, /// directional navigation, or on its own (e.g. an app with only text input). This whole module is @@ -92,20 +75,13 @@ pub struct PointerFocusPlugin; impl Plugin for PointerFocusPlugin { fn build(&self, app: &mut App) { app.add_observer(click_to_focus); - // Temporary bridge: re-use the tab-navigation focus resolver so pointer clicks can - // acquire focus on `TabIndex` targets even without `TabNavigationPlugin`. Idempotent, so - // it is safe when `TabNavigationPlugin` registers it too. See the plugin docs above. - app.add_observer(acquire_focus_tab_index); } } #[cfg(test)] mod tests { use super::*; - use crate::{ - tab_navigation::{TabIndex, TabNavigationPlugin}, - AcquireFocus, InputFocus, InputFocusPlugin, - }; + use crate::{AcquireFocus, InputFocus, InputFocusPlugin}; use bevy_app::App; use bevy_ecs::hierarchy::ChildOf; use bevy_input::InputPlugin; @@ -125,26 +101,6 @@ mod tests { (app, window) } - /// Like [`pointer_focus_app`], but *with* [`TabNavigationPlugin`], so `acquire_focus_tab_index` - /// is installed alongside the window-clearing `on_window_acquire_focus_clear`. This is the full configuration - /// used by the `standard_widgets` example. - fn pointer_focus_app_with_tab_navigation() -> (App, Entity) { - let mut app = App::new(); - app.add_plugins(( - InputPlugin, - InputFocusPlugin, - PointerFocusPlugin, - TabNavigationPlugin, - )); - let window = app - .world_mut() - .spawn((Window::default(), PrimaryWindow)) - .id(); - // Resolve initial focus (focus goes to the primary window). - app.update(); - (app, window) - } - /// With no tab navigation installed, an `AcquireFocus` on a non-focusable entity must still /// bubble up to the window and clear focus. This results in "click outside to unfocus" behavior. #[test] @@ -166,63 +122,4 @@ mod tests { assert_eq!(app.world().resource::().get(), None); } - - /// Regression test for the "click back in" case: with only [`PointerFocusPlugin`] installed (no - /// tab navigation), an `AcquireFocus` on a `TabIndex` target must now *focus* it rather than - /// bubbling past to the window and clearing focus. This is what makes clicking back into a - /// focusable element (e.g. an `EditableText` with a `TabIndex`) work without `TabNavigationPlugin`, - /// and it is the behavior the temporary `acquire_focus_tab_index` bridge in `PointerFocusPlugin` - /// provides. - #[test] - fn acquire_focus_focuses_tab_index_target_without_tab_navigation() { - let (mut app, window) = pointer_focus_app(); - - let focusable = app.world_mut().spawn((TabIndex(0), ChildOf(window))).id(); - app.world_mut().trigger(AcquireFocus { - focused_entity: focusable, - window, - }); - app.update(); - - assert_eq!(app.world().resource::().get(), Some(focusable)); - } - - /// Control: an `AcquireFocus` triggered *directly* on an entity that carries `TabIndex` focuses - /// it. This mirrors the existing `acquire_focus_focuses_entity_with_tab_index` test and anchors - /// the child-vs-direct distinction in the test below. - #[test] - fn acquire_focus_focuses_directly_targeted_tab_index_entity() { - let (mut app, window) = pointer_focus_app_with_tab_navigation(); - - let focusable = app.world_mut().spawn((TabIndex(0), ChildOf(window))).id(); - app.world_mut().trigger(AcquireFocus { - focused_entity: focusable, - window, - }); - app.update(); - - assert_eq!(app.world().resource::().get(), Some(focusable)); - } - - /// The `standard_widgets` checkbox case: the pointer picks a non-focusable *child* node, and - /// `click_to_focus` triggers `AcquireFocus` on that child. The request must bubble up to the - /// child's parent — which carries `TabIndex` (like the `Checkbox` root) — and focus the parent, - /// rather than bubbling past it to the window and clearing focus. - #[test] - fn acquire_focus_on_child_focuses_tab_index_parent() { - let (mut app, window) = pointer_focus_app_with_tab_navigation(); - - // Parent stands in for the `Checkbox` root (has `TabIndex`); child stands in for a - // non-focusable inner node (the box/label) that is the actual pick target. - let parent = app.world_mut().spawn((TabIndex(0), ChildOf(window))).id(); - let child = app.world_mut().spawn(ChildOf(parent)).id(); - - app.world_mut().trigger(AcquireFocus { - focused_entity: child, - window, - }); - app.update(); - - assert_eq!(app.world().resource::().get(), Some(parent)); - } } diff --git a/crates/bevy_input_focus/src/tab_navigation.rs b/crates/bevy_input_focus/src/tab_navigation.rs index cca706ede5a26..48c61d7df40e6 100644 --- a/crates/bevy_input_focus/src/tab_navigation.rs +++ b/crates/bevy_input_focus/src/tab_navigation.rs @@ -6,8 +6,9 @@ //! The order of tabbing is determined by the index, with lower indices being tabbed first. //! If two entities have the same index, then the order is determined by the order of //! the entities in the ECS hierarchy (as determined by Parent/Child). -//! * An index < 0 means that the entity is not focusable via sequential navigation, but +//! * An index < 0 means that the entity is not reachable via sequential navigation, but //! can still be focused via direct selection. +//! * A [`Focusable`] entity without a [`TabIndex`] has an implicit index of zero. //! //! Tabbable entities must be descendants of a [`TabGroup`] entity, which is a component that //! marks a tree of entities as containing tabbable elements. The order of tab groups @@ -32,18 +33,18 @@ use bevy_ecs::{ entity::Entity, hierarchy::{ChildOf, Children}, observer::On, - query::{With, Without}, + query::{Has, With, Without}, system::{Commands, Query, Res, ResMut, SystemParam}, }; use bevy_input::{ keyboard::{KeyCode, KeyboardInput}, ButtonInput, ButtonState, }; -use bevy_window::{PrimaryWindow, Window}; +use bevy_window::PrimaryWindow; use log::warn; use thiserror::Error; -use crate::{AcquireFocus, FocusCause, FocusedInput, InputFocus, InputFocusVisible}; +use crate::{FocusCause, Focusable, FocusedInput, InputFocus, InputFocusVisible}; #[cfg(feature = "bevy_reflect")] use { @@ -51,11 +52,12 @@ use { bevy_reflect::{prelude::*, Reflect}, }; -/// A component which indicates that an entity wants to participate in tab navigation. +/// A component which controls an entity's sequential-navigation order. /// /// Note that you must also add the [`TabGroup`] component to the entity's ancestor in order /// for this component to have any effect. #[derive(Debug, Default, Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[require(Focusable)] #[cfg_attr( feature = "bevy_reflect", derive(Reflect), @@ -167,6 +169,7 @@ pub struct TabNavigation<'w, 's> { 's, ( Entity, + Has, Option<&'static TabIndex>, Option<&'static Children>, Option<&'static InheritedVisibility>, @@ -181,7 +184,7 @@ impl TabNavigation<'_, '_> { /// Navigate to the desired focusable entity, relative to the current focused entity. /// /// Change the [`NavAction`] to navigate in a different direction. - /// Focusable entities are determined by the presence of the [`TabIndex`] component. + /// Focusable entities are determined by the presence of the [`Focusable`] component. /// /// If there is no currently focused entity, then this function will return either the first /// or last focusable entity, depending on the direction of navigation. For example, if @@ -217,7 +220,7 @@ impl TabNavigation<'_, '_> { /// depending on [`NavAction`]. This assumes that the parent entity has a [`TabGroup`] /// component. /// - /// Focusable entities are determined by the presence of the [`TabIndex`] component. + /// Focusable entities are determined by the presence of the [`Focusable`] component. pub fn initialize( &self, parent: Entity, @@ -334,16 +337,17 @@ impl TabNavigation<'_, '_> { parent: Entity, tab_group_idx: usize, ) { - if let Ok((entity, tabindex, children, inherited_visibility)) = + if let Ok((entity, focusable, tabindex, children, inherited_visibility)) = self.tabindex_query.get(parent) { // Skip hidden entities and their entire subtree. An entity without an // `InheritedVisibility` component (e.g. a non-UI entity) is treated as visible. if inherited_visibility.is_none_or(|v| v.get()) { - if let Some(tabindex) = tabindex - && tabindex.0 >= 0 - { - out.push((entity, *tabindex, tab_group_idx)); + if focusable { + let tabindex = tabindex.copied().unwrap_or_default(); + if tabindex.0 >= 0 { + out.push((entity, tabindex, tab_group_idx)); + } } if let Some(children) = children { for child in children.iter() { @@ -364,51 +368,12 @@ impl TabNavigation<'_, '_> { } } -/// Observer which focuses the target of an [`AcquireFocus`] request when it has a [`TabIndex`]. -/// -/// This is the tab-navigation-specific half of focus acquisition: only entities that opt into tab -/// navigation via [`TabIndex`] are treated as focus targets here. When the target is focusable, this -/// stops the request (so it never reaches the window) and focuses it; otherwise the request keeps -/// bubbling and is eventually handled by the generalized -/// [`on_window_acquire_focus_clear`](crate::on_window_acquire_focus_clear) observer at the window. -/// -/// The [`Without`] bound is a defensive guard — a window would never carry a [`TabIndex`] in -/// practice, but excluding it keeps this observer's responsibility (focus a focusable child) cleanly -/// separate from the window-clearing fallback in [`on_window_acquire_focus_clear`](crate::on_window_acquire_focus_clear). -/// -/// The `focus.get()` guard avoids spurious mutations so change detection only fires on real changes. -/// -#[cfg_attr( - feature = "bevy_picking", - doc = "This observer is also registered by -[`PointerFocusPlugin`](crate::pointer_focus::PointerFocusPlugin) as a temporary bridge so pointer -clicks can acquire focus without [`TabNavigationPlugin`]. Because `add_observer` does not -deduplicate, it may therefore run twice per request when both plugins are present; keep it -idempotent (stop propagation, only mutate focus on a real change) so the second run is a no-op." -)] -pub fn acquire_focus_tab_index( - mut acquire_focus: On, - focusable: Query<(), (With, Without)>, - mut focus: ResMut, -) { - // If the entity has a TabIndex - if focusable.contains(acquire_focus.focused_entity) { - // Stop and focus it - acquire_focus.propagate(false); - // Don't mutate unless we need to, for change detection - if focus.get() != Some(acquire_focus.focused_entity) { - focus.set(acquire_focus.focused_entity, FocusCause::Navigated); - } - } -} - /// Plugin for navigating between focusable entities using keyboard input. pub struct TabNavigationPlugin; impl Plugin for TabNavigationPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, setup_tab_navigation); - app.add_observer(acquire_focus_tab_index); } } @@ -467,9 +432,11 @@ pub fn handle_tab_navigation( #[cfg(test)] mod tests { - use bevy_ecs::system::SystemState; + use bevy_ecs::{system::SystemState, world::World}; + use bevy_window::Window; use super::*; + use crate::AcquireFocus; #[test] fn test_tab_navigation() { @@ -500,6 +467,59 @@ mod tests { assert_eq!(last_entity, Ok(tab_entity_2)); } + #[test] + fn focusable_has_implicit_zero_tab_index() { + let mut world = World::new(); + let tab_group = world.spawn(TabGroup::default()).id(); + let first = world.spawn((TabIndex(-1), ChildOf(tab_group))).id(); + let explicit_zero = world.spawn((TabIndex(0), ChildOf(tab_group))).id(); + let implicit = world.spawn((Focusable, ChildOf(tab_group))).id(); + let explicit = world.spawn((TabIndex(1), ChildOf(tab_group))).id(); + let plain = world.spawn(ChildOf(tab_group)).id(); + + let mut system_state: SystemState = SystemState::new(&mut world); + let nav = system_state.get(&world).unwrap(); + assert_eq!( + nav.navigate(&InputFocus::default(), NavAction::First), + Ok(explicit_zero) + ); + assert_eq!( + nav.navigate(&InputFocus::from_entity(explicit_zero), NavAction::Next), + Ok(implicit) + ); + assert_eq!( + nav.navigate(&InputFocus::from_entity(implicit), NavAction::Next), + Ok(explicit) + ); + assert_ne!( + nav.navigate(&InputFocus::default(), NavAction::First), + Ok(first) + ); + assert_ne!( + nav.navigate(&InputFocus::default(), NavAction::First), + Ok(plain) + ); + } + + #[test] + fn removing_focusable_disables_tab_navigation_without_removing_tab_index() { + let mut world = World::new(); + let tab_group = world.spawn(TabGroup::default()).id(); + let disabled = world.spawn((TabIndex(0), ChildOf(tab_group))).id(); + let enabled = world.spawn((Focusable, ChildOf(tab_group))).id(); + assert!(world.entity(disabled).contains::()); + + world.entity_mut(disabled).remove::(); + assert!(world.entity(disabled).contains::()); + + let mut system_state: SystemState = SystemState::new(&mut world); + let nav = system_state.get(&world).unwrap(); + assert_eq!( + nav.navigate(&InputFocus::default(), NavAction::First), + Ok(enabled) + ); + } + #[test] fn test_tab_navigation_between_groups_is_sorted_by_group() { let mut app = App::new(); @@ -541,9 +561,7 @@ mod tests { assert_eq!(prev_entity_from_start_of_group, Ok(tab_entity_2)); } - /// Sets up an app with a primary window and both the input-focus and tab-navigation plugins, - /// so both `AcquireFocus` observers (window-clearing in `bevy_input_focus`, and - /// `acquire_focus_tab_index` here) are registered, with initial focus resolved. + /// Sets up an app with a primary window and both the input-focus and tab-navigation plugins. fn acquire_focus_app() -> (App, Entity) { use crate::InputFocusPlugin; use bevy_input::InputPlugin; @@ -563,7 +581,8 @@ mod tests { fn acquire_focus_focuses_entity_with_tab_index() { let (mut app, window) = acquire_focus_app(); - let focusable = app.world_mut().spawn(TabIndex(0)).id(); + // A negative index excludes sequential navigation, but remains directly focusable. + let focusable = app.world_mut().spawn(TabIndex(-1)).id(); app.world_mut().trigger(AcquireFocus { focused_entity: focusable, @@ -578,10 +597,8 @@ mod tests { fn acquire_focus_does_not_focus_entity_without_tab_index() { let (mut app, window) = acquire_focus_app(); - // A non-focusable entity must never become focused just because it was the request target: - // only `TabIndex` entities are valid focus targets for `acquire_focus_tab_index`. The - // request instead bubbles up to the window, where the generalized - // `on_window_acquire_focus_clear` observer clears focus. + // A non-focusable entity must never become focused just because it was the request target. + // The request instead bubbles up to the window, where focus is cleared. let non_focusable = app.world_mut().spawn(ChildOf(window)).id(); app.world_mut().trigger(AcquireFocus { focused_entity: non_focusable, diff --git a/crates/bevy_ui/src/auto_directional_navigation.rs b/crates/bevy_ui/src/auto_directional_navigation.rs index 8ed52e82e3d8e..84761063c1401 100644 --- a/crates/bevy_ui/src/auto_directional_navigation.rs +++ b/crates/bevy_ui/src/auto_directional_navigation.rs @@ -27,7 +27,7 @@ use bevy_input_focus::{ AutoNavigationConfig, DirectionalNavigation, DirectionalNavigationError, FocusableArea, }, navigator::find_best_candidate, - FocusCause, + FocusCause, Focusable, }; use bevy_reflect::{prelude::*, Reflect}; @@ -92,6 +92,7 @@ use bevy_reflect::{prelude::*, Reflect}; /// # Additional Requirements /// /// Entities must also have: +/// - [`Focusable`] - automatically added as a required component /// - [`ComputedNode`] - for size information /// - [`UiGlobalTransform`] - for position information /// @@ -103,6 +104,7 @@ use bevy_reflect::{prelude::*, Reflect}; /// [`auto_generate_navigation_edges`](bevy_input_focus::directional_navigation::auto_generate_navigation_edges) /// directly in your own system instead of using this component. #[derive(Component, Default, Debug, Clone, Copy, PartialEq, Reflect)] +#[require(Focusable)] #[reflect(Component, Default, Debug, PartialEq, Clone)] pub struct AutoDirectionalNavigation { /// Whether to also consider `TabIndex` for navigation order hints. @@ -118,7 +120,7 @@ pub struct AutoDirectionalNavigation { #[derive(SystemParam, Debug)] pub struct AutoDirectionalNavigator<'w, 's> { /// A system parameter for the manual directional navigation system provided by `bevy_input_focus` - pub manual_directional_navigation: DirectionalNavigation<'w>, + pub manual_directional_navigation: DirectionalNavigation<'w, 's>, /// Configuration for the automated portion of the navigation algorithm. pub config: Res<'w, AutoNavigationConfig>, /// The entities which can possibly be navigated to automatically. @@ -132,7 +134,7 @@ pub struct AutoDirectionalNavigator<'w, 's> { &'static UiGlobalTransform, &'static InheritedVisibility, ), - With, + (With, With), >, /// A query used to get the target camera and the [`FocusableArea`] for a given entity to be used in automatic navigation. camera_and_focusable_area_query: Query< @@ -278,3 +280,33 @@ fn get_rotated_bounds(size: Vec2, rotation: f32) -> Vec2 { size.x * sin_r + size.y * cos_r, ) } + +#[cfg(test)] +mod tests { + use super::*; + use bevy_app::App; + use bevy_input_focus::{AcquireFocus, InputFocus, InputFocusPlugin}; + use bevy_window::Window; + + #[test] + fn auto_directional_navigation_is_focusable_without_tab_index() { + let mut app = App::new(); + app.add_plugins(InputFocusPlugin); + let window = app.world_mut().spawn(Window::default()).id(); + let directional = app + .world_mut() + .spawn(AutoDirectionalNavigation::default()) + .id(); + + assert!(app.world().entity(directional).contains::()); + app.world_mut().trigger(AcquireFocus { + focused_entity: directional, + window, + }); + + assert_eq!( + app.world().resource::().get(), + Some(directional) + ); + } +} diff --git a/crates/bevy_ui_widgets/src/button.rs b/crates/bevy_ui_widgets/src/button.rs index 66669ed3f4bec..c03b2c6d8887e 100644 --- a/crates/bevy_ui_widgets/src/button.rs +++ b/crates/bevy_ui_widgets/src/button.rs @@ -12,7 +12,7 @@ use bevy_ecs::{ }; use bevy_input::keyboard::{KeyCode, KeyboardInput}; use bevy_input::ButtonState; -use bevy_input_focus::FocusedInput; +use bevy_input_focus::{Focusable, FocusedInput}; use bevy_picking::events::{ PointerCancel, PointerClick, PointerDragEnd, PointerPress, PointerRelease, }; @@ -25,7 +25,7 @@ use crate::Activate; /// indicate whether the button is currently being pressed by the user. It emits an [`Activate`] /// event when the button is un-pressed. #[derive(Component, Default, Debug, Clone)] -#[require(AccessibilityNode(accesskit::Node::new(Role::Button)))] +#[require(AccessibilityNode(accesskit::Node::new(Role::Button)), Focusable)] #[derive(Reflect)] #[reflect(Component)] pub struct Button; diff --git a/crates/bevy_ui_widgets/src/checkbox.rs b/crates/bevy_ui_widgets/src/checkbox.rs index 808cb38a2d78e..20dabc3840a8b 100644 --- a/crates/bevy_ui_widgets/src/checkbox.rs +++ b/crates/bevy_ui_widgets/src/checkbox.rs @@ -12,7 +12,7 @@ use bevy_ecs::{ }; use bevy_input::keyboard::{KeyCode, KeyboardInput}; use bevy_input::ButtonState; -use bevy_input_focus::{FocusCause, FocusedInput, InputFocus, InputFocusVisible}; +use bevy_input_focus::{FocusCause, Focusable, FocusedInput, InputFocus, InputFocusVisible}; use bevy_picking::events::{ PointerCancel, PointerClick, PointerDragEnd, PointerPress, PointerRelease, }; @@ -38,7 +38,11 @@ use bevy_ecs::entity::Entity; /// and how to respond to state changes, see the [crate-level documentation]. /// [crate-level documentation]: crate #[derive(Component, Debug, Default, Clone)] -#[require(AccessibilityNode(accesskit::Node::new(Role::CheckBox)), Checkable)] +#[require( + AccessibilityNode(accesskit::Node::new(Role::CheckBox)), + Checkable, + Focusable +)] #[derive(Reflect)] #[reflect(Component)] pub struct Checkbox; diff --git a/crates/bevy_ui_widgets/src/list.rs b/crates/bevy_ui_widgets/src/list.rs index 53197e56210a1..b2b6eec99f70e 100644 --- a/crates/bevy_ui_widgets/src/list.rs +++ b/crates/bevy_ui_widgets/src/list.rs @@ -13,7 +13,7 @@ use bevy_ecs::{ }; use bevy_input::keyboard::{KeyCode, KeyboardInput}; use bevy_input::ButtonState; -use bevy_input_focus::{FocusGained, FocusLost, FocusedInput, InputFocusVisible}; +use bevy_input_focus::{FocusGained, FocusLost, Focusable, FocusedInput, InputFocusVisible}; use bevy_picking::events::PointerClick; use bevy_reflect::Reflect; use bevy_ui::{InteractionDisabled, Selectable, Selected}; @@ -32,7 +32,8 @@ use crate::{ScrollIntoView, ValueChange}; #[derive(Component, Debug, Clone, Default)] #[require( AccessibilityNode(accesskit::Node::new(Role::ListBox)), - ActiveDescendant + ActiveDescendant, + Focusable )] pub struct ListBox; diff --git a/crates/bevy_ui_widgets/src/menu.rs b/crates/bevy_ui_widgets/src/menu.rs index 2bba371535187..0dc80c1f13754 100644 --- a/crates/bevy_ui_widgets/src/menu.rs +++ b/crates/bevy_ui_widgets/src/menu.rs @@ -49,7 +49,7 @@ use bevy_input::{ }; use bevy_input_focus::{ tab_navigation::{NavAction, TabGroup, TabNavigation}, - FocusCause, FocusedInput, InputFocus, InputFocusSystems, + FocusCause, Focusable, FocusedInput, InputFocus, InputFocusSystems, }; use bevy_log::warn; use bevy_picking::events::{ @@ -132,7 +132,7 @@ pub struct MenuPopup { /// Component that defines a menu item. #[derive(Component, Debug, Clone, Default)] -#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))] +#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)), Focusable)] #[derive(Reflect)] #[reflect(Component)] pub struct MenuItem; diff --git a/crates/bevy_ui_widgets/src/radio.rs b/crates/bevy_ui_widgets/src/radio.rs index e865c659b0461..c5ecd18be3c92 100644 --- a/crates/bevy_ui_widgets/src/radio.rs +++ b/crates/bevy_ui_widgets/src/radio.rs @@ -12,7 +12,7 @@ use bevy_ecs::{ }; use bevy_input::keyboard::{KeyCode, KeyboardInput}; use bevy_input::ButtonState; -use bevy_input_focus::FocusedInput; +use bevy_input_focus::{Focusable, FocusedInput}; use bevy_picking::events::{ PointerCancel, PointerClick, PointerDragEnd, PointerPress, PointerRelease, }; @@ -42,7 +42,7 @@ use crate::{ActivateOnPress, ValueChange}; /// and how to respond to state changes, see the [crate-level documentation]. /// [crate-level documentation]: crate #[derive(Component, Debug, Clone, Default)] -#[require(AccessibilityNode(accesskit::Node::new(Role::RadioGroup)))] +#[require(AccessibilityNode(accesskit::Node::new(Role::RadioGroup)), Focusable)] #[derive(Reflect)] #[reflect(Component)] pub struct RadioGroup; diff --git a/crates/bevy_ui_widgets/src/slider.rs b/crates/bevy_ui_widgets/src/slider.rs index f8748e0a55a19..61730c3b34933 100644 --- a/crates/bevy_ui_widgets/src/slider.rs +++ b/crates/bevy_ui_widgets/src/slider.rs @@ -18,7 +18,7 @@ use bevy_ecs::{ }; use bevy_input::keyboard::{KeyCode, KeyboardInput}; use bevy_input::ButtonState; -use bevy_input_focus::FocusedInput; +use bevy_input_focus::{Focusable, FocusedInput}; use bevy_log::warn_once; use bevy_math::ops; use bevy_picking::events::{ @@ -107,7 +107,8 @@ pub enum TrackClick { SliderDragState, SliderValue, SliderRange, - SliderStep + SliderStep, + Focusable )] #[derive(Reflect)] #[reflect(Component)] diff --git a/crates/bevy_ui_widgets/src/tabs.rs b/crates/bevy_ui_widgets/src/tabs.rs index 279c1fb4af3cc..4d3b42dabfbda 100644 --- a/crates/bevy_ui_widgets/src/tabs.rs +++ b/crates/bevy_ui_widgets/src/tabs.rs @@ -280,7 +280,7 @@ fn tab_on_key_input( /// /// - [`Selected`] markers mirror a validated `SelectedTab` (the referenced entity must be an /// enabled direct child; anything else counts as no selection). -/// - [`TabIndex`] follows the roving-tabindex pattern: one tab per list is focusable (the +/// - [`TabIndex`] follows the roving-tabindex pattern: one tab per list is sequentially reachable (the /// focused tab, else the selected tab, else the first enabled tab), so Tab/Shift+Tab skip /// the strip while arrow keys move within it. /// diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 61addb7fd8beb..dcbd8f3490bf1 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -13,7 +13,7 @@ use bevy_ecs::{prelude::*, reflect::ReflectComponent}; use bevy_input::keyboard::{Key, KeyCode, KeyboardInput}; use bevy_input::{ButtonInput, InputSystems}; use bevy_input_focus::{ - FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, + FocusCause, FocusGained, FocusLost, Focusable, FocusedInput, InputFocus, InputFocusSystems, }; use bevy_math::Vec2; use bevy_picking::events::{PointerDrag, PointerPress, PointerRelease, PointerState}; @@ -68,7 +68,11 @@ fn mac_host() -> bool { /// Editable text widget. #[derive(Component, Clone, Default, Reflect)] -#[require(EditableText, AccessibilityNode(accesskit::Node::new(Role::TextInput)))] +#[require( + EditableText, + AccessibilityNode(accesskit::Node::new(Role::TextInput)), + Focusable +)] #[reflect(Component)] pub struct TextInput;