use super::{assert_named, heading, merge_class};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HeadingLevel {
H1,
#[default]
H2,
H3,
H4,
H5,
H6,
}
#[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>,
}
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
}
}
#[must_use]
pub fn card(body: &maud::Markup, config: &CardConfig<'_>) -> maud::Markup {
let root_class = merge_class("autumn-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="autumn-card__header" {
@if let Some(title) = &config.title {
(heading(config.level, None, "autumn-card__title", title))
}
@if let Some(action) = &config.header_action {
(action)
}
}
}
div class="autumn-card__body" { (body) }
@if let Some(footer) = &config.footer {
div class="autumn-card__footer" { (footer) }
}
}
}
}
#[must_use]
pub fn stat_card(label: &str, value: &str, link: Option<(&str, &str)>) -> maud::Markup {
maud::html! {
div class="autumn-stat-card" {
div class="autumn-stat-card__label" { (label) }
div class="autumn-stat-card__value" { (value) }
@if let Some((href, text)) = link {
div class="autumn-stat-card__link" {
a href=(href) { (text) }
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CtaStyle {
#[default]
Primary,
Secondary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cta {
pub label: String,
pub href: String,
pub style: CtaStyle,
}
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,
}
}
}
#[derive(Debug, Clone)]
pub struct HeroConfig {
title: maud::Markup,
level: HeadingLevel,
subtitle: Option<maud::Markup>,
ctas: Vec<Cta>,
class: Option<String>,
}
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
}
}
#[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) }
}
}
}
}
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ModalConfig {
footer: Option<maud::Markup>,
level: HeadingLevel,
class: Option<String>,
light_dismiss: bool,
size: DialogSize,
close_label: Option<String>,
}
impl ModalConfig {
#[must_use]
pub const fn new() -> Self {
Self {
footer: None,
level: HeadingLevel::H2,
class: None,
light_dismiss: false,
size: DialogSize::Md,
close_label: None,
}
}
#[must_use]
pub const fn size(mut self, size: DialogSize) -> Self {
self.size = size;
self
}
#[must_use]
pub fn close_button(mut self, label: impl Into<String>) -> Self {
self.close_label = Some(label.into());
self
}
#[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
}
}
#[must_use]
pub fn modal(id: &str, title: &str, body: &maud::Markup, config: &ModalConfig) -> maud::Markup {
let size_class = config.size.modifier("autumn-modal");
let root_class = merge_class(
&merge_class("autumn-modal", size_class.as_deref()),
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" {
@if let Some(label) = &config.close_label {
(dialog_close_x("autumn-modal__close", id, label))
}
(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) }
}
}
}
}
}
#[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)
}
}
}
#[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)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DialogSize {
Sm,
#[default]
Md,
Lg,
Full,
}
impl DialogSize {
fn modifier(self, base: &str) -> Option<String> {
let suffix = match self {
Self::Sm => "sm",
Self::Md => return None,
Self::Lg => "lg",
Self::Full => "full",
};
Some(format!("{base}--{suffix}"))
}
}
fn dialog_close_x(class: &str, dialog_id: &str, label: &str) -> maud::Markup {
maud::html! {
button type="button" class=(class) aria-label=(label)
command="close" commandfor=(dialog_id) data-modal-close=(dialog_id) {
span aria-hidden="true" { "×" }
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DrawerSide {
Left,
#[default]
Right,
Top,
Bottom,
}
impl DrawerSide {
const fn class(self) -> &'static str {
match self {
Self::Left => "autumn-drawer--left",
Self::Right => "autumn-drawer--right",
Self::Top => "autumn-drawer--top",
Self::Bottom => "autumn-drawer--bottom",
}
}
}
#[derive(Debug, Clone)]
pub struct DrawerConfig {
footer: Option<maud::Markup>,
level: HeadingLevel,
class: Option<String>,
side: DrawerSide,
size: DialogSize,
modal: bool,
light_dismiss: bool,
open: bool,
close_label: Option<String>,
}
impl Default for DrawerConfig {
fn default() -> Self {
Self::new()
}
}
impl DrawerConfig {
#[must_use]
pub const fn new() -> Self {
Self {
footer: None,
level: HeadingLevel::H2,
class: None,
side: DrawerSide::Right,
size: DialogSize::Md,
modal: true,
light_dismiss: true,
open: false,
close_label: None,
}
}
#[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 side(mut self, side: DrawerSide) -> Self {
self.side = side;
self
}
#[must_use]
pub const fn size(mut self, size: DialogSize) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn modal(mut self, modal: bool) -> Self {
self.modal = modal;
self
}
#[must_use]
pub const fn light_dismiss(mut self, light_dismiss: bool) -> Self {
self.light_dismiss = light_dismiss;
self
}
#[must_use]
pub const fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
#[must_use]
pub fn close_button(mut self, label: impl Into<String>) -> Self {
self.close_label = Some(label.into());
self
}
}
#[must_use]
pub fn drawer(id: &str, title: &str, body: &maud::Markup, config: &DrawerConfig) -> maud::Markup {
let size_class = config.size.modifier("autumn-drawer");
let classes = [
Some("autumn-drawer"),
Some(config.side.class()),
size_class.as_deref(),
config.class.as_deref().filter(|c| !c.is_empty()),
];
let root_class = classes.into_iter().flatten().collect::<Vec<_>>().join(" ");
let title_id = format!("{id}-title");
let closedby = match (config.light_dismiss, config.modal) {
(false, _) => None,
(true, true) => Some("any"),
(true, false) => Some("closerequest"),
};
let aria_modal = config.modal.then_some("true");
let open = config.open && !config.modal;
maud::html! {
dialog id=(id) class=(root_class) role="dialog" aria-modal=[aria_modal]
aria-labelledby=(title_id) closedby=[closedby] open[open] {
div class="autumn-drawer__content" {
@if let Some(label) = &config.close_label {
(dialog_close_x("autumn-drawer__close", id, label))
}
(heading(config.level, Some(title_id.as_str()), "autumn-drawer__title", &maud::html! { (title) }))
div class="autumn-drawer__body" { (body) }
@if let Some(footer) = &config.footer {
div class="autumn-drawer__footer" { (footer) }
}
}
}
}
}
#[must_use]
pub fn drawer_trigger(
label: &str,
dialog_id: &str,
config: &DrawerConfig,
class: Option<&str>,
) -> maud::Markup {
if config.modal {
return modal_trigger(label, dialog_id, class);
}
maud::html! {
button type="button" class=[class] aria-controls=(dialog_id) data-drawer-open=(dialog_id) {
(label)
}
}
}
#[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" data-autumn-tabs {
div class="autumn-tabs__list" role="tablist"
data-autumn-roving data-autumn-orientation="horizontal" {
@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)
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct AccordionConfig<'a> {
name: &'a str,
multiple: bool,
open: Vec<usize>,
level: HeadingLevel,
class: Option<&'a str>,
attrs: &'a [(&'a str, &'a str)],
}
impl<'a> AccordionConfig<'a> {
#[must_use]
pub const fn new(name: &'a str) -> Self {
Self {
name,
multiple: false,
open: Vec::new(),
level: HeadingLevel::H3,
class: None,
attrs: &[],
}
}
#[must_use]
pub const fn multiple(mut self) -> Self {
self.multiple = true;
self
}
#[must_use]
pub fn open(mut self, index: usize) -> Self {
self.open.push(index);
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]
pub const fn attrs(mut self, attrs: &'a [(&'a str, &'a str)]) -> Self {
self.attrs = attrs;
self
}
}
#[must_use]
pub fn accordion(items: &[(&str, maud::Markup)], config: &AccordionConfig<'_>) -> maud::Markup {
let exclusive_open = config
.open
.iter()
.copied()
.filter(|&i| i < items.len())
.min();
let is_open = |i: usize| {
if config.multiple {
config.open.contains(&i)
} else {
exclusive_open == Some(i)
}
};
let group = (!config.multiple).then_some(config.name);
let root_class = merge_class("autumn-accordion", config.class);
let mut attrs: Vec<(&str, &str)> = vec![("id", config.name), ("class", &root_class)];
attrs.extend_from_slice(config.attrs);
let body = maud::html! {
@for (i, (title, body)) in items.iter().enumerate() {
details class="autumn-accordion__item" name=[group] open[is_open(i)] {
summary class="autumn-accordion__summary" {
(heading(
config.level,
None,
"autumn-accordion__heading",
&maud::html! { (title) },
))
}
div class="autumn-accordion__body" { (body) }
}
}
};
crate::form::widget_element("div", &attrs, &crate::form::FieldOptions::new(), body)
}
#[derive(Debug, Clone, Copy)]
pub struct CollapsibleConfig<'a> {
open: bool,
class: Option<&'a str>,
attrs: &'a [(&'a str, &'a str)],
}
impl Default for CollapsibleConfig<'_> {
fn default() -> Self {
Self::new()
}
}
impl<'a> CollapsibleConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
open: false,
class: None,
attrs: &[],
}
}
#[must_use]
pub const fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
#[must_use]
pub const fn attrs(mut self, attrs: &'a [(&'a str, &'a str)]) -> Self {
self.attrs = attrs;
self
}
}
#[must_use]
pub fn collapsible(summary: &str, body: &maud::Markup) -> maud::Markup {
collapsible_with(summary, body, &CollapsibleConfig::new())
}
#[must_use]
pub fn collapsible_with(
summary: &str,
body: &maud::Markup,
config: &CollapsibleConfig<'_>,
) -> maud::Markup {
let root_class = merge_class("autumn-collapsible", config.class);
let mut attrs: Vec<(&str, &str)> = vec![("class", &root_class)];
if config.open {
attrs.push(("open", ""));
}
attrs.extend(config.attrs.iter().filter(|(name, _)| *name != "open"));
let inner = maud::html! {
summary class="autumn-collapsible__summary" { (summary) }
div class="autumn-collapsible__body" { (body) }
};
crate::form::widget_element("details", &attrs, &crate::form::FieldOptions::new(), inner)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonVariant {
#[default]
Primary,
Secondary,
Ghost,
Destructive,
Link,
}
impl ButtonVariant {
const fn class(self) -> &'static str {
match self {
Self::Primary => "autumn-button--primary",
Self::Secondary => "autumn-button--secondary",
Self::Ghost => "autumn-button--ghost",
Self::Destructive => "autumn-button--destructive",
Self::Link => "autumn-button--link",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonSize {
Small,
#[default]
Medium,
Large,
}
impl ButtonSize {
const fn class(self) -> &'static str {
match self {
Self::Small => "autumn-button--sm",
Self::Medium => "autumn-button--md",
Self::Large => "autumn-button--lg",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum ButtonBusy {
#[default]
No,
Now,
OnRequest,
}
#[derive(Debug, Clone, Default)]
pub struct ButtonConfig<'a> {
variant: ButtonVariant,
size: ButtonSize,
kind: crate::a11y::ButtonType,
icon: Option<maud::Markup>,
icon_only: bool,
busy: ButtonBusy,
disabled: bool,
id: Option<&'a str>,
class: Option<&'a str>,
attrs: &'a [(&'a str, &'a str)],
}
impl<'a> ButtonConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
variant: ButtonVariant::Primary,
size: ButtonSize::Medium,
kind: crate::a11y::ButtonType::Button,
icon: None,
icon_only: false,
busy: ButtonBusy::No,
disabled: false,
id: None,
class: None,
attrs: &[],
}
}
#[must_use]
pub const fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
#[must_use]
pub const fn size(mut self, size: ButtonSize) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn kind(mut self, kind: crate::a11y::ButtonType) -> Self {
self.kind = kind;
self
}
#[must_use]
pub const fn submit(mut self) -> Self {
self.kind = crate::a11y::ButtonType::Submit;
self
}
#[must_use]
pub fn icon(mut self, icon: maud::Markup) -> Self {
self.icon = Some(icon);
self
}
#[must_use]
pub const fn icon_only(mut self) -> Self {
self.icon_only = true;
self
}
#[must_use]
pub const fn loading(mut self) -> Self {
self.busy = ButtonBusy::Now;
self
}
#[must_use]
pub const fn loading_on_request(mut self) -> Self {
self.busy = ButtonBusy::OnRequest;
self
}
#[must_use]
pub const fn disabled(mut self) -> Self {
self.disabled = true;
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]
pub const fn attrs(mut self, attrs: &'a [(&'a str, &'a str)]) -> Self {
self.attrs = attrs;
self
}
}
#[must_use]
#[track_caller]
pub fn button(label: &str, config: &ButtonConfig<'_>) -> maud::Markup {
assert_named("button", label);
debug_assert!(
!config.icon_only || config.icon.is_some(),
"button: icon_only() needs an icon(), or the button shows nothing"
);
let mut root = format!(
"autumn-button {} {}",
config.variant.class(),
config.size.class()
);
if config.icon_only {
root.push_str(" autumn-button--icon");
}
let root = merge_class(&root, config.class);
let mut attrs: Vec<(&str, &str)> = vec![("type", config.kind.as_str()), ("class", &root)];
if let Some(id) = config.id {
attrs.push(("id", id));
}
match config.busy {
ButtonBusy::Now => attrs.extend([("disabled", ""), ("aria-busy", "true")]),
ButtonBusy::OnRequest => attrs.push(("data-autumn-busy", "")),
ButtonBusy::No => {}
}
if config.disabled && config.busy != ButtonBusy::Now {
attrs.push(("disabled", ""));
}
let label_class = if config.icon_only {
"autumn-button__label autumn-visually-hidden"
} else {
"autumn-button__label"
};
let body = maud::html! {
@if config.busy != ButtonBusy::No {
span class="autumn-button__spinner autumn-spinner__icon" aria-hidden="true" {}
}
@if let Some(icon) = &config.icon {
span class="autumn-button__icon" aria-hidden="true" { (icon) }
}
span class=(label_class) { (label) }
};
let mut out = String::from("<button");
for (name, value) in &attrs {
crate::form::push_attr(&mut out, name, value);
}
for &(name, value) in config.attrs {
let well_formed = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.'));
if well_formed && !attrs.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
crate::form::push_attr(&mut out, name, value);
attrs.push((name, value));
}
}
out.push('>');
out.push_str(&body.into_string());
out.push_str("</button>");
maud::PreEscaped(out)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ButtonGroupConfig<'a> {
vertical: bool,
class: Option<&'a str>,
}
impl<'a> ButtonGroupConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
vertical: false,
class: None,
}
}
#[must_use]
pub const fn vertical(mut self) -> Self {
self.vertical = true;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
#[track_caller]
pub fn button_group(label: &str, buttons: &[maud::Markup]) -> maud::Markup {
button_group_with(label, buttons, &ButtonGroupConfig::new())
}
#[must_use]
#[track_caller]
pub fn button_group_with(
label: &str,
buttons: &[maud::Markup],
config: &ButtonGroupConfig<'_>,
) -> maud::Markup {
assert_named("button_group", label);
let base = if config.vertical {
"autumn-button-group autumn-button-group--vertical"
} else {
"autumn-button-group"
};
let root = merge_class(base, config.class);
maud::html! {
div class=(root) role="group" aria-label=(label) {
@for button in buttons { (button) }
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SeparatorConfig<'a> {
vertical: bool,
decorative: bool,
class: Option<&'a str>,
}
impl<'a> SeparatorConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
vertical: false,
decorative: false,
class: None,
}
}
#[must_use]
pub const fn vertical(mut self) -> Self {
self.vertical = true;
self
}
#[must_use]
pub const fn decorative(mut self) -> Self {
self.decorative = true;
self
}
#[must_use]
pub const fn class(mut self, class: &'a str) -> Self {
self.class = Some(class);
self
}
}
#[must_use]
pub fn separator() -> maud::Markup {
separator_with(&SeparatorConfig::new())
}
#[must_use]
pub fn separator_with(config: &SeparatorConfig<'_>) -> maud::Markup {
let decorative = config.decorative.then_some("none");
if config.vertical {
let root = merge_class("autumn-separator autumn-separator--vertical", config.class);
maud::html! {
span class=(root) role=(decorative.unwrap_or("separator"))
aria-orientation=[(!config.decorative).then_some("vertical")] {}
}
} else {
let root = merge_class("autumn-separator", config.class);
maud::html! { hr class=(root) role=[decorative]; }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::{property_list, selector_to_id};
use maud::html;
#[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 card_config_defaults() {
let html = card(&maud::html! {}, &CardConfig::new()).into_string();
assert!(!html.contains("autumn-card__header"), "{html}");
assert!(!html.contains("autumn-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="autumn-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="autumn-card""#), "{html}");
assert!(html.contains(r#"class="autumn-card__body""#), "{html}");
assert!(html.contains("hello"), "{html}");
assert!(!html.contains("autumn-card__header"), "{html}");
assert!(!html.contains("autumn-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="autumn-card__title">Posts</h2>"#),
"{html}"
);
assert!(html.contains(r#"class="autumn-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="autumn-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("autumn-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="autumn-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("autumn-card__header"), "{html}");
assert!(!html.contains("autumn-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="autumn-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("autumn-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="autumn-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="autumn-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="autumn-stat-card""#), "{html}");
assert!(
html.contains(r#"class="autumn-stat-card__label""#),
"{html}"
);
assert!(
html.contains(r#"class="autumn-stat-card__value""#),
"{html}"
);
assert!(html.contains("Users"), "{html}");
assert!(html.contains("42"), "{html}");
assert!(!html.contains("autumn-stat-card__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="autumn-stat-card__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);
}
fn btn(config: &ButtonConfig<'_>) -> String {
button("Save", config).into_string()
}
#[test]
fn button_default_is_a_medium_primary_type_button() {
let html = btn(&ButtonConfig::new());
assert!(html.starts_with("<button"), "{html}");
assert!(html.contains(r#"type="button""#), "{html}");
assert!(
html.contains(r#"class="autumn-button autumn-button--primary autumn-button--md""#),
"{html}"
);
assert!(
html.contains(r#"<span class="autumn-button__label">Save</span>"#),
"{html}"
);
assert!(!html.contains("disabled"), "{html}");
assert!(!html.contains("aria-busy"), "{html}");
assert!(!html.contains("autumn-button__spinner"), "{html}");
}
#[test]
fn button_variant_and_size_set_their_classes() {
for (variant, class) in [
(ButtonVariant::Primary, "autumn-button--primary"),
(ButtonVariant::Secondary, "autumn-button--secondary"),
(ButtonVariant::Ghost, "autumn-button--ghost"),
(ButtonVariant::Destructive, "autumn-button--destructive"),
(ButtonVariant::Link, "autumn-button--link"),
] {
let html = btn(&ButtonConfig::new().variant(variant));
assert!(html.contains(class), "{variant:?}: {html}");
}
for (size, class) in [
(ButtonSize::Small, "autumn-button--sm"),
(ButtonSize::Medium, "autumn-button--md"),
(ButtonSize::Large, "autumn-button--lg"),
] {
let html = btn(&ButtonConfig::new().size(size));
assert!(html.contains(class), "{size:?}: {html}");
}
}
#[test]
fn button_kind_sets_the_type() {
assert!(btn(&ButtonConfig::new().submit()).contains(r#"type="submit""#));
let reset = ButtonConfig::new().kind(crate::a11y::ButtonType::Reset);
assert!(btn(&reset).contains(r#"type="reset""#));
}
#[test]
fn button_icon_is_hidden_and_before_the_label() {
let icon = html! { svg viewBox="0 0 1 1" {} };
let html = btn(&ButtonConfig::new().icon(icon));
let at_icon = html
.find(r#"<span class="autumn-button__icon" aria-hidden="true"><svg"#)
.unwrap_or_else(|| panic!("no icon: {html}"));
let at_label = html.find("autumn-button__label").unwrap();
assert!(at_icon < at_label, "{html}");
}
#[test]
fn icon_only_button_keeps_the_label_as_hidden_text() {
let icon = html! { "×" };
let html = btn(&ButtonConfig::new().icon(icon).icon_only());
assert!(html.contains("autumn-button--icon"), "{html}");
assert!(
html.contains(
r#"<span class="autumn-button__label autumn-visually-hidden">Save</span>"#
),
"{html}"
);
assert!(!html.contains("aria-label"), "{html}");
}
#[test]
fn loading_button_is_busy_disabled_and_has_a_spinner() {
let html = btn(&ButtonConfig::new().loading());
assert!(html.contains(r#"aria-busy="true""#), "{html}");
assert!(html.contains("disabled"), "{html}");
assert!(
html.contains(
r#"<span class="autumn-button__spinner autumn-spinner__icon" aria-hidden="true"></span>"#
),
"{html}"
);
let at_spinner = html.find("autumn-button__spinner").unwrap();
assert!(at_spinner < html.find("autumn-button__label").unwrap());
assert!(!html.contains("data-autumn-busy"), "{html}");
}
#[test]
fn loading_on_request_marks_the_button_for_the_runtime() {
let html = btn(&ButtonConfig::new().loading_on_request());
assert!(html.contains("data-autumn-busy"), "{html}");
assert!(html.contains("autumn-button__spinner"), "{html}");
assert!(!html.contains("aria-busy"), "{html}");
assert!(!html.contains("disabled"), "{html}");
}
#[test]
fn the_last_loading_call_wins() {
let html = btn(&ButtonConfig::new().loading_on_request().loading());
assert!(html.contains(r#"aria-busy="true""#), "{html}");
assert!(!html.contains("data-autumn-busy"), "{html}");
let html = btn(&ButtonConfig::new().loading().loading_on_request());
assert!(html.contains("data-autumn-busy"), "{html}");
assert!(!html.contains("aria-busy"), "{html}");
let html = btn(&ButtonConfig::new().disabled().loading());
assert_eq!(html.matches("disabled").count(), 1, "{html}");
}
#[test]
fn button_id_class_and_disabled() {
let html = btn(&ButtonConfig::new().id("save").class("wide").disabled());
assert!(html.contains(r#"id="save""#), "{html}");
assert!(html.contains("autumn-button--md wide"), "{html}");
assert!(html.contains("disabled"), "{html}");
}
#[test]
fn button_attrs_pass_through_but_cannot_replace_owned_ones() {
let attrs = [
("hx-post", "/save?a=1&b=2"),
("hx-indicator", "this"),
("type", "submit"),
("CLASS", "evil"),
("hx-on::after-request", "this.form.reset()"),
("data-user_id", "7"),
("style", "color:red"),
("title", "a\"b"),
("title", "second"),
("Bad Name", "x"),
("\"><script>", "x"),
];
let html = btn(&ButtonConfig::new().attrs(&attrs));
assert!(html.contains(r#"hx-post="/save?a=1&b=2""#), "{html}");
assert!(html.contains(r#"hx-indicator="this""#), "{html}");
assert_eq!(html.matches("type=").count(), 1, "{html}");
assert!(html.contains(r#"type="button""#), "{html}");
assert!(!html.contains("evil"), "{html}");
assert!(
html.contains(r#"hx-on::after-request="this.form.reset()""#),
"{html}"
);
assert!(html.contains(r#"data-user_id="7""#), "{html}");
assert!(html.contains(r#"style="color:red""#), "{html}");
assert!(html.contains(r#"title="a"b""#), "{html}");
assert!(!html.contains("second"), "{html}");
assert!(!html.contains("Bad Name"), "{html}");
assert!(!html.contains("<script>"), "{html}");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "icon_only() needs an icon()")]
fn icon_only_without_an_icon_panics_in_debug() {
let _ = btn(&ButtonConfig::new().icon_only());
}
#[test]
fn button_label_is_escaped() {
let html = button("<script>x</script>", &ButtonConfig::new()).into_string();
assert!(html.contains("<script>x</script>"), "{html}");
assert!(!html.contains("<script>"), "{html}");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "accessible name")]
fn button_with_a_blank_label_panics_in_debug() {
let _ = button(" ", &ButtonConfig::new());
}
#[test]
fn button_group_is_a_named_group() {
let a = button("Left", &ButtonConfig::new());
let b = button("Right", &ButtonConfig::new());
let html = button_group("Text alignment", &[a, b]).into_string();
assert!(
html.starts_with(
r#"<div class="autumn-button-group" role="group" aria-label="Text alignment">"#
),
"{html}"
);
assert!(
html.contains(">Left<") && html.contains(">Right<"),
"{html}"
);
assert!(html.ends_with("</div>"), "{html}");
}
#[test]
fn button_group_vertical_and_class() {
let config = ButtonGroupConfig::new().vertical().class("tools");
let html = button_group_with("Tools", &[], &config).into_string();
assert!(
html.contains(r#"class="autumn-button-group autumn-button-group--vertical tools""#),
"{html}"
);
}
#[test]
fn separator_is_an_hr() {
assert_eq!(
separator().into_string(),
r#"<hr class="autumn-separator">"#
);
let html = separator_with(&SeparatorConfig::new().decorative().class("x")).into_string();
assert_eq!(html, r#"<hr class="autumn-separator x" role="none">"#);
}
#[test]
fn vertical_separator_has_role_and_orientation() {
let html = separator_with(&SeparatorConfig::new().vertical()).into_string();
assert_eq!(
html,
concat!(
r#"<span class="autumn-separator autumn-separator--vertical" "#,
r#"role="separator" aria-orientation="vertical"></span>"#,
)
);
let html = separator_with(&SeparatorConfig::new().vertical().decorative()).into_string();
assert!(html.contains(r#"role="none""#), "{html}");
assert!(!html.contains("aria-orientation"), "{html}");
}
}