Skip to main content

azul_css/props/style/
spatial_nav.rs

1//! CSS Spatial Navigation Level 1 - the two per-container overrides.
2//!
3//! 9a-i-a made an arrow key try focus first and fall back to scrolling, which
4//! is the spec's default behaviour and is right almost everywhere. These two
5//! properties are how a container opts OUT of that default, and neither is
6//! expressible any other way:
7//!
8//! - [`StyleSpatialNavigationAction`] forces the choice on a scroll container:
9//!   always scroll (a map, a canvas, a code editor - places where an arrow
10//!   means "pan", never "jump to the next button"), or always move focus.
11//! - [`StyleSpatialNavigationContain`] makes an element a spatial navigation
12//!   CONTAINER even when it is not a scroll container, so navigation inside a
13//!   panel stays inside it.
14//!
15//! Both are from `css-nav-1`, and both have `auto` as their initial value, so
16//! adding them changes nothing until a stylesheet asks.
17
18use crate::{corety::AzString, props::formatter::PrintAsCssValue};
19
20/// `spatial-navigation-action` - what an arrow key does on a scroll container.
21///
22/// ```css
23/// .map     { spatial-navigation-action: scroll; }  /* arrows always pan   */
24/// .menu    { spatial-navigation-action: focus; }   /* arrows never scroll */
25/// ```
26///
27/// The default is [`Auto`](Self::Auto), which is the ordered fallback 9a-i-a
28/// implements: move focus if there is somewhere to move it, otherwise scroll.
29///
30/// NOT INHERITED. The property answers "what does an arrow do when THIS
31/// element is the scroll container", and a container that pans is routinely
32/// full of ordinary focusable controls that must keep behaving normally -
33/// inheriting `scroll` into them would make every button inside a map
34/// unreachable by keyboard.
35#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
36#[repr(C)]
37pub enum StyleSpatialNavigationAction {
38    /// Move focus if a candidate lies in that direction; otherwise scroll.
39    #[default]
40    Auto,
41    /// Always move focus. If there is no candidate the container does NOT
42    /// scroll - the search continues outward instead, so an arrow at the edge
43    /// of a menu escapes it rather than nudging it.
44    Focus,
45    /// Always scroll, changing nothing about focus, even when focusable
46    /// children are sitting right there. What a map, a canvas or a code
47    /// editor wants.
48    Scroll,
49}
50
51impl PrintAsCssValue for StyleSpatialNavigationAction {
52    fn print_as_css_value(&self) -> String {
53        String::from(match self {
54            Self::Auto => "auto",
55            Self::Focus => "focus",
56            Self::Scroll => "scroll",
57        })
58    }
59}
60
61/// `spatial-navigation-contain` - whether this element is a spatial
62/// navigation container.
63///
64/// ```css
65/// .sidebar { spatial-navigation-contain: contain; }
66/// ```
67///
68/// Under `auto`, only scroll containers (and the viewport) are containers,
69/// which is the spec's default. `contain` adds one for an element that does
70/// not scroll - a toolbar, a dialog, a sidebar - so that arrow keys resolve
71/// among its descendants first and only leave it when nothing inside answers.
72///
73/// NOT INHERITED, and for a sharper reason than the action property: it marks
74/// ONE element as a boundary. Inheriting it would make every descendant a
75/// boundary too, which is the same as having none - each nested container
76/// would trap navigation one level deeper until an arrow could not move at
77/// all.
78#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
79#[repr(C)]
80pub enum StyleSpatialNavigationContain {
81    /// A container only if this element is a scroll container.
82    #[default]
83    Auto,
84    /// A container regardless of whether it scrolls.
85    Contain,
86}
87
88impl PrintAsCssValue for StyleSpatialNavigationContain {
89    fn print_as_css_value(&self) -> String {
90        String::from(match self {
91            Self::Auto => "auto",
92            Self::Contain => "contain",
93        })
94    }
95}
96
97/// `spatial-navigation-action` parse error.
98#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub enum CssSpatialNavigationActionParseError<'a> {
100    InvalidValue(&'a str),
101}
102
103impl core::fmt::Display for CssSpatialNavigationActionParseError<'_> {
104    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
105        match self {
106            Self::InvalidValue(v) => write!(
107                f,
108                "Invalid spatial-navigation-action value: \"{v}\" (expected auto, focus or scroll)"
109            ),
110        }
111    }
112}
113
114/// Owned mirror of [`CssSpatialNavigationActionParseError`].
115// `AzString`, not `String`, and `#[repr(C, u8)]`, not bare `repr(C)`. Both
116// are FFI requirements this type cannot opt out of: it is reachable from the
117// exposed parse-error surface, the codegen builds its mirror from `AzString`,
118// and a payload enum with no repr compiles silently and is undefined across
119// the boundary. `CssAppRegionParseErrorOwned` beside it is the same shape for
120// the same reasons - api.json's own checker passes either way, so this is
121// caught only by building the generated C ABI.
122#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
123#[repr(C, u8)]
124pub enum CssSpatialNavigationActionParseErrorOwned {
125    InvalidValue(AzString),
126}
127
128impl<'a> CssSpatialNavigationActionParseError<'a> {
129    #[must_use]
130    pub fn to_contained(&self) -> CssSpatialNavigationActionParseErrorOwned {
131        match self {
132            Self::InvalidValue(v) => {
133                CssSpatialNavigationActionParseErrorOwned::InvalidValue((*v).into())
134            }
135        }
136    }
137}
138
139impl CssSpatialNavigationActionParseErrorOwned {
140    #[must_use]
141    pub fn to_shared(&self) -> CssSpatialNavigationActionParseError<'_> {
142        match self {
143            Self::InvalidValue(v) => CssSpatialNavigationActionParseError::InvalidValue(v.as_str()),
144        }
145    }
146}
147
148/// `spatial-navigation-contain` parse error.
149#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
150pub enum CssSpatialNavigationContainParseError<'a> {
151    InvalidValue(&'a str),
152}
153
154impl core::fmt::Display for CssSpatialNavigationContainParseError<'_> {
155    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
156        match self {
157            Self::InvalidValue(v) => write!(
158                f,
159                "Invalid spatial-navigation-contain value: \"{v}\" (expected auto or contain)"
160            ),
161        }
162    }
163}
164
165/// Owned mirror of [`CssSpatialNavigationContainParseError`].
166// `AzString`, not `String`, and `#[repr(C, u8)]`, not bare `repr(C)`. Both
167// are FFI requirements this type cannot opt out of: it is reachable from the
168// exposed parse-error surface, the codegen builds its mirror from `AzString`,
169// and a payload enum with no repr compiles silently and is undefined across
170// the boundary. `CssAppRegionParseErrorOwned` beside it is the same shape for
171// the same reasons - api.json's own checker passes either way, so this is
172// caught only by building the generated C ABI.
173#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
174#[repr(C, u8)]
175pub enum CssSpatialNavigationContainParseErrorOwned {
176    InvalidValue(AzString),
177}
178
179impl<'a> CssSpatialNavigationContainParseError<'a> {
180    #[must_use]
181    pub fn to_contained(&self) -> CssSpatialNavigationContainParseErrorOwned {
182        match self {
183            Self::InvalidValue(v) => {
184                CssSpatialNavigationContainParseErrorOwned::InvalidValue((*v).into())
185            }
186        }
187    }
188}
189
190impl CssSpatialNavigationContainParseErrorOwned {
191    #[must_use]
192    pub fn to_shared(&self) -> CssSpatialNavigationContainParseError<'_> {
193        match self {
194            Self::InvalidValue(v) => {
195                CssSpatialNavigationContainParseError::InvalidValue(v.as_str())
196            }
197        }
198    }
199}
200
201#[cfg(feature = "parser")]
202/// # Errors
203///
204/// Returns an error if `input` is not `auto`, `focus` or `scroll`.
205pub fn parse_style_spatial_navigation_action(
206    input: &str,
207) -> Result<StyleSpatialNavigationAction, CssSpatialNavigationActionParseError<'_>> {
208    match input.trim() {
209        "auto" => Ok(StyleSpatialNavigationAction::Auto),
210        "focus" => Ok(StyleSpatialNavigationAction::Focus),
211        "scroll" => Ok(StyleSpatialNavigationAction::Scroll),
212        _ => Err(CssSpatialNavigationActionParseError::InvalidValue(input)),
213    }
214}
215
216#[cfg(feature = "parser")]
217/// # Errors
218///
219/// Returns an error if `input` is not `auto` or `contain`.
220pub fn parse_style_spatial_navigation_contain(
221    input: &str,
222) -> Result<StyleSpatialNavigationContain, CssSpatialNavigationContainParseError<'_>> {
223    match input.trim() {
224        "auto" => Ok(StyleSpatialNavigationContain::Auto),
225        "contain" => Ok(StyleSpatialNavigationContain::Contain),
226        _ => Err(CssSpatialNavigationContainParseError::InvalidValue(input)),
227    }
228}
229
230#[cfg(all(test, feature = "parser"))]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn the_action_keywords_parse_and_round_trip() {
236        for (text, value) in [
237            ("auto", StyleSpatialNavigationAction::Auto),
238            ("focus", StyleSpatialNavigationAction::Focus),
239            ("scroll", StyleSpatialNavigationAction::Scroll),
240        ] {
241            assert_eq!(parse_style_spatial_navigation_action(text), Ok(value));
242            assert_eq!(value.print_as_css_value(), text);
243        }
244        // Surrounding whitespace survives the tokenizer in some paths.
245        assert_eq!(
246            parse_style_spatial_navigation_action("  scroll "),
247            Ok(StyleSpatialNavigationAction::Scroll)
248        );
249    }
250
251    #[test]
252    fn the_contain_keywords_parse_and_round_trip() {
253        for (text, value) in [
254            ("auto", StyleSpatialNavigationContain::Auto),
255            ("contain", StyleSpatialNavigationContain::Contain),
256        ] {
257            assert_eq!(parse_style_spatial_navigation_contain(text), Ok(value));
258            assert_eq!(value.print_as_css_value(), text);
259        }
260    }
261
262    /// `none` is NOT a spelling of either. Accepting it would silently turn a
263    /// typo into the initial value, which reads as "the property did nothing".
264    #[test]
265    fn a_wrong_keyword_is_an_error_and_not_the_default() {
266        assert!(parse_style_spatial_navigation_action("none").is_err());
267        assert!(parse_style_spatial_navigation_action("contain").is_err());
268        assert!(parse_style_spatial_navigation_contain("none").is_err());
269        assert!(parse_style_spatial_navigation_contain("focus").is_err());
270    }
271
272    /// THE PROPERTY NAME HAS TO REACH THE PARSER, and a keyword parser that
273    /// works in isolation proves nothing about that: the name table, the
274    /// `CssPropertyType` arm and the dispatch all have to agree, and each is
275    /// in a different file.
276    #[test]
277    fn both_properties_parse_from_their_css_name() {
278        use crate::props::property::{
279            get_css_key_map, parse_css_property, CssProperty, CssPropertyType,
280        };
281
282        let map = get_css_key_map();
283        let ty = CssPropertyType::from_str("spatial-navigation-action", &map)
284            .expect("`spatial-navigation-action` must be a known property name");
285        assert_eq!(ty, CssPropertyType::SpatialNavigationAction);
286        assert_eq!(
287            parse_css_property(ty, "scroll"),
288            Ok(CssProperty::SpatialNavigationAction(
289                crate::css::CssPropertyValue::Exact(StyleSpatialNavigationAction::Scroll)
290            ))
291        );
292
293        let ty = CssPropertyType::from_str("spatial-navigation-contain", &map)
294            .expect("`spatial-navigation-contain` must be a known property name");
295        assert_eq!(ty, CssPropertyType::SpatialNavigationContain);
296        assert_eq!(
297            parse_css_property(ty, "contain"),
298            Ok(CssProperty::SpatialNavigationContain(
299                crate::css::CssPropertyValue::Exact(StyleSpatialNavigationContain::Contain)
300            ))
301        );
302
303        // Neither moves a box nor paints a pixel, so neither may charge a
304        // layout pass. The default for an unlisted property is `true`, which
305        // is why this is worth pinning.
306        assert!(!CssPropertyType::SpatialNavigationAction.can_trigger_relayout());
307        assert!(!CssPropertyType::SpatialNavigationContain.can_trigger_relayout());
308    }
309
310    /// Both default to `auto`, which is what makes adding them a no-op for
311    /// every stylesheet that does not mention them.
312    #[test]
313    fn both_properties_default_to_auto() {
314        assert_eq!(
315            StyleSpatialNavigationAction::default(),
316            StyleSpatialNavigationAction::Auto
317        );
318        assert_eq!(
319            StyleSpatialNavigationContain::default(),
320            StyleSpatialNavigationContain::Auto
321        );
322    }
323}