Skip to main content

freya_core/accessibility/
focus.rs

1use keyboard_types::{
2    Key,
3    Modifiers,
4    NamedKey,
5};
6
7use crate::{
8    accessibility::id::AccessibilityId,
9    integration::{
10        ACCESSIBILITY_ROOT_ID,
11        AccessibilityGenerator,
12    },
13    lifecycle::reactive::use_reactive,
14    platform::{
15        NavigationMode,
16        Platform,
17    },
18    prelude::{
19        AccessibilityFocusStrategy,
20        KeyboardEventData,
21        Memo,
22        ScreenReader,
23        UserEvent,
24        consume_root_context,
25        use_hook,
26        use_memo,
27    },
28};
29
30/// Extension trait for [`AccessibilityId`].
31///
32/// Pair an id with an element through `.a11y_id(...)`, then call any of these
33/// methods on the id to interact with focus.
34///
35/// ```rust, no_run
36/// # use freya::prelude::*;
37/// fn focusable_box() -> impl IntoElement {
38///     let a11y_id = use_a11y();
39///     rect()
40///         .a11y_id(a11y_id)
41///         .a11y_focusable(true)
42///         .on_mouse_down(move |_| a11y_id.request_focus())
43///         .child(if a11y_id.is_focused() {
44///             "Focused"
45///         } else {
46///             "Not focused"
47///         })
48/// }
49/// ```
50pub trait AccessibilityIdExt {
51    /// Whether the linked node is currently focused (via keyboard or pointer).
52    fn is_focused(&self) -> bool;
53
54    /// Request focus to be moved to the linked node.
55    fn request_focus(&self);
56
57    /// Request focus to be cleared from the linked node.
58    fn request_unfocus(&self);
59
60    /// Generate a unique [`AccessibilityId`]. Prefer [`use_a11y`] for component-scoped ids.
61    fn new_unique() -> AccessibilityId;
62}
63
64impl AccessibilityIdExt for AccessibilityId {
65    fn is_focused(&self) -> bool {
66        let platform = Platform::get();
67        *platform.focused_accessibility_id.read() == *self
68    }
69
70    fn request_focus(&self) {
71        let platform = Platform::get();
72
73        if *platform.focused_accessibility_id.peek() != *self {
74            Platform::get().send(UserEvent::FocusAccessibilityNode(
75                AccessibilityFocusStrategy::Node(*self),
76            ));
77        }
78    }
79
80    fn request_unfocus(&self) {
81        let platform = Platform::get();
82
83        if *platform.focused_accessibility_id.peek() == *self {
84            Platform::get().send(UserEvent::FocusAccessibilityNode(
85                AccessibilityFocusStrategy::Node(ACCESSIBILITY_ROOT_ID),
86            ));
87        }
88    }
89
90    fn new_unique() -> Self {
91        let accessibility_generator = consume_root_context::<AccessibilityGenerator>();
92        AccessibilityId(accessibility_generator.new_id())
93    }
94}
95
96/// Create a unique [`AccessibilityId`] that persists for the lifetime of the component.
97pub fn use_a11y() -> AccessibilityId {
98    use_hook(AccessibilityId::new_unique)
99}
100
101/// Focus state for an [`AccessibilityId`], distinguishing keyboard vs pointer focus.
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub enum Focus {
104    /// The node is not focused.
105    Not,
106    /// The node is focused after a pointer (mouse / touch) interaction.
107    Pointer,
108    /// The node is focused while the user is navigating with the keyboard.
109    Keyboard,
110}
111
112impl Focus {
113    /// Whether the node is focused, regardless of how it got focused.
114    pub fn is_focused(&self) -> bool {
115        matches!(self, Self::Pointer | Self::Keyboard)
116    }
117}
118
119/// Extension trait for [`KeyboardEventData`] with focus-related helpers.
120pub trait KeyboardEventExt {
121    /// Whether this event is the "press" gesture for a focusable node (`Enter` / `Space`,
122    /// or `Ctrl+Alt+Space` on macOS with a screen reader).
123    fn is_press_event(&self) -> bool;
124}
125
126impl KeyboardEventExt for KeyboardEventData {
127    fn is_press_event(&self) -> bool {
128        let is_space = matches!(self.key, Key::Character(ref s) if s == " ");
129        let is_enter = self.key == Key::Named(NamedKey::Enter);
130
131        if cfg!(target_os = "macos") {
132            let screen_reader = ScreenReader::get();
133            if screen_reader.is_on() {
134                is_space
135                    && self.modifiers.contains(Modifiers::CONTROL)
136                    && self.modifiers.contains(Modifiers::ALT)
137            } else {
138                is_enter || is_space
139            }
140        } else {
141            is_enter || is_space
142        }
143    }
144}
145
146/// Reactively track the [`Focus`] state of an [`AccessibilityId`].
147///
148/// ```rust, no_run
149/// # use freya::prelude::*;
150/// fn highlighted_box() -> impl IntoElement {
151///     let a11y_id = use_a11y();
152///     let focus = use_focus(a11y_id);
153///     rect()
154///         .a11y_id(a11y_id)
155///         .a11y_focusable(true)
156///         .maybe(focus() == Focus::Keyboard, |el| {
157///             el.border(Border::new().fill(Color::BLUE).width(2.))
158///         })
159/// }
160/// ```
161pub fn use_focus(a11y_id: AccessibilityId) -> Memo<Focus> {
162    let id = use_reactive(&a11y_id);
163    use_memo(move || {
164        let platform = Platform::get();
165        let is_focused = *platform.focused_accessibility_id.read() == id();
166        let is_keyboard = *platform.navigation_mode.read() == NavigationMode::Keyboard;
167
168        match (is_focused, is_keyboard) {
169            (true, false) => Focus::Pointer,
170            (true, true) => Focus::Keyboard,
171            _ => Focus::Not,
172        }
173    })
174}