#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SearchMethod {
#[default]
Get,
Post,
}
#[derive(Debug, Clone)]
pub struct ActiveSearchConfig<'a> {
pub action: &'a str,
pub method: SearchMethod,
pub target: &'a str,
pub indicator: Option<&'a str>,
pub debounce_ms: u32,
pub min_length: u32,
pub param_name: &'a str,
pub initial_load: bool,
pub placeholder: Option<&'a str>,
}
impl<'a> ActiveSearchConfig<'a> {
#[must_use]
pub const fn new(action: &'a str, target: &'a str) -> Self {
Self {
action,
method: SearchMethod::Get,
target,
indicator: None,
debounce_ms: 300,
min_length: 1,
param_name: "q",
initial_load: false,
placeholder: None,
}
}
#[must_use]
pub const fn post(mut self) -> Self {
self.method = SearchMethod::Post;
self
}
#[must_use]
pub const fn debounce(mut self, ms: u32) -> Self {
self.debounce_ms = ms;
self
}
#[must_use]
pub const fn min_length(mut self, n: u32) -> Self {
self.min_length = n;
self
}
#[must_use]
pub const fn indicator(mut self, selector: &'a str) -> Self {
self.indicator = Some(selector);
self
}
#[must_use]
pub const fn initial_load(mut self) -> Self {
self.initial_load = true;
self
}
#[must_use]
pub const fn placeholder(mut self, text: &'a str) -> Self {
self.placeholder = Some(text);
self
}
#[must_use]
pub const fn param_name(mut self, name: &'a str) -> Self {
self.param_name = name;
self
}
}
#[derive(Debug, Clone)]
pub struct AutocompleteConfig<'a> {
pub action: &'a str,
pub indicator: Option<&'a str>,
pub debounce_ms: u32,
pub min_length: u32,
pub query_param: &'a str,
pub value_name: &'a str,
pub placeholder: Option<&'a str>,
pub fallback_options: Option<&'a [(&'a str, &'a str)]>,
pub free_text: bool,
pub initial_value: Option<&'a str>,
}
impl<'a> AutocompleteConfig<'a> {
#[must_use]
pub const fn new(action: &'a str, value_name: &'a str) -> Self {
Self {
action,
indicator: None,
debounce_ms: 300,
min_length: 1,
query_param: "q",
value_name,
placeholder: None,
fallback_options: None,
free_text: false,
initial_value: None,
}
}
#[must_use]
pub const fn debounce(mut self, ms: u32) -> Self {
self.debounce_ms = ms;
self
}
#[must_use]
pub const fn min_length(mut self, n: u32) -> Self {
self.min_length = n;
self
}
#[must_use]
pub const fn indicator(mut self, selector: &'a str) -> Self {
self.indicator = Some(selector);
self
}
#[must_use]
pub const fn query_param(mut self, name: &'a str) -> Self {
self.query_param = name;
self
}
#[must_use]
pub const fn placeholder(mut self, text: &'a str) -> Self {
self.placeholder = Some(text);
self
}
#[must_use]
pub const fn fallback_options(mut self, options: &'a [(&'a str, &'a str)]) -> Self {
self.fallback_options = Some(options);
self
}
#[must_use]
pub const fn free_text(mut self) -> Self {
self.free_text = true;
self
}
#[must_use]
pub const fn initial_value(mut self, value: &'a str) -> Self {
self.initial_value = Some(value);
self
}
}
fn build_trigger(debounce_ms: u32, initial_load: bool) -> String {
let mut trigger = format!("input changed delay:{debounce_ms}ms");
if initial_load {
trigger.push_str(", load");
}
trigger
}
fn selector_to_id(selector: &str) -> &str {
selector.strip_prefix('#').unwrap_or(selector)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search_input(id: &str, label: &str, config: &ActiveSearchConfig<'_>) -> maud::Markup {
let trigger = build_trigger(config.debounce_ms, config.initial_load);
let aria_controls = selector_to_id(config.target);
let (hx_get, hx_post) = match config.method {
SearchMethod::Get => (Some(config.action), None::<&str>),
SearchMethod::Post => (None, Some(config.action)),
};
maud::html! {
div class="autumn-search" {
label for=(id) class="autumn-search__label" { (label) }
input
type="search"
id=(id)
name=(config.param_name)
autocomplete="off"
aria-controls=(aria_controls)
placeholder=[config.placeholder]
class="autumn-search__input"
data-ac-min-length=(config.min_length)
hx-get=[hx_get]
hx-post=[hx_post]
hx-trigger=(trigger)
hx-target=(config.target)
hx-indicator=[config.indicator];
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search_results(id: &str) -> maud::Markup {
maud::html! {
div
id=(id)
role="status"
aria-live="polite"
aria-atomic="true" {}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search(id: &str, label: &str, config: &ActiveSearchConfig<'_>) -> maud::Markup {
debug_assert!(
config.target.starts_with('#'),
"active_search: config.target must be a #id selector (e.g. \"#my-results\"), got {:?}. \
Use active_search_input + active_search_results directly for other selectors.",
config.target
);
let results_id = selector_to_id(config.target).to_string();
let noscript_method = match config.method {
SearchMethod::Get => "get",
SearchMethod::Post => "post",
};
maud::html! {
div id=(format!("{id}-wrapper")) {
(active_search_input(id, label, config))
(active_search_results(&results_id))
noscript {
form action=(config.action) method=(noscript_method) {
label for=(format!("{id}-noscript")) { (label) }
input
type="search"
id=(format!("{id}-noscript"))
name=(config.param_name)
placeholder=[config.placeholder];
button type="submit" { "Search" }
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn active_search_empty_state(message: &str) -> maud::Markup {
maud::html! {
div
role="status"
aria-live="polite"
class="search-empty" {
(message)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn autocomplete_input(id: &str, label: &str, config: &AutocompleteConfig<'_>) -> maud::Markup {
let query_id = format!("{id}-query");
let value_id = format!("{id}-value");
let options_id = format!("{id}-options");
let trigger = build_trigger(config.debounce_ms, false);
let target = format!("#{options_id}");
maud::html! {
div
id=(format!("{id}-wrapper"))
class="autumn-autocomplete"
data-ac-value-id=(value_id)
data-ac-value-name=(config.value_name)
data-ac-free-text[config.free_text] {
label for=(query_id) class="autumn-autocomplete__label" { (label) }
input
type="search"
id=(query_id)
name=(config.query_param)
autocomplete="off"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls=(options_id)
value=(config.initial_value.unwrap_or(""))
placeholder=[config.placeholder]
class="autumn-autocomplete__input"
data-ac-query
data-ac-min-length=(config.min_length)
hx-get=(config.action)
hx-trigger=(trigger)
hx-target=(target)
hx-indicator=[config.indicator];
input
type="hidden"
id=(value_id)
value=(config.initial_value.unwrap_or(""));
div
id=(options_id)
role="listbox"
aria-label=(label)
aria-live="polite"
class="autumn-autocomplete__options" {}
noscript {
select name=(config.value_name) aria-label=(label) {
option value="" { "— select —" }
@if let Some(opts) = config.fallback_options {
@for (val, lbl) in opts {
option value=(val) selected[config.initial_value == Some(*val)] { (lbl) }
}
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn autocomplete_option(value: &str, label: &str) -> maud::Markup {
maud::html! {
div
role="option"
tabindex="0"
class="autumn-autocomplete__option"
data-value=(value) {
(label)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn autocomplete_empty_state(message: &str) -> maud::Markup {
maud::html! {
div
role="status"
aria-live="polite"
class="autocomplete-empty" {
(message)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn property_list(rows: &[(&str, maud::Markup)]) -> maud::Markup {
maud::html! {
dl class="autumn-property-list" {
@for (label, value) in rows {
dt { (label) }
dd { (value) }
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn transition_controls(
action: &str,
field: &str,
current: &str,
transitions: &[(&str, &str, Option<&str>)],
can: impl Fn(&str) -> bool,
csrf: Option<&crate::security::CsrfToken>,
csrf_field: Option<&crate::security::CsrfFormField>,
) -> maud::Markup {
let csrf_field_name = csrf_field.map_or("_csrf", |f| f.0.as_str());
maud::html! {
div class="autumn-transition-controls" role="group" aria-label=(format!("{field} transitions")) {
@for (from, to, _guard) in transitions {
@if *from == current {
form method="post" action=(action) class="autumn-transition" {
@if let Some(tok) = csrf {
input type="hidden" name=(csrf_field_name) value=(tok.token());
}
input type="hidden" name=(field) value=(to);
button type="submit" disabled[!can(to)] { (format!("Mark as {to}")) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
pub use crate::pagination::SortDir;
#[cfg(feature = "maud")]
pub struct Column<'a, T> {
pub header: &'a str,
pub sort_key: Option<&'a str>,
pub cell: Box<dyn Fn(&T) -> maud::Markup + Send + 'a>,
}
#[cfg(feature = "maud")]
impl<'a, T> Column<'a, T> {
#[must_use]
pub fn new(header: &'a str, cell: impl Fn(&T) -> maud::Markup + Send + 'a) -> Self {
Self {
header,
sort_key: None,
cell: Box::new(cell),
}
}
#[must_use]
pub const fn sortable(mut self, sort_key: &'a str) -> Self {
self.sort_key = Some(sort_key);
self
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct DataTableConfig<'a> {
pub caption: Option<&'a str>,
pub empty_message: &'a str,
pub base_path: &'a str,
pub query: &'a str,
pub sort_param: &'a str,
pub dir_param: &'a str,
pub page_param: &'a str,
pub active_sort: Option<&'a str>,
pub active_dir: SortDir,
pub class: Option<&'a str>,
}
#[cfg(feature = "maud")]
impl<'a> DataTableConfig<'a> {
#[must_use]
pub const fn new(empty_message: &'a str) -> Self {
Self {
caption: None,
empty_message,
base_path: "",
query: "",
sort_param: "sort",
dir_param: "dir",
page_param: "page",
active_sort: None,
active_dir: SortDir::Asc,
class: None,
}
}
#[must_use]
pub const fn caption(mut self, caption: &'a str) -> Self {
self.caption = Some(caption);
self
}
#[must_use]
pub const fn base_path(mut self, base_path: &'a str) -> Self {
self.base_path = base_path;
self
}
#[must_use]
pub const fn query(mut self, query: &'a str) -> Self {
self.query = query;
self
}
#[must_use]
pub const fn active_sort(mut self, key: &'a str) -> Self {
self.active_sort = Some(key);
self
}
#[must_use]
pub const fn active_dir(mut self, dir: SortDir) -> Self {
self.active_dir = dir;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[cfg(feature = "maud")]
fn dt_filter_query(query: &str, drop_keys: &[&str]) -> String {
let query = query.strip_prefix('?').unwrap_or(query);
query
.split('&')
.filter(|pair| {
if pair.is_empty() {
return false;
}
let key = pair.split('=').next().unwrap_or(pair);
!drop_keys.contains(&key)
})
.collect::<Vec<&str>>()
.join("&")
}
#[cfg(feature = "maud")]
#[must_use]
pub fn data_table<T>(
rows: &[T],
columns: &[Column<'_, T>],
config: &DataTableConfig<'_>,
) -> maud::Markup {
let col_count = columns.len();
let filtered = dt_filter_query(
config.query,
&[config.sort_param, config.dir_param, config.page_param],
);
maud::html! {
table class=[config.class] {
@if let Some(caption) = config.caption {
caption { (caption) }
}
thead {
tr {
@for col in columns {
@if let Some(sort_key) = col.sort_key {
@let is_active = config.active_sort == Some(sort_key);
@let link_dir = if is_active {
config.active_dir.toggled()
} else {
SortDir::Asc
};
@let aria_sort = if is_active {
config.active_dir.aria_value()
} else {
"none"
};
@let href = if filtered.is_empty() {
format!(
"{}?{}={}&{}={}",
config.base_path,
config.sort_param,
sort_key,
config.dir_param,
link_dir.param_value(),
)
} else {
format!(
"{}?{}&{}={}&{}={}",
config.base_path,
filtered,
config.sort_param,
sort_key,
config.dir_param,
link_dir.param_value(),
)
};
th scope="col" aria-sort=(aria_sort) {
a href=(href) { (col.header) }
}
} @else {
th scope="col" { (col.header) }
}
}
}
}
tbody {
@if rows.is_empty() {
tr {
td colspan=(col_count) {
span role="status" { (config.empty_message) }
}
}
} @else {
@for row in rows {
tr {
@for col in columns {
td { ((col.cell)(row)) }
}
}
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Crumb<'a> {
pub label: &'a str,
pub href: Option<&'a str>,
}
#[cfg(feature = "maud")]
impl<'a> Crumb<'a> {
#[must_use]
pub const fn link(label: &'a str, href: &'a str) -> Self {
Self {
label,
href: Some(href),
}
}
#[must_use]
pub const fn current(label: &'a str) -> Self {
Self { label, href: None }
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn breadcrumb(crumbs: &[Crumb<'_>]) -> maud::Markup {
if crumbs.is_empty() {
return maud::html! {};
}
let last = crumbs.len() - 1;
maud::html! {
nav aria-label="Breadcrumb" class="autumn-breadcrumb" {
ol class="autumn-breadcrumb__list" {
@for (i, crumb) in crumbs.iter().enumerate() {
li class="autumn-breadcrumb__item" {
@if i > 0 {
span aria-hidden="true" class="autumn-breadcrumb__separator" { "›" }
}
@if i == last {
span aria-current="page" class="autumn-breadcrumb__current" { (crumb.label) }
} @else if let Some(href) = crumb.href {
a href=(href) class="autumn-breadcrumb__link" { (crumb.label) }
} @else {
span class="autumn-breadcrumb__text" { (crumb.label) }
}
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NavLinkMatch {
#[default]
Exact,
Prefix,
}
#[cfg(feature = "maud")]
#[must_use]
pub fn nav_link(current_path: &str, href: &str, label: &str) -> maud::Markup {
nav_link_matched(current_path, href, label, NavLinkMatch::Exact)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn nav_link_matched(
current_path: &str,
href: &str,
label: &str,
mode: NavLinkMatch,
) -> maud::Markup {
let active = nav_link_is_active(current_path, href, mode);
maud::html! {
a href=(href) class=[active.then_some("active")] aria-current=[active.then_some("page")] {
(label)
}
}
}
#[cfg(feature = "maud")]
fn nav_link_is_active(current_path: &str, href: &str, mode: NavLinkMatch) -> bool {
match mode {
NavLinkMatch::Exact => current_path == href,
NavLinkMatch::Prefix => {
if current_path == href {
return true;
}
let href = if href.len() > 1 {
href.trim_end_matches('/')
} else {
href
};
!href.is_empty() && crate::router::path_matches_route_prefix(current_path, href)
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NavBarLayout {
#[default]
Horizontal,
Sidebar,
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct NavLinkItem {
href: String,
label: String,
match_mode: Option<NavLinkMatch>,
}
#[cfg(feature = "maud")]
impl NavLinkItem {
fn new(
href: impl Into<String>,
label: impl Into<String>,
match_mode: Option<NavLinkMatch>,
) -> Self {
Self {
href: href.into(),
label: label.into(),
match_mode,
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct NavMenu {
label: String,
links: Vec<NavLinkItem>,
}
#[cfg(feature = "maud")]
impl NavMenu {
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
links: Vec::new(),
}
}
#[must_use]
pub fn link(self, href: impl Into<String>, label: impl Into<String>) -> Self {
self.link_matched(href, label, NavLinkMatch::Exact)
}
#[must_use]
pub fn link_matched(
mut self,
href: impl Into<String>,
label: impl Into<String>,
mode: NavLinkMatch,
) -> Self {
self.links.push(NavLinkItem::new(href, label, Some(mode)));
self
}
#[must_use]
pub fn plain_link(mut self, href: impl Into<String>, label: impl Into<String>) -> Self {
self.links.push(NavLinkItem::new(href, label, None));
self
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub enum NavItem {
Link(NavLinkItem),
Menu(NavMenu),
Section(String),
}
#[cfg(feature = "maud")]
impl NavItem {
#[must_use]
pub fn link(href: impl Into<String>, label: impl Into<String>) -> Self {
Self::link_matched(href, label, NavLinkMatch::Exact)
}
#[must_use]
pub fn link_matched(
href: impl Into<String>,
label: impl Into<String>,
mode: NavLinkMatch,
) -> Self {
Self::Link(NavLinkItem::new(href, label, Some(mode)))
}
#[must_use]
pub fn plain_link(href: impl Into<String>, label: impl Into<String>) -> Self {
Self::Link(NavLinkItem::new(href, label, None))
}
#[must_use]
pub const fn menu(menu: NavMenu) -> Self {
Self::Menu(menu)
}
#[must_use]
pub fn section(label: impl Into<String>) -> Self {
Self::Section(label.into())
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct NavBarConfig {
id: String,
aria_label: String,
brand: Option<(maud::Markup, Option<String>)>,
items: Vec<NavItem>,
trailing: Vec<NavItem>,
layout: NavBarLayout,
toggle_label: String,
class: Option<String>,
}
#[cfg(feature = "maud")]
impl Default for NavBarConfig {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "maud")]
impl NavBarConfig {
#[must_use]
pub fn new() -> Self {
Self {
id: "autumn-nav".to_string(),
aria_label: "Main".to_string(),
brand: None,
items: Vec::new(),
trailing: Vec::new(),
layout: NavBarLayout::Horizontal,
toggle_label: "Menu".to_string(),
class: None,
}
}
#[must_use]
pub fn brand(self, label: &str, href: &str) -> Self {
self.brand_html(maud::html! { (label) }, Some(href))
}
#[must_use]
pub fn brand_html(mut self, brand: maud::Markup, href: Option<&str>) -> Self {
self.brand = Some((brand, href.map(str::to_string)));
self
}
#[must_use]
pub fn item(mut self, item: NavItem) -> Self {
self.items.push(item);
self
}
#[must_use]
pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
self.items.extend(items);
self
}
#[must_use]
pub fn trailing(mut self, item: NavItem) -> Self {
self.trailing.push(item);
self
}
#[must_use]
pub fn aria_label(mut self, label: &str) -> Self {
self.aria_label = label.to_string();
self
}
#[must_use]
pub const fn layout(mut self, layout: NavBarLayout) -> Self {
self.layout = layout;
self
}
#[must_use]
pub fn id(mut self, id: &str) -> Self {
self.id = id.to_string();
self
}
#[must_use]
pub fn toggle_label(mut self, label: &str) -> Self {
self.toggle_label = label.to_string();
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn nav_bar(current_path: &str, config: &NavBarConfig) -> maud::Markup {
let root_class = merge_class(
match config.layout {
NavBarLayout::Horizontal => "autumn-nav",
NavBarLayout::Sidebar => "autumn-nav autumn-nav--sidebar",
},
config.class.as_deref(),
);
let collapse_id = format!("{}-collapse", config.id);
let mut menu_counter = 0usize;
maud::html! {
nav id=(config.id) class=(root_class) aria-label=(config.aria_label) data-autumn-nav {
@if let Some((brand, href)) = &config.brand {
@if let Some(href) = href {
a class="autumn-nav__brand" href=(href) { (brand) }
} @else {
span class="autumn-nav__brand" { (brand) }
}
}
button type="button" class="autumn-nav__toggle" aria-expanded="false"
aria-controls=(collapse_id) hidden data-nav-toggle {
(config.toggle_label)
}
div id=(collapse_id) class="autumn-nav__collapse" {
@if !config.items.is_empty() {
ul class="autumn-nav__items" {
(render_nav_items(current_path, &config.items, &config.id, &mut menu_counter))
}
}
@if !config.trailing.is_empty() {
ul class="autumn-nav__items autumn-nav__items--trailing" {
(render_nav_items(current_path, &config.trailing, &config.id, &mut menu_counter))
}
}
}
}
}
}
#[cfg(feature = "maud")]
fn render_nav_items(
current_path: &str,
items: &[NavItem],
id: &str,
menu_counter: &mut usize,
) -> maud::Markup {
maud::html! {
@for item in items {
@match item {
NavItem::Link(link) => {
li class="autumn-nav__item" { (render_nav_link_item(current_path, link)) }
}
NavItem::Section(label) => {
li class="autumn-nav__section" { (label) }
}
NavItem::Menu(menu) => {
(render_nav_menu(current_path, menu, id, menu_counter))
}
}
}
}
}
#[cfg(feature = "maud")]
fn render_nav_link_item(current_path: &str, link: &NavLinkItem) -> maud::Markup {
if let Some(mode) = link.match_mode {
return nav_link_matched(current_path, &link.href, &link.label, mode);
}
maud::html! { a href=(link.href) { (link.label) } }
}
#[cfg(feature = "maud")]
fn render_nav_menu(
current_path: &str,
menu: &NavMenu,
id: &str,
menu_counter: &mut usize,
) -> maud::Markup {
*menu_counter += 1;
let menu_id = format!("{id}-menu-{menu_counter}");
maud::html! {
li class="autumn-nav__item autumn-nav__item--menu" {
button type="button" class="autumn-nav__menu-toggle" aria-expanded="true"
aria-controls=(menu_id) data-nav-menu-toggle {
(menu.label)
}
ul id=(menu_id) class="autumn-nav__menu" {
@for link in &menu.links {
li class="autumn-nav__item" { (render_nav_link_item(current_path, link)) }
}
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HeadingLevel {
H1,
#[default]
H2,
H3,
H4,
H5,
H6,
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default)]
pub struct CardConfig<'a> {
title: Option<maud::Markup>,
level: HeadingLevel,
header_action: Option<maud::Markup>,
footer: Option<maud::Markup>,
class: Option<&'a str>,
}
#[cfg(feature = "maud")]
impl<'a> CardConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
title: None,
level: HeadingLevel::H2,
header_action: None,
footer: None,
class: None,
}
}
#[must_use]
pub fn title(mut self, title: &str) -> Self {
self.title = Some(maud::html! { (title) });
self
}
#[must_use]
pub fn title_html(mut self, title: maud::Markup) -> Self {
self.title = Some(title);
self
}
#[must_use]
pub const fn level(mut self, level: HeadingLevel) -> Self {
self.level = level;
self
}
#[must_use]
pub fn header_action(mut self, action: maud::Markup) -> Self {
self.header_action = Some(action);
self
}
#[must_use]
pub fn footer(mut self, footer: maud::Markup) -> Self {
self.footer = Some(footer);
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[cfg(feature = "maud")]
fn merge_class(base: &str, extra: Option<&str>) -> String {
match extra {
Some(e) if !e.is_empty() => format!("{base} {e}"),
_ => base.to_string(),
}
}
#[cfg(feature = "maud")]
fn heading(
level: HeadingLevel,
id: Option<&str>,
class: &str,
content: &maud::Markup,
) -> maud::Markup {
maud::html! {
@match level {
HeadingLevel::H1 => h1 id=[id] class=(class) { (content) },
HeadingLevel::H2 => h2 id=[id] class=(class) { (content) },
HeadingLevel::H3 => h3 id=[id] class=(class) { (content) },
HeadingLevel::H4 => h4 id=[id] class=(class) { (content) },
HeadingLevel::H5 => h5 id=[id] class=(class) { (content) },
HeadingLevel::H6 => h6 id=[id] class=(class) { (content) },
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn card(body: &maud::Markup, config: &CardConfig<'_>) -> maud::Markup {
let root_class = merge_class("card", config.class);
let has_header = config.title.is_some() || config.header_action.is_some();
maud::html! {
div class=(root_class) {
@if has_header {
div class="card-header" {
@if let Some(title) = &config.title {
(heading(config.level, None, "card-title", title))
}
@if let Some(action) = &config.header_action {
(action)
}
}
}
div class="card-body" { (body) }
@if let Some(footer) = &config.footer {
div class="card-footer" { (footer) }
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn stat_card(label: &str, value: &str, link: Option<(&str, &str)>) -> maud::Markup {
maud::html! {
div class="stat-card" {
div class="stat-label" { (label) }
div class="stat-value" { (value) }
@if let Some((href, text)) = link {
div class="stat-link" {
a href=(href) { (text) }
}
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CtaStyle {
#[default]
Primary,
Secondary,
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cta {
pub label: String,
pub href: String,
pub style: CtaStyle,
}
#[cfg(feature = "maud")]
impl Cta {
#[must_use]
pub fn primary(label: impl Into<String>, href: impl Into<String>) -> Self {
Self {
label: label.into(),
href: href.into(),
style: CtaStyle::Primary,
}
}
#[must_use]
pub fn secondary(label: impl Into<String>, href: impl Into<String>) -> Self {
Self {
label: label.into(),
href: href.into(),
style: CtaStyle::Secondary,
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct HeroConfig {
title: maud::Markup,
level: HeadingLevel,
subtitle: Option<maud::Markup>,
ctas: Vec<Cta>,
class: Option<String>,
}
#[cfg(feature = "maud")]
impl HeroConfig {
#[must_use]
pub fn new(title: &str) -> Self {
Self {
title: maud::html! { (title) },
level: HeadingLevel::H1,
subtitle: None,
ctas: Vec::new(),
class: None,
}
}
#[must_use]
pub fn title_html(mut self, title: maud::Markup) -> Self {
self.title = title;
self
}
#[must_use]
pub const fn level(mut self, level: HeadingLevel) -> Self {
self.level = level;
self
}
#[must_use]
pub fn subtitle(mut self, subtitle: &str) -> Self {
self.subtitle = Some(maud::html! { (subtitle) });
self
}
#[must_use]
pub fn subtitle_html(mut self, subtitle: maud::Markup) -> Self {
self.subtitle = Some(subtitle);
self
}
#[must_use]
pub fn cta(mut self, cta: Cta) -> Self {
self.ctas.push(cta);
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn hero(config: &HeroConfig) -> maud::Markup {
let root_class = merge_class("autumn-hero", config.class.as_deref());
maud::html! {
section class=(root_class) {
(heading(config.level, None, "autumn-hero__title", &config.title))
@if let Some(subtitle) = &config.subtitle {
p class="autumn-hero__subtitle" { (subtitle) }
}
@if !config.ctas.is_empty() {
div class="autumn-hero__actions" {
@for cta in &config.ctas {
@match cta.style {
CtaStyle::Primary => {
a href=(cta.href) class="autumn-hero__cta autumn-hero__cta--primary" { (cta.label) }
}
CtaStyle::Secondary => {
a href=(cta.href) class="autumn-hero__cta autumn-hero__cta--secondary" { (cta.label) }
}
}
}
}
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default)]
pub struct ModalConfig {
footer: Option<maud::Markup>,
level: HeadingLevel,
class: Option<String>,
light_dismiss: bool,
}
#[cfg(feature = "maud")]
impl ModalConfig {
#[must_use]
pub const fn new() -> Self {
Self {
footer: None,
level: HeadingLevel::H2,
class: None,
light_dismiss: false,
}
}
#[must_use]
pub fn footer(mut self, footer: maud::Markup) -> Self {
self.footer = Some(footer);
self
}
#[must_use]
pub const fn level(mut self, level: HeadingLevel) -> Self {
self.level = level;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub const fn light_dismiss(mut self, light_dismiss: bool) -> Self {
self.light_dismiss = light_dismiss;
self
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn modal(id: &str, title: &str, body: &maud::Markup, config: &ModalConfig) -> maud::Markup {
let root_class = merge_class("autumn-modal", config.class.as_deref());
let title_id = format!("{id}-title");
let closedby = config.light_dismiss.then_some("any");
maud::html! {
dialog id=(id) class=(root_class) role="dialog" aria-modal="true"
aria-labelledby=(title_id) closedby=[closedby] {
div class="autumn-modal__content" {
(heading(config.level, Some(title_id.as_str()), "autumn-modal__title", &maud::html! { (title) }))
div class="autumn-modal__body" { (body) }
@if let Some(footer) = &config.footer {
div class="autumn-modal__footer" { (footer) }
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn modal_trigger(label: &str, dialog_id: &str, class: Option<&str>) -> maud::Markup {
maud::html! {
button type="button" class=[class]
command="show-modal" commandfor=(dialog_id) data-modal-open=(dialog_id) {
(label)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn modal_close_button(label: &str, dialog_id: &str, class: Option<&str>) -> maud::Markup {
maud::html! {
button type="button" class=[class]
command="close" commandfor=(dialog_id) data-modal-close=(dialog_id) {
(label)
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct ConfirmActionConfig<'a> {
title: &'a str,
message: Option<maud::Markup>,
confirm_label: Option<&'a str>,
cancel_label: &'a str,
trigger_class: Option<&'a str>,
confirm_class: Option<&'a str>,
danger: bool,
csrf_field: Option<&'a str>,
attrs: &'a [(&'a str, &'a str)],
level: HeadingLevel,
modal_class: Option<&'a str>,
light_dismiss: bool,
}
#[cfg(feature = "maud")]
impl<'a> ConfirmActionConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
title: "Are you sure?",
message: None,
confirm_label: None,
cancel_label: "Cancel",
trigger_class: None,
confirm_class: None,
danger: true,
csrf_field: None,
attrs: &[],
level: HeadingLevel::H2,
modal_class: None,
light_dismiss: false,
}
}
#[must_use]
pub const fn title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
#[must_use]
pub fn message(mut self, message: maud::Markup) -> Self {
self.message = Some(message);
self
}
#[must_use]
pub const fn confirm_label(mut self, label: &'a str) -> Self {
self.confirm_label = Some(label);
self
}
#[must_use]
pub const fn cancel_label(mut self, label: &'a str) -> Self {
self.cancel_label = label;
self
}
#[must_use]
pub const fn trigger_class(mut self, class: &'a str) -> Self {
self.trigger_class = Some(class);
self
}
#[must_use]
pub const fn confirm_class(mut self, class: &'a str) -> Self {
self.confirm_class = Some(class);
self
}
#[must_use]
pub const fn danger(mut self, danger: bool) -> Self {
self.danger = danger;
self
}
#[must_use]
pub const fn csrf_field(mut self, csrf_field: &'a str) -> Self {
self.csrf_field = Some(csrf_field);
self
}
#[must_use]
pub const fn attrs(mut self, attrs: &'a [(&'a str, &'a str)]) -> Self {
self.attrs = attrs;
self
}
#[must_use]
pub const fn level(mut self, level: HeadingLevel) -> Self {
self.level = level;
self
}
#[must_use]
pub const fn modal_class(mut self, class: &'a str) -> Self {
self.modal_class = Some(class);
self
}
#[must_use]
pub const fn light_dismiss(mut self, light_dismiss: bool) -> Self {
self.light_dismiss = light_dismiss;
self
}
}
#[cfg(feature = "maud")]
impl Default for ConfirmActionConfig<'_> {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn confirm_action(
id: &str,
trigger_label: &str,
action: &str,
method: http::Method,
csrf_token: &str,
config: &ConfirmActionConfig<'_>,
) -> maud::Markup {
let confirm_label = config.confirm_label.unwrap_or(trigger_label);
let danger_class = config.danger.then_some("autumn-modal__confirm--danger");
let confirm_class = merge_class(
&merge_class("autumn-modal__confirm", danger_class),
config.confirm_class,
);
let mut button_options = crate::links::ButtonToOptions::new()
.class(confirm_class.as_str())
.form_class("autumn-modal__confirm-form")
.attrs(config.attrs);
if let Some(field) = config.csrf_field {
button_options = button_options.csrf_field(field);
}
let confirm_button = crate::links::button_to_with(
confirm_label,
action,
method.clone(),
csrf_token,
&button_options,
);
let footer = maud::html! {
(modal_close_button(config.cancel_label, id, Some("autumn-modal__cancel")))
(confirm_button)
};
let body = config.message.clone().unwrap_or_else(|| maud::html! {});
let mut modal_config = ModalConfig::new()
.footer(footer)
.level(config.level)
.light_dismiss(config.light_dismiss);
if let Some(class) = config.modal_class {
modal_config = modal_config.class(class);
}
let mut noscript_options = crate::links::ButtonToOptions::new().attrs(config.attrs);
if let Some(class) = config.trigger_class {
noscript_options = noscript_options.class(class);
}
if let Some(field) = config.csrf_field {
noscript_options = noscript_options.csrf_field(field);
}
let noscript_button =
crate::links::button_to_with(trigger_label, action, method, csrf_token, &noscript_options);
maud::html! {
div class="autumn-confirm" {
(modal_trigger(trigger_label, id, config.trigger_class))
noscript { (noscript_button) }
(modal(id, config.title, &body, &modal_config))
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn tabs(
id: &str,
active_id: Option<&str>,
panels: &[(&str, &str, maud::Markup)],
) -> maud::Markup {
let tab_ids: Vec<String> = panels
.iter()
.map(|(panel_id, _, _)| format!("{id}-tab-{panel_id}"))
.collect();
let active_index = active_id
.and_then(|target| {
panels
.iter()
.position(|(panel_id, _, _)| *panel_id == target)
})
.unwrap_or(0);
maud::html! {
div id=(id) class="autumn-tabs" {
div class="autumn-tabs__list" role="tablist" {
@for (i, (panel_id, label, _)) in panels.iter().enumerate() {
@let tab_class = merge_class(
"autumn-tabs__tab",
(i == active_index).then_some("autumn-tabs__tab--active"),
);
a id=(tab_ids[i]) class=(tab_class) role="tab" href=(format!("#{panel_id}"))
aria-controls=(panel_id) aria-selected=(if i == active_index { "true" } else { "false" }) {
(label)
}
}
}
@for (i, (panel_id, _, body)) in panels.iter().enumerate() {
@let panel_class = merge_class(
"autumn-tabs__panel",
(i == active_index).then_some("autumn-tabs__panel--active"),
);
section id=(panel_id) class=(panel_class) role="tabpanel"
aria-labelledby=(tab_ids[i]) tabindex="0" {
(body)
}
}
}
}
}
#[cfg(feature = "maud")]
fn fnv1a_hash(s: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in s.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BadgeVariant {
#[default]
Neutral,
Info,
Success,
Warning,
Danger,
}
#[cfg(feature = "maud")]
impl BadgeVariant {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Neutral => "neutral",
Self::Info => "info",
Self::Success => "success",
Self::Warning => "warning",
Self::Danger => "danger",
}
}
#[must_use]
pub fn for_label(label: &str) -> Self {
match label.trim().to_ascii_lowercase().as_str() {
"success" | "active" | "published" | "done" | "complete" | "completed" | "approved"
| "enabled" | "online" | "paid" | "live" | "ok" | "passed" | "ready" => Self::Success,
"info" | "new" | "open" | "in_progress" | "in progress" | "processing" | "queued"
| "running" | "scheduled" => Self::Info,
"warning" | "warn" | "draft" | "pending" | "pending_review" | "review" | "paused"
| "trial" | "expiring" | "on_hold" => Self::Warning,
"danger" | "error" | "failed" | "failure" | "rejected" | "cancelled" | "canceled"
| "disabled" | "offline" | "overdue" | "banned" | "deleted" | "blocked" => Self::Danger,
"" | "neutral" | "inactive" | "unknown" | "archived" | "closed" | "none" => {
Self::Neutral
}
other => {
const SET: [BadgeVariant; 4] = [
BadgeVariant::Info,
BadgeVariant::Success,
BadgeVariant::Warning,
BadgeVariant::Danger,
];
SET[(fnv1a_hash(other) % 4) as usize]
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default)]
pub struct BadgeConfig<'a> {
title: Option<&'a str>,
aria_label: Option<&'a str>,
class: Option<&'a str>,
}
#[cfg(feature = "maud")]
impl<'a> BadgeConfig<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
#[must_use]
pub const fn aria_label(mut self, label: &'a str) -> Self {
self.aria_label = Some(label);
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn badge(label: &str, variant: BadgeVariant) -> maud::Markup {
badge_with(label, variant, &BadgeConfig::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn badge_with(label: &str, variant: BadgeVariant, config: &BadgeConfig<'_>) -> maud::Markup {
let root = merge_class(&format!("badge badge--{}", variant.as_str()), config.class);
maud::html! {
span class=(root) title=[config.title] aria-label=[config.aria_label] { (label) }
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn status_tag(label: &str) -> maud::Markup {
badge(label, BadgeVariant::Neutral)
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AvatarSize {
Small,
#[default]
Medium,
Large,
}
#[cfg(feature = "maud")]
impl AvatarSize {
#[must_use]
pub const fn px(self) -> u32 {
match self {
Self::Small => 24,
Self::Medium => 40,
Self::Large => 64,
}
}
const fn class(self) -> &'static str {
match self {
Self::Small => "autumn-avatar--sm",
Self::Medium => "autumn-avatar--md",
Self::Large => "autumn-avatar--lg",
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default)]
pub struct AvatarConfig<'a> {
image_url: Option<&'a str>,
size: AvatarSize,
class: Option<&'a str>,
}
#[cfg(feature = "maud")]
impl<'a> AvatarConfig<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn image(mut self, url: &'a str) -> Self {
self.image_url = Some(url);
self
}
#[must_use]
pub const fn size(mut self, size: AvatarSize) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[cfg(feature = "maud")]
fn avatar_initials(name: &str) -> String {
let words: Vec<&str> = name.split_whitespace().collect();
let first_char = |w: &str| w.chars().next();
match words.as_slice() {
[] => "?".to_string(),
[only] => {
first_char(only).map_or_else(|| "?".to_string(), |c| c.to_uppercase().to_string())
}
[first, .., last] => match (first_char(first), first_char(last)) {
(Some(a), Some(b)) => format!("{}{}", a.to_uppercase(), b.to_uppercase()),
_ => "?".to_string(),
},
}
}
#[cfg(feature = "maud")]
const AVATAR_PALETTE: [&str; 12] = [
"autumn-avatar--c0",
"autumn-avatar--c1",
"autumn-avatar--c2",
"autumn-avatar--c3",
"autumn-avatar--c4",
"autumn-avatar--c5",
"autumn-avatar--c6",
"autumn-avatar--c7",
"autumn-avatar--c8",
"autumn-avatar--c9",
"autumn-avatar--c10",
"autumn-avatar--c11",
];
#[cfg(feature = "maud")]
fn avatar_palette_class(name: &str) -> &'static str {
const _: () = assert!(AVATAR_PALETTE.len() == 12);
AVATAR_PALETTE[(fnv1a_hash(name.trim()) % 12) as usize]
}
#[cfg(feature = "maud")]
#[must_use]
pub fn avatar(name: &str, config: &AvatarConfig<'_>) -> maud::Markup {
let px = config.size.px();
let size_class = config.size.class();
let alt = if name.trim().is_empty() {
"avatar".to_string()
} else {
name.to_string()
};
match config.image_url {
Some(url) if !url.trim().is_empty() => {
let root = merge_class(
&format!("autumn-avatar autumn-avatar--image {size_class}"),
config.class,
);
maud::html! {
img class=(root) src=(url) alt=(alt)
loading="lazy" width=(px) height=(px);
}
}
_ => {
let palette = avatar_palette_class(name);
let root = merge_class(
&format!("autumn-avatar autumn-avatar--initials {size_class} {palette}"),
config.class,
);
maud::html! {
span class=(root) role="img" aria-label=(alt) title=(alt) {
span class="autumn-avatar__initials" aria-hidden="true" { (avatar_initials(name)) }
}
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlertVariant {
#[default]
Info,
Success,
Warning,
Error,
}
#[cfg(feature = "maud")]
impl AlertVariant {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Success => "success",
Self::Warning => "warning",
Self::Error => "error",
}
}
const fn role(self) -> &'static str {
match self {
Self::Error | Self::Warning => "alert",
Self::Info | Self::Success => "status",
}
}
fn icon(self) -> maud::Markup {
let glyph = match self {
Self::Info => "i",
Self::Success => "✓",
Self::Warning => "!",
Self::Error => "×",
};
maud::html! {
svg class="alert__icon-svg" viewBox="0 0 20 20" width="20" height="20"
aria-hidden="true" focusable="false" {
circle cx="10" cy="10" r="10" fill="currentColor" {}
text x="10" y="15" text-anchor="middle" font-size="13"
font-family="sans-serif" fill="#fff" { (glyph) }
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default)]
pub struct AlertConfig<'a> {
title: Option<&'a str>,
icon: bool,
dismissible: bool,
class: Option<&'a str>,
}
#[cfg(feature = "maud")]
impl<'a> AlertConfig<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
#[must_use]
pub const fn icon(mut self, show: bool) -> Self {
self.icon = show;
self
}
#[must_use]
pub const fn dismissible(mut self, yes: bool) -> Self {
self.dismissible = yes;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn alert(variant: AlertVariant, body: maud::Markup) -> maud::Markup {
alert_with(variant, body, &AlertConfig::new())
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn alert_with(
variant: AlertVariant,
body: maud::Markup,
config: &AlertConfig<'_>,
) -> maud::Markup {
let root = merge_class(&format!("alert alert-{}", variant.as_str()), config.class);
maud::html! {
div class=(root) role=(variant.role()) {
@if config.icon {
span class="alert__icon" { (variant.icon()) }
}
div class="alert__content" {
@if let Some(title) = config.title {
(heading(HeadingLevel::H2, None, "alert__title", &maud::html! { (title) }))
}
div class="alert__body" { (body) }
}
@if config.dismissible {
label class="alert__dismiss" {
input type="checkbox" class="alert__dismiss-toggle"
aria-label="Dismiss this message";
span aria-hidden="true" { "×" }
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn error_summary<T>(changeset: &crate::form::Changeset<T>) -> Option<maud::Markup> {
if changeset.is_valid() {
return None;
}
let mut messages: Vec<&str> = Vec::new();
let mut fields: Vec<&String> = changeset.all_errors().keys().collect();
fields.sort();
for field in fields {
for msg in &changeset.all_errors()[field] {
messages.push(msg.as_str());
}
}
let body = maud::html! {
ul class="alert__errors" {
@for msg in &messages {
li { (msg) }
}
}
};
Some(alert_with(
AlertVariant::Error,
body,
&AlertConfig::new()
.title("Please fix the following errors")
.icon(true),
))
}
pub const DEFAULT_TOAST_REGION_ID: &str = "toast-region";
#[cfg(feature = "maud")]
#[must_use]
pub fn toast_region(id: &str) -> maud::Markup {
let id = selector_to_id(id);
maud::html! {
div id=(id) class="autumn-toast-region" aria-live="polite" aria-atomic="false" {}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn toast(message: &str, variant: AlertVariant) -> maud::Markup {
toast_in(DEFAULT_TOAST_REGION_ID, message, variant)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn toast_in(region_id: &str, message: &str, variant: AlertVariant) -> maud::Markup {
let (role, live): (Option<&str>, Option<&str>) = match variant {
AlertVariant::Error => (Some("alert"), Some("assertive")),
AlertVariant::Info | AlertVariant::Success | AlertVariant::Warning => (None, None),
};
let class = format!("autumn-toast autumn-toast--{}", variant.as_str());
let region_id = selector_to_id(region_id);
let oob = format!("beforeend:#{region_id}");
maud::html! {
div hx-swap-oob=(oob) {
div class=(class) role=[role] aria-live=[live] aria-atomic="true" {
span class="autumn-toast__message" { (message) }
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FeedMode {
#[default]
Reveal,
Button,
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone)]
pub struct FeedConfig<'a> {
pub url: &'a str,
pub mode: FeedMode,
pub cursor_param: &'a str,
pub load_more_label: &'a str,
}
#[cfg(feature = "maud")]
impl<'a> FeedConfig<'a> {
#[must_use]
pub const fn new(url: &'a str) -> Self {
Self {
url,
mode: FeedMode::Reveal,
cursor_param: "cursor",
load_more_label: "Load more",
}
}
#[must_use]
pub const fn mode(mut self, mode: FeedMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub const fn button(mut self) -> Self {
self.mode = FeedMode::Button;
self
}
#[must_use]
pub const fn cursor_param(mut self, param: &'a str) -> Self {
self.cursor_param = param;
self
}
#[must_use]
pub const fn load_more_label(mut self, label: &'a str) -> Self {
self.load_more_label = label;
self
}
}
#[cfg(feature = "maud")]
fn encode_query_component(value: &str) -> String {
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
const QUERY_COMPONENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'+')
.add(b'/')
.add(b';')
.add(b'=')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}')
.add(b'<')
.add(b'>');
utf8_percent_encode(value, QUERY_COMPONENT).to_string()
}
#[cfg(feature = "maud")]
fn feed_next_url(config: &FeedConfig<'_>, cursor: &str) -> String {
let (base, fragment) = match config.url.split_once('#') {
Some((base, frag)) => (base, Some(frag)),
None => (config.url, None),
};
let sep = if base.contains('?') { '&' } else { '?' };
let param = encode_query_component(config.cursor_param);
let value = encode_query_component(cursor);
let mut url = format!("{base}{sep}{param}={value}");
if let Some(frag) = fragment {
url.push('#');
url.push_str(frag);
}
url
}
#[cfg(feature = "maud")]
fn feed_sentinel(next_cursor: &str, config: &FeedConfig<'_>) -> maud::Markup {
let next_url = feed_next_url(config, next_cursor);
let trigger = match config.mode {
FeedMode::Reveal => Some("revealed, click"),
FeedMode::Button => None,
};
maud::html! {
div class="autumn-feed__sentinel" {
a class="autumn-feed__more" href=(next_url)
hx-get=(next_url) hx-target="closest .autumn-feed__sentinel"
hx-swap="outerHTML" hx-trigger=[trigger] {
(config.load_more_label)
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn feed_page(
items: maud::Markup,
next_cursor: Option<&str>,
config: &FeedConfig<'_>,
) -> maud::Markup {
maud::html! {
(items)
@if let Some(cursor) = next_cursor {
(feed_sentinel(cursor, config))
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn infinite_feed(
items: maud::Markup,
next_cursor: Option<&str>,
config: &FeedConfig<'_>,
) -> maud::Markup {
maud::html! {
div class="autumn-feed" {
(feed_page(items, next_cursor, config))
}
}
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default)]
pub struct ChartConfig<'a> {
class: Option<&'a str>,
title: Option<&'a str>,
desc: Option<&'a str>,
min: Option<f64>,
max: Option<f64>,
table_fallback: bool,
}
#[cfg(feature = "maud")]
impl<'a> ChartConfig<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
#[must_use]
pub const fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
#[must_use]
pub const fn desc(mut self, desc: &'a str) -> Self {
self.desc = Some(desc);
self
}
#[must_use]
pub const fn min(mut self, min: f64) -> Self {
self.min = Some(min);
self
}
#[must_use]
pub const fn max(mut self, max: f64) -> Self {
self.max = Some(max);
self
}
#[must_use]
pub const fn with_table(mut self) -> Self {
self.table_fallback = true;
self
}
}
#[cfg(feature = "maud")]
fn fmt_coord(v: f64) -> String {
let s = format!("{v:.3}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() || trimmed == "-0" {
"0".to_string()
} else {
trimmed.to_string()
}
}
#[cfg(feature = "maud")]
fn fmt_value(v: f64) -> String {
fmt_coord(v)
}
#[cfg(feature = "maud")]
fn resolve_bounds(points: &[(&str, f64)], config: &ChartConfig<'_>) -> (f64, f64) {
let mut data_min = f64::INFINITY;
let mut data_max = f64::NEG_INFINITY;
for (_, v) in points {
if v.is_finite() {
data_min = data_min.min(*v);
data_max = data_max.max(*v);
}
}
if !data_min.is_finite() {
data_min = 0.0;
data_max = 1.0;
}
let mut min = config.min.filter(|v| v.is_finite()).unwrap_or(data_min);
let mut max = config.max.filter(|v| v.is_finite()).unwrap_or(data_max);
if max < min {
std::mem::swap(&mut min, &mut max);
}
(min, max)
}
#[cfg(feature = "maud")]
fn resolve_bar_bounds(points: &[(&str, f64)], config: &ChartConfig<'_>) -> (f64, f64) {
let mut data_min = f64::INFINITY;
let mut data_max = f64::NEG_INFINITY;
for (_, v) in points {
if v.is_finite() {
data_min = data_min.min(*v);
data_max = data_max.max(*v);
}
}
if !data_min.is_finite() {
data_min = 0.0;
data_max = 1.0;
}
let mut min = config
.min
.filter(|v| v.is_finite())
.unwrap_or_else(|| data_min.min(0.0));
let mut max = config
.max
.filter(|v| v.is_finite())
.unwrap_or_else(|| data_max.max(0.0));
if max < min {
std::mem::swap(&mut min, &mut max);
}
(min, max)
}
#[cfg(feature = "maud")]
fn norm_frac(v: f64, min: f64, max: f64) -> f64 {
let span = max - min;
if span <= 0.0 || !v.is_finite() {
0.5
} else {
((v - min) / span).clamp(0.0, 1.0)
}
}
#[cfg(feature = "maud")]
#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
fn chart_coords(
points: &[(&str, f64)],
width: f64,
height: f64,
pad: f64,
min: f64,
max: f64,
) -> Vec<(f64, f64)> {
let n = points.len();
let usable_w = width - 2.0 * pad;
let usable_h = height - 2.0 * pad;
points
.iter()
.enumerate()
.map(|(i, (_, value))| {
let x = if n <= 1 {
width / 2.0
} else {
pad + (i as f64 / (n - 1) as f64) * usable_w
};
let frac = norm_frac(*value, min, max);
let y = pad + (1.0 - frac) * usable_h;
(x, y)
})
.collect()
}
#[cfg(feature = "maud")]
fn chart_default_title(kind: &str) -> &'static str {
match kind {
"bar" => "Bar chart",
"line" => "Line chart",
_ => "Sparkline",
}
}
#[cfg(feature = "maud")]
fn chart_desc(points: &[(&str, f64)], config: &ChartConfig<'_>) -> String {
if let Some(desc) = config.desc {
return desc.to_string();
}
if points.is_empty() {
return "No data".to_string();
}
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
for (_, v) in points {
if v.is_finite() {
min = min.min(*v);
max = max.max(*v);
}
}
if !min.is_finite() {
min = 0.0;
max = 1.0;
}
format!(
"{} data points, ranging from {} to {}.",
points.len(),
fmt_value(min),
fmt_value(max)
)
}
#[cfg(feature = "maud")]
fn chart_table(points: &[(&str, f64)]) -> maud::Markup {
maud::html! {
table class="autumn-chart__table autumn-visually-hidden" {
thead { tr { th { "Label" } th { "Value" } } }
tbody {
@for (label, value) in points {
tr {
td { (label) }
td { (fmt_value(*value)) }
}
}
}
}
}
}
#[cfg(feature = "maud")]
fn chart_root(
kind: &str,
view_box: &str,
points: &[(&str, f64)],
config: &ChartConfig<'_>,
body: &maud::Markup,
) -> maud::Markup {
let root = merge_class(&format!("autumn-chart autumn-chart--{kind}"), config.class);
let label = config.title.unwrap_or_else(|| chart_default_title(kind));
let desc = chart_desc(points, config);
maud::html! {
div class=(root) {
svg class="autumn-chart__svg" viewBox=(view_box)
preserveAspectRatio="none" role="img" aria-label=(label)
focusable="false" {
title { (label) }
desc { (desc) }
(body)
}
@if config.table_fallback {
(chart_table(points))
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn sparkline(points: &[(&str, f64)]) -> maud::Markup {
sparkline_with(points, &ChartConfig::default())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn sparkline_with(points: &[(&str, f64)], config: &ChartConfig<'_>) -> maud::Markup {
const W: f64 = 100.0;
const H: f64 = 30.0;
const PAD: f64 = 2.0;
let (min, max) = resolve_bounds(points, config);
let coords = chart_coords(points, W, H, PAD, min, max);
let body = maud::html! {
@if coords.len() == 1 {
circle class="autumn-chart__point" cx=(fmt_coord(coords[0].0))
cy=(fmt_coord(coords[0].1)) r="1.5" {}
} @else if coords.len() > 1 {
polyline class="autumn-chart__line" points=(polyline_points(&coords)) {}
}
};
chart_root("sparkline", "0 0 100 30", points, config, &body)
}
#[cfg(feature = "maud")]
fn polyline_points(coords: &[(f64, f64)]) -> String {
let mut s = String::new();
for (i, (x, y)) in coords.iter().enumerate() {
if i > 0 {
s.push(' ');
}
s.push_str(&fmt_coord(*x));
s.push(',');
s.push_str(&fmt_coord(*y));
}
s
}
#[cfg(feature = "maud")]
#[must_use]
pub fn bar_chart(series: &[(&str, f64)]) -> maud::Markup {
bar_chart_with(series, &ChartConfig::default())
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
pub fn bar_chart_with(series: &[(&str, f64)], config: &ChartConfig<'_>) -> maud::Markup {
const W: f64 = 100.0;
const H: f64 = 40.0;
const PAD: f64 = 2.0;
let (min, max) = resolve_bar_bounds(series, config);
let usable_h = H - 2.0 * PAD;
let bottom = H - PAD;
let span = max - min;
let zero_frac = if span > 0.0 {
((0.0 - min) / span).clamp(0.0, 1.0)
} else {
0.0
};
let y_zero = bottom - zero_frac * usable_h;
let n = series.len();
let body = maud::html! {
@if n > 0 {
@let slot = (W - 2.0 * PAD) / n as f64;
@let bar_w = slot * 0.7;
@for (i, (_, v)) in series.iter().enumerate() {
@let v_frac = if span > 0.0 && v.is_finite() {
((*v - min) / span).clamp(0.0, 1.0)
} else {
zero_frac
};
@let y_v = bottom - v_frac * usable_h;
@let y = y_zero.min(y_v);
@let bar_h = (y_zero - y_v).abs();
@let x = PAD + i as f64 * slot + (slot - bar_w) / 2.0;
rect class="autumn-chart__bar" x=(fmt_coord(x)) y=(fmt_coord(y))
width=(fmt_coord(bar_w)) height=(fmt_coord(bar_h)) {}
}
}
};
chart_root("bar", "0 0 100 40", series, config, &body)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn line_chart(series: &[(&str, f64)]) -> maud::Markup {
line_chart_with(series, &ChartConfig::default())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn line_chart_with(series: &[(&str, f64)], config: &ChartConfig<'_>) -> maud::Markup {
const W: f64 = 100.0;
const H: f64 = 40.0;
const PAD: f64 = 3.0;
let (min, max) = resolve_bounds(series, config);
let coords = chart_coords(series, W, H, PAD, min, max);
let body = maud::html! {
@if coords.len() > 1 {
polyline class="autumn-chart__line" points=(polyline_points(&coords)) {}
}
@for (x, y) in &coords {
circle class="autumn-chart__point" cx=(fmt_coord(*x)) cy=(fmt_coord(*y)) r="1.5" {}
}
};
chart_root("line", "0 0 100 40", series, config, &body)
}
#[cfg(all(test, feature = "maud"))]
mod tests {
use super::*;
const fn sample_transitions() -> &'static [(&'static str, &'static str, Option<&'static str>)] {
&[
("draft", "published", Some("can_publish")),
("published", "archived", None),
]
}
#[test]
fn transition_controls_only_renders_edges_from_current() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(html.contains(r#"value="published""#), "{html}");
assert!(!html.contains(r#"value="archived""#), "{html}");
}
#[test]
fn transition_controls_renders_terminal_current_as_empty_group() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"archived",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(
html.contains(r#"class="autumn-transition-controls""#),
"{html}"
);
assert!(!html.contains("<form"), "{html}");
assert!(!html.contains("<button"), "{html}");
}
#[test]
fn transition_controls_guard_true_button_is_enabled() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|to| to == "published", None,
None,
)
.into_string();
assert!(
html.contains("<button type=\"submit\">Mark as published</button>"),
"{html}"
);
assert!(!html.contains("disabled"), "{html}");
}
#[test]
fn transition_controls_guard_false_button_is_disabled() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| false, None,
None,
)
.into_string();
assert!(html.contains(r#"value="published""#), "{html}");
assert!(html.contains("disabled"), "{html}");
}
#[test]
fn transition_controls_guardless_edge_from_current_is_enabled() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"published",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(html.contains(r#"value="archived""#), "{html}");
assert!(
html.contains("<button type=\"submit\">Mark as archived</button>"),
"{html}"
);
assert!(!html.contains("disabled"), "{html}");
}
#[test]
fn transition_controls_form_is_post_no_js_and_carries_target_in_body() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(
html.contains(r#"<form method="post" action="/orders/42/transitions/status""#),
"{html}"
);
assert!(
html.contains(r#"<input type="hidden" name="status" value="published">"#),
"{html}"
);
assert!(!html.contains("onclick"), "{html}");
assert!(!html.contains("hx-"), "{html}");
assert!(!html.contains("<script"), "{html}");
}
#[test]
fn transition_controls_csrf_hidden_input_present_when_token_supplied() {
let token = crate::security::CsrfToken::new("secret-token".to_string());
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
Some(&token),
None,
)
.into_string();
assert!(
html.contains(r#"<input type="hidden" name="_csrf" value="secret-token">"#),
"{html}"
);
}
#[test]
fn transition_controls_csrf_absent_when_no_token() {
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
None,
None,
)
.into_string();
assert!(!html.contains("_csrf"), "{html}");
}
#[test]
fn transition_controls_honors_custom_csrf_field_name() {
let token = crate::security::CsrfToken::new("secret-token".to_string());
let field = crate::security::CsrfFormField("csrf_custom".to_string());
let html = transition_controls(
"/orders/42/transitions/status",
"status",
"draft",
sample_transitions(),
|_to| true,
Some(&token),
Some(&field),
)
.into_string();
assert!(
html.contains(r#"<input type="hidden" name="csrf_custom" value="secret-token">"#),
"{html}"
);
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
#[test]
fn trigger_has_debounce() {
let t = build_trigger(300, false);
assert!(t.contains("delay:300ms"), "{t}");
}
#[test]
fn trigger_has_changed_modifier() {
let t = build_trigger(300, false);
assert!(t.contains("changed"), "{t}");
}
#[test]
fn trigger_has_no_filter_expressions() {
let t = build_trigger(300, false);
assert!(!t.contains("this.value.length"), "{t}");
assert!(!t.contains('['), "{t}");
}
#[test]
fn trigger_initial_load_appends_load() {
let t = build_trigger(300, true);
assert!(t.contains(", load"), "{t}");
}
#[test]
fn trigger_no_initial_load_by_default() {
let t = build_trigger(300, false);
assert!(!t.contains("load"), "{t}");
}
#[test]
fn trigger_custom_debounce() {
let t = build_trigger(750, false);
assert!(t.contains("delay:750ms"), "{t}");
}
#[test]
fn selector_to_id_strips_hash() {
assert_eq!(selector_to_id("#my-results"), "my-results");
}
#[test]
fn selector_to_id_passthrough_without_hash() {
assert_eq!(selector_to_id("results"), "results");
}
#[test]
fn config_defaults() {
let c = ActiveSearchConfig::new("/s", "#r");
assert_eq!(c.method, SearchMethod::Get);
assert_eq!(c.debounce_ms, 300);
assert_eq!(c.min_length, 1);
assert_eq!(c.param_name, "q");
assert!(!c.initial_load);
assert!(c.indicator.is_none());
assert!(c.placeholder.is_none());
}
#[test]
fn config_post_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r").post().method,
SearchMethod::Post
);
}
#[test]
fn config_debounce_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.debounce(500)
.debounce_ms,
500
);
}
#[test]
fn config_min_length_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r").min_length(3).min_length,
3
);
}
#[test]
fn config_initial_load_builder() {
assert!(
ActiveSearchConfig::new("/s", "#r")
.initial_load()
.initial_load
);
}
#[test]
fn config_indicator_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.indicator("#spin")
.indicator,
Some("#spin")
);
}
#[test]
fn config_placeholder_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.placeholder("hint")
.placeholder,
Some("hint")
);
}
#[test]
fn config_param_name_builder() {
assert_eq!(
ActiveSearchConfig::new("/s", "#r")
.param_name("query")
.param_name,
"query"
);
}
#[test]
fn input_defaults_to_hx_get() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"hx-get="/search""#), "{html}");
assert!(!html.contains("hx-post"), "{html}");
}
#[test]
fn input_uses_hx_post_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").post();
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"hx-post="/search""#), "{html}");
assert!(!html.contains("hx-get"), "{html}");
}
#[test]
fn input_trigger_has_default_debounce() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("delay:300ms"), "{html}");
}
#[test]
fn input_configurable_debounce() {
let config = ActiveSearchConfig::new("/search", "#results").debounce(500);
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("delay:500ms"), "{html}");
}
#[test]
fn input_configurable_min_length() {
let config = ActiveSearchConfig::new("/search", "#results").min_length(3);
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("hx-trigger"), "{html}");
assert!(!html.contains("this.value.length"), "{html}");
}
#[test]
fn input_no_filter_when_min_length_zero() {
let config = ActiveSearchConfig::new("/search", "#results").min_length(0);
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains("this.value.length"), "{html}");
}
#[test]
fn input_initial_load_in_trigger() {
let config = ActiveSearchConfig::new("/search", "#results").initial_load();
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(", load"), "{html}");
}
#[test]
fn input_no_initial_load_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains(", load"), "{html}");
}
#[test]
fn input_target_selector() {
let config = ActiveSearchConfig::new("/search", "#my-results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("hx-target=\"#my-results\""), "{html}");
}
#[test]
fn input_indicator_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").indicator("#spinner");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("hx-indicator=\"#spinner\""), "{html}");
}
#[test]
fn input_no_indicator_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains("hx-indicator"), "{html}");
}
#[test]
fn input_renders_label() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search Posts", &config).into_string();
assert!(html.contains("Search Posts"), "{html}");
assert!(html.contains("<label"), "{html}");
}
#[test]
fn input_label_for_matches_id() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("my-search", "Search", &config).into_string();
assert!(html.contains(r#"for="my-search""#), "{html}");
assert!(html.contains(r#"id="my-search""#), "{html}");
}
#[test]
fn input_has_aria_controls() {
let config = ActiveSearchConfig::new("/search", "#my-results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("aria-controls"), "{html}");
assert!(html.contains("my-results"), "{html}");
}
#[test]
fn input_type_is_search() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"type="search""#), "{html}");
}
#[test]
fn input_placeholder_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").placeholder("Type to search…");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains("Type to search"), "{html}");
}
#[test]
fn input_no_placeholder_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search_input("q", "Search", &config).into_string();
assert!(!html.contains("placeholder"), "{html}");
}
#[test]
fn input_custom_param_name() {
let config = ActiveSearchConfig::new("/search", "#results").param_name("query");
let html = active_search_input("q", "Search", &config).into_string();
assert!(html.contains(r#"name="query""#), "{html}");
}
#[test]
fn results_correct_id() {
let html = active_search_results("my-results").into_string();
assert!(html.contains(r#"id="my-results""#), "{html}");
}
#[test]
fn results_role_status() {
let html = active_search_results("r").into_string();
assert!(html.contains(r#"role="status""#), "{html}");
}
#[test]
fn results_aria_live_polite() {
let html = active_search_results("r").into_string();
assert!(html.contains(r#"aria-live="polite""#), "{html}");
}
#[test]
fn results_aria_atomic() {
let html = active_search_results("r").into_string();
assert!(html.contains(r#"aria-atomic="true""#), "{html}");
}
#[test]
fn widget_includes_input_and_results() {
let config = ActiveSearchConfig::new("/search", "#s-results");
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains(r#"type="search""#), "{html}");
assert!(html.contains(r#"id="s-results""#), "{html}");
}
#[test]
fn widget_results_id_matches_target_selector() {
let config = ActiveSearchConfig::new("/search", "#custom-results");
let html = active_search("search-widget", "Search", &config).into_string();
assert!(html.contains(r#"id="custom-results""#), "{html}");
}
#[test]
fn widget_has_noscript_fallback() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains("<noscript>"), "{html}");
assert!(html.contains("<form"), "{html}");
assert!(html.contains(r#"type="submit""#), "{html}");
}
#[test]
fn widget_noscript_get_by_default() {
let config = ActiveSearchConfig::new("/search", "#results");
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains(r#"method="get""#), "{html}");
}
#[test]
fn widget_noscript_post_when_configured() {
let config = ActiveSearchConfig::new("/search", "#results").post();
let html = active_search("s", "Search", &config).into_string();
assert!(html.contains(r#"method="post""#), "{html}");
}
#[test]
fn autocomplete_visible_search_input() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"type="search""#), "{html}");
assert!(html.contains(r#"name="q""#), "{html}");
}
#[test]
fn autocomplete_visible_input_uses_query_param() {
let config = AutocompleteConfig::new("/ac", "value_field").query_param("search");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"name="search""#), "{html}");
}
#[test]
fn autocomplete_hidden_value_field() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"type="hidden""#), "{html}");
assert!(html.contains(r#"id="x-value""#), "{html}");
}
#[test]
fn autocomplete_hidden_field_empty_initial_value() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"type="hidden""#), "{html}");
assert!(html.contains(r#"value="""#), "{html}");
}
#[test]
fn autocomplete_listbox_container() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"role="listbox""#), "{html}");
}
#[test]
fn autocomplete_wrapper_has_data_attributes_for_runtime() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"data-ac-value-id="x-value""#), "{html}");
assert!(
html.contains(r#"data-ac-value-name="value_field""#),
"{html}"
);
assert!(html.contains("data-ac-query"), "{html}");
assert!(html.contains("data-ac-min-length"), "{html}");
}
#[test]
fn autocomplete_free_text_mode_sets_data_attribute() {
let config = AutocompleteConfig::new("/ac", "value_field").free_text();
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("data-ac-free-text"), "{html}");
}
#[test]
fn autocomplete_id_mode_no_free_text_attribute() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(!html.contains("data-ac-free-text"), "{html}");
}
#[test]
fn autocomplete_combobox_role() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"role="combobox""#), "{html}");
}
#[test]
fn autocomplete_aria_expanded_false() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"aria-expanded="false""#), "{html}");
}
#[test]
fn autocomplete_aria_autocomplete_list() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"aria-autocomplete="list""#), "{html}");
}
#[test]
fn autocomplete_has_aria_controls() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("aria-controls"), "{html}");
}
#[test]
fn autocomplete_renders_label() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "My Label", &config).into_string();
assert!(html.contains("My Label"), "{html}");
assert!(html.contains("<label"), "{html}");
}
#[test]
fn autocomplete_label_for_matches_query_input_id() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("tag", "Tag", &config).into_string();
assert!(html.contains(r#"for="tag-query""#), "{html}");
assert!(html.contains(r#"id="tag-query""#), "{html}");
}
#[test]
fn autocomplete_has_noscript_fallback() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("<noscript>"), "{html}");
assert!(html.contains("<select"), "{html}");
}
#[test]
fn autocomplete_noscript_select_uses_value_name() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"name="value_field""#), "{html}");
}
#[test]
fn autocomplete_fallback_options_rendered_in_noscript() {
let opts: &[(&str, &str)] = &[("1", "Alpha"), ("2", "Beta")];
let config = AutocompleteConfig::new("/ac", "value_field").fallback_options(opts);
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("Alpha"), "{html}");
assert!(html.contains("Beta"), "{html}");
assert!(html.contains(r#"value="1""#), "{html}");
assert!(html.contains(r#"value="2""#), "{html}");
}
#[test]
fn autocomplete_noscript_select_marks_initial_value_selected() {
let opts: &[(&str, &str)] = &[("1", "Alpha"), ("2", "Beta")];
let config = AutocompleteConfig::new("/ac", "value_field")
.fallback_options(opts)
.initial_value("2");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(
html.contains(r#"value="2" selected"#),
"the option matching initial_value must be marked selected: {html}"
);
assert!(
!html.contains(r#"value="1" selected"#),
"only the matching option should be selected: {html}"
);
}
#[test]
fn autocomplete_has_hx_get() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"hx-get="/ac""#), "{html}");
}
#[test]
fn autocomplete_hx_trigger_has_debounce() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("hx-trigger"), "{html}");
assert!(html.contains("delay:300ms"), "{html}");
}
#[test]
fn autocomplete_configurable_debounce() {
let config = AutocompleteConfig::new("/ac", "value_field").debounce(600);
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("delay:600ms"), "{html}");
}
#[test]
fn autocomplete_configurable_min_length() {
let config = AutocompleteConfig::new("/ac", "value_field").min_length(2);
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains(r#"data-ac-min-length="2""#), "{html}");
assert!(!html.contains("this.value.length"), "{html}");
}
#[test]
fn autocomplete_indicator_when_configured() {
let config = AutocompleteConfig::new("/ac", "value_field").indicator("#ld");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("hx-indicator=\"#ld\""), "{html}");
}
#[test]
fn autocomplete_no_indicator_by_default() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(!html.contains("hx-indicator"), "{html}");
}
#[test]
fn autocomplete_listbox_has_aria_live() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(html.contains("aria-live"), "{html}");
}
#[test]
fn autocomplete_listbox_has_no_inline_handlers() {
let config = AutocompleteConfig::new("/ac", "value_field");
let html = autocomplete_input("x", "Label", &config).into_string();
assert!(!html.contains("hx-on:keydown"), "{html}");
assert!(!html.contains("hx-on:click"), "{html}");
assert!(!html.contains("hx-on:input"), "{html}");
assert!(!html.contains("oninput"), "{html}");
}
#[test]
fn option_renders_label_and_value() {
let html = autocomplete_option("42", "My Tag").into_string();
assert!(html.contains("My Tag"), "{html}");
assert!(html.contains(r#"data-value="42""#), "{html}");
}
#[test]
fn option_has_role_option() {
let html = autocomplete_option("1", "Option").into_string();
assert!(html.contains(r#"role="option""#), "{html}");
}
#[test]
fn option_is_keyboard_focusable() {
let html = autocomplete_option("1", "Option").into_string();
assert!(html.contains("tabindex"), "{html}");
}
#[test]
fn ac_empty_state_renders_message() {
let html = autocomplete_empty_state("No results found").into_string();
assert!(html.contains("No results found"), "{html}");
}
#[test]
fn ac_empty_state_announced_to_screen_readers() {
let html = autocomplete_empty_state("No results").into_string();
assert!(
html.contains(r#"role="status""#) || html.contains("aria-live"),
"{html}"
);
}
#[test]
fn search_empty_state_renders_message() {
let html = active_search_empty_state("No matching posts").into_string();
assert!(html.contains("No matching posts"), "{html}");
}
#[test]
fn search_empty_state_announced_to_screen_readers() {
let html = active_search_empty_state("Nothing found").into_string();
assert!(
html.contains(r#"role="status""#) || html.contains("aria-live"),
"{html}"
);
}
#[test]
fn breadcrumb_empty_slice_renders_nothing_no_panic() {
let html = breadcrumb(&[]).into_string();
assert!(html.is_empty(), "expected empty output, got: {html}");
}
#[test]
fn breadcrumb_wrapped_in_nav_with_aria_label() {
let crumbs = [Crumb::link("Home", "/"), Crumb::current("Posts")];
let html = breadcrumb(&crumbs).into_string();
assert!(html.contains("<nav"), "{html}");
assert!(html.contains("aria-label"), "{html}");
}
#[test]
fn breadcrumb_contains_ordered_list() {
let crumbs = [Crumb::link("Home", "/"), Crumb::current("Posts")];
let html = breadcrumb(&crumbs).into_string();
assert!(html.contains("<ol"), "{html}");
assert!(html.contains("</ol>"), "{html}");
}
#[test]
fn breadcrumb_last_crumb_has_aria_current_page() {
let crumbs = [
Crumb::link("Home", "/"),
Crumb::link("Posts", "/posts"),
Crumb::current("My Post"),
];
let html = breadcrumb(&crumbs).into_string();
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn breadcrumb_preceding_crumbs_render_as_links() {
let crumbs = [
Crumb::link("Home", "/"),
Crumb::link("Posts", "/posts"),
Crumb::current("My Post"),
];
let html = breadcrumb(&crumbs).into_string();
assert!(html.contains(r#"href="/""#), "{html}");
assert!(html.contains(r#"href="/posts""#), "{html}");
}
#[test]
fn breadcrumb_last_crumb_is_not_a_link() {
let crumbs = [Crumb::link("Home", "/"), Crumb::current("Current Page")];
let html = breadcrumb(&crumbs).into_string();
assert!(html.contains("Current Page"), "{html}");
assert!(!html.contains("<a href=\"\">Current Page"), "{html}");
assert!(html.contains("aria-current=\"page\""), "{html}");
assert!(html.contains("autumn-breadcrumb__current"), "{html}");
}
#[test]
fn breadcrumb_single_crumb_no_leading_separator() {
let crumbs = [Crumb::current("Only Page")];
let html = breadcrumb(&crumbs).into_string();
assert!(!html.contains("aria-hidden"), "{html}");
assert!(html.contains("Only Page"), "{html}");
}
#[test]
fn breadcrumb_single_crumb_has_aria_current_page() {
let crumbs = [Crumb::current("Only Page")];
let html = breadcrumb(&crumbs).into_string();
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn breadcrumb_separators_are_aria_hidden() {
let crumbs = [
Crumb::link("Home", "/"),
Crumb::link("Posts", "/posts"),
Crumb::current("Current"),
];
let html = breadcrumb(&crumbs).into_string();
assert!(html.contains(r#"aria-hidden="true""#), "{html}");
}
#[test]
fn breadcrumb_label_containing_html_is_escaped() {
let crumbs = [
Crumb::link("<script>alert(1)</script>", "/"),
Crumb::current("Safe"),
];
let html = breadcrumb(&crumbs).into_string();
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
}
#[test]
fn breadcrumb_href_containing_html_is_escaped() {
let crumbs = [
Crumb::link("Home", r#"/" onerror="bad"#),
Crumb::current("Page"),
];
let html = breadcrumb(&crumbs).into_string();
assert!(!html.contains("onerror=\"bad"), "{html}");
}
#[test]
fn breadcrumb_crumb_link_constructor() {
let c = Crumb::link("Posts", "/posts");
assert_eq!(c.label, "Posts");
assert_eq!(c.href, Some("/posts"));
}
#[test]
fn breadcrumb_crumb_current_constructor() {
let c = Crumb::current("My Page");
assert_eq!(c.label, "My Page");
assert!(c.href.is_none());
}
#[test]
fn breadcrumb_non_last_crumb_without_href_renders_span_not_bare_text() {
let crumbs = [Crumb::current("Unlinked Middle"), Crumb::current("Current")];
let html = breadcrumb(&crumbs).into_string();
assert!(
html.contains("<span class=\"autumn-breadcrumb__text\">Unlinked Middle</span>"),
"{html}"
);
assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
}
#[test]
fn nav_link_exact_match_is_active() {
let html = nav_link("/admin/posts", "/admin/posts", "Posts").into_string();
assert!(html.contains(r#"class="active""#), "{html}");
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn nav_link_no_match_is_inactive() {
let html = nav_link("/admin/jobs", "/admin/posts", "Posts").into_string();
assert!(!html.contains("active"), "{html}");
assert!(!html.contains("aria-current"), "{html}");
}
#[test]
fn nav_link_exact_mode_rejects_sub_paths() {
let html = nav_link("/admin/posts/3", "/admin/posts", "Posts").into_string();
assert!(!html.contains("active"), "{html}");
assert!(!html.contains("aria-current"), "{html}");
}
#[test]
fn nav_link_prefix_match_is_active() {
let html = nav_link_matched(
"/admin/posts/3/edit",
"/admin/posts",
"Posts",
NavLinkMatch::Prefix,
)
.into_string();
assert!(html.contains(r#"class="active""#), "{html}");
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn nav_link_prefix_mode_rejects_false_positive() {
let html = nav_link_matched(
"/admin/postsX",
"/admin/posts",
"Posts",
NavLinkMatch::Prefix,
)
.into_string();
assert!(!html.contains("active"), "{html}");
assert!(!html.contains("aria-current"), "{html}");
}
#[test]
fn nav_link_prefix_mode_exact_path_is_active() {
let html = nav_link_matched(
"/admin/posts",
"/admin/posts",
"Posts",
NavLinkMatch::Prefix,
)
.into_string();
assert!(html.contains(r#"class="active""#), "{html}");
}
#[test]
fn nav_link_prefix_mode_trailing_slash_href_still_matches() {
let html = nav_link_matched(
"/admin/posts/3/edit",
"/admin/posts/",
"Posts",
NavLinkMatch::Prefix,
)
.into_string();
assert!(html.contains(r#"class="active""#), "{html}");
}
#[test]
fn nav_link_prefix_mode_empty_href_never_matches() {
let html = nav_link_matched("/admin/posts/3/edit", "", "Posts", NavLinkMatch::Prefix)
.into_string();
assert!(!html.contains("active"), "{html}");
assert!(!html.contains("aria-current"), "{html}");
}
#[test]
fn nav_link_prefix_mode_root_href_does_not_match_every_path() {
let html =
nav_link_matched("/admin/posts", "/", "Home", NavLinkMatch::Prefix).into_string();
assert!(!html.contains("active"), "{html}");
let html = nav_link_matched("/", "/", "Home", NavLinkMatch::Prefix).into_string();
assert!(html.contains(r#"class="active""#), "{html}");
}
#[test]
fn nav_link_renders_label_and_href() {
let html = nav_link("/somewhere/else", "/admin/posts", "Posts").into_string();
assert!(html.contains(r#"href="/admin/posts""#), "{html}");
assert!(html.contains(">Posts<"), "{html}");
}
#[test]
fn nav_bar_renders_single_nav_landmark_with_default_label() {
let config = NavBarConfig::new();
let html = nav_bar("/", &config).into_string();
assert_eq!(html.matches("<nav").count(), 1, "{html}");
assert!(html.contains(r#"aria-label="Main""#), "{html}");
assert!(html.contains(r#"class="autumn-nav""#), "{html}");
assert!(html.contains("data-autumn-nav"), "{html}");
}
#[test]
fn nav_bar_custom_aria_label() {
let config = NavBarConfig::new().aria_label("Admin navigation");
let html = nav_bar("/", &config).into_string();
assert!(html.contains(r#"aria-label="Admin navigation""#), "{html}");
}
#[test]
fn nav_bar_brand_text_renders_brand_link() {
let config = NavBarConfig::new().brand("Acme", "/");
let html = nav_bar("/", &config).into_string();
assert!(
html.contains(r#"<a class="autumn-nav__brand" href="/">Acme</a>"#),
"{html}"
);
}
#[test]
fn nav_bar_brand_html_without_href_renders_span() {
let config = NavBarConfig::new().brand_html(maud::html! { "Acme" }, None);
let html = nav_bar("/", &config).into_string();
assert!(
html.contains(r#"<span class="autumn-nav__brand">Acme</span>"#),
"{html}"
);
assert!(!html.contains("<a class=\"autumn-nav__brand\""), "{html}");
}
#[test]
fn nav_bar_without_brand_omits_brand_element() {
let config = NavBarConfig::new();
let html = nav_bar("/", &config).into_string();
assert!(!html.contains("autumn-nav__brand"), "{html}");
}
#[test]
fn nav_bar_active_link_gets_aria_current() {
let config = NavBarConfig::new().item(NavItem::link("/posts", "Posts"));
let html = nav_bar("/posts", &config).into_string();
assert_eq!(html.matches(r#"aria-current="page""#).count(), 1, "{html}");
assert!(html.contains(r#"class="active""#), "{html}");
}
#[test]
fn nav_bar_prefix_match_mode_activates_on_subpath() {
let config = NavBarConfig::new().item(NavItem::link_matched(
"/posts",
"Posts",
NavLinkMatch::Prefix,
));
let html = nav_bar("/posts/3/edit", &config).into_string();
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn nav_bar_plain_link_never_active() {
let config = NavBarConfig::new().item(NavItem::plain_link("/actuator/ui", "Actuator"));
let html = nav_bar("/actuator/ui", &config).into_string();
assert!(!html.contains("aria-current"), "{html}");
assert!(!html.contains(r#"class="active""#), "{html}");
assert!(html.contains(r#"href="/actuator/ui""#), "{html}");
}
#[test]
fn nav_bar_section_renders_label_li() {
let config = NavBarConfig::new().item(NavItem::section("System"));
let html = nav_bar("/", &config).into_string();
assert!(
html.contains(r#"<li class="autumn-nav__section">System</li>"#),
"{html}"
);
}
#[test]
fn nav_bar_menu_renders_button_controlling_ul() {
let config = NavBarConfig::new().item(NavItem::menu(
NavMenu::new("Products").link("/widgets", "Widgets"),
));
let html = nav_bar("/", &config).into_string();
assert!(html.contains(r#"type="button""#), "{html}");
assert!(html.contains("data-nav-menu-toggle"), "{html}");
assert!(html.contains(r#"aria-expanded="true""#), "{html}");
assert!(
html.contains(r#"aria-controls="autumn-nav-menu-1""#),
"{html}"
);
assert!(
html.contains(r#"<ul id="autumn-nav-menu-1" class="autumn-nav__menu""#),
"{html}"
);
}
#[test]
fn nav_bar_menu_sub_link_active_gets_aria_current() {
let config = NavBarConfig::new().item(NavItem::menu(
NavMenu::new("Products").link("/widgets", "Widgets"),
));
let html = nav_bar("/widgets", &config).into_string();
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn nav_bar_menu_ids_unique_and_respect_custom_id() {
let config = NavBarConfig::new()
.item(NavItem::menu(NavMenu::new("A").link("/a", "A")))
.item(NavItem::menu(NavMenu::new("B").link("/b", "B")))
.id("top");
let html = nav_bar("/", &config).into_string();
assert!(html.contains(r#"aria-controls="top-menu-1""#), "{html}");
assert!(html.contains(r#"id="top-menu-1""#), "{html}");
assert!(html.contains(r#"aria-controls="top-menu-2""#), "{html}");
assert!(html.contains(r#"id="top-menu-2""#), "{html}");
assert!(html.contains(r#"id="top-collapse""#), "{html}");
}
#[test]
fn nav_bar_toggle_button_is_hidden_with_accessible_name() {
let config = NavBarConfig::new();
let html = nav_bar("/", &config).into_string();
assert!(html.contains("data-nav-toggle"), "{html}");
assert!(html.contains("hidden"), "{html}");
assert!(html.contains(r#"aria-expanded="false""#), "{html}");
assert!(
html.contains(r#"aria-controls="autumn-nav-collapse""#),
"{html}"
);
assert!(html.contains(">Menu<"), "{html}");
let config = NavBarConfig::new().toggle_label("Navigation");
let html = nav_bar("/", &config).into_string();
assert!(html.contains(">Navigation<"), "{html}");
}
#[test]
fn nav_bar_trailing_slot_renders_separate_list() {
let config = NavBarConfig::new().trailing(NavItem::plain_link("/login", "Log in"));
let html = nav_bar("/", &config).into_string();
assert!(html.contains("autumn-nav__items--trailing"), "{html}");
assert!(html.contains(r#"href="/login""#), "{html}");
let config = NavBarConfig::new();
let html = nav_bar("/", &config).into_string();
assert!(!html.contains("autumn-nav__items--trailing"), "{html}");
}
#[test]
fn nav_bar_sidebar_layout_adds_modifier() {
let config = NavBarConfig::new().layout(NavBarLayout::Sidebar);
let html = nav_bar("/", &config).into_string();
assert!(html.contains("autumn-nav--sidebar"), "{html}");
let config = NavBarConfig::new();
let html = nav_bar("/", &config).into_string();
assert!(!html.contains("autumn-nav--sidebar"), "{html}");
}
#[test]
fn nav_bar_class_merges_extra() {
let config = NavBarConfig::new().class("admin-sidebar");
let html = nav_bar("/", &config).into_string();
assert!(
html.contains(r#"class="autumn-nav admin-sidebar""#),
"{html}"
);
}
#[test]
fn nav_item_constructors_accept_owned_strings_without_extra_copy() {
let post_id = 3;
let href: String = format!("/posts/{post_id}");
let label: String = "Posts".to_string();
let config = NavBarConfig::new()
.item(NavItem::link(href, label))
.item(NavItem::plain_link("/x".to_string(), "X".to_string()))
.item(NavItem::section("System".to_string()))
.item(NavItem::menu(
NavMenu::new("Products".to_string())
.link(format!("/widgets/{post_id}"), "Widgets".to_string())
.plain_link("/external".to_string(), "External".to_string()),
));
let html = nav_bar("/posts/3", &config).into_string();
assert!(html.contains(r#"href="/posts/3""#), "{html}");
assert!(html.contains(r#"aria-current="page""#), "{html}");
}
#[test]
fn nav_bar_escapes_labels() {
let config = NavBarConfig::new()
.brand("<script>alert(1)</script>", "/")
.item(NavItem::link("/posts", "<script>alert(2)</script>"))
.item(NavItem::menu(
NavMenu::new("<script>alert(3)</script>").link("/x", "<script>alert(4)</script>"),
));
let html = nav_bar("/", &config).into_string();
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
}
#[test]
fn nav_bar_emits_no_inline_style_or_script() {
let config = NavBarConfig::new()
.brand("Acme", "/")
.item(NavItem::link("/", "Home"))
.item(NavItem::menu(
NavMenu::new("Products").link("/widgets", "Widgets"),
))
.trailing(NavItem::plain_link("/login", "Log in"));
let html = nav_bar("/", &config).into_string();
assert!(!html.contains("style="), "{html}");
assert!(!html.contains("<script"), "{html}");
}
#[test]
fn property_list_empty_slice_renders_empty_dl_never_panics() {
let html = property_list(&[]).into_string();
assert!(html.contains("<dl"), "{html}");
assert!(!html.contains("<dt"), "{html}");
}
#[test]
fn property_list_has_autumn_property_list_class() {
let html = property_list(&[]).into_string();
assert!(html.contains(r#"class="autumn-property-list""#), "{html}");
}
#[test]
fn property_list_renders_dt_and_dd_per_row() {
let rows = vec![
("Title", maud::html! { "My Post" }),
("Published", maud::html! { "true" }),
];
let html = property_list(&rows).into_string();
assert!(html.contains("<dt>Title</dt>"), "{html}");
assert!(html.contains("<dt>Published</dt>"), "{html}");
assert!(html.contains("<dd>My Post</dd>"), "{html}");
assert!(html.contains("<dd>true</dd>"), "{html}");
}
#[test]
fn property_list_escapes_label() {
let rows = vec![("<script>alert(1)</script>", maud::html! { "safe" })];
let html = property_list(&rows).into_string();
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
}
#[test]
fn property_list_renders_markup_value_unescaped() {
let rows = vec![("Link", maud::html! { a href="/foo" { "click" } })];
let html = property_list(&rows).into_string();
assert!(html.contains(r#"<a href="/foo">click</a>"#), "{html}");
}
#[test]
fn property_list_multiple_rows_all_rendered() {
let rows = vec![
("A", maud::html! { "1" }),
("B", maud::html! { "2" }),
("C", maud::html! { "3" }),
];
let html = property_list(&rows).into_string();
assert_eq!(html.matches("<dt>").count(), 3, "{html}");
assert_eq!(html.matches("<dd>").count(), 3, "{html}");
}
#[test]
fn sortdir_param_value() {
assert_eq!(SortDir::Asc.param_value(), "asc");
assert_eq!(SortDir::Desc.param_value(), "desc");
}
#[test]
fn sortdir_aria_value() {
assert_eq!(SortDir::Asc.aria_value(), "ascending");
assert_eq!(SortDir::Desc.aria_value(), "descending");
}
#[test]
fn sortdir_toggled() {
assert_eq!(SortDir::Asc.toggled(), SortDir::Desc);
assert_eq!(SortDir::Desc.toggled(), SortDir::Asc);
}
#[test]
fn data_table_config_defaults() {
let cfg = DataTableConfig::new("No items");
assert_eq!(cfg.empty_message, "No items");
assert_eq!(cfg.sort_param, "sort");
assert_eq!(cfg.dir_param, "dir");
assert_eq!(cfg.page_param, "page");
assert!(cfg.active_sort.is_none());
assert_eq!(cfg.active_dir, SortDir::Asc);
assert!(cfg.caption.is_none());
assert!(cfg.class.is_none());
assert_eq!(cfg.base_path, "");
assert_eq!(cfg.query, "");
}
#[test]
fn data_table_config_builders() {
let cfg = DataTableConfig::new("No items")
.base_path("/posts")
.query("q=foo")
.caption("Posts table")
.active_sort("title")
.active_dir(SortDir::Desc)
.class("my-table");
assert_eq!(cfg.base_path, "/posts");
assert_eq!(cfg.query, "q=foo");
assert_eq!(cfg.caption, Some("Posts table"));
assert_eq!(cfg.active_sort, Some("title"));
assert_eq!(cfg.active_dir, SortDir::Desc);
assert_eq!(cfg.class, Some("my-table"));
}
#[test]
fn data_table_has_thead_th_scope_col() {
let cols: Vec<Column<i32>> = vec![
Column::new("Name", |_| maud::html! { "n" }),
Column::new("Age", |_| maud::html! { "a" }),
];
let html = data_table(&[1i32], &cols, &DataTableConfig::new("empty")).into_string();
assert!(html.contains("<thead"), "{html}");
assert!(html.contains(r#"scope="col""#), "{html}");
assert!(html.contains("Name"), "{html}");
assert!(html.contains("Age"), "{html}");
}
#[test]
fn data_table_renders_tbody_rows_and_cells() {
let cols: Vec<Column<&str>> = vec![Column::new("Word", |row| maud::html! { (*row) })];
let html =
data_table(&["hello", "world"], &cols, &DataTableConfig::new("empty")).into_string();
assert!(html.contains("<tbody"), "{html}");
assert!(html.contains("hello"), "{html}");
assert!(html.contains("world"), "{html}");
assert_eq!(
html.matches("<tr").count(),
3,
"1 header row + 2 data rows: {html}"
);
}
#[test]
fn data_table_caption_when_set() {
let cols: Vec<Column<i32>> = vec![Column::new("X", |_| maud::html! { "x" })];
let html = data_table(
&[1i32],
&cols,
&DataTableConfig::new("e").caption("My Table"),
)
.into_string();
assert!(html.contains("<caption"), "{html}");
assert!(html.contains("My Table"), "{html}");
}
#[test]
fn data_table_no_caption_by_default() {
let cols: Vec<Column<i32>> = vec![Column::new("X", |_| maud::html! { "x" })];
let html = data_table(&[1i32], &cols, &DataTableConfig::new("e")).into_string();
assert!(!html.contains("<caption"), "{html}");
}
#[test]
fn data_table_applies_class_when_set() {
let cols: Vec<Column<i32>> = vec![Column::new("X", |_| maud::html! { "x" })];
let html =
data_table(&[1i32], &cols, &DataTableConfig::new("e").class("styled")).into_string();
assert!(html.contains(r#"class="styled""#), "{html}");
}
#[test]
fn data_table_empty_renders_single_colspan_row() {
let cols: Vec<Column<i32>> = vec![
Column::new("A", |_| maud::html! { "a" }),
Column::new("B", |_| maud::html! { "b" }),
Column::new("C", |_| maud::html! { "c" }),
];
let html = data_table(&[], &cols, &DataTableConfig::new("Nothing here")).into_string();
assert!(html.contains("Nothing here"), "{html}");
assert!(html.contains(r#"colspan="3""#), "{html}");
assert_eq!(html.matches("<tr").count(), 2, "header + empty row: {html}");
}
#[test]
fn data_table_empty_message_announced() {
let cols: Vec<Column<i32>> = vec![Column::new("X", |_| maud::html! { "x" })];
let html = data_table(&[], &cols, &DataTableConfig::new("No results")).into_string();
assert!(html.contains(r#"role="status""#), "{html}");
}
#[test]
fn data_table_nonsortable_header_is_plain() {
let cols: Vec<Column<i32>> = vec![Column::new("Title", |_| maud::html! { "t" })];
let html = data_table(&[1i32], &cols, &DataTableConfig::new("e")).into_string();
let thead_end = html.find("</thead>").unwrap_or(html.len());
let thead = &html[..thead_end];
assert!(!thead.contains("<a "), "{html}");
assert!(!thead.contains("aria-sort"), "{html}");
}
#[test]
fn data_table_sortable_inactive_has_link_and_aria_sort_none() {
let cols: Vec<Column<i32>> =
vec![Column::new("Title", |_| maud::html! { "t" }).sortable("title")];
let cfg = DataTableConfig::new("e").base_path("/posts");
let html = data_table(&[1i32], &cols, &cfg).into_string();
assert!(html.contains(r#"aria-sort="none""#), "{html}");
assert!(html.contains("sort=title"), "{html}");
assert!(html.contains("dir=asc"), "{html}");
}
#[test]
fn data_table_sortable_active_reflects_dir_and_toggles() {
let cols: Vec<Column<i32>> =
vec![Column::new("Title", |_| maud::html! { "t" }).sortable("title")];
let cfg = DataTableConfig::new("e")
.base_path("/posts")
.active_sort("title")
.active_dir(SortDir::Asc);
let html = data_table(&[1i32], &cols, &cfg).into_string();
assert!(html.contains(r#"aria-sort="ascending""#), "{html}");
assert!(html.contains("dir=desc"), "{html}");
}
#[test]
fn data_table_sortable_link_preserves_other_query_params() {
let cols: Vec<Column<i32>> =
vec![Column::new("Name", |_| maud::html! { "n" }).sortable("name")];
let cfg = DataTableConfig::new("e")
.base_path("/posts")
.query("q=hello");
let html = data_table(&[1i32], &cols, &cfg).into_string();
assert!(html.contains("q=hello"), "{html}");
}
#[test]
fn data_table_sortable_link_drops_existing_sort_dir_page() {
let cols: Vec<Column<i32>> =
vec![Column::new("Name", |_| maud::html! { "n" }).sortable("name")];
let cfg = DataTableConfig::new("e")
.base_path("/posts")
.query("q=foo&sort=old&dir=asc&page=3");
let html = data_table(&[1i32], &cols, &cfg).into_string();
assert!(html.contains("sort=name"), "{html}");
assert!(!html.contains("page=3"), "{html}");
assert!(html.contains("q=foo"), "{html}");
assert!(!html.contains("sort=old"), "{html}");
}
#[test]
fn card_config_defaults() {
let html = card(&maud::html! {}, &CardConfig::new()).into_string();
assert!(!html.contains("card-header"), "{html}");
assert!(!html.contains("card-footer"), "{html}");
let html2 = card(&maud::html! {}, &CardConfig::new().title("X")).into_string();
assert!(html2.contains("<h2"), "{html2}");
}
#[test]
fn card_config_builders_chain() {
let html = card(
&maud::html! {},
&CardConfig::new()
.title("T")
.level(HeadingLevel::H3)
.class("wide"),
)
.into_string();
assert!(html.contains(r#"class="card wide""#), "{html}");
assert!(html.contains("<h3"), "{html}");
assert!(html.contains('T'), "{html}");
}
#[test]
fn card_has_root_and_body_classes() {
let body = maud::html! { p { "hello" } };
let html = card(&body, &CardConfig::new()).into_string();
assert!(html.contains(r#"class="card""#), "{html}");
assert!(html.contains(r#"class="card-body""#), "{html}");
assert!(html.contains("hello"), "{html}");
assert!(!html.contains("card-header"), "{html}");
assert!(!html.contains("card-footer"), "{html}");
}
#[test]
fn card_renders_title_as_h2_by_default() {
let body = maud::html! {};
let html = card(&body, &CardConfig::new().title("Posts")).into_string();
assert!(
html.contains(r#"<h2 class="card-title">Posts</h2>"#),
"{html}"
);
assert!(html.contains(r#"class="card-header""#), "{html}");
}
#[test]
fn card_title_respects_heading_level() {
let body = maud::html! {};
let html = card(&body, &CardConfig::new().title("X").level(HeadingLevel::H3)).into_string();
assert!(html.contains(r#"<h3 class="card-title">"#), "{html}");
assert!(!html.contains("<h2"), "{html}");
}
#[test]
fn card_omits_header_when_empty() {
let body = maud::html! {};
let html = card(&body, &CardConfig::new()).into_string();
assert!(!html.contains("card-header"), "{html}");
}
#[test]
fn card_renders_header_action() {
let action = maud::html! { a class="btn" href="/new" { "New" } };
let body = maud::html! {};
let html = card(&body, &CardConfig::new().header_action(action)).into_string();
assert!(html.contains(r#"class="card-header""#), "{html}");
assert!(html.contains(r#"class="btn""#), "{html}");
assert!(html.contains(r#"href="/new""#), "{html}");
}
#[test]
fn card_header_action_only_no_title() {
let action = maud::html! { button { "Click" } };
let body = maud::html! {};
let html = card(&body, &CardConfig::new().header_action(action)).into_string();
assert!(html.contains("card-header"), "{html}");
assert!(!html.contains("card-title"), "{html}");
assert!(!html.contains("<h2"), "{html}");
}
#[test]
fn card_renders_footer() {
let footer = maud::html! { span { "Save" } };
let body = maud::html! {};
let html = card(&body, &CardConfig::new().footer(footer)).into_string();
assert!(html.contains(r#"class="card-footer""#), "{html}");
assert!(html.contains("Save"), "{html}");
}
#[test]
fn card_omits_footer_when_none() {
let body = maud::html! {};
let html = card(&body, &CardConfig::new()).into_string();
assert!(!html.contains("card-footer"), "{html}");
}
#[test]
fn card_extra_class_escape_hatch() {
let body = maud::html! {};
let html = card(&body, &CardConfig::new().class("dashboard")).into_string();
assert!(html.contains(r#"class="card dashboard""#), "{html}");
}
#[test]
fn card_escapes_title() {
let body = maud::html! {};
let html = card(&body, &CardConfig::new().title("<script>alert(1)</script>")).into_string();
assert!(html.contains("<script>"), "{html}");
assert!(!html.contains("<script>alert"), "{html}");
}
#[test]
fn card_title_html_passes_markup() {
let rich = maud::html! {
span { "Posts" }
span class="muted" { " (42)" }
};
let body = maud::html! {};
let html = card(&body, &CardConfig::new().title_html(rich)).into_string();
assert!(html.contains(r#"class="muted""#), "{html}");
assert!(html.contains("(42)"), "{html}");
}
#[test]
fn card_body_composes_property_list() {
let rows = vec![("Name", maud::html! { "Alice" })];
let body = property_list(&rows);
let html = card(&body, &CardConfig::new().title("Detail")).into_string();
assert!(html.contains(r#"class="card-body""#), "{html}");
assert!(html.contains(r#"class="autumn-property-list""#), "{html}");
}
#[test]
fn stat_card_renders_label_value() {
let html = stat_card("Users", "42", None).into_string();
assert!(html.contains(r#"class="stat-card""#), "{html}");
assert!(html.contains(r#"class="stat-label""#), "{html}");
assert!(html.contains(r#"class="stat-value""#), "{html}");
assert!(html.contains("Users"), "{html}");
assert!(html.contains("42"), "{html}");
assert!(!html.contains("stat-link"), "{html}");
}
#[test]
fn stat_card_renders_optional_link() {
let html = stat_card("Users", "42", Some(("/users", "View all →"))).into_string();
assert!(html.contains(r#"class="stat-link""#), "{html}");
assert!(html.contains(r#"href="/users""#), "{html}");
assert!(html.contains("View all"), "{html}");
}
#[test]
fn stat_card_escapes_value() {
let html = stat_card("L", "<b>x</b>", None).into_string();
assert!(html.contains("<b>"), "{html}");
assert!(!html.contains("<b>x</b>"), "{html}");
}
#[test]
fn hero_title_only() {
let html = hero(&HeroConfig::new("Welcome")).into_string();
assert!(html.contains("Welcome"), "{html}");
assert!(html.contains(r#"class="autumn-hero""#), "{html}");
assert!(html.contains("<h1"), "{html}");
assert_eq!(html.matches("<h1").count(), 1, "{html}");
assert!(!html.contains("autumn-hero__subtitle"), "{html}");
assert!(!html.contains("autumn-hero__actions"), "{html}");
assert!(!html.contains("<a"), "{html}");
}
#[test]
fn hero_title_and_subtitle() {
let html = hero(&HeroConfig::new("Welcome").subtitle("A tagline")).into_string();
assert!(html.contains("Welcome"), "{html}");
assert!(html.contains(r#"class="autumn-hero__subtitle""#), "{html}");
assert!(html.contains("A tagline"), "{html}");
}
#[test]
fn hero_title_subtitle_two_ctas() {
let html = hero(
&HeroConfig::new("Welcome")
.subtitle("A tagline")
.cta(Cta::primary("Get started", "/signup"))
.cta(Cta::secondary("Learn more", "/about")),
)
.into_string();
assert!(html.contains(r#"class="autumn-hero__actions""#), "{html}");
assert!(html.contains(r#"href="/signup""#), "{html}");
assert!(html.contains("Get started"), "{html}");
assert!(html.contains(r#"href="/about""#), "{html}");
assert!(html.contains("Learn more"), "{html}");
assert!(html.contains("autumn-hero__cta--primary"), "{html}");
assert!(html.contains("autumn-hero__cta--secondary"), "{html}");
}
#[test]
fn hero_heading_level_configurable() {
let html = hero(&HeroConfig::new("Welcome").level(HeadingLevel::H2)).into_string();
assert!(html.contains("<h2"), "{html}");
assert!(!html.contains("<h1"), "{html}");
}
#[test]
fn hero_default_heading_level_is_h1() {
let config = HeroConfig::new("Welcome");
let html = hero(&config).into_string();
assert!(html.contains("<h1"), "{html}");
}
#[test]
fn hero_escapes_title_and_subtitle() {
let html = hero(&HeroConfig::new("<b>hi</b>").subtitle("<i>tag</i>")).into_string();
assert!(html.contains("<b>"), "{html}");
assert!(!html.contains("<b>hi</b>"), "{html}");
assert!(html.contains("<i>"), "{html}");
assert!(!html.contains("<i>tag</i>"), "{html}");
}
#[test]
fn hero_extra_class_escape_hatch() {
let html = hero(&HeroConfig::new("Welcome").class("centered")).into_string();
assert!(html.contains(r#"class="autumn-hero centered""#), "{html}");
}
#[test]
fn hero_cta_and_class_accept_owned_strings_built_inline() {
let post_id = 42;
let html = hero(
&HeroConfig::new("Welcome")
.class(format!("theme-{post_id}"))
.cta(Cta::primary(
format!("Read post {post_id}"),
format!("/posts/{post_id}"),
)),
)
.into_string();
assert!(html.contains(r#"class="autumn-hero theme-42""#), "{html}");
assert!(html.contains(r#"href="/posts/42""#), "{html}");
assert!(html.contains("Read post 42"), "{html}");
}
#[test]
fn cta_style_default_is_primary() {
assert_eq!(CtaStyle::default(), CtaStyle::Primary);
}
#[test]
fn sparkline_is_accessible_inline_svg() {
let html = sparkline(&[("a", 1.0), ("b", 2.0), ("c", 1.5)]).into_string();
assert!(html.contains(r#"role="img""#), "{html}");
assert!(
html.contains("autumn-chart autumn-chart--sparkline"),
"{html}"
);
assert!(html.contains("autumn-chart__svg"), "{html}");
assert!(html.contains("<polyline"), "{html}");
assert!(html.contains("<title>Sparkline</title>"), "{html}");
assert!(html.contains("<desc>"), "{html}");
}
#[test]
fn bar_chart_emits_a_rect_per_datum() {
let html = bar_chart(&[("Q1", 3.0), ("Q2", 5.0), ("Q3", 4.0)]).into_string();
assert_eq!(html.matches("<rect").count(), 3, "{html}");
assert!(html.contains("autumn-chart--bar"), "{html}");
assert!(html.contains("autumn-chart__bar"), "{html}");
}
#[test]
fn line_chart_emits_polyline_and_points() {
let html = line_chart(&[("a", 1.0), ("b", 9.0), ("c", 4.0)]).into_string();
assert!(html.contains("autumn-chart--line"), "{html}");
assert!(html.contains("<polyline"), "{html}");
assert_eq!(html.matches("<circle").count(), 3, "{html}");
}
#[test]
fn charts_never_emit_script_or_inline_style() {
for html in [
sparkline(&[("a", 1.0), ("b", 2.0)]).into_string(),
bar_chart(&[("a", 1.0), ("b", 2.0)]).into_string(),
line_chart(&[("a", 1.0), ("b", 2.0)]).into_string(),
] {
assert!(!html.contains("<script"), "no scripts: {html}");
assert!(!html.contains("style="), "no inline style: {html}");
assert!(!html.contains("http://"), "no external assets: {html}");
}
}
#[test]
fn chart_escapes_hostile_labels_in_table_fallback() {
let html = bar_chart_with(
&[("<script>alert(1)</script>", 5.0)],
&ChartConfig::new().with_table(),
)
.into_string();
assert!(html.contains("<script>"), "{html}");
assert!(!html.contains("<script>alert"), "{html}");
}
#[test]
fn empty_input_renders_valid_svg_without_panic() {
for html in [
sparkline(&[]).into_string(),
bar_chart(&[]).into_string(),
line_chart(&[]).into_string(),
] {
assert!(html.contains(r#"role="img""#), "{html}");
assert!(html.contains("<desc>No data</desc>"), "{html}");
assert!(!html.contains("NaN"), "no NaN coordinates: {html}");
assert!(!html.contains("<script"), "{html}");
}
}
#[test]
fn single_point_does_not_divide_by_zero() {
let html = sparkline(&[("only", 7.0)]).into_string();
assert!(
html.contains("<circle"),
"single point renders a dot: {html}"
);
assert!(!html.contains("NaN"), "{html}");
assert!(html.contains(r#"cx="50""#), "{html}");
}
#[test]
fn all_equal_positive_values_render_full_height_bars() {
let html = bar_chart(&[("a", 4.0), ("b", 4.0), ("c", 4.0)]).into_string();
assert!(!html.contains("NaN"), "{html}");
assert_eq!(html.matches(r#"height="36""#).count(), 3, "{html}");
assert!(!html.contains(r#"height="18""#), "not half height: {html}");
}
#[test]
fn all_zero_bars_render_zero_height() {
let html = bar_chart(&[("a", 0.0), ("b", 0.0)]).into_string();
assert!(!html.contains("NaN"), "{html}");
assert_eq!(html.matches(r#"height="0""#).count(), 2, "{html}");
assert!(
!html.contains(r#"height="18""#),
"no half-height bars: {html}"
);
}
#[cfg(test)]
fn bar_heights(html: &str) -> Vec<f64> {
html.match_indices(r#"height=""#)
.map(|(i, m)| {
let rest = &html[i + m.len()..];
let end = rest.find('"').unwrap();
rest[..end].parse::<f64>().unwrap()
})
.collect()
}
#[test]
fn bar_auto_baseline_includes_zero_so_min_positive_bar_is_visible() {
let html = bar_chart(&[("Q1", 12.0), ("Q2", 19.0), ("Q3", 7.0)]).into_string();
assert!(!html.contains("NaN"), "{html}");
let heights = bar_heights(&html);
assert_eq!(heights.len(), 3, "{html}");
let (q1, q2, q3) = (heights[0], heights[1], heights[2]);
assert!(q3 > 0.0, "smallest bar must be visible, got {q3}: {html}");
assert!(
!html.contains(r#"height="0""#),
"no zero-height bar: {html}"
);
assert!(q2 > q3, "tallest (Q2=19) taller than Q3=7: {q2} vs {q3}");
assert!(q2 > q1 && q1 > q3, "12 between 7 and 19: {q1}");
assert!((q2 - 36.0).abs() < 1e-6, "max bar is full height: {q2}");
let expected_q3 = 7.0 / 19.0 * 36.0;
assert!(
(q3 - expected_q3).abs() < 1e-3,
"Q3 scaled from zero baseline: {q3}"
);
}
#[test]
fn explicit_bar_min_override_wins_over_zero_baseline() {
let html = bar_chart_with(
&[("Q1", 12.0), ("Q2", 19.0), ("Q3", 7.0)],
&ChartConfig::new().min(7.0),
)
.into_string();
assert!(!html.contains("NaN"), "{html}");
let heights = bar_heights(&html);
assert_eq!(heights.len(), 3, "{html}");
assert!(
(heights[2] - 0.0).abs() < 1e-9,
"explicit min pins Q3 to zero: {}",
heights[2]
);
assert!(html.contains(r#"height="0""#), "{html}");
}
#[test]
fn negative_bars_diverge_downward_largest_magnitude_is_tallest() {
let html = bar_chart(&[("a", -10.0), ("b", -5.0)]).into_string();
assert!(!html.contains("NaN"), "{html}");
let heights = bar_heights(&html);
assert_eq!(heights.len(), 2, "{html}");
let (a, b) = (heights[0], heights[1]);
assert!(a > 0.0, "largest-magnitude bar must be visible: {a}");
assert!(a > b, "|-10| taller than |-5|: {a} vs {b}");
assert!((a - 36.0).abs() < 1e-6, "-10 fills usable height: {a}");
assert!((b - 18.0).abs() < 1e-6, "-5 is half height: {b}");
assert!(
!html.contains(r#"height="0""#),
"no zero-height bar for a nonzero series: {html}"
);
}
#[cfg(test)]
fn bar_ys(html: &str) -> Vec<f64> {
html.match_indices(r#"y=""#)
.map(|(i, m)| {
let rest = &html[i + m.len()..];
let end = rest.find('"').unwrap();
rest[..end].parse::<f64>().unwrap()
})
.collect()
}
#[test]
fn mixed_sign_bars_share_a_zero_baseline() {
let html = bar_chart(&[("up", 10.0), ("down", -5.0)]).into_string();
assert!(!html.contains("NaN"), "{html}");
let heights = bar_heights(&html);
let ys = bar_ys(&html);
assert_eq!(heights.len(), 2, "{html}");
assert_eq!(ys.len(), 2, "{html}");
assert!(heights[0] > 0.0 && heights[1] > 0.0, "both visible: {html}");
let y_zero = 26.0;
let up_bottom = ys[0] + heights[0];
assert!(
(up_bottom - y_zero).abs() < 1e-6,
"positive bar's bottom edge on the zero line: {up_bottom} vs {y_zero}"
);
assert!(
(ys[1] - y_zero).abs() < 1e-6,
"negative bar's top edge on the zero line: {} vs {y_zero}",
ys[1]
);
}
#[test]
fn negative_values_scale_without_panic() {
let html = line_chart(&[("a", -5.0), ("b", 0.0), ("c", 5.0)]).into_string();
assert!(!html.contains("NaN"), "{html}");
assert!(html.contains("<polyline"), "{html}");
assert!(html.contains(r#"points="3,37"#), "min at bottom: {html}");
}
#[test]
fn explicit_axis_override_changes_coordinates() {
let data = [("a", 0.0), ("b", 5.0), ("c", 10.0)];
let auto = sparkline(&data).into_string();
let overridden =
sparkline_with(&data, &ChartConfig::new().min(0.0).max(100.0)).into_string();
assert_ne!(
auto, overridden,
"caller min/max override must move the plotted points"
);
assert!(overridden.contains("50,26.7"), "{overridden}");
assert!(auto.contains("50,15"), "{auto}");
}
#[test]
fn non_finite_axis_override_is_ignored_no_nan_in_output() {
let data = [("a", 1.0), ("b", 2.0), ("c", 3.0)];
let bad_configs = [
ChartConfig::new().max(f64::NAN),
ChartConfig::new().min(f64::INFINITY),
ChartConfig::new().min(f64::NEG_INFINITY),
ChartConfig::new().max(f64::INFINITY),
];
let check = |name: &str, render: &dyn Fn(&ChartConfig<'_>) -> String| {
let auto = render(&ChartConfig::new());
for config in &bad_configs {
let overridden = render(config);
for needle in ["NaN", "inf", "Infinity"] {
assert!(
!overridden.contains(needle),
"{name}: non-finite override leaked `{needle}` into SVG: {overridden}"
);
}
assert_eq!(
auto, overridden,
"{name}: non-finite override must be ignored (auto-scale used)"
);
}
};
check("sparkline", &|config| {
sparkline_with(&data, config).into_string()
});
check("line_chart", &|config| {
line_chart_with(&data, config).into_string()
});
check("bar_chart", &|config| {
bar_chart_with(&data, config).into_string()
});
}
#[test]
fn desc_reports_data_range_not_axis_override() {
let html = sparkline_with(
&[("a", 0.0), ("b", 5.0), ("c", 10.0)],
&ChartConfig::new().max(100.0),
)
.into_string();
assert!(
html.contains("<desc>3 data points, ranging from 0 to 10.</desc>"),
"{html}"
);
assert!(
!html.contains("ranging from 0 to 100"),
"must not leak axis override into desc: {html}"
);
}
#[test]
fn table_fallback_lists_labels_and_values() {
let html = line_chart_with(
&[("Mon", 3.0), ("Tue", 5.5)],
&ChartConfig::new().with_table(),
)
.into_string();
assert!(html.contains("autumn-chart__table"), "{html}");
assert!(html.contains("autumn-visually-hidden"), "{html}");
assert!(html.contains("<td>Mon</td>"), "{html}");
assert!(html.contains("<td>5.5</td>"), "{html}");
}
#[test]
fn config_title_and_desc_feed_accessibility() {
let html = sparkline_with(
&[("a", 1.0), ("b", 2.0)],
&ChartConfig::new()
.title("Weekly signups")
.desc("Signups per day for the last week"),
)
.into_string();
assert!(html.contains(r#"aria-label="Weekly signups""#), "{html}");
assert!(html.contains("<title>Weekly signups</title>"), "{html}");
assert!(
html.contains("<desc>Signups per day for the last week</desc>"),
"{html}"
);
}
#[test]
fn config_class_is_merged_onto_root() {
let html = bar_chart_with(&[("a", 1.0)], &ChartConfig::new().class("dashboard-widget"))
.into_string();
assert!(
html.contains("autumn-chart autumn-chart--bar dashboard-widget"),
"{html}"
);
}
#[test]
fn fmt_coord_trims_trailing_zeros() {
assert_eq!(fmt_coord(10.0), "10");
assert_eq!(fmt_coord(0.5), "0.5");
assert_eq!(fmt_coord(0.0), "0");
assert_eq!(fmt_coord(-0.0), "0");
assert_eq!(fmt_coord(1.2345), "1.234");
}
}