Skip to content

Design Specification: NavigationRail widget #860

@M-Adoo

Description

@M-Adoo

NavigationRail Design Specification

NavigationRail is a side navigation widget designed for medium and large screens, providing efficient access to top-level navigation and dynamically adjusting layout based on available space. This design strictly follows the Ribir Interactive Widget Design Standard (v2.4).


1. Core Principles

1.1 Composition First

NavigationRail is a pure selection container:

  • Widget responsibility: Manage selection state, provide visual feedback, trigger selection events
  • Application responsibility: Handle side effects in on_select event (route navigation, permission checks, etc.)
  • No built-in routing: Routing is an application-level concern

1.2 Unidirectional Data Flow

  1. State lifting: Selection state is managed by NavigationRail
  2. Event-driven: User interactions trigger RailSelect events without directly modifying UI
  3. Data-driven: Application updates data → Pipe emits → UI updates (Path A)

1.3 Identifier Strategy

Key specification: Each RailItem can optionally specify a key for stable identification.

  • If key is provided: Used directly for selection matching
  • If key is omitted: NavigationRail automatically uses the item's index ("0", "1", "2", ...) as the key

Runtime guarantee: After ComposeChild, all RailItems have a valid key (either user-provided or auto-generated index).

key vs reuse_id:

Attribute Layer Purpose
key Business Selection state matching
reuse_id Framework Widget instance reuse

They are completely independent and can differ.


2. Interaction Model

2.1 Controlled Protocol

Mode DSL Behavior
Controlled selected: pipe!($model.key) UI follows Pipe, requires manual model updates
Two-way binding selected: TwoWay::new(model.key) Auto-syncs UI ↔ data
Uncontrolled selected: Some("home") Widget manages internally

Limitation: Action Items cannot use TwoWay (see Section 4.4).

2.2 Event Definition

/// Navigation item selection event
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RailSelect {
  pub from: Option<String>,  // Previously selected identifier (for animation direction, history)
  pub to: String,            // Newly selected identifier
}

pub type RailSelectEvent = CustomEvent<RailSelect>;

3. Layout and Adaptation

3.1 Layout Modes

Mode Width Layout
Collapsed 80dp Vertical stack, centered icons
Expanded 240-320dp Horizontal layout, icons and labels side by side

3.2 Static Configuration (via Provider)

Label display strategy (in collapsed mode):

RailLabelPolicy Behavior
None Icon only
OnSelected Label only on selected item
Always Label on all items

Content alignment:

RailContentAlign Effect
Align::Start Top alignment
Align::Center Center (default)
Align::End Bottom alignment

Why use Provider:

  • Dynamic state (selected, expanded) → Widget properties
  • Static configuration (label_policy, content_align) → Provider

3.3 Selection State Rendering

Pass selection state via class name:

// NavigationRail internal
let class = if is_selected {
  class_names![RAIL_ITEM, RAIL_ITEM_SELECTED]
} else {
  class_names![RAIL_ITEM, RAIL_ITEM_UNSELECTED]
};

Theme system defines corresponding styles.

3.4 Section Adaptation

RailSection automatically switches based on RailExpanded state:

  • Expanded: Text title
  • Collapsed: Divider separator

4. Usage Examples

4.1 Basic Usage

navigation_rail! {
  selected: TwoWay::new(app.current_section),
  
  // Without explicit key: auto-generated index is used
  @RailItem { @{ svg_registry::HOME }, @{ "Home" } }      // key = "0"
  
  // With explicit key: stable identification
  @RailItem { key: "profile", @{ svg_registry::PROFILE }, @{ "Profile" } }  // key = "profile"
}

@match $app.current_section.as_deref() {
  Some("0") => @HomePage,
  Some("profile") => @ProfilePage,
  _ => @Void,
}

4.2 Router Integration

navigation_rail! {
  selected: pipe!({
    match Location::of(ctx).path() {
      "/" => Some("home".to_string()),
      "/profile" => Some("profile".to_string()),
      _ => None,
    }
  }),
  
  on_select: move |e| {
    let route = match e.value().to.as_str() {
      "home" => "/",
      "profile" => "/profile",
      _ => return,
    };
    Location::of(ctx).write().navigate(route);
  },
  
  @RailItem { key: "home", @{ svg_registry::HOME }, @{ "Home" } }
  @RailItem { key: "profile", @{ svg_registry::PROFILE }, @{ "Profile" } }
}

4.3 Action Items (Non-navigation)

⚠️ Important: Action Items cannot use TwoWay, must use controlled mode.

navigation_rail! {
  selected: pipe!($app.current_view),  // ✅ Controlled mode
  
  on_select: move |e| {
    match e.value().to.as_str() {
      "logout" => app.logout(),  // Action: don't update selected
      "create" => show_create_dialog(),  // Action: don't update selected
      _ => {
        $write(app).current_view = Some(e.value().to.clone());  // Navigation: manually update
      }
    }
  },
  
  @RailItem { key: "home", @{ svg_registry::HOME }, @{ "Home" } }
  @RailSection { @{ "Actions" } }
  @RailItem { key: "logout", @{ svg_registry::LOGOUT }, @{ "Logout" } }
}

5. Type Definitions

5.1 Configuration and State

/// Label display strategy in collapsed mode
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum RailLabelPolicy {
  #[default]
  None,        // Icon only
  OnSelected,  // Label only on selected item
  Always,      // Label on all items
}

/// Global expanded state Provider
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub struct RailExpanded(pub bool);

/// Label display strategy Provider
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub struct RailLabelPolicy(pub RailLabelPolicy);

/// Content alignment Provider
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub struct RailContentAlign(pub Align);

/// RailItem structure metadata Provider
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct RailItemMetadata {
  pub has_label: bool,
  pub has_badge: bool,
}

5.2 Event Types

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RailSelect {
  pub from: Option<String>,
  pub to: String,
}

pub type RailSelectEvent = CustomEvent<RailSelect>;

5.3 Style Classes

class_names! {
  NAVIGATION_RAIL,
  RAIL_MENU,
  RAIL_ACTION,
  RAIL_CONTENT,
  RAIL_FOOTER,
  RAIL_ITEM,
  RAIL_ITEM_SELECTED,
  RAIL_ITEM_UNSELECTED,
  RAIL_ITEM_ICON,
  RAIL_ITEM_LABEL,
  RAIL_ITEM_INDICATOR,
  RAIL_ITEM_BADGE,
  RAIL_SECTION,
  RAIL_SECTION_TITLE,
}

5.4 Widget Definitions

/// Navigation item
#[declare]
pub struct RailItem {
  /// Business identifier
  /// - User-provided: used directly
  /// - User-omitted: NavigationRail auto-supplements index string
  /// - Runtime guarantee: always `Some` after ComposeChild
  #[declare(default)]
  pub key: Option<String>,
}

/// Navigation section
#[declare]
pub struct RailSection {
  pub title: TextValue,
}

/// NavigationRail main widget
#[declare]
pub struct NavigationRail {
  /// Currently selected item identifier
  #[declare(default, event = RailSelectEvent)]
  pub selected: Option<String>,
  
  /// Whether in expanded state
  #[declare(default)]
  pub expanded: bool,
  
  /// Internal navigation item list
  #[declare(skip)]
  items: Vec<String>,
}

impl NavigationRail {
  /// Get all navigation item keys
  pub fn keys(&self) -> &[String];
  
  /// Calculate next key (non-cyclic)
  /// 
  /// **Behavior**:
  /// - Valid selection and not last: return next
  /// - No selection or invalid: return first
  /// - Last item: return None
  /// - Empty list: return None
  pub fn next_key(&self) -> Option<&str>;
  
  /// Calculate previous key (non-cyclic)
  /// 
  /// **Behavior**:
  /// - Valid selection and not first: return previous
  /// - No selection or invalid: return last
  /// - First item: return None
  /// - Empty list: return None
  pub fn prev_key(&self) -> Option<&str>;
  
  /// Calculate next key (cyclic)
  pub fn next_key_cyclic(&self) -> Option<&str>;
  
  /// Calculate previous key (cyclic)
  pub fn prev_key_cyclic(&self) -> Option<&str>;
}

5.5 Templates and Auxiliary Types

#[derive(Template)]
pub struct RailMenu(pub Widget<'static>);

#[derive(Template)]
pub struct RailAction(pub Widget<'static>);

#[derive(Template)]
pub struct RailFooter(pub Widget<'static>);

#[derive(Template)]
pub struct RailItemChildren<'c> {
  pub icon: Widget<'c>,
  pub label: Option<TextValue>,
  pub badge: Option<Widget<'c>>,
}

#[derive(Template)]
pub enum RailChild {
  Menu(RailMenu),
  Action(RailAction),
  Item(RailItem),
  Section(RailSection),
  Footer(RailFooter),
}

6. Design Decisions

6.1 Why no built-in routing?

Following the composition first principle: the widget focuses on selection mechanism, routing is an application-level concern. Users can implement arbitrary logic in on_select (permissions, confirmations, analytics, etc.) and compose with any routing solution.

6.2 Why does the event include from and to?

  • Animation direction: from → to determines upward/downward slide
  • History tracking: know where the user came from
  • Event self-containment: no need to access external state

6.3 Why Option<String> instead of i32 index?

  • Stability: key is unaffected by list order changes
  • Semantic: selected: Some("settings") is more intuitive than selected: 2
  • Serialization-friendly: strings can be directly serialized
  • Backward compatible: indices can be converted to strings ("0", "1")

6.4 Why provide read-only query methods instead of direct mutation methods?

Directly providing select_next() would cause data-UI separation (violating Path A). Read-only query methods let the application layer update the data model, and Pipe automatically syncs the UI, conforming to Ribir's data flow specification.

6.5 Why use Provider for static configuration?

  • Dynamic state (changes with user interaction) → Widget properties
  • Static configuration (application-level settings, rarely changes) → Provider

Benefits: reduces widget property count, theme can configure uniformly, can be dynamically overridden at runtime.


7. Theme and Styling

7.1 Class-Based Styling

Defines comprehensive class_names! ensuring all visual details (spacing, colors, animations) can be overridden by the theme layer.

7.2 Structural Metadata

RailItem exposes internal structure information (whether it has Label, whether it has Badge) via RailItemMetadata Provider, allowing the theme system to implement pixel-perfect alignment and conditional styling.


Status: ✅ Finalized

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions