use core::fmt::{self, Debug};
use waterui::accessibility::{AccessibilityChildren, AccessibilityRole, AccessibilityState};
use waterui::color::Color;
use waterui::layout::padding::EdgeInsets;
use waterui::reactive::SignalExt as _;
use waterui::shape::{Capsule, ShapeExt as _};
use waterui::{Binding, Environment, Str, View, ViewExt as _};
use waterui_controls::label::{IntoLabel, Label};
use waterui_core::handler::{Handler, boxed_action};
use waterui_core::view::TupleViews;
use crate::color::{
OnSecondaryContainer, OnSurface, OnSurfaceVariant, SecondaryContainer, Surface,
};
use crate::semantics::{conditional_color, interaction_style};
use crate::theme::typography;
const COLLAPSED_CONTAINER_WIDTH: f32 = 96.0;
const COLLAPSED_NARROW_CONTAINER_WIDTH: f32 = 80.0;
const TOP_SPACE: f32 = 44.0;
const COLLAPSED_ITEM_VERTICAL_SPACE: f32 = 4.0;
const EXPANDED_CONTAINER_WIDTH_MINIMUM: f32 = 220.0;
const EXPANDED_CONTAINER_WIDTH_MAXIMUM: f32 = 360.0;
const ITEM_HEIGHT: f32 = 64.0;
const ITEM_VERTICAL_SPACE: f32 = 6.0;
const ITEM_ICON_SIZE: f32 = 24.0;
const ACTIVE_INDICATOR_HORIZONTAL_SPACE: f32 = 16.0;
const ACTIVE_INDICATOR_ICON_LABEL_SPACE: f32 = 8.0;
const COLLAPSED_ACTIVE_INDICATOR_WIDTH: f32 = 56.0;
const COLLAPSED_ACTIVE_INDICATOR_HEIGHT: f32 = 32.0;
const COLLAPSED_ICON_LABEL_SPACE: f32 = 4.0;
const COLLAPSED_ITEM_HEIGHT: f32 = 56.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum NavigationRailLayout {
#[default]
Collapsed,
CollapsedNarrow,
Expanded,
}
impl NavigationRailLayout {
#[must_use]
pub const fn is_expanded(self) -> bool {
matches!(self, Self::Expanded)
}
#[must_use]
pub const fn container_width(self) -> f32 {
match self {
Self::Collapsed => COLLAPSED_CONTAINER_WIDTH,
Self::CollapsedNarrow => COLLAPSED_NARROW_CONTAINER_WIDTH,
Self::Expanded => EXPANDED_CONTAINER_WIDTH_MINIMUM,
}
}
#[must_use]
pub const fn max_container_width(self) -> f32 {
match self {
Self::Collapsed => COLLAPSED_CONTAINER_WIDTH,
Self::CollapsedNarrow => COLLAPSED_NARROW_CONTAINER_WIDTH,
Self::Expanded => EXPANDED_CONTAINER_WIDTH_MAXIMUM,
}
}
#[must_use]
pub const fn item_spacing(self) -> f32 {
match self {
Self::Collapsed | Self::CollapsedNarrow => COLLAPSED_ITEM_VERTICAL_SPACE,
Self::Expanded => ITEM_VERTICAL_SPACE,
}
}
}
pub struct NavigationRail<Items> {
items: Items,
layout: NavigationRailLayout,
}
impl<Items> Debug for NavigationRail<Items> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NavigationRail")
.field("layout", &self.layout)
.finish_non_exhaustive()
}
}
impl<Items> NavigationRail<Items> {
#[must_use]
pub const fn new(items: Items) -> Self {
Self {
items,
layout: NavigationRailLayout::Collapsed,
}
}
#[must_use]
pub const fn layout(mut self, layout: NavigationRailLayout) -> Self {
self.layout = layout;
self
}
}
impl<Items> View for NavigationRail<Items>
where
Items: TupleViews + 'static,
{
fn body(self, _env: &Environment) -> impl View {
let layout = self.layout;
waterui::component::vstack(self.items)
.spacing(layout.item_spacing())
.padding_with(EdgeInsets::new(TOP_SPACE, 0.0, 0.0, 0.0))
.min_width(layout.container_width())
.max_width(layout.max_container_width())
.max_height(f32::MAX)
.background(Surface)
.a11y_label("Navigation")
.a11y_role(AccessibilityRole::TabList)
}
}
pub struct NavigationRailItem<Icon, Action = fn(&Environment)> {
label: Label,
accessibility_label: Str,
icon: Icon,
selected: Binding<bool>,
layout: NavigationRailLayout,
action: Action,
}
impl<Icon, Action> Debug for NavigationRailItem<Icon, Action> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NavigationRailItem")
.field("label", &self.label)
.field("layout", &self.layout)
.finish_non_exhaustive()
}
}
impl<Icon> NavigationRailItem<Icon> {
#[must_use]
pub fn new(label: impl IntoLabel, icon: Icon, selected: &Binding<bool>) -> Self {
let label = label.into_label();
let accessibility_label = crate::semantics::label_plain_text(&label);
Self {
label,
accessibility_label,
icon,
selected: selected.clone(),
layout: NavigationRailLayout::Collapsed,
action: noop,
}
}
}
impl<Icon, Action> NavigationRailItem<Icon, Action> {
#[must_use]
pub const fn layout(mut self, layout: NavigationRailLayout) -> Self {
self.layout = layout;
self
}
#[must_use]
pub fn action<F, Args>(self, action: F) -> NavigationRailItem<Icon, impl FnMut(&Environment)>
where
F: Handler<Args, ()> + 'static,
{
NavigationRailItem {
label: self.label,
accessibility_label: self.accessibility_label,
icon: self.icon,
selected: self.selected,
layout: self.layout,
action: boxed_action(action),
}
}
}
impl<Icon, Action> View for NavigationRailItem<Icon, Action>
where
Icon: Clone + View + 'static,
Action: FnMut(&Environment) + 'static,
{
fn body(self, _env: &Environment) -> impl View {
let mut action = self.action;
let accessibility_label = self.accessibility_label.clone();
let accessibility_state = self
.selected
.map(|selected| AccessibilityState::new().selected(selected));
let indicator_color = conditional_color(
self.selected.clone(),
SecondaryContainer,
Color::transparent(),
);
let icon_color = conditional_color(
self.selected.clone(),
OnSecondaryContainer,
OnSurfaceVariant,
);
let label_color = conditional_color(self.selected.clone(), OnSurface, OnSurfaceVariant);
let state_layer_color =
conditional_color(self.selected, OnSecondaryContainer, OnSurfaceVariant);
let icon = self
.icon
.foreground(icon_color)
.width(ITEM_ICON_SIZE)
.height(ITEM_ICON_SIZE);
let label = self
.label
.font(typography::label_medium())
.foreground(label_color);
let content = if self.layout.is_expanded() {
waterui::component::hstack((icon, label))
.spacing(ACTIVE_INDICATOR_ICON_LABEL_SPACE)
.padding_with(EdgeInsets::new(
0.0,
0.0,
ACTIVE_INDICATOR_HORIZONTAL_SPACE,
ACTIVE_INDICATOR_HORIZONTAL_SPACE,
))
.height(ITEM_HEIGHT)
.background(Capsule.fill(indicator_color))
.install(interaction_style(
state_layer_color,
f64::from(ITEM_HEIGHT / 2.0),
))
.anyview()
} else {
waterui::component::vstack((
waterui::component::zstack((
Capsule
.fill(indicator_color)
.width(COLLAPSED_ACTIVE_INDICATOR_WIDTH)
.height(COLLAPSED_ACTIVE_INDICATOR_HEIGHT),
icon,
))
.install(interaction_style(
state_layer_color,
f64::from(COLLAPSED_ACTIVE_INDICATOR_HEIGHT / 2.0),
)),
label,
))
.spacing(COLLAPSED_ICON_LABEL_SPACE)
.height(COLLAPSED_ITEM_HEIGHT)
.anyview()
};
content
.max_width(f32::INFINITY)
.on_tap(move |env: Environment| action(&env))
.a11y_label(accessibility_label)
.a11y_role(AccessibilityRole::Tab)
.a11y_state_signal(accessibility_state)
.a11y_children(AccessibilityChildren::ExcludeDescendants)
}
}
const fn noop(_env: &Environment) {}
#[must_use]
pub const fn navigation_rail<Items>(items: Items) -> NavigationRail<Items> {
NavigationRail::new(items)
}
#[must_use]
pub fn navigation_rail_item<Icon>(
label: impl IntoLabel,
icon: Icon,
selected: &Binding<bool>,
) -> NavigationRailItem<Icon> {
NavigationRailItem::new(label, icon, selected)
}
#[cfg(test)]
mod tests {
use super::{
ACTIVE_INDICATOR_HORIZONTAL_SPACE, ACTIVE_INDICATOR_ICON_LABEL_SPACE,
COLLAPSED_ACTIVE_INDICATOR_HEIGHT, COLLAPSED_ACTIVE_INDICATOR_WIDTH,
COLLAPSED_CONTAINER_WIDTH, COLLAPSED_ICON_LABEL_SPACE, COLLAPSED_ITEM_HEIGHT,
COLLAPSED_ITEM_VERTICAL_SPACE, COLLAPSED_NARROW_CONTAINER_WIDTH,
EXPANDED_CONTAINER_WIDTH_MAXIMUM, EXPANDED_CONTAINER_WIDTH_MINIMUM, ITEM_HEIGHT,
ITEM_ICON_SIZE, ITEM_VERTICAL_SPACE, NavigationRailLayout, TOP_SPACE,
};
#[test]
fn navigation_rail_tokens_match_compose_navigation_rail_tokens() {
assert_eq!(COLLAPSED_CONTAINER_WIDTH, 96.0);
assert_eq!(COLLAPSED_NARROW_CONTAINER_WIDTH, 80.0);
assert_eq!(TOP_SPACE, 44.0);
assert_eq!(COLLAPSED_ITEM_VERTICAL_SPACE, 4.0);
assert_eq!(EXPANDED_CONTAINER_WIDTH_MINIMUM, 220.0);
assert_eq!(EXPANDED_CONTAINER_WIDTH_MAXIMUM, 360.0);
assert_eq!(ITEM_HEIGHT, 64.0);
assert_eq!(ITEM_VERTICAL_SPACE, 6.0);
assert_eq!(ITEM_ICON_SIZE, 24.0);
assert_eq!(ACTIVE_INDICATOR_HORIZONTAL_SPACE, 16.0);
assert_eq!(ACTIVE_INDICATOR_ICON_LABEL_SPACE, 8.0);
}
#[test]
fn collapsed_item_matches_compose_vertical_item_tokens() {
assert_eq!(COLLAPSED_ACTIVE_INDICATOR_WIDTH, 56.0);
assert_eq!(COLLAPSED_ACTIVE_INDICATOR_HEIGHT, 32.0);
assert_eq!(COLLAPSED_ICON_LABEL_SPACE, 4.0);
assert_eq!(COLLAPSED_ITEM_HEIGHT, 56.0);
assert_eq!(
COLLAPSED_ACTIVE_INDICATOR_HEIGHT / 2.0,
16.0,
"collapsed indicator capsule radius"
);
}
#[test]
fn each_layout_reports_its_own_geometry() {
assert!(!NavigationRailLayout::Collapsed.is_expanded());
assert!(!NavigationRailLayout::CollapsedNarrow.is_expanded());
assert!(NavigationRailLayout::Expanded.is_expanded());
assert_eq!(
NavigationRailLayout::Collapsed.container_width(),
COLLAPSED_CONTAINER_WIDTH
);
assert_eq!(
NavigationRailLayout::CollapsedNarrow.container_width(),
COLLAPSED_NARROW_CONTAINER_WIDTH
);
assert_eq!(
NavigationRailLayout::Expanded.container_width(),
EXPANDED_CONTAINER_WIDTH_MINIMUM
);
assert!(
NavigationRailLayout::Expanded.container_width()
> NavigationRailLayout::Collapsed.container_width()
);
for layout in [
NavigationRailLayout::Collapsed,
NavigationRailLayout::CollapsedNarrow,
] {
assert_eq!(layout.container_width(), layout.max_container_width());
}
assert_eq!(
NavigationRailLayout::Expanded.max_container_width(),
EXPANDED_CONTAINER_WIDTH_MAXIMUM
);
}
}