feat(examples): add hemx html pattern gallery

Add examples/html_examples as a copy-pasteable gallery for the first htmx-style HTML UX patterns using exact htmx URL slugs in the page and README. The slice covers click-to-edit, edit-row, delete-row, inline-validation, click-to-load, and active-search with .heml templates, generated resources, server-owned Rust state, and runtime-free selector targeting.

req: htmx_equivalents/001

req: htmx_equivalents/002

req: examples/001
This commit is contained in:
slhx agent
2026-06-23 07:22:40 +02:00
parent f1d84e4837
commit b3d72e8d82
16 changed files with 764 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
#[hemx::surface]
pub mod ui {}
+565
View File
@@ -0,0 +1,565 @@
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use hemplate::Hemplate;
use hemx::advanced::Effect;
use hemx::{Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js, runtime_js_path, EffectResponse, Form, InteractionHandlers,
InteractionRequest, PageRequest,
};
use hemx_html_examples::ui;
use hemx_html_examples::ui::{contact_card, editable_row, gallery};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct GalleryState {
contacts: Mutex<Vec<ContactRecord>>,
rows: Mutex<Vec<RowRecord>>,
loaded_count: Mutex<usize>,
email: Mutex<String>,
email_status: Mutex<String>,
query: Mutex<String>,
}
#[derive(Clone)]
struct ContactRecord {
id: u64,
name: String,
email: String,
editing: bool,
}
#[derive(Clone)]
struct RowRecord {
id: u64,
title: String,
editing: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Id(u64);
impl std::str::FromStr for Id {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
value.parse::<u64>().map(Id).map_err(|_| "invalid id")
}
}
impl std::fmt::Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[hemx::form("edit_contact")]
struct EditContact {
id: Id,
}
#[hemx::form("edit_row")]
struct EditRow {
id: Id,
}
#[hemx::form("delete_row")]
struct DeleteRow {
id: Id,
}
#[hemx::form("load_more")]
struct LoadMore {
request: String,
}
#[hemx::form("save_contact")]
struct SaveContact {
id: Id,
name: String,
email: String,
}
#[hemx::form("save_row")]
struct SaveRow {
id: Id,
title: String,
}
#[hemx::form("validate_email")]
struct ValidateEmail {
email: String,
}
#[hemx::form("search")]
struct SearchInput {
query: String,
}
#[derive(Hemplate)]
struct AppShell {
runtime_src: &'static str,
body: Html,
}
#[derive(Hemplate)]
struct Gallery {
contacts: Vec<ContactCard>,
rows: Vec<EditableRow>,
email: String,
email_status: String,
loaded_rows: Vec<LoadedRow>,
load_status: String,
query: String,
search_status: String,
search_results: Vec<SearchResult>,
}
#[derive(Hemplate, Clone)]
struct ContactCard {
id: Id,
name: String,
email: String,
editing: bool,
}
impl hemx::KeyedPartial for ContactCard {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate, Clone)]
struct EditableRow {
id: Id,
title: String,
editing: bool,
}
impl hemx::KeyedPartial for EditableRow {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate, Clone)]
struct LoadedRow {
id: Id,
title: String,
}
impl hemx::KeyedPartial for LoadedRow {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate, Clone)]
struct SearchResult {
id: Id,
label: String,
}
impl hemx::KeyedPartial for SearchResult {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[tokio::main]
async fn main() {
let state = Arc::new(GalleryState::seeded());
let app = Router::new()
.route("/", get(home).post(interact))
.route(runtime_js_path(), get(runtime))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3029));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("hemx HTML examples: http://{addr}");
axum::serve(listener, app).await.unwrap();
}
impl GalleryState {
fn seeded() -> Self {
Self {
contacts: Mutex::new(vec![ContactRecord {
id: 1,
name: "Ada Lovelace".into(),
email: "ada@example.com".into(),
editing: false,
}]),
rows: Mutex::new(vec![
RowRecord {
id: 1,
title: "Write boring HTML".into(),
editing: false,
},
RowRecord {
id: 2,
title: "Keep state on the server".into(),
editing: false,
},
]),
loaded_count: Mutex::new(2),
email: Mutex::new(String::new()),
email_status: Mutex::new("Waiting for an email".into()),
query: Mutex::new(String::new()),
}
}
}
async fn home(State(state): State<Arc<GalleryState>>, request: PageRequest) -> impl IntoResponse {
request
.page_html(ui::page(&gallery_view(&state)), shell)
.title("hemx HTML examples")
.fingerprint(ui::BUILD_FINGERPRINT)
}
async fn interact(
State(state): State<Arc<GalleryState>>,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
request.dispatch_async(handlers(state)).await
}
async fn runtime() -> impl IntoResponse {
runtime_js()
}
#[hemx::app(gallery_handlers, contact_card_handlers, editable_row_handlers)]
fn handlers(state: Arc<GalleryState>) -> InteractionHandlers {
interactions(ui::BUILD_FINGERPRINT)
}
fn shell(body: Html) -> Html {
ui::page(&AppShell {
runtime_src: runtime_js_path(),
body,
})
}
fn gallery_view(state: &GalleryState) -> Gallery {
let contacts = state
.contacts
.lock()
.unwrap()
.iter()
.cloned()
.map(contact_card_view)
.collect();
let rows = state
.rows
.lock()
.unwrap()
.iter()
.cloned()
.map(editable_row_view)
.collect();
let loaded_count = *state.loaded_count.lock().unwrap();
let email = state.email.lock().unwrap().clone();
let email_status = state.email_status.lock().unwrap().clone();
let query = state.query.lock().unwrap().clone();
let search_results = search_results_for(&query);
Gallery {
contacts,
rows,
email,
email_status,
loaded_rows: loaded_rows(loaded_count),
load_status: format!("Showing {loaded_count} rows"),
query: query.clone(),
search_status: if query.is_empty() {
"Showing all results".into()
} else {
format!("Results for {query}")
},
search_results,
}
}
fn contact_card_view(record: ContactRecord) -> ContactCard {
ContactCard {
id: Id(record.id),
name: record.name,
email: record.email,
editing: record.editing,
}
}
fn editable_row_view(record: RowRecord) -> EditableRow {
EditableRow {
id: Id(record.id),
title: record.title,
editing: record.editing,
}
}
fn loaded_rows(count: usize) -> Vec<LoadedRow> {
(1..=count)
.map(|id| LoadedRow {
id: Id(id as u64),
title: format!("Loaded row {id}"),
})
.collect()
}
fn search_results_for(query: &str) -> Vec<SearchResult> {
["Alpha", "Beta", "Gamma", "Delta"]
.into_iter()
.enumerate()
.filter(|(_, label)| {
query.is_empty() || label.to_lowercase().contains(&query.to_lowercase())
})
.map(|(index, label)| SearchResult {
id: Id(index as u64 + 1),
label: label.into(),
})
.collect()
}
#[hemx::component("gallery")]
mod gallery_handlers {
use super::*;
#[hemx::handler]
async fn validate_email(
State(state): State<Arc<GalleryState>>,
Form(input): Form<ValidateEmail>,
) -> impl IntoEffect {
let email = input.email.trim().to_owned();
if !email.contains('@') {
*state.email.lock().unwrap() = email;
*state.email_status.lock().unwrap() = "Email needs an @ sign".into();
return vec![
gallery::validate_email_form.focus("email"),
gallery::validate_email_form.error("email", "Use a real email address"),
gallery::email_status.set("Email needs an @ sign"),
];
}
*state.email.lock().unwrap() = email.clone();
*state.email_status.lock().unwrap() = format!("{email} is valid");
vec![
gallery::email_status.set(format!("{email} is valid")),
gallery::validate_email_form.clear(),
]
}
#[hemx::handler]
async fn load_more(
State(state): State<Arc<GalleryState>>,
Form(input): Form<LoadMore>,
) -> impl IntoEffect {
let _ = input.request;
let mut loaded = state.loaded_count.lock().unwrap();
*loaded += 2;
let rows = loaded_rows(*loaded)
.into_iter()
.map(|row| gallery::loaded_row.replace(row))
.collect::<Vec<Effect>>();
let mut effects = rows;
effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded)));
effects
}
#[hemx::handler]
async fn search(
State(state): State<Arc<GalleryState>>,
Form(input): Form<SearchInput>,
) -> impl IntoEffect {
let query = input.query.trim().to_owned();
*state.query.lock().unwrap() = query.clone();
let results = search_results_for(&query)
.into_iter()
.map(|result| gallery::search_result.replace(result))
.collect::<Vec<Effect>>();
let mut effects = results;
effects.push(gallery::search_status.set(if query.is_empty() {
"Showing all results".into()
} else {
format!("Results for {query}")
}));
effects
}
}
#[hemx::component("contact_card")]
mod contact_card_handlers {
use super::*;
#[hemx::handler]
async fn edit_contact(
State(state): State<Arc<GalleryState>>,
Form(input): Form<EditContact>,
) -> impl IntoEffect {
let mut contacts = state.contacts.lock().unwrap();
if let Some(contact) = contacts.iter_mut().find(|contact| contact.id == input.id.0) {
contact.editing = true;
return Some(gallery::contact_card.replace(&contact_card_view(contact.clone())));
}
None
}
#[hemx::handler]
async fn save_contact(
State(state): State<Arc<GalleryState>>,
Form(input): Form<SaveContact>,
) -> impl IntoEffect {
if input.name.trim().is_empty() || !input.email.contains('@') {
return Some(contact_card::save_contact_form.focus("name"));
}
let mut contacts = state.contacts.lock().unwrap();
if let Some(contact) = contacts.iter_mut().find(|contact| contact.id == input.id.0) {
contact.name = input.name;
contact.email = input.email;
contact.editing = false;
return Some(gallery::contact_card.replace(&contact_card_view(contact.clone())));
}
None
}
}
#[hemx::component("editable_row")]
mod editable_row_handlers {
use super::*;
#[hemx::handler]
async fn edit_row(
State(state): State<Arc<GalleryState>>,
Form(input): Form<EditRow>,
) -> impl IntoEffect {
let mut rows = state.rows.lock().unwrap();
if let Some(row) = rows.iter_mut().find(|row| row.id == input.id.0) {
row.editing = true;
return Some(gallery::editable_row.replace(editable_row_view(row.clone())));
}
None
}
#[hemx::handler]
async fn save_row(
State(state): State<Arc<GalleryState>>,
Form(input): Form<SaveRow>,
) -> impl IntoEffect {
if input.title.trim().is_empty() {
return Some(editable_row::save_row_form.focus("title"));
}
let mut rows = state.rows.lock().unwrap();
if let Some(row) = rows.iter_mut().find(|row| row.id == input.id.0) {
row.title = input.title;
row.editing = false;
return Some(gallery::editable_row.replace(editable_row_view(row.clone())));
}
None
}
#[hemx::handler]
async fn delete_row(
State(state): State<Arc<GalleryState>>,
Form(input): Form<DeleteRow>,
) -> impl IntoEffect {
let mut rows = state.rows.lock().unwrap();
let before = rows.len();
rows.retain(|row| row.id != input.id.0);
(rows.len() != before).then(|| gallery::editable_row.remove(input.id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use hemx_test::inspect_batch;
fn form<I>(handle: hemx::Handle<I>, fields: &[(&str, &str)]) -> hemx_axum::InteractionForm {
hemx_axum::InteractionForm::for_handle(
handle,
fields
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
)
}
#[tokio::test]
async fn gallery_covers_core_html_patterns_with_generated_resources() {
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state).render().expect("render gallery");
assert!(html.contains("data-hemx-root=\"gallery\""));
for slug in [
"click-to-edit",
"edit-row",
"delete-row",
"inline-validation",
"click-to-load",
"active-search",
] {
assert!(
html.contains(&format!("data-htmx-example=\"{slug}\"")),
"missing exact htmx slug {slug}"
);
}
assert!(html.contains("data-hemx-form=\"validate_email\""));
assert!(html.contains("data-hemx-slot=\"loaded_row\""));
assert!(html.contains("data-hemx-slot=\"search_result\""));
let edit = inspect_batch(
InteractionRequest::from(form(contact_card::edit_contact, &[("id", "1")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(edit.updates_html_containing(gallery::contact_card, "save_contact"));
let save = inspect_batch(
InteractionRequest::from(form(
contact_card::save_contact,
&[("id", "1"), ("name", "Ada"), ("email", "ada@hemx.test")],
))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(save.updates_html_containing(gallery::contact_card, "ada@hemx.test"));
let invalid = inspect_batch(
InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(invalid.payload_contains("Use a real email address"));
assert!(invalid.updates_text(gallery::email_status));
let load = inspect_batch(
InteractionRequest::from(form(gallery::load_more, &[("request", "more")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(load.replaces_keyed_html_containing(gallery::loaded_row, "4", "Loaded row 4"));
let search = inspect_batch(
InteractionRequest::from(form(gallery::search, &[("query", "ga")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma"));
let delete = inspect_batch(
InteractionRequest::from(form(editable_row::delete_row, &[("id", "1")]))
.dispatch_async(handlers(state))
.await
.unwrap()
.batch,
);
assert!(delete.removes_key(gallery::editable_row, "1"));
}
}