fix(examples): repair html dynamic partial interactions

Preserve data-hemx-revealed through hemx-build and make html_examples lazy/load-more/infinite/search interactions return server-driven partial updates with regression coverage.

req: convention/005

req: htmx_equivalents/002

req: examples/001
This commit is contained in:
slhx agent
2026-06-25 12:56:43 +02:00
parent 3b14bb57ca
commit acddea21ee
3 changed files with 79 additions and 13 deletions
+1
View File
@@ -11,6 +11,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Start from product intent and requirements; inspect code only after the target behavior is clear. - Start from product intent and requirements; inspect code only after the target behavior is clear.
- Read `REQUIREMENTS.md` before changing behavior. - Read `REQUIREMENTS.md` before changing behavior.
- If behavior changes, update `REQUIREMENTS.md` in the same change. - If behavior changes, update `REQUIREMENTS.md` in the same change.
- If implementation work does not change durable product obligations, acceptance, safety/recovery behavior, or verification duties, say `REQUIREMENT IMPACT: none` in the handoff and why.
- Cite relevant requirements in code, tests, or docs as `req: <component>/001`. - Cite relevant requirements in code, tests, or docs as `req: <component>/001`.
- Do not add citation-only padding to satisfy tooling; cite only where the requirement constrains the text. - Do not add citation-only padding to satisfy tooling; cite only where the requirement constrains the text.
- Use requirement tags consistently: stable area tags like `[parser]`, `[auth]`, `[ui]`; temporary planning tags like `[bootstrap]`, `[mvp]`, or `[milestone-1]` only while they are useful. - Use requirement tags consistently: stable area tags like `[parser]`, `[auth]`, `[ui]`; temporary planning tags like `[bootstrap]`, `[mvp]`, or `[milestone-1]` only while they are useful.
+77 -13
View File
@@ -498,10 +498,12 @@ mod gallery_handlers {
) -> impl IntoEffect { ) -> impl IntoEffect {
let _ = input.request; let _ = input.request;
let mut count = state.infinite_count.lock().unwrap(); let mut count = state.infinite_count.lock().unwrap();
let first_new = *count + 1;
*count += 3; *count += 3;
let rows = loaded_rows(*count) let rows = loaded_rows(*count)
.into_iter() .into_iter()
.map(|row| gallery::infinite_row.replace(row)) .filter(|row| row.id.0 >= first_new as u64)
.map(|row| gallery::infinite_row.append(row))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut effects = rows; let mut effects = rows;
effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count))); effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count)));
@@ -538,10 +540,12 @@ mod gallery_handlers {
) -> impl IntoEffect { ) -> impl IntoEffect {
let _ = input.request; let _ = input.request;
let mut loaded = state.loaded_count.lock().unwrap(); let mut loaded = state.loaded_count.lock().unwrap();
let first_new = *loaded + 1;
*loaded += 2; *loaded += 2;
let rows = loaded_rows(*loaded) let rows = loaded_rows(*loaded)
.into_iter() .into_iter()
.map(|row| gallery::loaded_row.replace(row)) .filter(|row| row.id.0 >= first_new as u64)
.map(|row| gallery::loaded_row.append(row))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut effects = rows; let mut effects = rows;
effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded))); effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded)));
@@ -554,12 +558,22 @@ mod gallery_handlers {
Form(input): Form<SearchInput>, Form(input): Form<SearchInput>,
) -> impl IntoEffect { ) -> impl IntoEffect {
let query = input.query.trim().to_owned(); let query = input.query.trim().to_owned();
*state.query.lock().unwrap() = query.clone(); let previous_query = {
let results = search_results_for(&query) let mut stored_query = state.query.lock().unwrap();
let previous_query = stored_query.clone();
*stored_query = query.clone();
previous_query
};
let previous_results = search_results_for(&previous_query);
let mut effects = previous_results
.into_iter() .into_iter()
.map(|result| gallery::search_result.replace(result)) .map(|result| gallery::search_result.remove(result.id.to_string()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut effects = results; effects.extend(
search_results_for(&query)
.into_iter()
.map(|result| gallery::search_result.append(result)),
);
effects.push(gallery::search_status.set(if query.is_empty() { effects.push(gallery::search_status.set(if query.is_empty() {
"Showing all results".into() "Showing all results".into()
} else { } else {
@@ -675,6 +689,17 @@ mod tests {
); );
} }
#[test]
fn revealed_forms_preserve_data_hemx_revealed_attribute() {
// req: convention/005
let state = Arc::new(GalleryState::seeded());
let html = gallery_view(&state).render().expect("render gallery");
assert!(
html.contains("data-hemx-revealed=\"true\""),
"lazy-load and infinite-scroll forms must preserve data-hemx-revealed attribute"
);
}
#[test] #[test]
fn readme_maps_every_htmx_example_slug() { fn readme_maps_every_htmx_example_slug() {
let readme = include_str!("../README.md"); let readme = include_str!("../README.md");
@@ -782,6 +807,17 @@ mod tests {
assert!(lazy.updates_text(gallery::lazy_panel)); assert!(lazy.updates_text(gallery::lazy_panel));
assert!(lazy.payload_contains("Lazy content loaded")); assert!(lazy.payload_contains("Lazy content loaded"));
let more = inspect_batch(
InteractionRequest::from(form(gallery::load_more, &[("request", "more")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(more.payload_contains("Loaded row 3"));
assert!(more.payload_contains("Loaded row 4"));
assert!(more.updates_text(gallery::load_status));
let invalid = inspect_batch( let invalid = inspect_batch(
InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")])) InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")]))
.dispatch_async(handlers(state.clone())) .dispatch_async(handlers(state.clone()))
@@ -792,6 +828,20 @@ mod tests {
assert!(invalid.payload_contains("Use a real email address")); assert!(invalid.payload_contains("Use a real email address"));
assert!(invalid.updates_text(gallery::email_status)); assert!(invalid.updates_text(gallery::email_status));
let valid = inspect_batch(
InteractionRequest::from(form(
gallery::validate_email,
&[("email", "xyz@example.com")],
))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(valid.payload_contains("xyz@example.com is valid"));
assert!(!valid.payload_contains("Use a real email address"));
assert!(valid.updates_text(gallery::email_status));
let infinite = inspect_batch( let infinite = inspect_batch(
InteractionRequest::from(form(gallery::infinite_scroll, &[("request", "more")])) InteractionRequest::from(form(gallery::infinite_scroll, &[("request", "more")]))
.dispatch_async(handlers(state.clone())) .dispatch_async(handlers(state.clone()))
@@ -799,11 +849,8 @@ mod tests {
.unwrap() .unwrap()
.batch, .batch,
); );
assert!(infinite.replaces_keyed_html_containing( assert!(infinite.payload_contains("Loaded row 4"));
gallery::infinite_row, assert!(infinite.payload_contains("Loaded row 6"));
"6",
"Loaded row 6"
));
let progress = inspect_batch( let progress = inspect_batch(
InteractionRequest::from(form(gallery::tick_progress, &[("request", "tick")])) InteractionRequest::from(form(gallery::tick_progress, &[("request", "tick")]))
@@ -841,7 +888,7 @@ mod tests {
.unwrap() .unwrap()
.batch, .batch,
); );
assert!(load.replaces_keyed_html_containing(gallery::loaded_row, "4", "Loaded row 4")); assert!(load.payload_contains("Loaded row 6"));
let search = inspect_batch( let search = inspect_batch(
InteractionRequest::from(form(gallery::search, &[("query", "ga")])) InteractionRequest::from(form(gallery::search, &[("query", "ga")]))
@@ -850,7 +897,24 @@ mod tests {
.unwrap() .unwrap()
.batch, .batch,
); );
assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma")); assert!(search.removes_key(gallery::search_result, "1"));
assert!(search.inserts_html_containing(gallery::search_result, "3", "Gamma"));
let row_save = inspect_batch(
InteractionRequest::from(form(
editable_row::save_row,
&[("id", "1"), ("title", "Write dynamic HTML")],
))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(row_save.replaces_keyed_html_containing(
gallery::editable_row,
"1",
"Write dynamic HTML"
));
let delete = inspect_batch( let delete = inspect_batch(
InteractionRequest::from(form(editable_row::delete_row, &[("id", "1")])) InteractionRequest::from(form(editable_row::delete_row, &[("id", "1")]))
+1
View File
@@ -1047,6 +1047,7 @@ fn __hemx_lower_html(html: &str, table: &[(&str, &str, u32)]) -> ::std::string::
"data-hemx-slot" => "data-sid", "data-hemx-slot" => "data-sid",
"data-hemx-handle" => "data-hid", "data-hemx-handle" => "data-hid",
"data-hemx-form" => "data-fid", "data-hemx-form" => "data-fid",
"data-hemx-revealed" => "data-hemx-revealed",
"data-hemx-atom" => "data-aid", "data-hemx-atom" => "data-aid",
_ => continue, _ => continue,
}; };