use super::{
DEFAULT_TOAST_REGION_ID, HeadingLevel, ModalConfig, assert_named, heading, merge_class, modal,
modal_close_button, modal_trigger, selector_to_id,
};
#[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,
}
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
}
}
impl Default for ConfirmActionConfig<'_> {
fn default() -> Self {
Self::new()
}
}
#[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))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TooltipPlacement {
#[default]
Top,
Bottom,
Start,
End,
}
impl TooltipPlacement {
const fn class(self) -> &'static str {
match self {
Self::Top => "autumn-tooltip--top",
Self::Bottom => "autumn-tooltip--bottom",
Self::Start => "autumn-tooltip--start",
Self::End => "autumn-tooltip--end",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TooltipConfig<'a> {
placement: TooltipPlacement,
class: Option<&'a str>,
}
impl<'a> TooltipConfig<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn placement(mut self, placement: TooltipPlacement) -> Self {
self.placement = placement;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
pub fn tooltip(id: &str, text: &str, trigger: impl FnOnce(&str) -> maud::Markup) -> maud::Markup {
tooltip_with(id, text, &TooltipConfig::new(), trigger)
}
#[must_use]
pub fn tooltip_with(
id: &str,
text: &str,
config: &TooltipConfig<'_>,
trigger: impl FnOnce(&str) -> maud::Markup,
) -> maud::Markup {
let id = selector_to_id(id);
let text = text.trim();
let root = merge_class(
&format!("autumn-tooltip {}", config.placement.class()),
config.class,
);
maud::html! {
span class=(root) data-autumn-tooltip {
(trigger(id))
span id=(id) class="autumn-tooltip__bubble" role="tooltip"
data-autumn-dismiss="escape" { (text) }
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlertVariant {
#[default]
Info,
Success,
Warning,
Error,
}
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="autumn-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 class="autumn-alert__icon-glyph" x="10" y="15" text-anchor="middle"
font-size="13" font-family="sans-serif" fill="#fff" { (glyph) }
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AlertConfig<'a> {
title: Option<&'a str>,
icon: bool,
dismissible: bool,
class: Option<&'a str>,
}
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
}
}
#[must_use]
pub fn alert(variant: AlertVariant, body: maud::Markup) -> maud::Markup {
alert_with(variant, body, &AlertConfig::new())
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn alert_with(
variant: AlertVariant,
body: maud::Markup,
config: &AlertConfig<'_>,
) -> maud::Markup {
alert_root(variant, &body, config, None)
}
fn alert_root(
variant: AlertVariant,
body: &maud::Markup,
config: &AlertConfig<'_>,
focus_id: Option<&str>,
) -> maud::Markup {
let root = merge_class(
&format!("autumn-alert autumn-alert--{}", variant.as_str()),
config.class,
);
maud::html! {
div id=[focus_id] class=(root) role=(variant.role())
tabindex=[focus_id.map(|_| "-1")] autofocus[focus_id.is_some()] {
@if config.icon {
span class="autumn-alert__icon" { (variant.icon()) }
}
div class="autumn-alert__content" {
@if let Some(title) = config.title {
(heading(HeadingLevel::H2, None, "autumn-alert__title", &maud::html! { (title) }))
}
div class="autumn-alert__body" { (body) }
}
@if config.dismissible {
label class="autumn-alert__dismiss" {
input type="checkbox" class="autumn-alert__dismiss-toggle"
aria-label="Dismiss this message";
span aria-hidden="true" { "×" }
}
}
}
}
}
pub const ERROR_SUMMARY_ID: &str = "error-summary";
#[must_use]
pub fn error_summary<T>(changeset: &crate::form::Changeset<T>) -> Option<maud::Markup> {
if changeset.is_valid() {
return None;
}
let mut fields: Vec<&String> = changeset.all_errors().keys().collect();
fields.sort();
let body = maud::html! {
ul class="autumn-alert__errors" {
@for field in fields {
@let href = field_fragment(field);
@for msg in &changeset.all_errors()[field] {
@if let Some(href) = &href {
li { a href=(href) { (msg) } }
} @else {
li { (msg) }
}
}
}
}
};
Some(alert_root(
AlertVariant::Error,
&body,
&AlertConfig::new()
.title("Please fix the following errors")
.icon(true),
Some(ERROR_SUMMARY_ID),
))
}
const FRAGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'!')
.remove(b'$')
.remove(b'&')
.remove(b'\'')
.remove(b'(')
.remove(b')')
.remove(b'*')
.remove(b'+')
.remove(b',')
.remove(b';')
.remove(b'=')
.remove(b':')
.remove(b'@')
.remove(b'/')
.remove(b'?');
fn field_fragment(field: &str) -> Option<String> {
let last = field.rsplit('.').next().unwrap_or(field);
if field.is_empty() || last.starts_with("__") {
return None;
}
Some(format!(
"#{}",
percent_encoding::utf8_percent_encode(field, FRAGMENT)
))
}
const DEFAULT_TOAST_CLOSE_LABEL: &str = "Dismiss notification";
#[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" popover="manual" data-autumn-toast-region
aria-live="polite" aria-atomic="false" {}
}
}
#[derive(Debug, Clone, Default)]
pub struct ToastConfig<'a> {
region: Option<&'a str>,
duration: Option<std::time::Duration>,
persistent: bool,
close_label: Option<&'a str>,
}
impl<'a> ToastConfig<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn region(mut self, id: &'a str) -> Self {
self.region = Some(id);
self
}
#[must_use]
pub const fn duration(mut self, duration: std::time::Duration) -> Self {
self.duration = Some(duration);
self
}
#[must_use]
pub const fn persistent(mut self, persistent: bool) -> Self {
self.persistent = persistent;
self
}
#[must_use]
pub const fn close_label(mut self, label: &'a str) -> Self {
self.close_label = Some(label);
self
}
}
#[must_use]
pub fn toast(message: &str, variant: AlertVariant) -> maud::Markup {
toast_with(message, variant, &ToastConfig::new())
}
#[must_use]
pub fn toast_in(region_id: &str, message: &str, variant: AlertVariant) -> maud::Markup {
toast_with(message, variant, &ToastConfig::new().region(region_id))
}
#[must_use]
pub fn toast_with(message: &str, variant: AlertVariant, config: &ToastConfig<'_>) -> 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 mut class = format!("autumn-toast autumn-toast--{}", variant.as_str());
if config.persistent {
class.push_str(" autumn-toast--persistent");
}
let duration = config
.duration
.filter(|_| !config.persistent)
.map(|d| d.as_millis().to_string());
let close_label = config.close_label.unwrap_or(DEFAULT_TOAST_CLOSE_LABEL);
let region_id = selector_to_id(config.region.unwrap_or(DEFAULT_TOAST_REGION_ID));
let oob = format!("beforeend:#{region_id}");
maud::html! {
div hx-swap-oob=(oob) {
div class=(class) data-autumn-toast data-autumn-toast-duration=[duration]
aria-atomic="true" {
span class="autumn-toast__message" role=[role] aria-live=[live]
aria-atomic=[role.map(|_| "true")] { (message) }
button type="button" class="autumn-toast__close" data-autumn-toast-close
aria-label=(close_label) hidden {
span aria-hidden="true" { "×" }
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpinnerSize {
Small,
#[default]
Medium,
Large,
}
impl SpinnerSize {
const fn class(self) -> &'static str {
match self {
Self::Small => "autumn-spinner--sm",
Self::Medium => "autumn-spinner--md",
Self::Large => "autumn-spinner--lg",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SpinnerConfig<'a> {
size: SpinnerSize,
indicator: Option<&'a str>,
class: Option<&'a str>,
}
impl<'a> SpinnerConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
size: SpinnerSize::Medium,
indicator: None,
class: None,
}
}
#[must_use]
pub const fn size(mut self, size: SpinnerSize) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn indicator(mut self, id: &'a str) -> Self {
self.indicator = Some(id);
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
#[track_caller]
pub fn spinner(label: &str) -> maud::Markup {
spinner_with(label, &SpinnerConfig::new())
}
#[must_use]
#[track_caller]
pub fn spinner_with(label: &str, config: &SpinnerConfig<'_>) -> maud::Markup {
assert_named("spinner", label);
let mut root = format!("autumn-spinner {}", config.size.class());
if config.indicator.is_some() {
root.push_str(" autumn-indicator");
}
let root = merge_class(&root, config.class);
maud::html! {
span id=[config.indicator.map(selector_to_id)] class=(root) role="status" {
span class="autumn-spinner__icon" aria-hidden="true" {}
span class="autumn-visually-hidden" { (label) }
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SkeletonShape {
#[default]
Line,
Block,
Circle,
}
impl SkeletonShape {
const fn class(self) -> &'static str {
match self {
Self::Line => "autumn-skeleton--line",
Self::Block => "autumn-skeleton--block",
Self::Circle => "autumn-skeleton--circle",
}
}
}
pub const MAX_SKELETON_COUNT: usize = 64;
#[derive(Debug, Clone, Copy)]
pub struct SkeletonConfig<'a> {
count: usize,
indicator: Option<&'a str>,
class: Option<&'a str>,
}
impl Default for SkeletonConfig<'_> {
fn default() -> Self {
Self::new()
}
}
impl<'a> SkeletonConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
count: 1,
indicator: None,
class: None,
}
}
#[must_use]
pub const fn count(mut self, count: usize) -> Self {
self.count = count;
self
}
#[must_use]
pub const fn indicator(mut self, id: &'a str) -> Self {
self.indicator = Some(id);
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
pub fn skeleton(shape: SkeletonShape) -> maud::Markup {
skeleton_with(shape, &SkeletonConfig::new())
}
#[must_use]
pub fn skeleton_with(shape: SkeletonShape, config: &SkeletonConfig<'_>) -> maud::Markup {
let mut root = format!("autumn-skeleton {}", shape.class());
if config.indicator.is_some() {
root.push_str(" autumn-indicator");
}
let root = merge_class(&root, config.class);
maud::html! {
span id=[config.indicator.map(selector_to_id)] class=(root) aria-hidden="true" {
@for _ in 0..config.count.clamp(1, MAX_SKELETON_COUNT) {
span class="autumn-skeleton__item" {}
}
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ProgressConfig<'a> {
visible_label: bool,
value_text: Option<&'a str>,
id: Option<&'a str>,
class: Option<&'a str>,
}
impl<'a> ProgressConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
visible_label: false,
value_text: None,
id: None,
class: None,
}
}
#[must_use]
pub const fn visible_label(mut self) -> Self {
self.visible_label = true;
self
}
#[must_use]
pub const fn value_text(mut self, text: &'a str) -> Self {
self.value_text = Some(text);
self
}
#[must_use]
pub const fn id(mut self, id: &'a str) -> Self {
self.id = Some(id);
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
#[track_caller]
pub fn progress(label: &str, value: Option<f64>, max: f64) -> maud::Markup {
progress_with(label, value, max, &ProgressConfig::new())
}
#[must_use]
#[track_caller]
pub fn progress_with(
label: &str,
value: Option<f64>,
max: f64,
config: &ProgressConfig<'_>,
) -> maud::Markup {
assert_named("progress", label);
let max = if max.is_finite() && max > 0.0 {
max
} else {
1.0
};
let value = value.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, max));
let shown = value.map(|v| {
config
.value_text
.map_or_else(|| percent(v, 0.0, max, true), str::to_owned)
});
let value_text = config.value_text.filter(|_| value.is_some());
let field = config.visible_label;
let bar_class = if field {
"autumn-progress".to_owned()
} else {
merge_class("autumn-progress", config.class)
};
let bar = maud::html! {
progress class=(bar_class) id=[config.id.filter(|_| !field)] aria-label=(label)
aria-valuetext=[value_text] value=[value.map(fmt_number)] max=(fmt_number(max)) {
@if let Some(text) = &shown { (text) }
}
};
if field {
range_field(label, shown.as_deref(), config.id, config.class, &bar)
} else {
bar
}
}
fn range_field(
label: &str,
value: Option<&str>,
id: Option<&str>,
class: Option<&str>,
bar: &maud::Markup,
) -> maud::Markup {
maud::html! {
div class=(merge_class("autumn-progress-field", class)) id=[id] {
div class="autumn-progress-field__header" aria-hidden="true" {
span class="autumn-progress-field__label" { (label) }
@if let Some(value) = value {
span class="autumn-progress-field__value" { (value) }
}
}
(bar)
}
}
}
fn percent(value: f64, min: f64, max: f64, floor: bool) -> String {
let span = max / 2.0 - min / 2.0;
let ratio = if span > 0.0 {
(value / 2.0 - min / 2.0) / span
} else {
1.0
};
let pct = ratio * 100.0;
let pct = if floor {
(pct + 1e-9).floor()
} else {
pct.round()
};
format!("{}%", fmt_number(pct))
}
fn fmt_number(value: f64) -> String {
format!("{}", value + 0.0)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct MeterConfig<'a> {
low: Option<f64>,
high: Option<f64>,
optimum: Option<f64>,
visible_label: bool,
value_text: Option<&'a str>,
id: Option<&'a str>,
class: Option<&'a str>,
}
impl<'a> MeterConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
low: None,
high: None,
optimum: None,
visible_label: false,
value_text: None,
id: None,
class: None,
}
}
#[must_use]
pub const fn low(mut self, low: f64) -> Self {
self.low = Some(low);
self
}
#[must_use]
pub const fn high(mut self, high: f64) -> Self {
self.high = Some(high);
self
}
#[must_use]
pub const fn optimum(mut self, optimum: f64) -> Self {
self.optimum = Some(optimum);
self
}
#[must_use]
pub const fn visible_label(mut self) -> Self {
self.visible_label = true;
self
}
#[must_use]
pub const fn value_text(mut self, text: &'a str) -> Self {
self.value_text = Some(text);
self
}
#[must_use]
pub const fn id(mut self, id: &'a str) -> Self {
self.id = Some(id);
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
#[track_caller]
pub fn meter(label: &str, value: f64, range: std::ops::RangeInclusive<f64>) -> maud::Markup {
meter_with(label, value, range, &MeterConfig::new())
}
#[must_use]
#[track_caller]
pub fn meter_with(
label: &str,
value: f64,
range: std::ops::RangeInclusive<f64>,
config: &MeterConfig<'_>,
) -> maud::Markup {
assert_named("meter", label);
let (start, end) = range.into_inner();
let start = if start.is_finite() { start } else { 0.0 };
let end = if end.is_finite() { end } else { 1.0 };
let (min, max) = if start <= end {
(start, end)
} else {
(end, start)
};
let value = if value.is_finite() {
value.clamp(min, max)
} else {
min
};
let clamp = |x: Option<f64>| {
x.filter(|x| x.is_finite())
.map(|x| fmt_number(x.clamp(min, max)))
};
let shown = config
.value_text
.map_or_else(|| percent(value, min, max, false), str::to_owned);
let field = config.visible_label;
let bar_class = if field {
"autumn-meter".to_owned()
} else {
merge_class("autumn-meter", config.class)
};
let bar = maud::html! {
meter class=(bar_class) id=[config.id.filter(|_| !field)] aria-label=(label)
aria-valuetext=[config.value_text] value=(fmt_number(value))
min=(fmt_number(min)) max=(fmt_number(max))
low=[clamp(config.low)] high=[clamp(config.high)] optimum=[clamp(config.optimum)] {
(shown)
}
};
if field {
range_field(label, Some(&shown), config.id, config.class, &bar)
} else {
bar
}
}
#[derive(Debug, Clone, Default)]
pub struct EmptyStateConfig<'a> {
icon: Option<maud::Markup>,
level: HeadingLevel,
class: Option<&'a str>,
}
impl<'a> EmptyStateConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
icon: None,
level: HeadingLevel::H2,
class: None,
}
}
#[must_use]
pub fn icon(mut self, icon: maud::Markup) -> Self {
self.icon = Some(icon);
self
}
#[must_use]
pub const fn level(mut self, level: HeadingLevel) -> Self {
self.level = level;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
#[track_caller]
pub fn empty_state(title: &str, body: &str, action: Option<&maud::Markup>) -> maud::Markup {
empty_state_with(title, body, action, &EmptyStateConfig::new())
}
#[must_use]
#[track_caller]
pub fn empty_state_with(
title: &str,
body: &str,
action: Option<&maud::Markup>,
config: &EmptyStateConfig<'_>,
) -> maud::Markup {
assert_named("empty_state", title);
let root = merge_class("autumn-empty-state", config.class);
maud::html! {
div class=(root) {
@if let Some(icon) = &config.icon {
div class="autumn-empty-state__icon" aria-hidden="true" { (icon) }
}
(heading(config.level, None, "autumn-empty-state__title", &maud::html! { (title) }))
@if !body.trim().is_empty() {
p class="autumn-empty-state__body" { (body) }
}
@if let Some(action) = action {
div class="autumn-empty-state__action" { (action) }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use maud::html;
#[test]
fn skeleton_count_is_capped() {
let config = SkeletonConfig::new().count(usize::MAX);
let html = skeleton_with(SkeletonShape::Line, &config).into_string();
assert_eq!(
html.matches("autumn-skeleton__item").count(),
MAX_SKELETON_COUNT
);
}
#[test]
fn percent_rounds_progress_down_and_survives_huge_ranges() {
let config = ProgressConfig::new().visible_label();
let html = progress_with("Upload", Some(99.6), 100.0, &config).into_string();
assert!(html.contains(">99%</span>"), "{html}");
for value in [29.0, 57.0, 58.0] {
let html = progress_with("Upload", Some(value), 100.0, &config).into_string();
assert!(html.contains(&format!(">{value}%</span>")), "{html}");
}
let config = MeterConfig::new().visible_label();
let html = meter_with("Huge", 0.0, -f64::MAX..=f64::MAX, &config).into_string();
assert!(html.contains(">50%</span>"), "{html}");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "accessible name")]
fn empty_state_with_a_blank_title_panics_in_debug() {
let _ = empty_state(" ", "body", None);
}
#[test]
fn spinner_is_a_status_with_hidden_text() {
let html = spinner("Loading results").into_string();
assert_eq!(
html,
concat!(
r#"<span class="autumn-spinner autumn-spinner--md" role="status">"#,
r#"<span class="autumn-spinner__icon" aria-hidden="true"></span>"#,
r#"<span class="autumn-visually-hidden">Loading results</span></span>"#,
)
);
}
#[test]
fn spinner_size_indicator_and_class() {
for (size, class) in [
(SpinnerSize::Small, "autumn-spinner--sm"),
(SpinnerSize::Large, "autumn-spinner--lg"),
] {
let html = spinner_with("Wait", &SpinnerConfig::new().size(size)).into_string();
assert!(html.contains(class), "{html}");
}
let config = SpinnerConfig::new().indicator("#busy").class("inline");
let html = spinner_with("Wait", &config).into_string();
assert!(html.contains(r#"id="busy""#), "{html}");
assert!(
html.contains(r#"class="autumn-spinner autumn-spinner--md autumn-indicator inline""#),
"{html}"
);
assert!(!spinner("Wait").into_string().contains("autumn-indicator"));
}
#[test]
fn skeleton_is_hidden_from_screen_readers() {
let html = skeleton(SkeletonShape::Line).into_string();
assert_eq!(
html,
concat!(
r#"<span class="autumn-skeleton autumn-skeleton--line" aria-hidden="true">"#,
r#"<span class="autumn-skeleton__item"></span></span>"#,
)
);
}
#[test]
fn skeleton_shapes_count_and_indicator() {
for (shape, class) in [
(SkeletonShape::Block, "autumn-skeleton--block"),
(SkeletonShape::Circle, "autumn-skeleton--circle"),
] {
assert!(skeleton(shape).into_string().contains(class));
}
let three = SkeletonConfig::new().count(3);
let html = skeleton_with(SkeletonShape::Line, &three).into_string();
assert_eq!(html.matches("autumn-skeleton__item").count(), 3, "{html}");
let zero = SkeletonConfig::new().count(0);
let html = skeleton_with(SkeletonShape::Line, &zero).into_string();
assert_eq!(html.matches("autumn-skeleton__item").count(), 1, "{html}");
let config = SkeletonConfig::new()
.indicator("#feed-loading")
.class("wide");
let html = skeleton_with(SkeletonShape::Block, &config).into_string();
assert!(html.contains(r#"id="feed-loading""#), "{html}");
assert!(
html.contains(
r#"class="autumn-skeleton autumn-skeleton--block autumn-indicator wide""#
),
"{html}"
);
}
#[test]
fn progress_is_a_named_native_bar() {
let html = progress("Upload", Some(40.0), 100.0).into_string();
assert_eq!(
html,
r#"<progress class="autumn-progress" aria-label="Upload" value="40" max="100">40%</progress>"#
);
}
#[test]
fn progress_with_no_value_is_indeterminate() {
for value in [None, Some(f64::NAN), Some(f64::INFINITY)] {
let html = progress("Loading", value, 100.0).into_string();
assert!(!html.contains("value="), "{value:?}: {html}");
assert!(html.contains(r#"max="100""#), "{html}");
assert!(html.ends_with("></progress>"), "{html}");
}
}
#[test]
fn progress_clamps_the_value_and_fixes_a_bad_max() {
let html = progress("Upload", Some(150.0), 100.0).into_string();
assert!(html.contains(r#"value="100""#), "{html}");
let html = progress("Upload", Some(-3.0), 100.0).into_string();
assert!(html.contains(r#"value="0""#), "{html}");
assert!(!html.contains("-0"), "{html}");
for max in [0.0, -5.0, f64::NAN, f64::INFINITY] {
let html = progress("Upload", Some(0.5), max).into_string();
assert!(html.contains(r#"max="1""#), "{max}: {html}");
assert!(html.contains(r#"value="0.5""#), "{max}: {html}");
}
}
#[test]
fn progress_visible_label_shows_label_and_value() {
let config = ProgressConfig::new().visible_label().id("up").class("big");
let html = progress_with("Upload", Some(1.0), 4.0, &config).into_string();
assert!(
html.starts_with(r#"<div class="autumn-progress-field big" id="up">"#),
"{html}"
);
assert!(
html.contains(concat!(
r#"<div class="autumn-progress-field__header" aria-hidden="true">"#,
r#"<span class="autumn-progress-field__label">Upload</span>"#,
r#"<span class="autumn-progress-field__value">25%</span></div>"#,
)),
"{html}"
);
assert!(html.contains(r#"aria-label="Upload""#), "{html}");
}
#[test]
fn progress_value_text_replaces_the_percent() {
let config = ProgressConfig::new()
.visible_label()
.value_text("3 of 8 files");
let html = progress_with("Upload", Some(3.0), 8.0, &config).into_string();
assert!(html.contains(r#"aria-valuetext="3 of 8 files""#), "{html}");
assert!(html.contains(">3 of 8 files</span>"), "{html}");
assert!(!html.contains("38%"), "{html}");
let html = progress_with("Upload", None, 8.0, &config).into_string();
assert!(!html.contains("aria-valuetext"), "{html}");
}
#[test]
fn progress_without_visible_label_puts_id_and_class_on_the_bar() {
let config = ProgressConfig::new().id("up").class("thin");
let html = progress_with("Upload", Some(0.2), 1.0, &config).into_string();
assert!(
html.starts_with(r#"<progress class="autumn-progress thin" id="up""#),
"{html}"
);
}
#[test]
fn meter_is_a_named_native_meter() {
let html = meter("Disk use", 70.0, 0.0..=100.0).into_string();
assert_eq!(
html,
r#"<meter class="autumn-meter" aria-label="Disk use" value="70" min="0" max="100">70%</meter>"#
);
}
#[test]
fn meter_thresholds_are_clamped_to_the_range() {
let config = MeterConfig::new().low(-10.0).high(80.0).optimum(500.0);
let html = meter_with("Score", 5.0, 0.0..=10.0, &config).into_string();
assert!(html.contains(r#"low="0""#), "{html}");
assert!(html.contains(r#"high="10""#), "{html}");
assert!(html.contains(r#"optimum="10""#), "{html}");
let nan = MeterConfig::new().low(f64::NAN);
let html = meter_with("Score", 5.0, 0.0..=10.0, &nan).into_string();
assert!(!html.contains("low="), "{html}");
}
#[test]
fn meter_fixes_a_reversed_or_bad_range_and_value() {
#[allow(clippy::reversed_empty_ranges)]
let html = meter("Score", 5.0, 10.0..=0.0).into_string();
assert!(html.contains(r#"min="0" max="10""#), "{html}");
let html = meter("Score", f64::NAN, 2.0..=4.0).into_string();
assert!(html.contains(r#"value="2""#), "{html}");
let html = meter("Score", 9.0, f64::NAN..=f64::INFINITY).into_string();
assert!(html.contains(r#"value="1" min="0" max="1""#), "{html}");
}
#[test]
fn meter_visible_label_and_value_text() {
let config = MeterConfig::new()
.visible_label()
.value_text("7 GB of 10 GB")
.id("disk");
let html = meter_with("Disk use", 7.0, 0.0..=10.0, &config).into_string();
assert!(
html.starts_with(r#"<div class="autumn-progress-field" id="disk">"#),
"{html}"
);
assert!(html.contains(">7 GB of 10 GB</span>"), "{html}");
assert!(html.contains(r#"aria-valuetext="7 GB of 10 GB""#), "{html}");
}
#[test]
fn empty_state_has_title_body_and_action() {
let action = html! { a href="/posts/new" { "New post" } };
let html =
empty_state("No posts yet", "Write your first post.", Some(&action)).into_string();
assert_eq!(
html,
concat!(
r#"<div class="autumn-empty-state">"#,
r#"<h2 class="autumn-empty-state__title">No posts yet</h2>"#,
r#"<p class="autumn-empty-state__body">Write your first post.</p>"#,
r#"<div class="autumn-empty-state__action"><a href="/posts/new">New post</a></div>"#,
"</div>",
)
);
}
#[test]
fn empty_state_omits_an_empty_body_and_no_action() {
let html = empty_state("Nothing here", " ", None).into_string();
assert!(!html.contains("autumn-empty-state__body"), "{html}");
assert!(!html.contains("autumn-empty-state__action"), "{html}");
}
#[test]
fn empty_state_icon_level_and_class() {
let config = EmptyStateConfig::new()
.icon(html! { svg {} })
.level(HeadingLevel::H3)
.class("card");
let html = empty_state_with("No <b>data</b>", "x", None, &config).into_string();
assert!(
html.starts_with(concat!(
r#"<div class="autumn-empty-state card">"#,
r#"<div class="autumn-empty-state__icon" aria-hidden="true"><svg></svg></div>"#,
)),
"{html}"
);
assert!(
html.contains(
r#"<h3 class="autumn-empty-state__title">No <b>data</b></h3>"#
),
"{html}"
);
}
}