snora-widgets 0.36.1

Optional prefab iced widgets for the Snora GUI framework — header, footer, sidebar, menu, icon.
Documentation
//! Chip primitives for Snora Design (RFC-032).
//!
//! Two variants:
//!
//! * [`filter`] — a toggle chip for filtering or categorizing. Solid accent
//!   background + `accent_text` foreground when selected; neutral border at
//!   rest.
//! * [`removable`] — a chip with a separate remove (×) button.
//!
//! Both are backed by `iced::widget::button` and are keyboard-reachable.
//! The application owns selection/filter state.
//!
//! # Contrast design (M-4 fix)
//!
//! Prior to v0.24, the selected chip used a semi-transparent accent tint
//! (alpha 0.15–0.30) as the background with the full accent color as text.
//! After compositing over the surface, hovered/pressed states failed
//! WCAG AA (4.5:1). Replaced with a solid `accent` background +
//! `accent_text` foreground, which yields ≥6.7:1 across all four presets.
//! The contrast test `chip_selected_text_over_accent_background` verifies
//! this.
//!
//! # iced 0.14 focus limitation
//!
//! No custom focus ring — `button::Status` has no `Focused` variant.
//! Documented limitation, not a regression (RFC-025, RFC-027).
//!
//! # iced 0.14 accessible label limitation
//!
//! The dismiss/remove button uses the "×" glyph as its visible label. iced
//! 0.14 does not expose a separate accessible label for buttons. If the
//! application requires a more descriptive label for assistive technology,
//! pass a string such as `"Dismiss"` or `"Remove <tag>"` as a `text`
//! element instead of `"×"` — this is a future customization point.
//!
//! # Usage
//!
//! ```rust,no_run
//! use snora_design::Tokens;
//! use snora_widgets::design::chip;
//!
//! #[derive(Clone)]
//! enum Message { ToggleDrafts, ToggleTag, RemoveTag }
//!
//! let tokens = Tokens::light();
//! let show_drafts = true; // stands in for e.g. `self.show_drafts`
//!
//! let _draft_chip: iced::Element<'_, Message> =
//!     chip::filter(&tokens, "Draft", show_drafts, Message::ToggleDrafts);
//! let _tag_chip: iced::Element<'_, Message> = chip::removable(
//!     &tokens,
//!     "Tag: Rust",
//!     true,
//!     Message::ToggleTag,
//!     Message::RemoveTag,
//! );
//! ```

use iced::{
    Border, Color, Element,
    widget::{button, container, row, text},
};
use snora_design::Tokens;

use snora_style as style;

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

/// Blends a color toward black by `amount`. Used for hover/press states.
fn darken(color: Color, amount: f32) -> Color {
    Color {
        r: (color.r - amount).max(0.0),
        g: (color.g - amount).max(0.0),
        b: (color.b - amount).max(0.0),
        a: color.a,
    }
}

/// The `remove` button's square pointer-target size (RFC-061): computed
/// the same way its height already resolves — `line_box + 2 × spacing.xs`
/// — so the width fix tracks any future token change automatically
/// rather than hard-coding a second "24".
fn remove_btn_target_size(tokens: &Tokens) -> f32 {
    tokens.typography.label.size * tokens.typography.label.line_height + 2.0 * tokens.spacing.xs
}

/// Selected chip style: solid accent background + accent_text foreground.
///
/// This replaces the previous semi-transparent tint approach, which failed
/// WCAG AA (4.5:1) at hovered (α=0.22) and pressed (α=0.30) states after
/// compositing over the surface. Solid background + paired foreground role
/// yields ≥6.7:1 across all four built-in presets.
fn chip_style_selected(tokens: &Tokens, status: button::Status) -> button::Style {
    let accent = style::color::to_iced_color(tokens.palette.accent);
    let accent_text = style::color::to_iced_color(tokens.palette.accent_text);
    let bg = match status {
        button::Status::Active => accent,
        button::Status::Hovered => darken(accent, 0.06),
        button::Status::Pressed => darken(accent, 0.12),
        button::Status::Disabled => Color { a: 0.5, ..accent },
    };
    button::Style {
        background: Some(bg.into()),
        text_color: accent_text,
        border: Border::default()
            .rounded(tokens.radius.pill)
            .color(accent)
            .width(1.0),
        shadow: iced::Shadow::default(),
        snap: true,
    }
}

fn chip_style_unselected(tokens: &Tokens, status: button::Status) -> button::Style {
    let border_col = style::color::to_iced_color(tokens.palette.border);
    let text_col = style::color::to_iced_color(tokens.palette.text_secondary);
    let surface = style::color::to_iced_color(tokens.palette.surface);
    let bg = match status {
        button::Status::Active => surface,
        button::Status::Hovered => darken(surface, 0.04),
        button::Status::Pressed => darken(surface, 0.08),
        button::Status::Disabled => Color { a: 0.5, ..surface },
    };
    button::Style {
        background: Some(bg.into()),
        text_color: text_col,
        border: Border::default()
            .rounded(tokens.radius.pill)
            .color(border_col)
            .width(1.0),
        shadow: iced::Shadow::default(),
        snap: true,
    }
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// A toggle chip for filtering or categorizing content.
///
/// Shows a solid accent background and `accent_text` foreground when
/// `selected` (WCAG AA ≥6.7:1 across all built-in presets). Emits
/// `on_toggle` when pressed. Pass `None` to disable.
#[must_use]
pub fn filter<'a, Message: Clone + 'a>(
    tokens: &Tokens,
    label: impl Into<String>,
    selected: bool,
    on_toggle: impl Into<Option<Message>>,
) -> Element<'a, Message> {
    let t = tokens.clone();
    let style_fn = if selected {
        chip_style_selected
    } else {
        chip_style_unselected
    };
    button(text(label.into()).size(style::text::label_size(tokens)))
        .on_press_maybe(on_toggle.into())
        .padding([tokens.spacing.xs, tokens.spacing.sm])
        .style(move |_theme, status| style_fn(&t, status))
        .into()
}

/// A chip with a separate remove (×) button.
///
/// The chip label toggles via `on_toggle`; the × button emits `on_remove`.
/// Both controls are `iced::widget::button` and are keyboard-reachable.
#[must_use]
pub fn removable<'a, Message: Clone + 'a>(
    tokens: &Tokens,
    label: impl Into<String>,
    selected: bool,
    on_toggle: impl Into<Option<Message>>,
    on_remove: impl Into<Option<Message>>,
) -> Element<'a, Message> {
    let t_label = tokens.clone();
    let t_remove = tokens.clone();
    let style_fn = if selected {
        chip_style_selected
    } else {
        chip_style_unselected
    };

    let label_btn: Element<'a, Message> =
        button(text(label.into()).size(style::text::label_size(tokens)))
            .on_press_maybe(on_toggle.into())
            .padding([tokens.spacing.xs, tokens.spacing.sm])
            .style(move |_theme, status| style_fn(&t_label, status))
            .into();

    // Pointer-target size (RFC-061): the "×" glyph's own advance width is
    // not token-derivable (font/shaping-dependent — measured at 15.0px
    // total for the shipped fallback font at the current tokens, well
    // under the 24px WCAG 2.5.8 floor). Padding alone cannot fix this
    // reliably: even bumping to `spacing.sm` only reaches 23.0px on that
    // same font, still short. Instead, the *content* box inside the
    // button is forced to a computed width and its text centered within
    // it — `iced::widget::button`'s own layout does not re-center a
    // child when the button is simply widened (see `layout::padded`),
    // so the centering has to happen one level in.
    let target_size = remove_btn_target_size(tokens);
    let content_width = target_size - 2.0 * tokens.spacing.xs;
    let remove_btn: Element<'a, Message> =
        button(container(text("×").size(style::text::label_size(tokens))).center_x(content_width))
            .on_press_maybe(on_remove.into())
            .padding([tokens.spacing.xs, tokens.spacing.xs])
            .style(move |_theme, status| style_fn(&t_remove, status))
            .into();

    row![label_btn, remove_btn].spacing(0).into()
}

#[cfg(test)]
mod tests;