use maud::{Markup, html};
#[derive(Debug, Clone, Copy, Default)]
pub struct DirectUploadConfig<'a> {
label: Option<&'a str>,
id: Option<&'a str>,
accept: Option<&'a str>,
multiple: bool,
}
impl<'a> DirectUploadConfig<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
label: None,
id: None,
accept: None,
multiple: false,
}
}
#[must_use]
pub const fn label(mut self, label: &'a str) -> Self {
self.label = Some(label);
self
}
#[must_use]
pub const fn id(mut self, id: &'a str) -> Self {
self.id = Some(id);
self
}
#[must_use]
pub const fn accept(mut self, accept: &'a str) -> Self {
self.accept = Some(accept);
self
}
#[must_use]
pub const fn multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
}
fn humanize(name: &str) -> String {
if name.is_empty() {
return "File upload".to_owned();
}
let spaced = name.replace('_', " ");
let mut chars = spaced.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().collect::<String>() + chars.as_str()
})
}
#[must_use]
pub fn direct_upload_field(
name: &str,
presign_url: &str,
config: &DirectUploadConfig<'_>,
) -> Markup {
let id = config.id.map_or_else(
|| format!("upload-{}", if name.is_empty() { "file" } else { name }),
str::to_owned,
);
let label_id = format!("{id}-label");
let label = config.label.map_or_else(|| humanize(name), str::to_owned);
html! {
div
class="autumn-direct-upload"
data-autumn-direct-upload
data-autumn-upload-url=(presign_url)
data-autumn-upload-field=(name)
data-controller="direct-upload"
data-direct-upload-url=(presign_url) {
label for=(id) id=(label_id) class="autumn-upload-label" { (label) }
input
type="file"
id=(id)
name=(name)
accept=[config.accept]
multiple[config.multiple]
aria-describedby=(format!("{id}-status {id}-error"))
data-direct-upload-target="input";
span class="autumn-upload-hint" data-autumn-upload-hint hidden="" {
@if config.multiple { "Or drop files here." } @else { "Or drop a file here." }
}
div
class="autumn-upload-progress"
role="progressbar"
aria-labelledby=(label_id)
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
hidden="" {
div class="autumn-upload-bar" {}
}
p class="autumn-upload-status" id=(format!("{id}-status")) role="status" {}
p class="autumn-upload-error" id=(format!("{id}-error")) role="alert" {}
div data-autumn-upload-fields {}
noscript {
p class="autumn-upload-noscript" {
"JavaScript is off. The file uploads with the form."
}
}
}
}
}
#[must_use]
pub fn direct_upload_input(name: &str, presign_url: &str, accept: Option<&str>) -> Markup {
let config = DirectUploadConfig {
accept,
..DirectUploadConfig::new()
};
direct_upload_field(name, presign_url, &config)
}
#[cfg(test)]
mod tests {
use super::*;
fn render(config: &DirectUploadConfig<'_>) -> String {
direct_upload_field("cover_image", "/uploads/presign", config).into_string()
}
#[test]
fn direct_upload_input_contains_required_attributes() {
let html =
direct_upload_input("cover_image", "/uploads/presign", Some("image/*")).into_string();
assert!(html.contains("data-autumn-direct-upload"), "{html}");
assert!(html.contains("data-autumn-upload-url=\"/uploads/presign\""));
assert!(html.contains("data-autumn-upload-field=\"cover_image\""));
assert!(html.contains("name=\"cover_image\""));
assert!(html.contains("type=\"file\""));
assert!(html.contains("accept=\"image/*\""));
assert!(html.contains("autumn-upload-progress"));
}
#[test]
fn direct_upload_input_without_accept() {
let html = direct_upload_input("avatar", "/presign", None).into_string();
assert!(!html.contains("accept="));
assert!(html.contains("name=\"avatar\""));
}
#[test]
fn the_input_has_a_visible_label_and_no_aria_label() {
let html = direct_upload_input("cover_image", "/presign", None).into_string();
assert!(html.contains("<label for=\"upload-cover_image\""), "{html}");
assert!(html.contains(">Cover image</label>"), "{html}");
assert!(html.contains("id=\"upload-cover_image\""), "{html}");
assert!(!html.contains("aria-label="), "{html}");
}
#[test]
fn an_empty_name_still_gets_a_label() {
let html = direct_upload_input("", "/presign", None).into_string();
assert!(html.contains(">File upload</label>"), "{html}");
}
#[test]
fn config_sets_label_id_and_multiple() {
let config = DirectUploadConfig::new()
.label("Photos")
.id("photos")
.multiple(true);
let html = render(&config);
assert!(html.contains("<label for=\"photos\""), "{html}");
assert!(html.contains(">Photos</label>"), "{html}");
assert!(html.contains("id=\"photos\""), "{html}");
assert!(html.contains("multiple"), "{html}");
}
#[test]
fn the_label_text_is_escaped() {
let html = render(&DirectUploadConfig::new().label("<b>x</b>"));
assert!(!html.contains("<b>x</b>"), "{html}");
}
#[test]
fn progress_status_and_error_are_announced() {
let html = direct_upload_input("file", "/presign", None).into_string();
assert!(html.contains("role=\"progressbar\""), "{html}");
assert!(html.contains("aria-valuemin=\"0\""), "{html}");
assert!(html.contains("aria-valuemax=\"100\""), "{html}");
assert!(html.contains("role=\"status\""), "{html}");
assert!(html.contains("role=\"alert\""), "{html}");
assert!(html.contains("data-autumn-upload-fields"), "{html}");
}
#[test]
fn the_progress_bar_is_named_by_the_label() {
let html = direct_upload_input("file", "/presign", None).into_string();
assert!(
html.contains("aria-labelledby=\"upload-file-label\""),
"{html}"
);
assert!(html.contains("id=\"upload-file-label\""), "{html}");
}
#[test]
fn the_dropzone_hint_is_hidden_until_the_script_runs() {
let html = direct_upload_input("file", "/presign", None).into_string();
assert!(html.contains("data-autumn-upload-hint"), "{html}");
let hint = html.split("data-autumn-upload-hint").nth(1).unwrap_or("");
assert!(
hint.split('>').next().unwrap_or("").contains("hidden"),
"{html}"
);
}
#[test]
fn without_script_the_input_is_a_plain_named_file_field() {
let html = direct_upload_input("file", "/presign", None).into_string();
let input = html
.split("<input")
.nth(1)
.and_then(|t| t.split('>').next())
.unwrap_or("");
assert!(input.contains("name=\"file\""), "{input}");
assert!(!input.contains("disabled"), "{input}");
assert!(!input.contains("hidden"), "{input}");
}
#[test]
fn the_noscript_note_says_the_file_goes_with_the_form() {
let html = direct_upload_input("file", "/presign", None).into_string();
assert!(html.contains("<noscript>"), "{html}");
assert!(html.contains("with the form"), "{html}");
assert!(!html.contains("requires JavaScript"), "{html}");
}
}