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
+1
View File
@@ -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.
Generated
+14
View File
@@ -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"
+1 -1
View File
@@ -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"
+5
View File
@@ -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
+2 -2
View File
@@ -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.
+22
View File
@@ -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" }
+37
View File
@@ -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 <http://127.0.0.1:3029>.
## 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.
+3
View File
@@ -0,0 +1,3 @@
fn main() {
hemx_build::app().run().unwrap();
}
+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"));
}
}
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hemx HTML examples</title>
<script defer="defer" +src="self.runtime_src"></script>
</head>
<body>
{+= self.body =+}
</body>
</html>
@@ -0,0 +1,69 @@
<main data-hemx-root="gallery">
<header>
<p>Copy-pasteable hemx HTML patterns</p>
<h1>HTML UX pattern gallery</h1>
<p>Server-owned Rust state, boring .heml, generated resources, and tiny runtime behavior.</p>
</header>
<section id="click-to-edit" data-htmx-example="click-to-edit" aria-labelledby="click-to-edit-heading">
<h2 id="click-to-edit-heading">click-to-edit</h2>
<div data-hemx-slot="contact_card">
<template h-for="contact in &self.contacts" h-key="contact.id">
{+ contact +}
</template>
</div>
</section>
<section id="edit-row" data-htmx-example="edit-row" aria-labelledby="edit-row-heading">
<h2 id="edit-row-heading">edit-row</h2>
<p id="delete-row" data-htmx-example="delete-row">delete-row uses the same keyed row partial and a generated remove effect.</p>
<table>
<thead>
<tr><th>Task</th><th>Actions</th></tr>
</thead>
<tbody data-hemx-slot="editable_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}
</template>
</tbody>
</table>
</section>
<section id="inline-validation" data-htmx-example="inline-validation" aria-labelledby="validation-heading">
<h2 id="validation-heading">inline-validation</h2>
<form data-hemx-handle="validate_email" data-hemx-form="validate_email">
<label>Email <input name="email" +value="self.email" required="required"></label>
<button type="submit">Validate</button>
<p data-hemx-error-for="email"></p>
<p data-hemx-slot="email_status">{+ self.email_status +}</p>
</form>
</section>
<section id="click-to-load" data-htmx-example="click-to-load" aria-labelledby="load-heading">
<h2 id="load-heading">click-to-load</h2>
<ul data-hemx-slot="loaded_row">
<template h-for="row in &self.loaded_rows" h-key="row.id">
{+ row +}
</template>
</ul>
<form data-hemx-handle="load_more" data-hemx-form="load_more">
<input type="hidden" name="request" value="more">
<button type="submit">Load more</button>
</form>
<p data-hemx-slot="load_status">{+ self.load_status +}</p>
</section>
<section id="active-search" data-htmx-example="active-search" aria-labelledby="search-heading">
<h2 id="search-heading">active-search</h2>
<form data-hemx-handle="search" data-hemx-form="search">
<label>Search <input name="query" +value="self.query"></label>
<button type="submit">Search</button>
</form>
<p data-hemx-slot="search_status">{+ self.search_status +}</p>
<ul data-hemx-slot="search_result">
<template h-for="result in &self.search_results" h-key="result.id">
{+ result +}
</template>
</ul>
</section>
</main>
@@ -0,0 +1,15 @@
<article +data-key="self.id">
<div h-if="!self.editing">
<h3>{+ self.name +}</h3>
<p>{+ self.email +}</p>
<form data-hemx-handle="edit_contact" data-hemx-form="edit_contact">
<button type="submit" name="id" +value="self.id">Edit</button>
</form>
</div>
<form h-if="self.editing" data-hemx-handle="save_contact" data-hemx-form="save_contact">
<input type="hidden" name="id" +value="self.id">
<label>Name <input name="name" +value="self.name" required="required"></label>
<label>Email <input name="email" +value="self.email" required="required"></label>
<button type="submit">Save</button>
</form>
</article>
@@ -0,0 +1,14 @@
<tr +data-key="self.id">
<td h-if="!self.editing">{+ self.title +}</td>
<td h-if="!self.editing">
<form data-hemx-handle="edit_row" data-hemx-form="edit_row"><button type="submit" name="id" +value="self.id">Edit</button></form>
<form data-hemx-handle="delete_row" data-hemx-form="delete_row"><button type="submit" name="id" +value="self.id">Delete</button></form>
</td>
<td h-if="self.editing" colspan="2">
<form data-hemx-handle="save_row" data-hemx-form="save_row">
<input type="hidden" name="id" +value="self.id">
<label>Task <input name="title" +value="self.title" required="required"></label>
<button type="submit">Save</button>
</form>
</td>
</tr>
@@ -0,0 +1 @@
<li +data-key="self.id">{+ self.title +}</li>
@@ -0,0 +1 @@
<li +data-key="self.id">{+ self.label +}</li>