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 {
pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
Self {
src: src.into(),
alt: alt.into(),
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 {
pub fn new(accessible_name: impl Into<String>) -> Self {
Self {
accessible_name: accessible_name.into(),
icon: None,
kind: ButtonType::default(),
class: None,
}
}
pub fn icon(icon: Markup, accessible_name: impl Into<String>) -> Self {
Self {
accessible_name: accessible_name.into(),
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 {
pub fn new(href: impl Into<String>, text: impl Into<String>) -> Self {
Self {
href: href.into(),
accessible_name: text.into(),
icon: None,
class: None,
new_tab: false,
}
}
pub fn icon(href: impl Into<String>, icon: Markup, accessible_name: impl Into<String>) -> Self {
Self {
href: href.into(),
accessible_name: accessible_name.into(),
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 {
pub fn new(accessible_name: impl Into<String>) -> Self {
Self {
accessible_name: accessible_name.into(),
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;
#[derive(Debug, Clone)]
enum LabelSource {
Visible(String),
Aria(String),
LabelledBy(String),
}
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]
pub fn label(self, text: impl Into<String>) -> TextField<Labeled> {
self.with_label(LabelSource::Visible(text.into()))
}
#[must_use]
pub fn aria_label(self, text: impl Into<String>) -> TextField<Labeled> {
self.with_label(LabelSource::Aria(text.into()))
}
#[must_use]
pub fn labelled_by(self, id: impl Into<String>) -> TextField<Labeled> {
self.with_label(LabelSource::LabelledBy(id.into()))
}
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) = match &self.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 = self
.aria_invalid
.map(|invalid| if invalid { "true" } else { "false" });
let aria_required = self.aria_required.then_some("true");
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]
pub fn label(self, text: impl Into<String>) -> TextArea<Labeled> {
self.with_label(LabelSource::Visible(text.into()))
}
#[must_use]
pub fn aria_label(self, text: impl Into<String>) -> TextArea<Labeled> {
self.with_label(LabelSource::Aria(text.into()))
}
#[must_use]
pub fn labelled_by(self, id: impl Into<String>) -> TextArea<Labeled> {
self.with_label(LabelSource::LabelledBy(id.into()))
}
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) = match &self.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 = self
.aria_invalid
.map(|invalid| if invalid { "true" } else { "false" });
let aria_required = self.aria_required.then_some("true");
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 {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
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]
pub fn label(self, text: impl Into<String>) -> Select<Labeled> {
self.with_label(LabelSource::Visible(text.into()))
}
#[must_use]
pub fn aria_label(self, text: impl Into<String>) -> Select<Labeled> {
self.with_label(LabelSource::Aria(text.into()))
}
#[must_use]
pub fn labelled_by(self, id: impl Into<String>) -> Select<Labeled> {
self.with_label(LabelSource::LabelledBy(id.into()))
}
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]
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) = match &self.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 = self
.aria_invalid
.map(|invalid| if invalid { "true" } else { "false" });
let aria_required = self.aria_required.then_some("true");
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]
pub fn label(self, text: impl Into<String>) -> Checkbox<Labeled> {
self.with_label(LabelSource::Visible(text.into()))
}
#[must_use]
pub fn aria_label(self, text: impl Into<String>) -> Checkbox<Labeled> {
self.with_label(LabelSource::Aria(text.into()))
}
#[must_use]
pub fn labelled_by(self, id: impl Into<String>) -> Checkbox<Labeled> {
self.with_label(LabelSource::LabelledBy(id.into()))
}
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) = match &self.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 = self
.aria_invalid
.map(|invalid| if invalid { "true" } else { "false" });
let aria_required = self.aria_required.then_some("true");
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]
pub fn label(self, text: impl Into<String>) -> FileField<Labeled> {
self.with_label(LabelSource::Visible(text.into()))
}
#[must_use]
pub fn aria_label(self, text: impl Into<String>) -> FileField<Labeled> {
self.with_label(LabelSource::Aria(text.into()))
}
#[must_use]
pub fn labelled_by(self, id: impl Into<String>) -> FileField<Labeled> {
self.with_label(LabelSource::LabelledBy(id.into()))
}
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) = match &self.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 = self
.aria_invalid
.map(|invalid| if invalid { "true" } else { "false" });
let aria_required = self.aria_required.then_some("true");
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))
}
}
}
#[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"));
}
}