feat(runtime): add tiny trigger timing conventions

Add boring runtime-level trigger timing primitives for delay, revealed, and interval while keeping them as attribute conventions rather than core effects or plugin surfaces. Promote lazy-load, infinite-scroll, progress-bar, value-select, and reset-user-input in examples/html_examples to runnable hemx patterns with exact htmx slugs and generated resources.

req: convention/001

req: convention/003

req: convention/005

req: htmx_equivalents/001

req: htmx_equivalents/002

req: examples/001
This commit is contained in:
slhx agent
2026-06-23 07:51:43 +02:00
parent 0922a46e21
commit cd7f64e5a3
9 changed files with 376 additions and 23 deletions
+236 -4
View File
@@ -19,8 +19,14 @@ struct GalleryState {
contacts: Mutex<Vec<ContactRecord>>,
rows: Mutex<Vec<RowRecord>>,
loaded_count: Mutex<usize>,
infinite_count: Mutex<usize>,
lazy_loaded: Mutex<bool>,
progress: Mutex<u8>,
email: Mutex<String>,
email_status: Mutex<String>,
category: Mutex<String>,
value: Mutex<String>,
reset_status: Mutex<String>,
query: Mutex<String>,
}
@@ -76,6 +82,11 @@ struct LoadMore {
request: String,
}
#[hemx::form("infinite_scroll")]
struct InfiniteScroll {
request: String,
}
#[hemx::form("save_contact")]
struct SaveContact {
id: Id,
@@ -89,11 +100,31 @@ struct SaveRow {
title: String,
}
#[hemx::form("lazy_load")]
struct LazyLoad {
request: String,
}
#[hemx::form("tick_progress")]
struct TickProgress {
request: String,
}
#[hemx::form("validate_email")]
struct ValidateEmail {
email: String,
}
#[hemx::form("choose_category")]
struct ChooseCategory {
category: String,
}
#[hemx::form("reset_message")]
struct ResetMessage {
message: String,
}
#[hemx::form("search")]
struct SearchInput {
query: String,
@@ -109,10 +140,17 @@ struct AppShell {
struct Gallery {
contacts: Vec<ContactCard>,
rows: Vec<EditableRow>,
email: String,
email_status: String,
lazy_panel: String,
loaded_rows: Vec<LoadedRow>,
load_status: String,
infinite_rows: Vec<LoadedRow>,
infinite_status: String,
progress: u8,
progress_label: String,
email: String,
email_status: String,
value_options: Vec<ValueOption>,
reset_status: String,
query: String,
search_status: String,
search_results: Vec<SearchResult>,
@@ -157,6 +195,20 @@ impl hemx::KeyedPartial for LoadedRow {
}
}
#[derive(Hemplate, Clone)]
struct ValueOption {
id: Id,
value: String,
label: String,
selected: bool,
}
impl hemx::KeyedPartial for ValueOption {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate, Clone)]
struct SearchResult {
id: Id,
@@ -205,8 +257,14 @@ impl GalleryState {
},
]),
loaded_count: Mutex::new(2),
infinite_count: Mutex::new(3),
lazy_loaded: Mutex::new(false),
progress: Mutex::new(0),
email: Mutex::new(String::new()),
email_status: Mutex::new("Waiting for an email".into()),
category: Mutex::new("letters".into()),
value: Mutex::new("alpha".into()),
reset_status: Mutex::new("No message sent yet".into()),
query: Mutex::new(String::new()),
}
}
@@ -260,17 +318,34 @@ fn gallery_view(state: &GalleryState) -> Gallery {
.map(editable_row_view)
.collect();
let loaded_count = *state.loaded_count.lock().unwrap();
let infinite_count = *state.infinite_count.lock().unwrap();
let lazy_loaded = *state.lazy_loaded.lock().unwrap();
let progress = *state.progress.lock().unwrap();
let email = state.email.lock().unwrap().clone();
let email_status = state.email_status.lock().unwrap().clone();
let category = state.category.lock().unwrap().clone();
let value = state.value.lock().unwrap().clone();
let reset_status = state.reset_status.lock().unwrap().clone();
let query = state.query.lock().unwrap().clone();
let search_results = search_results_for(&query);
Gallery {
contacts,
rows,
email,
email_status,
lazy_panel: if lazy_loaded {
"Lazy content loaded".into()
} else {
"Waiting to be revealed".into()
},
loaded_rows: loaded_rows(loaded_count),
load_status: format!("Showing {loaded_count} rows"),
infinite_rows: loaded_rows(infinite_count),
infinite_status: format!("Showing {infinite_count} rows"),
progress,
progress_label: format!("{progress}% complete"),
email,
email_status,
value_options: value_options_for(&category, &value),
reset_status,
query: query.clone(),
search_status: if query.is_empty() {
"Showing all results".into()
@@ -307,6 +382,22 @@ fn loaded_rows(count: usize) -> Vec<LoadedRow> {
.collect()
}
fn value_options_for(category: &str, selected: &str) -> Vec<ValueOption> {
let values = match category {
"numbers" => [(1, "one", "One"), (2, "two", "Two")],
_ => [(1, "alpha", "Alpha"), (2, "beta", "Beta")],
};
values
.into_iter()
.map(|(id, value, label)| ValueOption {
id: Id(id),
value: value.into(),
label: label.into(),
selected: value == selected,
})
.collect()
}
fn search_results_for(query: &str) -> Vec<SearchResult> {
["Alpha", "Beta", "Gamma", "Delta"]
.into_iter()
@@ -325,6 +416,86 @@ fn search_results_for(query: &str) -> Vec<SearchResult> {
mod gallery_handlers {
use super::*;
#[hemx::handler]
async fn lazy_load(
State(state): State<Arc<GalleryState>>,
Form(input): Form<LazyLoad>,
) -> impl IntoEffect {
let _ = input.request;
*state.lazy_loaded.lock().unwrap() = true;
gallery::lazy_panel.set("Lazy content loaded")
}
#[hemx::handler]
async fn tick_progress(
State(state): State<Arc<GalleryState>>,
Form(input): Form<TickProgress>,
) -> impl IntoEffect {
let _ = input.request;
let mut progress = state.progress.lock().unwrap();
*progress = (*progress + 25).min(100);
gallery::progress_meter.set(format!("{}% complete", *progress))
}
#[hemx::handler]
async fn choose_category(
State(state): State<Arc<GalleryState>>,
Form(input): Form<ChooseCategory>,
) -> impl IntoEffect {
let category = if input.category == "numbers" {
"numbers"
} else {
"letters"
}
.to_owned();
let value = if category == "numbers" {
"one"
} else {
"alpha"
}
.to_owned();
*state.category.lock().unwrap() = category.clone();
*state.value.lock().unwrap() = value.clone();
value_options_for(&category, &value)
.into_iter()
.map(|option| gallery::value_option.replace(option))
.collect::<Vec<Effect>>()
}
#[hemx::handler]
async fn reset_message(
State(state): State<Arc<GalleryState>>,
Form(input): Form<ResetMessage>,
) -> impl IntoEffect {
let status = if input.message.trim().is_empty() {
"Nothing to send".to_owned()
} else {
format!("Sent: {}", input.message.trim())
};
*state.reset_status.lock().unwrap() = status.clone();
vec![
gallery::reset_status.set(status),
gallery::reset_message_form.clear(),
]
}
#[hemx::handler]
async fn infinite_scroll(
State(state): State<Arc<GalleryState>>,
Form(input): Form<InfiniteScroll>,
) -> impl IntoEffect {
let _ = input.request;
let mut count = state.infinite_count.lock().unwrap();
*count += 3;
let rows = loaded_rows(*count)
.into_iter()
.map(|row| gallery::infinite_row.replace(row))
.collect::<Vec<Effect>>();
let mut effects = rows;
effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count)));
effects
}
#[hemx::handler]
async fn validate_email(
State(state): State<Arc<GalleryState>>,
@@ -531,8 +702,13 @@ mod tests {
"click-to-edit",
"edit-row",
"delete-row",
"lazy-load",
"inline-validation",
"infinite-scroll",
"click-to-load",
"progress-bar",
"value-select",
"reset-user-input",
"active-search",
] {
assert!(
@@ -541,7 +717,11 @@ mod tests {
);
}
assert!(html.contains("data-hemx-form=\"validate_email\""));
assert!(html.contains("data-hemx-revealed=\"true\""));
assert!(html.contains("data-hemx-interval=\"500ms\""));
assert!(html.contains("data-hemx-slot=\"loaded_row\""));
assert!(html.contains("data-hemx-slot=\"infinite_row\""));
assert!(html.contains("data-hemx-slot=\"value_option\""));
assert!(html.contains("data-hemx-slot=\"search_result\""));
let edit = inspect_batch(
@@ -565,6 +745,16 @@ mod tests {
);
assert!(save.updates_html_containing(gallery::contact_card, "ada@hemx.test"));
let lazy = inspect_batch(
InteractionRequest::from(form(gallery::lazy_load, &[("request", "lazy")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(lazy.updates_text(gallery::lazy_panel));
assert!(lazy.payload_contains("Lazy content loaded"));
let invalid = inspect_batch(
InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")]))
.dispatch_async(handlers(state.clone()))
@@ -575,6 +765,48 @@ mod tests {
assert!(invalid.payload_contains("Use a real email address"));
assert!(invalid.updates_text(gallery::email_status));
let infinite = inspect_batch(
InteractionRequest::from(form(gallery::infinite_scroll, &[("request", "more")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(infinite.replaces_keyed_html_containing(
gallery::infinite_row,
"6",
"Loaded row 6"
));
let progress = inspect_batch(
InteractionRequest::from(form(gallery::tick_progress, &[("request", "tick")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(progress.updates_text(gallery::progress_meter));
assert!(progress.payload_contains("25% complete"));
let values = inspect_batch(
InteractionRequest::from(form(gallery::choose_category, &[("category", "numbers")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(values.replaces_keyed_html_containing(gallery::value_option, "1", "One"));
let reset = inspect_batch(
InteractionRequest::from(form(gallery::reset_message, &[("message", "hello")]))
.dispatch_async(handlers(state.clone()))
.await
.unwrap()
.batch,
);
assert!(reset.updates_text(gallery::reset_status));
assert!(reset.resets_form(gallery::reset_message_form));
let load = inspect_batch(
InteractionRequest::from(form(gallery::load_more, &[("request", "more")]))
.dispatch_async(handlers(state.clone()))