use std::fmt::Write as _;
use maud::{Escaper, Markup, PreEscaped, Render, html};
#[derive(Debug, Clone)]
pub struct Img {
src: String,
alt: String,
class: Option<String>,
width: Option<u32>,
height: Option<u32>,
}
impl Img {
#[track_caller]
pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
Self {
src: src.into(),
alt: checked_name("Img::new", alt),
class: None,
width: None,
height: None,
}
}
pub fn decorative(src: impl Into<String>) -> Self {
Self {
src: src.into(),
alt: String::new(),
class: None,
width: None,
height: None,
}
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub const fn width(mut self, width: u32) -> Self {
self.width = Some(width);
self
}
#[must_use]
pub const fn height(mut self, height: u32) -> Self {
self.height = Some(height);
self
}
}
impl Render for Img {
fn render(&self) -> Markup {
html! {
img
src=(self.src)
alt=(self.alt)
class=[self.class.as_deref()]
width=[self.width]
height=[self.height];
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonType {
#[default]
Button,
Submit,
Reset,
}
impl ButtonType {
const fn as_str(self) -> &'static str {
match self {
Self::Button => "button",
Self::Submit => "submit",
Self::Reset => "reset",
}
}
}
#[derive(Debug, Clone)]
pub struct Button {
accessible_name: String,
icon: Option<Markup>,
kind: ButtonType,
class: Option<String>,
}
impl Button {
#[track_caller]
pub fn new(accessible_name: impl Into<String>) -> Self {
Self {
accessible_name: checked_name("Button::new", accessible_name),
icon: None,
kind: ButtonType::default(),
class: None,
}
}
#[track_caller]
pub fn icon(icon: Markup, accessible_name: impl Into<String>) -> Self {
Self {
accessible_name: checked_name("Button::icon", accessible_name),
icon: Some(icon),
kind: ButtonType::default(),
class: None,
}
}
#[must_use]
pub const fn kind(mut self, kind: ButtonType) -> Self {
self.kind = kind;
self
}
#[must_use]
pub const fn submit(mut self) -> Self {
self.kind = ButtonType::Submit;
self
}
#[must_use]
pub const fn button(mut self) -> Self {
self.kind = ButtonType::Button;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
}
impl Render for Button {
fn render(&self) -> Markup {
self.icon.as_ref().map_or_else(
|| {
html! {
button type=(self.kind.as_str()) class=[self.class.as_deref()] {
(self.accessible_name)
}
}
},
|icon| {
html! {
button
type=(self.kind.as_str())
aria-label=(self.accessible_name)
class=[self.class.as_deref()] {
(icon)
}
}
},
)
}
}
#[derive(Debug, Clone)]
pub struct Link {
href: String,
accessible_name: String,
icon: Option<Markup>,
class: Option<String>,
new_tab: bool,
}
impl Link {
#[track_caller]
pub fn new(href: impl Into<String>, text: impl Into<String>) -> Self {
Self {
href: href.into(),
accessible_name: checked_name("Link::new", text),
icon: None,
class: None,
new_tab: false,
}
}
#[track_caller]
pub fn icon(href: impl Into<String>, icon: Markup, accessible_name: impl Into<String>) -> Self {
Self {
href: href.into(),
accessible_name: checked_name("Link::icon", accessible_name),
icon: Some(icon),
class: None,
new_tab: false,
}
}
#[must_use]
pub const fn new_tab(mut self) -> Self {
self.new_tab = true;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
}
impl Render for Link {
fn render(&self) -> Markup {
let target = self.new_tab.then_some("_blank");
let rel = self.new_tab.then_some("noopener noreferrer");
self.icon.as_ref().map_or_else(
|| {
html! {
a href=(self.href) target=[target] rel=[rel] class=[self.class.as_deref()] {
(self.accessible_name)
}
}
},
|icon| {
html! {
a
href=(self.href)
aria-label=(self.accessible_name)
target=[target]
rel=[rel]
class=[self.class.as_deref()] {
(icon)
}
}
},
)
}
}
#[derive(Debug, Clone)]
pub struct MenuItem {
accessible_name: String,
href: Option<String>,
icon: Option<Markup>,
class: Option<String>,
}
impl MenuItem {
#[track_caller]
pub fn new(accessible_name: impl Into<String>) -> Self {
Self {
accessible_name: checked_name("MenuItem::new", accessible_name),
href: None,
icon: None,
class: None,
}
}
#[must_use]
pub fn href(mut self, href: impl Into<String>) -> Self {
self.href = Some(href.into());
self
}
#[must_use]
pub fn icon(mut self, icon: Markup) -> Self {
self.icon = Some(icon);
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
}
impl Render for MenuItem {
fn render(&self) -> Markup {
let aria_label = self.icon.is_some().then_some(self.accessible_name.as_str());
self.href.as_deref().map_or_else(
|| {
html! {
button
type="button"
role="menuitem"
aria-label=[aria_label]
class=[self.class.as_deref()] {
@match &self.icon {
Some(icon) => (icon),
None => (self.accessible_name),
}
}
}
},
|href| {
html! {
a href=(href) role="menuitem" aria-label=[aria_label] class=[self.class.as_deref()] {
@match &self.icon {
Some(icon) => (icon),
None => (self.accessible_name),
}
}
}
},
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct NoLabel;
#[derive(Debug, Clone, Copy)]
pub struct Labeled;
#[track_caller]
fn checked_name(entry: &str, name: impl Into<String>) -> String {
let name = name.into();
debug_assert!(
!is_blank(&name),
"{entry}: the text is empty or blank, so the element has no accessible name{}",
if entry == "Img::new" {
"; use Img::decorative(src) for an image that carries no information"
} else {
""
}
);
name
}
#[must_use]
pub fn is_blank(text: &str) -> bool {
text.chars().all(|c| {
c.is_whitespace()
|| matches!(
c,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}'
)
})
}
#[derive(Debug, Clone)]
enum LabelSource {
Visible(String),
Aria(String),
LabelledBy(String),
}
type LabelRenderAttrs<'a> = (
Option<&'a str>,
Option<&'a str>,
Option<&'a str>,
Option<&'static str>,
Option<&'static str>,
);
fn label_render_attrs(
label: Option<&LabelSource>,
aria_invalid: Option<bool>,
aria_required: bool,
) -> LabelRenderAttrs<'_> {
let (visible_label, aria_label, aria_labelledby) = match label {
Some(LabelSource::Visible(text)) => (Some(text.as_str()), None, None),
Some(LabelSource::Aria(text)) => (None, Some(text.as_str()), None),
Some(LabelSource::LabelledBy(id)) => (None, None, Some(id.as_str())),
None => (None, None, None),
};
let aria_invalid = aria_invalid.map(|invalid| if invalid { "true" } else { "false" });
let aria_required = aria_required.then_some("true");
(
visible_label,
aria_label,
aria_labelledby,
aria_invalid,
aria_required,
)
}
fn is_valid_hx_suffix(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
fn with_hx_attrs(element: Markup, hx: &[(String, String)]) -> Markup {
if hx.is_empty() {
return element;
}
let rendered = element.into_string();
let mut attrs = String::new();
for (name, value) in hx {
debug_assert!(
is_valid_hx_suffix(name),
"invalid hx-* attribute name {name:?}: only lowercase ASCII letters, \
digits, and '-' are permitted"
);
if !is_valid_hx_suffix(name) {
continue;
}
attrs.push_str(" hx-");
attrs.push_str(name);
attrs.push_str("=\"");
let _ = write!(Escaper::new(&mut attrs), "{value}");
attrs.push('"');
}
match rendered.find('>') {
Some(idx) => {
let mut out = String::with_capacity(rendered.len() + attrs.len());
out.push_str(&rendered[..idx]);
out.push_str(&attrs);
out.push_str(&rendered[idx..]);
PreEscaped(out)
}
None => PreEscaped(rendered),
}
}
#[derive(Debug, Clone)]
pub struct TextField<State> {
name: String,
input_type: String,
value: Option<String>,
required: bool,
aria_required: bool,
class: Option<String>,
label_class: Option<String>,
aria_invalid: Option<bool>,
described_by: Option<String>,
minlength: Option<u32>,
maxlength: Option<u32>,
min: Option<String>,
max: Option<String>,
step: Option<String>,
hx: Vec<(String, String)>,
label: Option<LabelSource>,
_state: std::marker::PhantomData<State>,
}
impl TextField<NoLabel> {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
input_type: "text".to_owned(),
value: None,
required: false,
aria_required: false,
class: None,
label_class: None,
aria_invalid: None,
described_by: None,
minlength: None,
maxlength: None,
min: None,
max: None,
step: None,
hx: Vec::new(),
label: None,
_state: std::marker::PhantomData,
}
}
#[must_use]
#[track_caller]
pub fn label(self, text: impl Into<String>) -> TextField<Labeled> {
self.with_label(LabelSource::Visible(checked_name("TextField::label", text)))
}
#[must_use]
#[track_caller]
pub fn aria_label(self, text: impl Into<String>) -> TextField<Labeled> {
self.with_label(LabelSource::Aria(checked_name(
"TextField::aria_label",
text,
)))
}
#[must_use]
#[track_caller]
pub fn labelled_by(self, id: impl Into<String>) -> TextField<Labeled> {
self.with_label(LabelSource::LabelledBy(checked_name(
"TextField::labelled_by",
id,
)))
}
fn with_label(self, label: LabelSource) -> TextField<Labeled> {
TextField {
name: self.name,
input_type: self.input_type,
value: self.value,
required: self.required,
aria_required: self.aria_required,
class: self.class,
label_class: self.label_class,
aria_invalid: self.aria_invalid,
described_by: self.described_by,
minlength: self.minlength,
maxlength: self.maxlength,
min: self.min,
max: self.max,
step: self.step,
hx: self.hx,
label: Some(label),
_state: std::marker::PhantomData,
}
}
}
impl<State> TextField<State> {
#[must_use]
pub fn input_type(mut self, input_type: impl Into<String>) -> Self {
self.input_type = input_type.into();
self
}
#[must_use]
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn aria_required(mut self) -> Self {
self.aria_required = true;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub fn label_class(mut self, class: impl Into<String>) -> Self {
self.label_class = Some(class.into());
self
}
#[must_use]
pub const fn aria_invalid(mut self, invalid: bool) -> Self {
self.aria_invalid = Some(invalid);
self
}
#[must_use]
pub fn described_by(mut self, id: impl Into<String>) -> Self {
self.described_by = Some(id.into());
self
}
#[must_use]
pub const fn minlength(mut self, minlength: u32) -> Self {
self.minlength = Some(minlength);
self
}
#[must_use]
pub const fn maxlength(mut self, maxlength: u32) -> Self {
self.maxlength = Some(maxlength);
self
}
#[must_use]
pub fn min(mut self, min: impl Into<String>) -> Self {
self.min = Some(min.into());
self
}
#[must_use]
pub fn max(mut self, max: impl Into<String>) -> Self {
self.max = Some(max.into());
self
}
#[must_use]
pub fn step(mut self, step: impl Into<String>) -> Self {
self.step = Some(step.into());
self
}
#[must_use]
pub fn hx(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.hx.push((name.into(), value.into()));
self
}
}
impl Render for TextField<Labeled> {
fn render(&self) -> Markup {
let (visible_label, aria_label, aria_labelledby, aria_invalid, aria_required) =
label_render_attrs(self.label.as_ref(), self.aria_invalid, self.aria_required);
let input = html! {
input
type=(self.input_type)
id=(self.name)
name=(self.name)
aria-label=[aria_label]
aria-labelledby=[aria_labelledby]
class=[self.class.as_deref()]
value=[self.value.as_deref()]
minlength=[self.minlength]
maxlength=[self.maxlength]
min=[self.min.as_deref()]
max=[self.max.as_deref()]
step=[self.step.as_deref()]
aria-invalid=[aria_invalid]
aria-describedby=[self.described_by.as_deref()]
aria-required=[aria_required]
required[self.required];
};
html! {
@if let Some(text) = visible_label {
label for=(self.name) class=[self.label_class.as_deref()] { (text) }
}
(with_hx_attrs(input, &self.hx))
}
}
}
#[derive(Debug, Clone)]
pub struct TextArea<State> {
name: String,
value: Option<String>,
required: bool,
aria_required: bool,
class: Option<String>,
label_class: Option<String>,
aria_invalid: Option<bool>,
described_by: Option<String>,
minlength: Option<u32>,
maxlength: Option<u32>,
rows: Option<u32>,
cols: Option<u32>,
hx: Vec<(String, String)>,
label: Option<LabelSource>,
_state: std::marker::PhantomData<State>,
}
impl TextArea<NoLabel> {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
value: None,
required: false,
aria_required: false,
class: None,
label_class: None,
aria_invalid: None,
described_by: None,
minlength: None,
maxlength: None,
rows: None,
cols: None,
hx: Vec::new(),
label: None,
_state: std::marker::PhantomData,
}
}
#[must_use]
#[track_caller]
pub fn label(self, text: impl Into<String>) -> TextArea<Labeled> {
self.with_label(LabelSource::Visible(checked_name("TextArea::label", text)))
}
#[must_use]
#[track_caller]
pub fn aria_label(self, text: impl Into<String>) -> TextArea<Labeled> {
self.with_label(LabelSource::Aria(checked_name(
"TextArea::aria_label",
text,
)))
}
#[must_use]
#[track_caller]
pub fn labelled_by(self, id: impl Into<String>) -> TextArea<Labeled> {
self.with_label(LabelSource::LabelledBy(checked_name(
"TextArea::labelled_by",
id,
)))
}
fn with_label(self, label: LabelSource) -> TextArea<Labeled> {
TextArea {
name: self.name,
value: self.value,
required: self.required,
aria_required: self.aria_required,
class: self.class,
label_class: self.label_class,
aria_invalid: self.aria_invalid,
described_by: self.described_by,
minlength: self.minlength,
maxlength: self.maxlength,
rows: self.rows,
cols: self.cols,
hx: self.hx,
label: Some(label),
_state: std::marker::PhantomData,
}
}
}
impl<State> TextArea<State> {
#[must_use]
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn aria_required(mut self) -> Self {
self.aria_required = true;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub fn label_class(mut self, class: impl Into<String>) -> Self {
self.label_class = Some(class.into());
self
}
#[must_use]
pub const fn aria_invalid(mut self, invalid: bool) -> Self {
self.aria_invalid = Some(invalid);
self
}
#[must_use]
pub fn described_by(mut self, id: impl Into<String>) -> Self {
self.described_by = Some(id.into());
self
}
#[must_use]
pub const fn minlength(mut self, minlength: u32) -> Self {
self.minlength = Some(minlength);
self
}
#[must_use]
pub const fn maxlength(mut self, maxlength: u32) -> Self {
self.maxlength = Some(maxlength);
self
}
#[must_use]
pub const fn rows(mut self, rows: u32) -> Self {
self.rows = Some(rows);
self
}
#[must_use]
pub const fn cols(mut self, cols: u32) -> Self {
self.cols = Some(cols);
self
}
#[must_use]
pub fn hx(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.hx.push((name.into(), value.into()));
self
}
}
impl Render for TextArea<Labeled> {
fn render(&self) -> Markup {
let (visible_label, aria_label, aria_labelledby, aria_invalid, aria_required) =
label_render_attrs(self.label.as_ref(), self.aria_invalid, self.aria_required);
let textarea = html! {
textarea
id=(self.name)
name=(self.name)
aria-label=[aria_label]
aria-labelledby=[aria_labelledby]
class=[self.class.as_deref()]
minlength=[self.minlength]
maxlength=[self.maxlength]
rows=[self.rows]
cols=[self.cols]
aria-invalid=[aria_invalid]
aria-describedby=[self.described_by.as_deref()]
aria-required=[aria_required]
required[self.required] {
@if let Some(v) = &self.value { (v) }
}
};
html! {
@if let Some(text) = visible_label {
label for=(self.name) class=[self.label_class.as_deref()] { (text) }
}
(with_hx_attrs(textarea, &self.hx))
}
}
}
#[derive(Debug, Clone)]
pub struct SelectOption {
value: String,
label: String,
selected: bool,
disabled: bool,
}
impl SelectOption {
#[track_caller]
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: checked_name("SelectOption::new", label),
selected: false,
disabled: false,
}
}
#[must_use]
pub const fn selected(mut self) -> Self {
self.selected = true;
self
}
#[must_use]
pub const fn disabled(mut self) -> Self {
self.disabled = true;
self
}
}
#[derive(Debug, Clone)]
pub struct Select<State> {
name: String,
options: Vec<SelectOption>,
required: bool,
aria_required: bool,
class: Option<String>,
label_class: Option<String>,
aria_invalid: Option<bool>,
described_by: Option<String>,
hx: Vec<(String, String)>,
label: Option<LabelSource>,
_state: std::marker::PhantomData<State>,
}
impl Select<NoLabel> {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
options: Vec::new(),
required: false,
aria_required: false,
class: None,
label_class: None,
aria_invalid: None,
described_by: None,
hx: Vec::new(),
label: None,
_state: std::marker::PhantomData,
}
}
#[must_use]
#[track_caller]
pub fn label(self, text: impl Into<String>) -> Select<Labeled> {
self.with_label(LabelSource::Visible(checked_name("Select::label", text)))
}
#[must_use]
#[track_caller]
pub fn aria_label(self, text: impl Into<String>) -> Select<Labeled> {
self.with_label(LabelSource::Aria(checked_name("Select::aria_label", text)))
}
#[must_use]
#[track_caller]
pub fn labelled_by(self, id: impl Into<String>) -> Select<Labeled> {
self.with_label(LabelSource::LabelledBy(checked_name(
"Select::labelled_by",
id,
)))
}
fn with_label(self, label: LabelSource) -> Select<Labeled> {
Select {
name: self.name,
options: self.options,
required: self.required,
aria_required: self.aria_required,
class: self.class,
label_class: self.label_class,
aria_invalid: self.aria_invalid,
described_by: self.described_by,
hx: self.hx,
label: Some(label),
_state: std::marker::PhantomData,
}
}
}
impl<State> Select<State> {
#[must_use]
#[track_caller]
pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
self.options.push(SelectOption::new(value, label));
self
}
#[must_use]
pub fn options(mut self, options: impl IntoIterator<Item = SelectOption>) -> Self {
self.options.extend(options);
self
}
#[must_use]
pub fn selected_value(mut self, value: impl Into<String>) -> Self {
let value = value.into();
for opt in &mut self.options {
opt.selected = opt.value == value;
}
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn aria_required(mut self) -> Self {
self.aria_required = true;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub fn label_class(mut self, class: impl Into<String>) -> Self {
self.label_class = Some(class.into());
self
}
#[must_use]
pub const fn aria_invalid(mut self, invalid: bool) -> Self {
self.aria_invalid = Some(invalid);
self
}
#[must_use]
pub fn described_by(mut self, id: impl Into<String>) -> Self {
self.described_by = Some(id.into());
self
}
#[must_use]
pub fn hx(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.hx.push((name.into(), value.into()));
self
}
}
impl Render for Select<Labeled> {
fn render(&self) -> Markup {
let (visible_label, aria_label, aria_labelledby, aria_invalid, aria_required) =
label_render_attrs(self.label.as_ref(), self.aria_invalid, self.aria_required);
let select = html! {
select
id=(self.name)
name=(self.name)
aria-label=[aria_label]
aria-labelledby=[aria_labelledby]
class=[self.class.as_deref()]
aria-invalid=[aria_invalid]
aria-describedby=[self.described_by.as_deref()]
aria-required=[aria_required]
required[self.required] {
@for opt in &self.options {
option value=(opt.value) selected[opt.selected] disabled[opt.disabled] {
(opt.label)
}
}
}
};
html! {
@if let Some(text) = visible_label {
label for=(self.name) class=[self.label_class.as_deref()] { (text) }
}
(with_hx_attrs(select, &self.hx))
}
}
}
#[derive(Debug, Clone)]
pub struct Checkbox<State> {
name: String,
value: Option<String>,
checked: bool,
required: bool,
aria_required: bool,
class: Option<String>,
label_class: Option<String>,
aria_invalid: Option<bool>,
described_by: Option<String>,
hx: Vec<(String, String)>,
label: Option<LabelSource>,
_state: std::marker::PhantomData<State>,
}
impl Checkbox<NoLabel> {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
value: None,
checked: false,
required: false,
aria_required: false,
class: None,
label_class: None,
aria_invalid: None,
described_by: None,
hx: Vec::new(),
label: None,
_state: std::marker::PhantomData,
}
}
#[must_use]
#[track_caller]
pub fn label(self, text: impl Into<String>) -> Checkbox<Labeled> {
self.with_label(LabelSource::Visible(checked_name("Checkbox::label", text)))
}
#[must_use]
#[track_caller]
pub fn aria_label(self, text: impl Into<String>) -> Checkbox<Labeled> {
self.with_label(LabelSource::Aria(checked_name(
"Checkbox::aria_label",
text,
)))
}
#[must_use]
#[track_caller]
pub fn labelled_by(self, id: impl Into<String>) -> Checkbox<Labeled> {
self.with_label(LabelSource::LabelledBy(checked_name(
"Checkbox::labelled_by",
id,
)))
}
fn with_label(self, label: LabelSource) -> Checkbox<Labeled> {
Checkbox {
name: self.name,
value: self.value,
checked: self.checked,
required: self.required,
aria_required: self.aria_required,
class: self.class,
label_class: self.label_class,
aria_invalid: self.aria_invalid,
described_by: self.described_by,
hx: self.hx,
label: Some(label),
_state: std::marker::PhantomData,
}
}
}
impl<State> Checkbox<State> {
#[must_use]
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
#[must_use]
pub const fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn aria_required(mut self) -> Self {
self.aria_required = true;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub fn label_class(mut self, class: impl Into<String>) -> Self {
self.label_class = Some(class.into());
self
}
#[must_use]
pub const fn aria_invalid(mut self, invalid: bool) -> Self {
self.aria_invalid = Some(invalid);
self
}
#[must_use]
pub fn described_by(mut self, id: impl Into<String>) -> Self {
self.described_by = Some(id.into());
self
}
#[must_use]
pub fn hx(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.hx.push((name.into(), value.into()));
self
}
}
impl Render for Checkbox<Labeled> {
fn render(&self) -> Markup {
let (visible_label, aria_label, aria_labelledby, aria_invalid, aria_required) =
label_render_attrs(self.label.as_ref(), self.aria_invalid, self.aria_required);
let input = html! {
input
type="checkbox"
id=(self.name)
name=(self.name)
value=[self.value.as_deref()]
aria-label=[aria_label]
aria-labelledby=[aria_labelledby]
class=[self.class.as_deref()]
aria-invalid=[aria_invalid]
aria-describedby=[self.described_by.as_deref()]
aria-required=[aria_required]
checked[self.checked]
required[self.required];
};
html! {
(with_hx_attrs(input, &self.hx))
@if let Some(text) = visible_label {
label for=(self.name) class=[self.label_class.as_deref()] { (text) }
}
}
}
}
#[derive(Debug, Clone)]
pub struct FileField<State> {
name: String,
accept: Option<String>,
multiple: bool,
required: bool,
aria_required: bool,
class: Option<String>,
label_class: Option<String>,
aria_invalid: Option<bool>,
described_by: Option<String>,
hx: Vec<(String, String)>,
label: Option<LabelSource>,
_state: std::marker::PhantomData<State>,
}
impl FileField<NoLabel> {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
accept: None,
multiple: false,
required: false,
aria_required: false,
class: None,
label_class: None,
aria_invalid: None,
described_by: None,
hx: Vec::new(),
label: None,
_state: std::marker::PhantomData,
}
}
#[must_use]
#[track_caller]
pub fn label(self, text: impl Into<String>) -> FileField<Labeled> {
self.with_label(LabelSource::Visible(checked_name("FileField::label", text)))
}
#[must_use]
#[track_caller]
pub fn aria_label(self, text: impl Into<String>) -> FileField<Labeled> {
self.with_label(LabelSource::Aria(checked_name(
"FileField::aria_label",
text,
)))
}
#[must_use]
#[track_caller]
pub fn labelled_by(self, id: impl Into<String>) -> FileField<Labeled> {
self.with_label(LabelSource::LabelledBy(checked_name(
"FileField::labelled_by",
id,
)))
}
fn with_label(self, label: LabelSource) -> FileField<Labeled> {
FileField {
name: self.name,
accept: self.accept,
multiple: self.multiple,
required: self.required,
aria_required: self.aria_required,
class: self.class,
label_class: self.label_class,
aria_invalid: self.aria_invalid,
described_by: self.described_by,
hx: self.hx,
label: Some(label),
_state: std::marker::PhantomData,
}
}
}
impl<State> FileField<State> {
#[must_use]
pub fn accept(mut self, accept: impl Into<String>) -> Self {
self.accept = Some(accept.into());
self
}
#[must_use]
pub const fn multiple(mut self) -> Self {
self.multiple = true;
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn aria_required(mut self) -> Self {
self.aria_required = true;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub fn label_class(mut self, class: impl Into<String>) -> Self {
self.label_class = Some(class.into());
self
}
#[must_use]
pub const fn aria_invalid(mut self, invalid: bool) -> Self {
self.aria_invalid = Some(invalid);
self
}
#[must_use]
pub fn described_by(mut self, id: impl Into<String>) -> Self {
self.described_by = Some(id.into());
self
}
#[must_use]
pub fn hx(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.hx.push((name.into(), value.into()));
self
}
}
impl Render for FileField<Labeled> {
fn render(&self) -> Markup {
let (visible_label, aria_label, aria_labelledby, aria_invalid, aria_required) =
label_render_attrs(self.label.as_ref(), self.aria_invalid, self.aria_required);
let input = html! {
input
type="file"
id=(self.name)
name=(self.name)
accept=[self.accept.as_deref()]
aria-label=[aria_label]
aria-labelledby=[aria_labelledby]
class=[self.class.as_deref()]
aria-invalid=[aria_invalid]
aria-describedby=[self.described_by.as_deref()]
aria-required=[aria_required]
multiple[self.multiple]
required[self.required];
};
html! {
@if let Some(text) = visible_label {
label for=(self.name) class=[self.label_class.as_deref()] { (text) }
}
(with_hx_attrs(input, &self.hx))
}
}
}
#[derive(Debug, Clone)]
pub struct RadioOption {
value: String,
label: String,
checked: bool,
disabled: bool,
}
impl RadioOption {
#[track_caller]
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: checked_name("RadioOption::new", label),
checked: false,
disabled: false,
}
}
#[must_use]
pub const fn checked(mut self) -> Self {
self.checked = true;
self
}
#[must_use]
pub const fn disabled(mut self) -> Self {
self.disabled = true;
self
}
}
#[derive(Debug, Clone)]
pub struct RadioGroup<State> {
name: String,
id_prefix: Option<String>,
options: Vec<RadioOption>,
required: bool,
aria_required: bool,
class: Option<String>,
label_class: Option<String>,
aria_invalid: Option<bool>,
described_by: Option<String>,
hx: Vec<(String, String)>,
label: Option<LabelSource>,
_state: std::marker::PhantomData<State>,
}
impl RadioGroup<NoLabel> {
pub fn new(name: impl Into<String>, first: RadioOption) -> Self {
Self {
name: name.into(),
id_prefix: None,
options: vec![first],
required: false,
aria_required: false,
class: None,
label_class: None,
aria_invalid: None,
described_by: None,
hx: Vec::new(),
label: None,
_state: std::marker::PhantomData,
}
}
#[must_use]
#[track_caller]
pub fn label(self, text: impl Into<String>) -> RadioGroup<Labeled> {
self.with_label(LabelSource::Visible(checked_name(
"RadioGroup::label",
text,
)))
}
#[must_use]
#[track_caller]
pub fn aria_label(self, text: impl Into<String>) -> RadioGroup<Labeled> {
self.with_label(LabelSource::Aria(checked_name(
"RadioGroup::aria_label",
text,
)))
}
#[must_use]
#[track_caller]
pub fn labelled_by(self, id: impl Into<String>) -> RadioGroup<Labeled> {
self.with_label(LabelSource::LabelledBy(checked_name(
"RadioGroup::labelled_by",
id,
)))
}
fn with_label(self, label: LabelSource) -> RadioGroup<Labeled> {
RadioGroup {
name: self.name,
id_prefix: self.id_prefix,
options: self.options,
required: self.required,
aria_required: self.aria_required,
class: self.class,
label_class: self.label_class,
aria_invalid: self.aria_invalid,
described_by: self.described_by,
hx: self.hx,
label: Some(label),
_state: std::marker::PhantomData,
}
}
}
impl<State> RadioGroup<State> {
#[must_use]
pub fn option(mut self, option: RadioOption) -> Self {
self.options.push(option);
self
}
#[must_use]
pub fn options(mut self, options: impl IntoIterator<Item = RadioOption>) -> Self {
self.options.extend(options);
self
}
#[must_use]
pub fn id_prefix(mut self, prefix: impl Into<String>) -> Self {
self.id_prefix = Some(prefix.into());
self
}
#[must_use]
pub fn checked_value(mut self, value: impl Into<String>) -> Self {
let value = value.into();
for option in &mut self.options {
option.checked = option.value == value;
}
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn aria_required(mut self) -> Self {
self.aria_required = true;
self
}
#[must_use]
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
#[must_use]
pub fn label_class(mut self, class: impl Into<String>) -> Self {
self.label_class = Some(class.into());
self
}
#[must_use]
pub const fn aria_invalid(mut self, invalid: bool) -> Self {
self.aria_invalid = Some(invalid);
self
}
#[must_use]
pub fn described_by(mut self, id: impl Into<String>) -> Self {
self.described_by = Some(id.into());
self
}
#[must_use]
pub fn hx(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.hx.push((name.into(), value.into()));
self
}
}
fn radio_option_ids(prefix: Option<&str>, name: &str, options: &[RadioOption]) -> Vec<String> {
let group = prefix.map_or_else(
|| selector_safe(&id_part(name)),
|prefix| format!("{}-{}", selector_safe(&id_part(prefix)), id_part(name)),
);
let mut seen = std::collections::HashSet::new();
let mut ids = Vec::with_capacity(options.len());
for (index, option) in options.iter().enumerate() {
let base = format!("{group}-{}", id_part(&option.value));
let mut id = base.clone();
let mut suffix = index;
while !seen.insert(id.clone()) {
id = format!("{base}-{suffix}");
suffix += 1;
}
ids.push(id);
}
ids
}
fn id_part(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if c == '-' {
out.push_str("--");
} else if c.is_ascii_alphanumeric() || c == '_' {
out.push(c);
} else {
out.push_str("--");
}
}
out
}
fn selector_safe(part: &str) -> String {
if part
.trim_start_matches('_')
.starts_with(|c: char| c.is_ascii_alphabetic())
{
part.to_owned()
} else {
format!("_{part}")
}
}
impl Render for RadioGroup<Labeled> {
fn render(&self) -> Markup {
let (visible_label, aria_label, aria_labelledby, aria_invalid, aria_required) =
label_render_attrs(self.label.as_ref(), self.aria_invalid, self.aria_required);
let ids = radio_option_ids(self.id_prefix.as_deref(), &self.name, &self.options);
let checked_at = self.options.iter().position(|o| o.checked);
let controls = html! {
@for (index, (option, id)) in self.options.iter().zip(&ids).enumerate() {
(with_hx_attrs(html! {
input
type="radio"
id=(id)
name=(self.name)
value=(option.value)
checked[checked_at == Some(index)]
disabled[option.disabled]
required[self.required]
aria-invalid=[aria_invalid];
}, &self.hx))
label for=(id) { (option.label) }
}
};
visible_label.map_or_else(
|| {
html! {
div
role="radiogroup"
aria-label=[aria_label]
aria-labelledby=[aria_labelledby]
class=[self.class.as_deref()]
aria-describedby=[self.described_by.as_deref()]
aria-required=[aria_required] {
(controls)
}
}
},
|text| {
html! {
fieldset
role="radiogroup"
class=[self.class.as_deref()]
aria-describedby=[self.described_by.as_deref()]
aria-required=[aria_required] {
legend class=[self.label_class.as_deref()] { (text) }
(controls)
}
}
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn img_new_requires_and_renders_alt() {
let markup = Img::new("/logo.png", "Company logo").render().into_string();
assert!(markup.contains("src=\"/logo.png\""));
assert!(markup.contains("alt=\"Company logo\""));
}
#[test]
fn img_decorative_sets_empty_alt() {
let markup = Img::decorative("/divider.png").render().into_string();
assert!(markup.contains("alt=\"\""));
}
#[test]
fn img_escapes_alt_text() {
let markup = Img::new("/x.png", "a \"quoted\" & <tagged> name")
.render()
.into_string();
assert!(!markup.contains("<tagged>"));
assert!(markup.contains("<tagged>"));
}
#[test]
fn button_new_renders_visible_label() {
let markup = Button::new("Save").submit().render().into_string();
assert!(markup.contains("type=\"submit\""));
assert!(markup.contains(">Save<"));
assert!(!markup.contains("aria-label"));
}
#[test]
fn button_icon_uses_aria_label() {
let icon = html! { span aria-hidden="true" { "x" } };
let markup = Button::icon(icon, "Close dialog").render().into_string();
assert!(markup.contains("aria-label=\"Close dialog\""));
}
#[test]
fn text_field_visible_label_is_associated() {
let markup = TextField::new("email")
.input_type("email")
.label("Email address")
.render()
.into_string();
assert!(markup.contains("<label for=\"email\">Email address</label>"));
assert!(markup.contains("id=\"email\""));
assert!(markup.contains("name=\"email\""));
assert!(markup.contains("type=\"email\""));
}
#[test]
fn text_field_aria_label_variant() {
let markup = TextField::new("q")
.aria_label("Search")
.render()
.into_string();
assert!(markup.contains("aria-label=\"Search\""));
}
#[test]
fn text_field_labelled_by_variant() {
let markup = TextField::new("q")
.labelled_by("search-heading")
.render()
.into_string();
assert!(markup.contains("aria-labelledby=\"search-heading\""));
}
#[test]
fn text_field_required_and_value() {
let markup = TextField::new("name")
.value("Ada")
.required()
.label("Name")
.render()
.into_string();
assert!(markup.contains("value=\"Ada\""));
assert!(markup.contains("required"));
}
#[test]
fn text_field_carries_class_and_error_wiring() {
let markup = TextField::new("email")
.input_type("email")
.class("autumn-field__input autumn-field__input--invalid")
.aria_invalid(true)
.described_by("email-error")
.label("Email address")
.render()
.into_string();
assert!(
markup.contains("class=\"autumn-field__input autumn-field__input--invalid\""),
"{markup}"
);
assert!(markup.contains("aria-invalid=\"true\""), "{markup}");
assert!(
markup.contains("aria-describedby=\"email-error\""),
"{markup}"
);
assert!(
markup.contains("<label for=\"email\">Email address</label>"),
"{markup}"
);
}
#[test]
fn text_field_aria_invalid_false_renders_explicitly() {
let markup = TextField::new("email")
.aria_invalid(false)
.label("Email address")
.render()
.into_string();
assert!(markup.contains("aria-invalid=\"false\""), "{markup}");
}
#[test]
fn text_field_aria_invalid_omitted_when_unset() {
let markup = TextField::new("email")
.label("Email address")
.render()
.into_string();
assert!(!markup.contains("aria-invalid"), "{markup}");
assert!(!markup.contains("aria-describedby"), "{markup}");
}
#[test]
fn text_field_string_length_constraints() {
let markup = TextField::new("title")
.minlength(3)
.maxlength(120)
.required()
.aria_required()
.label("Title")
.render()
.into_string();
assert!(markup.contains("minlength=\"3\""), "{markup}");
assert!(markup.contains("maxlength=\"120\""), "{markup}");
assert!(markup.contains("required"), "{markup}");
assert!(markup.contains("aria-required=\"true\""), "{markup}");
}
#[test]
fn text_field_numeric_constraints() {
let markup = TextField::new("ratio")
.input_type("number")
.min("0")
.max("1")
.step("any")
.label("Ratio")
.render()
.into_string();
assert!(markup.contains("type=\"number\""), "{markup}");
assert!(markup.contains("min=\"0\""), "{markup}");
assert!(markup.contains("max=\"1\""), "{markup}");
assert!(markup.contains("step=\"any\""), "{markup}");
}
#[test]
fn text_field_label_class_is_emitted() {
let html = TextField::new("email")
.label("Email address")
.label_class("autumn-field__label")
.render()
.into_string();
assert!(
html.contains(
r#"<label for="email" class="autumn-field__label">Email address</label>"#
),
"got: {html}"
);
}
#[test]
fn text_field_label_class_omitted_when_unset() {
let html = TextField::new("email")
.label("Email address")
.render()
.into_string();
assert!(
html.contains(r#"<label for="email">Email address</label>"#),
"got: {html}"
);
assert!(
!html.contains("class="),
"label should have no class when unset, got: {html}"
);
}
#[test]
fn text_field_new_attrs_survive_aria_label_variant() {
let markup = TextField::new("q")
.class("search")
.aria_invalid(true)
.aria_label("Search")
.render()
.into_string();
assert!(markup.contains("aria-label=\"Search\""), "{markup}");
assert!(markup.contains("class=\"search\""), "{markup}");
assert!(markup.contains("aria-invalid=\"true\""), "{markup}");
}
#[test]
fn is_valid_hx_suffix_accepts_safe_alphabet_only() {
assert!(is_valid_hx_suffix("post"));
assert!(is_valid_hx_suffix("trigger"));
assert!(is_valid_hx_suffix("on-2"));
assert!(is_valid_hx_suffix("swap-oob"));
assert!(!is_valid_hx_suffix(""));
assert!(!is_valid_hx_suffix("post autofocus"));
assert!(!is_valid_hx_suffix("post=x"));
assert!(!is_valid_hx_suffix("Post"));
assert!(!is_valid_hx_suffix("po st"));
assert!(!is_valid_hx_suffix("post\"onload"));
assert!(!is_valid_hx_suffix("hx_post"));
}
fn open_tag_of_input<'m>(markup: &'m str, value: &str) -> &'m str {
let needle = format!(r#"value="{value}""#);
let tag = markup
.split("<input")
.find(|fragment| fragment.contains(&needle))
.unwrap_or_else(|| panic!("no <input> with {needle} in {markup}"));
&tag[..tag.find('>').expect("input tag closes")]
}
fn open_tag_of_group(markup: &str) -> &str {
&markup[..markup.find('>').expect("group opens")]
}
#[test]
fn radio_group_renders_fieldset_legend_and_options() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.option(RadioOption::new("express", "Express"))
.label("Shipping speed")
.render()
.into_string();
assert!(markup.contains("<fieldset"), "{markup}");
assert!(
markup.contains("<legend>Shipping speed</legend>"),
"{markup}"
);
assert!(
markup.contains(r#"id="speed-standard""#) && markup.contains(r#"for="speed-standard""#),
"{markup}"
);
assert!(
markup.contains(r#"id="speed-express""#) && markup.contains(r#"for="speed-express""#),
"{markup}"
);
assert_eq!(markup.matches(r#"name="speed""#).count(), 2, "{markup}");
assert_eq!(markup.matches(r#"type="radio""#).count(), 2, "{markup}");
}
#[test]
fn radio_group_aria_label_uses_radiogroup_role() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.aria_label("Shipping speed")
.render()
.into_string();
assert!(markup.contains(r#"role="radiogroup""#), "{markup}");
assert!(
markup.contains(r#"aria-label="Shipping speed""#),
"{markup}"
);
assert!(!markup.contains("<legend"), "{markup}");
assert!(!markup.contains("<fieldset"), "{markup}");
assert_eq!(markup.matches(r#"type="radio""#).count(), 1, "{markup}");
assert!(
markup.contains(r#"id="speed-standard""#) && markup.contains(r#"for="speed-standard""#),
"{markup}"
);
}
#[test]
fn radio_group_labelled_by_uses_radiogroup_role() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.labelled_by("speed-heading")
.render()
.into_string();
assert!(markup.contains(r#"role="radiogroup""#), "{markup}");
assert!(
markup.contains(r#"aria-labelledby="speed-heading""#),
"{markup}"
);
}
#[test]
fn radio_group_marks_the_checked_option_only() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.option(RadioOption::new("express", "Express").checked())
.label("Shipping speed")
.render()
.into_string();
assert_eq!(markup.matches(" checked").count(), 1, "{markup}");
assert!(
open_tag_of_input(&markup, "express").contains(" checked"),
"{markup}"
);
}
#[test]
fn radio_group_disambiguates_colliding_option_ids() {
let markup = RadioGroup::new("plan", RadioOption::new("a b", "A B"))
.options([
RadioOption::new("a-b", "A-B"),
RadioOption::new("a b", "A B again"),
])
.label("Plan")
.render()
.into_string();
let ids: Vec<&str> = markup
.match_indices(r#" id=""#)
.map(|(at, _)| {
let rest = &markup[at + 5..];
&rest[..rest.find('"').expect("id closes")]
})
.collect();
assert_eq!(
ids,
vec!["plan-a--b", "plan-a--b-1", "plan-a--b-2"],
"{markup}"
);
for id in &ids {
assert!(markup.contains(&format!(r#"for="{id}""#)), "{markup}");
}
}
#[test]
fn radio_group_ids_do_not_collide_across_groups() {
let one = RadioGroup::new("a-b", RadioOption::new("c", "C"))
.label("One")
.render()
.into_string();
let two = RadioGroup::new("a", RadioOption::new("b-c", "BC"))
.label("Two")
.render()
.into_string();
let id_of = |markup: &str| {
let at = markup.find(r#" id=""#).expect("an id") + 5;
markup[at..][..markup[at..].find('"').expect("id closes")].to_owned()
};
assert_ne!(id_of(&one), id_of(&two), "{one}\n{two}");
}
#[test]
fn radio_group_ids_keep_the_plain_shape_when_nothing_needs_escaping() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.label("Shipping speed")
.render()
.into_string();
assert!(markup.contains(r#"id="speed-standard""#), "{markup}");
}
#[test]
fn radio_group_id_prefix_separates_repeated_groups() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.id_prefix("row-7")
.label("Shipping speed")
.render()
.into_string();
assert!(markup.contains(r#"id="row--7-speed-standard""#), "{markup}");
assert!(
markup.contains(r#"for="row--7-speed-standard""#),
"{markup}"
);
}
#[test]
fn radio_group_carries_required_and_error_wiring() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.option(RadioOption::new("express", "Express"))
.required()
.aria_required()
.aria_invalid(true)
.described_by("speed-error")
.class("field")
.label_class("field__legend")
.label("Shipping speed")
.render()
.into_string();
assert_eq!(markup.matches(" required").count(), 2, "{markup}");
assert!(
open_tag_of_input(&markup, "standard").contains(" required"),
"{markup}"
);
let group = open_tag_of_group(&markup);
assert!(group.contains(r#"aria-required="true""#), "{markup}");
assert!(
group.contains(r#"aria-describedby="speed-error""#),
"{markup}"
);
assert!(group.contains(r#"class="field""#), "{markup}");
assert!(!group.contains("aria-invalid"), "{markup}");
assert!(
open_tag_of_input(&markup, "standard").contains(r#"aria-invalid="true""#),
"{markup}"
);
assert!(markup.contains(r#"class="field__legend""#), "{markup}");
}
#[test]
fn radio_group_accepts_hx_attributes() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.hx("post", "/quote")
.label("Shipping speed")
.render()
.into_string();
assert!(markup.contains(r#"hx-post="/quote""#), "{markup}");
}
#[test]
fn radio_group_disabled_option_renders_disabled() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.option(RadioOption::new("overnight", "Overnight").disabled())
.label("Shipping speed")
.render()
.into_string();
assert_eq!(markup.matches(" disabled").count(), 1, "{markup}");
assert!(
open_tag_of_input(&markup, "overnight").contains(" disabled"),
"{markup}"
);
}
#[test]
fn radio_group_fieldset_carries_the_radiogroup_role() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.aria_required()
.label("Shipping speed")
.render()
.into_string();
let group = open_tag_of_group(&markup);
assert!(group.contains("<fieldset"), "{markup}");
assert!(group.contains(r#"role="radiogroup""#), "{markup}");
assert!(group.contains(r#"aria-required="true""#), "{markup}");
}
#[test]
fn radio_group_checks_at_most_one_option() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard").checked())
.option(RadioOption::new("express", "Express").checked())
.label("Shipping speed")
.render()
.into_string();
assert_eq!(markup.matches(" checked").count(), 1, "{markup}");
assert!(
open_tag_of_input(&markup, "standard").contains(" checked"),
"{markup}"
);
}
#[test]
fn radio_group_checked_value_overrides_every_option() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard").checked())
.option(RadioOption::new("express", "Express"))
.checked_value("express")
.label("Shipping speed")
.render()
.into_string();
assert_eq!(markup.matches(" checked").count(), 1, "{markup}");
let express = markup
.split(r#"value="express""#)
.nth(1)
.expect("express option rendered");
assert!(express.starts_with(" checked"), "{markup}");
}
#[test]
fn radio_group_aria_invalid_lands_on_every_control() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.option(RadioOption::new("express", "Express"))
.aria_invalid(true)
.described_by("speed-error")
.label("Shipping speed")
.render()
.into_string();
assert_eq!(
markup.matches(r#"aria-invalid="true""#).count(),
2,
"{markup}"
);
let group = open_tag_of_group(&markup);
assert!(!group.contains("aria-invalid"), "{markup}");
assert!(
group.contains(r#"aria-describedby="speed-error""#),
"{markup}"
);
}
#[test]
fn radio_group_label_class_is_ignored_without_a_visible_legend() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.label_class("field__legend")
.aria_label("Shipping speed")
.render()
.into_string();
assert!(!markup.contains("field__legend"), "{markup}");
}
#[test]
fn radio_group_hx_attributes_land_on_every_control() {
let markup = RadioGroup::new("speed", RadioOption::new("standard", "Standard"))
.option(RadioOption::new("express", "Express"))
.hx("post", "/quote")
.label("Shipping speed")
.render()
.into_string();
assert_eq!(markup.matches(r#"hx-post="/quote""#).count(), 2, "{markup}");
assert!(!open_tag_of_group(&markup).contains("hx-post"), "{markup}");
}
type Build = Box<dyn Fn() + std::panic::RefUnwindSafe>;
fn name_entry_points(name: &'static str) -> Vec<(&'static str, Build)> {
let first = || RadioOption::new("a", "A");
vec![
("Img::new", Box::new(move || drop(Img::new("/x.png", name)))),
("Button::new", Box::new(move || drop(Button::new(name)))),
(
"Button::icon",
Box::new(move || drop(Button::icon(html! {}, name))),
),
("Link::new", Box::new(move || drop(Link::new("/", name)))),
(
"Link::icon",
Box::new(move || drop(Link::icon("/", html! {}, name))),
),
("MenuItem::new", Box::new(move || drop(MenuItem::new(name)))),
(
"SelectOption::new",
Box::new(move || drop(SelectOption::new("v", name))),
),
(
"Select::option",
Box::new(move || drop(Select::new("f").option("v", name))),
),
(
"RadioOption::new",
Box::new(move || drop(RadioOption::new("v", name))),
),
(
"TextField::label",
Box::new(move || drop(TextField::new("f").label(name))),
),
(
"TextField::aria_label",
Box::new(move || drop(TextField::new("f").aria_label(name))),
),
(
"TextField::labelled_by",
Box::new(move || drop(TextField::new("f").labelled_by(name))),
),
(
"TextArea::label",
Box::new(move || drop(TextArea::new("f").label(name))),
),
(
"TextArea::aria_label",
Box::new(move || drop(TextArea::new("f").aria_label(name))),
),
(
"TextArea::labelled_by",
Box::new(move || drop(TextArea::new("f").labelled_by(name))),
),
(
"Select::label",
Box::new(move || drop(Select::new("f").label(name))),
),
(
"Select::aria_label",
Box::new(move || drop(Select::new("f").aria_label(name))),
),
(
"Select::labelled_by",
Box::new(move || drop(Select::new("f").labelled_by(name))),
),
(
"Checkbox::label",
Box::new(move || drop(Checkbox::new("f").label(name))),
),
(
"Checkbox::aria_label",
Box::new(move || drop(Checkbox::new("f").aria_label(name))),
),
(
"Checkbox::labelled_by",
Box::new(move || drop(Checkbox::new("f").labelled_by(name))),
),
(
"FileField::label",
Box::new(move || drop(FileField::new("f").label(name))),
),
(
"FileField::aria_label",
Box::new(move || drop(FileField::new("f").aria_label(name))),
),
(
"FileField::labelled_by",
Box::new(move || drop(FileField::new("f").labelled_by(name))),
),
(
"RadioGroup::label",
Box::new(move || drop(RadioGroup::new("g", first()).label(name))),
),
(
"RadioGroup::aria_label",
Box::new(move || drop(RadioGroup::new("g", first()).aria_label(name))),
),
(
"RadioGroup::labelled_by",
Box::new(move || drop(RadioGroup::new("g", first()).labelled_by(name))),
),
]
}
#[test]
#[cfg(debug_assertions)]
fn an_empty_or_blank_accessible_name_trips_a_debug_assert() {
for name in ["", " ", "\t\n", "\u{200B}", "\u{FEFF} "] {
for (entry, build) in name_entry_points(name) {
let result = std::panic::catch_unwind(&*build);
assert!(
result.is_err(),
"{entry}({name:?}) must not accept an empty name"
);
}
}
}
#[test]
fn a_real_accessible_name_is_accepted_everywhere() {
for (entry, build) in name_entry_points("Name") {
let result = std::panic::catch_unwind(&*build);
assert!(result.is_ok(), "{entry} rejected a real name");
}
}
#[test]
#[cfg(debug_assertions)]
fn the_empty_name_panic_names_the_primitive() {
let err = std::panic::catch_unwind(|| Img::new("/x.png", "")).expect_err("must panic");
let message = err
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| err.downcast_ref::<&str>().copied())
.unwrap_or_default();
assert!(message.contains("Img::new"), "{message}");
assert!(message.contains("Img::decorative"), "{message}");
}
#[test]
fn is_blank_matches_what_the_name_check_rejects() {
for text in ["", " ", "\t\n", "\u{200B}", "\u{FEFF} ", "\u{2060}"] {
assert!(is_blank(text), "{text:?}");
}
for text in ["a", " a ", "\u{200B}a"] {
assert!(!is_blank(text), "{text:?}");
}
}
#[test]
fn an_empty_value_is_not_a_name_and_stays_allowed() {
let markup = Select::new("s")
.options([SelectOption::new("", "— Select —").disabled()])
.label("Size")
.render()
.into_string();
assert!(markup.contains(r#"value="""#), "{markup}");
let markup = RadioGroup::new("g", RadioOption::new("", "None"))
.label("Choice")
.render()
.into_string();
assert!(markup.contains(r#"value="""#), "{markup}");
assert!(
Img::decorative("/x.png")
.render()
.into_string()
.contains(r#"alt="""#)
);
}
fn first_id(markup: &str) -> String {
let at = markup.find(r#" id=""#).expect("an id") + 5;
markup[at..][..markup[at..].find('"').expect("id closes")].to_owned()
}
fn is_css_ident(id: &str) -> bool {
id.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[test]
fn a_digit_led_group_name_gets_a_selector_safe_id() {
let markup = RadioGroup::new("1plan", RadioOption::new("2x", "Two"))
.label("Plan")
.render()
.into_string();
assert_eq!(first_id(&markup), "_1plan-2x", "{markup}");
assert!(markup.contains(r#"for="_1plan-2x""#), "{markup}");
}
#[test]
fn an_empty_group_name_and_value_get_a_selector_safe_id() {
let markup = RadioGroup::new("", RadioOption::new("", "None"))
.label("Plan")
.render()
.into_string();
assert_eq!(first_id(&markup), "_-", "{markup}");
}
#[test]
fn a_digit_led_id_prefix_gets_a_selector_safe_id() {
let markup = RadioGroup::new("speed", RadioOption::new("fast", "Fast"))
.id_prefix("7")
.label("Speed")
.render()
.into_string();
assert_eq!(first_id(&markup), "_7-speed-fast", "{markup}");
}
#[test]
fn an_underscore_led_name_cannot_collide_with_an_escaped_one() {
let escaped = RadioGroup::new("1plan", RadioOption::new("a", "A"))
.label("One")
.render()
.into_string();
let underscored = RadioGroup::new("_1plan", RadioOption::new("a", "A"))
.label("Two")
.render()
.into_string();
assert_eq!(first_id(&underscored), "__1plan-a", "{underscored}");
assert_ne!(first_id(&escaped), first_id(&underscored));
}
#[test]
fn a_letter_led_or_underscore_letter_name_keeps_its_id() {
for (name, id) in [
("speed", "speed-a"),
("_speed", "_speed-a"),
("Speed", "Speed-a"),
] {
let markup = RadioGroup::new(name, RadioOption::new("a", "A"))
.label("Speed")
.render()
.into_string();
assert_eq!(first_id(&markup), id, "{markup}");
}
}
proptest::proptest! {
#[test]
fn every_choice_id_is_a_css_identifier(
prefix in proptest::option::of("[a-z0-9_ .-]{0,6}"),
name in "[A-Za-z0-9_ .\\[\\]-]{0,8}",
values in proptest::collection::vec("[A-Za-z0-9_ .-]{0,6}", 1..4),
) {
let options: Vec<RadioOption> =
values.iter().map(|v| RadioOption::new(v.as_str(), "Choice")).collect();
let ids = radio_option_ids(prefix.as_deref(), &name, &options);
for id in &ids {
proptest::prop_assert!(is_css_ident(id), "{id:?}");
}
}
#[test]
fn the_selector_safe_rule_is_injective(a in "[A-Za-z0-9_-]{0,6}", b in "[A-Za-z0-9_-]{0,6}") {
proptest::prop_assume!(a != b);
proptest::prop_assert_ne!(selector_safe(&a), selector_safe(&b));
}
#[test]
fn an_escaped_part_never_meets_its_underscored_twin(a in "([0-9_-][A-Za-z0-9_-]{0,5})?") {
proptest::prop_assert_ne!(selector_safe(&a), selector_safe(&format!("_{a}")));
}
}
}