From b3d72e8d824d48e1928ae3380ea422b404d3541e Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 23 Jun 2026 07:22:40 +0200 Subject: [PATCH] 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 --- AGENTS.md | 1 + Cargo.lock | 14 + Cargo.toml | 2 +- README.md | 5 + REQUIREMENTS.md | 4 +- examples/html_examples/Cargo.toml | 22 + examples/html_examples/README.md | 37 ++ examples/html_examples/build.rs | 3 + examples/html_examples/src/lib.rs | 2 + examples/html_examples/src/main.rs | 565 ++++++++++++++++++ .../html_examples/templates/app_shell.heml | 12 + examples/html_examples/templates/gallery.heml | 69 +++ .../templates/partials/contact_card.heml | 15 + .../templates/partials/editable_row.heml | 14 + .../templates/partials/loaded_row.heml | 1 + .../templates/partials/search_result.heml | 1 + 16 files changed, 764 insertions(+), 3 deletions(-) create mode 100644 examples/html_examples/Cargo.toml create mode 100644 examples/html_examples/README.md create mode 100644 examples/html_examples/build.rs create mode 100644 examples/html_examples/src/lib.rs create mode 100644 examples/html_examples/src/main.rs create mode 100644 examples/html_examples/templates/app_shell.heml create mode 100644 examples/html_examples/templates/gallery.heml create mode 100644 examples/html_examples/templates/partials/contact_card.heml create mode 100644 examples/html_examples/templates/partials/editable_row.heml create mode 100644 examples/html_examples/templates/partials/loaded_row.heml create mode 100644 examples/html_examples/templates/partials/search_result.heml diff --git a/AGENTS.md b/AGENTS.md index 71e21c1..922ca8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - hemx core stays small: effects, typed ids, registries, and wire schema only. - Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates. - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. +- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state. req: htmx_equivalents/001 req: htmx_equivalents/002 req: examples/001 - Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands. req: ceremony/005 req: ceremony/006 - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. - The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. diff --git a/Cargo.lock b/Cargo.lock index 41cf9ad..83c3dbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -538,6 +538,20 @@ dependencies = [ "serde", ] +[[package]] +name = "hemx-html-examples" +version = "0.1.0" +dependencies = [ + "axum", + "hemplate", + "hemx", + "hemx-axum", + "hemx-build", + "hemx-test", + "scraper", + "tokio", +] + [[package]] name = "hemx-js" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index bdf512e..e87179c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"] +members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index d92665a..ef0bdf8 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,11 @@ See `docs/versioning.md`. PATH`; it includes a page, form, keyed row partial, notice slot, handlers, tests, and generated append/replace/remove/dynamic-batch updates. Start here. req: ceremony/005 +- `examples/html_examples`: copy-paste HTML pattern gallery for htmx-style + CRUD/form/search/load UX patterns. It proves the hemx idiom for click-to-edit, + edit row, delete row, inline validation, click-to-load, and active search with + `.heml`, generated resources, server-owned Rust state, and tiny runtime + behavior. req: htmx_equivalents/001 req: htmx_equivalents/002 req: examples/001 - `examples/saas`: compile-tested v1 tutorial app covering auth/session, CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain CSS, and one explicit island without provider-heavy platform scope. Read the diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 72fd506..bc57827 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -185,7 +185,7 @@ client app state framework. 001 hemx replaces common HTMX use-cases through typed equivalents, not HTMX syntax. ### req: htmx/002 -002 Easy equivalents must exist for: generated target replacement, append/prepend/remove, boosted links/forms, page swap, form submit, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, validation errors, modals, toasts, table rows, SVG fragments, and form error regions. +002 Easy equivalents must exist for: generated target replacement, append/prepend/remove, boosted links/forms, page swap, form submit, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, validation errors, modals, toasts, table rows, SVG fragments, and form error regions. The copy-paste HTML pattern gallery must cover the core CRUD/form/search/load patterns through boring `.heml`, generated targets/forms/handles, and server-owned Rust state before adding plugin-shaped or browser-policy-heavy patterns. [north_star] ### req: htmx/003 003 hemx core deliberately does not clone selector-based HTMX features: `hx-target` selectors, `hx-select`, `hx-include` selectors, `closest/find/this` target strings, or trigger mini-languages. Equivalent patterns use generated targets, typed params, forms, explicit handlers, and page/push adapters around partial swaps. @@ -813,7 +813,7 @@ what a valid business email is. ## examples ### req: examples/001 -001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus a complete session flow with rest/next state, progress, undo recovery, finish/export, recoverable invalid input and host/replay failures, and host boundary results returned through app code, and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core. +001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; `examples/html_examples` is the copy-paste HTML pattern gallery proving htmx-style CRUD/form/search/load patterns map to boring `.heml`, generated resources, and server-owned Rust state; the workout example is the phone-first local-first product exemplar for commands/events/projections plus a complete session flow with rest/next state, progress, undo recovery, finish/export, recoverable invalid input and host/replay failures, and host boundary results returned through app code, and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core. ### req: examples/002 002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions. diff --git a/examples/html_examples/Cargo.toml b/examples/html_examples/Cargo.toml new file mode 100644 index 0000000..086fbf4 --- /dev/null +++ b/examples/html_examples/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "hemx-html-examples" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +axum = "0.7" +hemplate = { path = "../../../hemplate/hemplate" } +hemx = { path = "../../hemx" } +hemx-axum = { path = "../../hemx-axum" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } + +[dev-dependencies] +scraper = "0.23" +hemx-test = { path = "../../hemx-test" } + +[build-dependencies] +hemx-build = { path = "../../hemx-build" } diff --git a/examples/html_examples/README.md b/examples/html_examples/README.md new file mode 100644 index 0000000..55fe3f3 --- /dev/null +++ b/examples/html_examples/README.md @@ -0,0 +1,37 @@ +# hemx HTML examples + +A copy-pasteable pattern gallery for the boring HTML UX patterns popularized by +htmx. The point is not to clone htmx attributes; it is to show the hemx idiom: +plain `.heml`, generated resources, server-owned Rust state, keyed partials, and +tiny runtime behavior. req: htmx_equivalents/001 req: htmx_equivalents/002 req: examples/001 + +Run it: + +```sh +cargo run -p hemx-html-examples +``` + +Open . + +## Pattern matrix + +Names match the htmx example URL slug exactly, e.g. `modal-custom` from +`https://htmx.org/examples/modal-custom/`. Rust resource names use normal +identifier spelling only where the language requires it. + +| htmx example slug | hemx idiom | Proof anchor | +| --- | --- | --- | +| `click-to-edit` | A read view and edit form are the same generated `contact_card` partial; the server toggles `editing` and returns `gallery::contact_card.replace(...)`. | `templates/partials/contact_card.heml`, `contact_card_handlers::edit_contact`, `save_contact` | +| `edit-row` | A table row is a keyed `.heml` partial with generated edit/save forms; no selector target strings. | `templates/partials/editable_row.heml`, `editable_row_handlers::edit_row`, `save_row` | +| `delete-row` | Server state removes the row and returns `gallery::editable_row.remove(id)`. | `editable_row_handlers::delete_row` | +| `inline-validation` | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` | +| `click-to-load` | The server owns the loaded count and returns generated keyed `loaded_row` replacements plus status text. | `gallery_handlers::load_more`, `LoadedRow` | +| `active-search` | The search form posts a query; the server derives result rows and replaces generated `search_result` keyed partials. | `gallery_handlers::search`, `SearchResult` | + +## Boundary + +This gallery deliberately skips the trophy-list patterns that would need a real +product reason or a browser/plugin policy first: drag-and-drop, modal libraries, +file upload progress, infinite scroll, web components, and authentication token +refresh. Add those only as vertical slices when hemx has a boring product-owned +answer, not as demo-only JavaScript. diff --git a/examples/html_examples/build.rs b/examples/html_examples/build.rs new file mode 100644 index 0000000..99fa6f3 --- /dev/null +++ b/examples/html_examples/build.rs @@ -0,0 +1,3 @@ +fn main() { + hemx_build::app().run().unwrap(); +} diff --git a/examples/html_examples/src/lib.rs b/examples/html_examples/src/lib.rs new file mode 100644 index 0000000..c21c7c1 --- /dev/null +++ b/examples/html_examples/src/lib.rs @@ -0,0 +1,2 @@ +#[hemx::surface] +pub mod ui {} diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs new file mode 100644 index 0000000..37b2ab8 --- /dev/null +++ b/examples/html_examples/src/main.rs @@ -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>, + rows: Mutex>, + loaded_count: Mutex, + email: Mutex, + email_status: Mutex, + query: Mutex, +} + +#[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 { + value.parse::().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, + rows: Vec, + email: String, + email_status: String, + loaded_rows: Vec, + load_status: String, + query: String, + search_status: String, + search_results: Vec, +} + +#[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>, 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>, + request: InteractionRequest, +) -> Result { + 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) -> 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 { + (1..=count) + .map(|id| LoadedRow { + id: Id(id as u64), + title: format!("Loaded row {id}"), + }) + .collect() +} + +fn search_results_for(query: &str) -> Vec { + ["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>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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::>(); + let mut effects = rows; + effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded))); + effects + } + + #[hemx::handler] + async fn search( + State(state): State>, + Form(input): Form, + ) -> 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::>(); + 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>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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>, + Form(input): Form, + ) -> 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(handle: hemx::Handle, 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")); + } +} diff --git a/examples/html_examples/templates/app_shell.heml b/examples/html_examples/templates/app_shell.heml new file mode 100644 index 0000000..4f13f59 --- /dev/null +++ b/examples/html_examples/templates/app_shell.heml @@ -0,0 +1,12 @@ + + + + + + hemx HTML examples + + + + {+= self.body =+} + + diff --git a/examples/html_examples/templates/gallery.heml b/examples/html_examples/templates/gallery.heml new file mode 100644 index 0000000..1d067e1 --- /dev/null +++ b/examples/html_examples/templates/gallery.heml @@ -0,0 +1,69 @@ +
+
+

Copy-pasteable hemx HTML patterns

+

HTML UX pattern gallery

+

Server-owned Rust state, boring .heml, generated resources, and tiny runtime behavior.

+
+ +
+

click-to-edit

+
+ +
+
+ +
+

edit-row

+

delete-row uses the same keyed row partial and a generated remove effect.

+ + + + + + + +
TaskActions
+
+ +
+

inline-validation

+
+ + +

+

{+ self.email_status +}

+
+
+ +
+

click-to-load

+
    + +
+
+ + +
+

{+ self.load_status +}

+
+ + +
diff --git a/examples/html_examples/templates/partials/contact_card.heml b/examples/html_examples/templates/partials/contact_card.heml new file mode 100644 index 0000000..a54d65b --- /dev/null +++ b/examples/html_examples/templates/partials/contact_card.heml @@ -0,0 +1,15 @@ +
+
+

{+ self.name +}

+

{+ self.email +}

+
+ +
+
+
+ + + + +
+
diff --git a/examples/html_examples/templates/partials/editable_row.heml b/examples/html_examples/templates/partials/editable_row.heml new file mode 100644 index 0000000..943de4f --- /dev/null +++ b/examples/html_examples/templates/partials/editable_row.heml @@ -0,0 +1,14 @@ + + {+ self.title +} + +
+
+ + +
+ + + +
+ + diff --git a/examples/html_examples/templates/partials/loaded_row.heml b/examples/html_examples/templates/partials/loaded_row.heml new file mode 100644 index 0000000..ff9ac98 --- /dev/null +++ b/examples/html_examples/templates/partials/loaded_row.heml @@ -0,0 +1 @@ +
  • {+ self.title +}
  • diff --git a/examples/html_examples/templates/partials/search_result.heml b/examples/html_examples/templates/partials/search_result.heml new file mode 100644 index 0000000..bb9938c --- /dev/null +++ b/examples/html_examples/templates/partials/search_result.heml @@ -0,0 +1 @@ +
  • {+ self.label +}