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:
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user